diff --git a/.github/workflows/acceptance_test_cd.yml b/.github/workflows/acceptance_test_cd.yml index a6f37c3..5b1147a 100644 --- a/.github/workflows/acceptance_test_cd.yml +++ b/.github/workflows/acceptance_test_cd.yml @@ -4,32 +4,33 @@ on: # Manual trigger workflow_dispatch: - # Run the test when the new PR to develop is created - # pull_request: - # branches: - # - develop - # paths: - # - 'backend/**' - # - 'frontend/**' - # - 'k8s/staging/**' - # - 'infrastructure/staging/**' - # - '.github/workflows/*staging*.yml' - - workflow_call: - inputs: - frontend_url: - required: true - type: string - product_service_url: - required: true - type: string + push: + branches: + - "feature/*staging*" + - "fix/*staging*" + paths: + - "playwright-python/**" + - ".github/workflows/*acceptance*.yml" + + # Run the test when the new PR to develop or main is created + pull_request: + branches: + - develop + - main + paths: + - 'backend/**' + - 'frontend/**' + - 'k8s/staging/**' + - 'infrastructure/staging/**' + - '.github/workflows/*staging*.yml' env: PYTHON_VERSION: "3.10" + FRONTEND_URL: http://localhost:3000 + USERS_SERVICE_URL: http://localhost:5000 + NOTES_SERVICE_URL: http://localhost:5001 jobs: - # Test Individual Services (Already triggered on feature_test workflows) - # Acceptance Tests (End-to-End) acceptance-tests: name: Acceptance Tests - End-to-end user flow @@ -52,23 +53,25 @@ jobs: - name: Wait for services to be ready run: | echo "Waiting for services to start..." - timeout 60 bash -c 'until curl -s http://localhost:5000/health > /dev/null; do sleep 2; done' - timeout 60 bash -c 'until curl -s http://localhost:5001/health > /dev/null; do sleep 2; done' - timeout 60 bash -c 'until curl -s http://localhost:3000 > /dev/null; do sleep 2; done' + timeout 60 bash -c 'until curl -s ${{ env.USERS_SERVICE_URL }}/health > /dev/null; do sleep 2; done' + timeout 60 bash -c 'until curl -s ${{ env.NOTES_SERVICE_URL }}/health > /dev/null; do sleep 2; done' + timeout 60 bash -c 'until curl -s ${{ env.FRONTEND_URL }} > /dev/null; do sleep 2; done' echo "Services are ready!" - name: Install Playwright run: | - cd ./playwright-python echo "Installing Playwright..." pip install pytest-playwright playwright install - pip install -r requirements.txt + pip install -r ./playwright-python/requirements.txt - name: Run acceptance tests + env: + FRONTEND_URL: ${{ env.FRONTEND_URL }} + USERS_SERVICE_URL: ${{ env.USERS_SERVICE_URL }} + NOTES_SERVICE_URL: ${{ env.NOTES_SERVICE_URL }} run: | echo "Runing acceptance tests with Playwright..." - cd ./playwright-python pytest ./playwright-python/tests/test_acceptance.py -v - name: Stop services diff --git a/.github/workflows/cd-staging-deploy.yml b/.github/workflows/cd-staging-deploy.yml index b2ebde6..b79d413 100644 --- a/.github/workflows/cd-staging-deploy.yml +++ b/.github/workflows/cd-staging-deploy.yml @@ -5,10 +5,11 @@ on: workflow_dispatch: - # Run the workflow when the new PR to develop is approved and merged + # Run the workflow when the new PR to develop or main is approved and merged push: branches: - develop + - main paths: - "backend/**" - "frontend/**" @@ -136,27 +137,27 @@ jobs: run: | echo "Setting up infrastructure with OpenTofu" - # - name: Setup OpenTofu - # uses: opentofu/setup-opentofu@v1 - # with: - # tofu_version: '1.6.0' + - name: Setup OpenTofu + uses: opentofu/setup-opentofu@v1 + with: + tofu_version: '1.6.0' - # - name: Log in to Azure - # uses: azure/login@v1 - # with: - # creds: {{ secrets.AZURE_CREDENTIALS }} + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} - # - name: OpenTofu Init - # run: tofu init + - name: OpenTofu Init + run: tofu init - # - name: OpenTofu Plan - # run: | - # tofu plan \ - # -var="git_sha={{ github.sha }}" \ - # -out=staging.tfplan + - name: OpenTofu Plan + run: | + tofu plan \ + -var="git_sha={{ github.sha }}" \ + -out=staging.tfplan - # - name: OpenTofu Apply - # run: tofu apply -auto-approve staging.tfplan + - name: OpenTofu Apply + run: tofu apply -auto-approve staging.tfplan # Deploy services to staging AKS deploy-to-staging: @@ -241,6 +242,10 @@ jobs: echo "Updating image tag in deployment manifest..." sed -i "s|_IMAGE_NAME_WITH_TAG_|${{ env.SHARED_ACR_LOGIN_SERVER }}/${{ needs.build-images.outputs.FRONTEND_IMAGE }}|g" k8s/staging/frontend-deployment.yaml + # Student Subscription only allow 2 public IP address, so as a demo, I remove the notes service + kubectl delete -f k8s/staging/notes-service-deployment.yaml + + # Apply frontend deployment echo "Deploying frontend to AKS..." kubectl apply -f k8s/staging/frontend-deployment.yaml @@ -317,18 +322,20 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - - - name: OpenTofu Init - run: | - echo "Init OpenTofu..." - - - name: OpenTofu Destroy - run: | - echo "Destroying staging infrastructure..." - - name: Deployment summary - if: success() + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Delete staging environment run: | - echo "Staging deployment successful!" - echo "Smoke tests passed!" - echo "Staging environment cleaned up!" \ No newline at end of file + az group delete \ + --name ${{ env.RESOURCE_GROUP_STAGING }} \ + --yes \ + --no-wait + + - name: Logout from Azure + run: az logout + \ No newline at end of file diff --git a/.github/workflows/feature_test_notes_service.yml b/.github/workflows/feature_test_notes_service.yml index 8f86c9c..1723aa0 100644 --- a/.github/workflows/feature_test_notes_service.yml +++ b/.github/workflows/feature_test_notes_service.yml @@ -14,9 +14,10 @@ on: - ".github/workflows/*notes_service*.yml" # Re-run the test when the new PR to develop is created - # pull_request: - # branches: - # - "develop" + pull_request: + branches: + - develop + - main jobs: quality-checks: diff --git a/.github/workflows/feature_test_users_service.yml b/.github/workflows/feature_test_users_service.yml index 070d51a..3943e34 100644 --- a/.github/workflows/feature_test_users_service.yml +++ b/.github/workflows/feature_test_users_service.yml @@ -14,9 +14,10 @@ on: - ".github/workflows/*users_service*.yml" # Re-run the test when the new PR to develop is created - # pull_request: - # branches: - # - "develop" + pull_request: + branches: + - develop + - main jobs: quality-checks: diff --git a/README.md b/README.md index 806b4b8..1859633 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,42 @@ # Microsoft Azure - Project with DevOps Feature -This project is a part of HD task for SIT722 - Software Deployment and Operations, focusing on learning DevOps Cycle and pipelines \ No newline at end of file +This project is a part of HD task for SIT722 - Software Deployment and Operations, focusing on learning DevOps Cycle and pipelines + +## Setup + +To run this CI/CD project, we must initialize some existing resource (as in real production, these resource always available) +- Initialize shared infrastructure, refer to section [Shared Azure Resource](#shared-existing) +- Initialize production infrastructure, refer to section [Production Azure Resource](#production-existing) + + +## Azure Infrastructure and Resources +### Staging (Dynamic and Automation) +The staging resource can either: +- Ephemeral environment where it is created, deploy, test and removed after the staging complete +- Remains active as a 1-1 replica of production for manual testing and troubleshooting + +To reduce cost for learning purpose only, this project follows the first approach. The staging infrastructure information can be found at `infrastructure/staging`, resources include: +- Staging resource group +- Staging AKS, with related deployment information (Kubernetes manifest) can be found at `k8s/staging` + +### Shared (Existing) +Shared resource is the existing resource on Azure, contains the resources that shared between staging and production. It is not created during CI-CD pipeline, and it requires manual review and manage since it relates to production. + +Shared resource setup can be found at `infrastructure/shared`, resources include: +- Shared resource group +- Shared container registry + +Commands +```bash +cd infrastructure/shared +tofu init +tofu plan +tofu apply +``` + +### Production (Existing) +Production environment is where we deliver the product to the user, it must pass the manual approvals and should only be merge with develop branch, after all tests and check passed. + +The production infrastructure information can be found at `infrastructure/production`, resources include: +- Staging resource group +- Staging AKS, with related deployment information (Kubernetes manifest) can be found at `k8s/production` \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index d6d13e5..c3b15eb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,18 @@ version: '3.8' services: + postgres-notes: + image: postgres:15-alpine + container_name: postgres-notes + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=notes + ports: + - "5532:5432" + volumes: + - notes_db_data:/var/lib/postgresql/data + notes-service: build: ./backend/notes_service ports: @@ -9,7 +21,7 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=notes - - POSTGRES_HOST=postgres + - POSTGRES_HOST=postgres-notes - POSTGRES_PORT=5432 depends_on: - postgres-notes @@ -17,17 +29,17 @@ services: volumes: - ./backend/notes_service/app:/code/app - postgres-notes: + postgres-users: image: postgres:15-alpine - container_name: postgres-notes + container_name: postgres-users environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=notes + - POSTGRES_DB=users ports: - - "5432:5432" + - "5533:5432" # Different host port to avoid conflict volumes: - - notes_db_data:/var/lib/postgresql/data + - users_db_data:/var/lib/postgresql/data users-service: build: ./backend/users_service @@ -37,28 +49,17 @@ services: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=postgres - POSTGRES_DB=users - - POSTGRES_HOST=postgres - - POSTGRES_PORT=5434 + - POSTGRES_HOST=postgres-users + - POSTGRES_PORT=5432 depends_on: - postgres-users command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload volumes: - ./backend/users_service/app:/code/app - postgres-users: - image: postgres:15-alpine - container_name: postgres-users - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=users - ports: - - "5434:5432" # Different host port to avoid conflict - volumes: - - users_db_data:/var/lib/postgresql/data - frontend: build: ./frontend + container_name: frontend ports: - "3000:80" depends_on: diff --git a/infrastructure/production/.terraform.lock.hcl b/infrastructure/production/.terraform.lock.hcl new file mode 100644 index 0000000..b5bb53a --- /dev/null +++ b/infrastructure/production/.terraform.lock.hcl @@ -0,0 +1,37 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/azurerm" { + version = "3.117.1" + constraints = "~> 3.0" + hashes = [ + "h1:OXBPoQpiwe519GeBfkmbfsDXO020v706RmWTYSuuUCE=", + "zh:1fedd2521c8ced1fbebd5d70fda376d42393cac5cc25c043c390b44d630d9e37", + "zh:634c16442fd8aaed6c3bccd0069f4a01399b141d2a993d85997e6a03f9f867cf", + "zh:637ae3787f87506e5b673f44a1b0f33cf75d7fa9c5353df6a2584488fc3d4328", + "zh:7c7741f66ff5b05051db4b6c3d9bad68c829f9e920a7f1debdca0ab8e50836a3", + "zh:9b454fa0b6c821db2c6a71e591a467a5b4802129509710b56f01ae7106058d86", + "zh:bb820ff92b4a77e9d70999ae30758d408728c6e782b4e1c8c4b6d53b8c3c8ff9", + "zh:d38cd7d5f99398fb96672cb27943b96ea2b7008f26d379a69e1c6c2f25051869", + "zh:d56f5a132181ab14e6be332996753cc11c0d3b1cfdd1a1b44ef484c67e38cc91", + "zh:d8a1e7cf218f46e6d0bd878ff70f92db7e800a15f01e96189a24864d10cde33b", + "zh:f67cf6d14d859a1d2a1dc615941a1740a14cb3f4ee2a34da672ff6729d81fa81", + ] +} + +provider "registry.opentofu.org/hashicorp/kubernetes" { + version = "2.38.0" + constraints = "~> 2.23" + hashes = [ + "h1:HGkB9bCmUqMRcR5/bAUOSqPBsx6DAIEnbT1fZ8vzI78=", + "zh:1096b41c4e5b2ee6c1980916fb9a8579bc1892071396f7a9432be058aabf3cbc", + "zh:2959fde9ae3d1deb5e317df0d7b02ea4977951ee6b9c4beb083c148ca8f3681c", + "zh:5082f98fcb3389c73339365f7df39fc6912bf2bd1a46d5f97778f441a67fd337", + "zh:620fd5d0fbc2d7a24ac6b420a4922e6093020358162a62fa8cbd37b2bac1d22e", + "zh:7f47c2de179bba35d759147c53082cad6c3449d19b0ec0c5a4ca8db5b06393e1", + "zh:89c3aa2a87e29febf100fd21cead34f9a4c0e6e7ae5f383b5cef815c677eb52a", + "zh:96eecc9f94938a0bc35b8a63d2c4a5f972395e44206620db06760b730d0471fc", + "zh:e15567c1095f898af173c281b66bffdc4f3068afdd9f84bb5b5b5521d9f29584", + "zh:ecc6b912629734a9a41a7cf1c4c73fb13b4b510afc9e7b2e0011d290bcd6d77f", + ] +} diff --git a/infrastructure/production/container_registry.tf b/infrastructure/production/container_registry.tf new file mode 100644 index 0000000..9fdd0ca --- /dev/null +++ b/infrastructure/production/container_registry.tf @@ -0,0 +1,7 @@ +# infrastructure/production/container_registry.tf + +# Reference the shared ACR from the shared resource group +data "azurerm_container_registry" "shared_acr" { + name = "${var.prefix}acr" + resource_group_name = "${var.prefix}-shared-rg" +} diff --git a/infrastructure/production/kubernetes_cluster.tf b/infrastructure/production/kubernetes_cluster.tf new file mode 100644 index 0000000..5f17b67 --- /dev/null +++ b/infrastructure/production/kubernetes_cluster.tf @@ -0,0 +1,59 @@ +# infrastructure/production/kubernetes_cluster.tf + +resource "azurerm_kubernetes_cluster" "production_aks" { + name = "${var.prefix}-${var.environment}-aks" + location = var.location + resource_group_name = azurerm_resource_group.production_rg.name + dns_prefix = "${var.prefix}-${var.environment}" + kubernetes_version = var.kubernetes_version + + default_node_pool { + name = "default" + node_count = var.node_count + vm_size = var.node_vm_size + + # Enable auto-scaling for cost optimization (optional for cost optimization) + # enable_auto_scaling = true + # min_count = 1 + # max_count = 3 + } + + # Use a system‐assigned managed identity + identity { + type = "SystemAssigned" + } + + tags = { + Environment = var.environment + ManagedBy = "Terraform" + GitSHA = var.git_sha + } + + # Uncomment if enabling auto-scaling above + # lifecycle { + # ignore_changes = [ + # default_node_pool[0].node_count + # ] + # } +} + +# Grant AKS permission to pull images from ACR +resource "azurerm_role_assignment" "aks_acr_pull" { + principal_id = azurerm_kubernetes_cluster.production_aks.kubelet_identity[0].object_id + role_definition_name = "AcrPull" + scope = data.azurerm_container_registry.shared_acr.id + skip_service_principal_aad_check = true +} + +# Create production namespace +resource "kubernetes_namespace" "production" { + metadata { + name = var.environment + labels = { + environment = var.environment + managed-by = "terraform" + } + } + + depends_on = [azurerm_kubernetes_cluster.production_aks] +} \ No newline at end of file diff --git a/infrastructure/production/outputs.tf b/infrastructure/production/outputs.tf new file mode 100644 index 0000000..c8ee3a5 --- /dev/null +++ b/infrastructure/production/outputs.tf @@ -0,0 +1,27 @@ +# infrastructure/production/outputs.tf + +output "resource_group_name" { + description = "Resource group name" + value = azurerm_resource_group.production_rg.name +} + +output "aks_cluster_name" { + description = "AKS cluster name" + value = azurerm_kubernetes_cluster.production_aks.name +} + +output "aks_kube_config" { + description = "AKS kubeconfig" + value = azurerm_kubernetes_cluster.production_aks.kube_config_raw + sensitive = true +} + +output "acr_login_server" { + description = "ACR login server" + value = data.azurerm_container_registry.shared_acr.login_server +} + +output "git_sha" { + description = "Git commit SHA" + value = var.git_sha +} diff --git a/infrastructure/production/provider.tf b/infrastructure/production/provider.tf new file mode 100644 index 0000000..fba66f3 --- /dev/null +++ b/infrastructure/production/provider.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~>3.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.23" + } + } + required_version = ">= 1.1.0" +} + +provider "azurerm" { + # Protect production + features { + resource_group { + prevent_deletion_if_contains_resources = true + } + } +} + +# Configure Kubernetes provider for production AKS +provider "kubernetes" { + host = azurerm_kubernetes_cluster.production_aks.kube_config[0].host + client_certificate = base64decode(azurerm_kubernetes_cluster.production_aks.kube_config[0].client_certificate) + client_key = base64decode(azurerm_kubernetes_cluster.production_aks.kube_config[0].client_key) + cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.production_aks.kube_config[0].cluster_ca_certificate) +} \ No newline at end of file diff --git a/infrastructure/production/resource_group.tf b/infrastructure/production/resource_group.tf new file mode 100644 index 0000000..a9cf8b1 --- /dev/null +++ b/infrastructure/production/resource_group.tf @@ -0,0 +1,13 @@ +# infrastructure/production/resource_group.tf + +resource "azurerm_resource_group" "production_rg" { + name = "${var.prefix}-${var.environment}-rg" + location = var.location + + tags = { + Environment = var.environment + ManagedBy = "Terraform" + GitSHA = var.git_sha + Critical = "true" + } +} \ No newline at end of file diff --git a/infrastructure/production/variables.tf b/infrastructure/production/variables.tf new file mode 100644 index 0000000..f183a3e --- /dev/null +++ b/infrastructure/production/variables.tf @@ -0,0 +1,44 @@ +# Specify the environment +variable "environment" { + description = "Environment name" + type = string + default = "production" +} + +# Specify the prefix, ensuring all resources have unique naming +variable "prefix" { + description = "Prefix for all resource names" + type = string + default = "sit722alicestd" +} + +# Resource configuration variables +variable "location" { + description = "Azure region" + type = string + default = "australiaeast" +} + +variable "kubernetes_version" { + description = "Kubernetes version" + type = string + default = "1.31.7" +} + +variable "node_count" { + description = "Number of AKS nodes" + type = number + default = 1 +} + +variable "node_vm_size" { + description = "VM size for AKS nodes" + type = string + default = "Standard_D2s_v3" +} + +variable "git_sha" { + description = "Git commit SHA for tagging" + type = string + default = "manual" +} \ No newline at end of file diff --git a/k8s/production/configmaps.yaml b/k8s/production/configmaps.yaml new file mode 100644 index 0000000..a985950 --- /dev/null +++ b/k8s/production/configmaps.yaml @@ -0,0 +1,24 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: notes-config + namespace: staging +data: + # Database Configuration + NOTES_DB_HOST: notes-db-service + NOTES_DB_NAME: notes + + USERS_DB_HOST: users-db-service + USERS_DB_NAME: users + + # POSTGRES_DB: notesdb + # POSTGRES_HOST: postgres-service + POSTGRES_PORT: "5432" + + # Service URLs (internal cluster communication) + NOTES_SERVICE_URL: http://notes-service:5001 + USERS_SERVICE_URL: http://users-service:5000 + + # Application Configuration + ENVIRONMENT: staging + LOG_LEVEL: debug \ No newline at end of file diff --git a/k8s/production/frontend-deployment.yaml b/k8s/production/frontend-deployment.yaml new file mode 100644 index 0000000..7dd040f --- /dev/null +++ b/k8s/production/frontend-deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: staging + labels: + app: frontend +spec: + replicas: 1 + selector: + matchLabels: + app: frontend + template: + metadata: + labels: + app: frontend + spec: + containers: + - name: frontend-container + image: _IMAGE_NAME_WITH_TAG_ # Placeholder for sit722aliceacr.azurecr.io/users_service: + imagePullPolicy: Always + ports: + - containerPort: 80 # Nginx runs on port 80 inside the container + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend # Service name matches + namespace: staging + labels: + app: frontend +spec: + selector: + app: frontend + ports: + - protocol: TCP + port: 80 # The port the service listens on inside the cluster + targetPort: 80 # The port on the Pod (containerPort where Nginx runs) + type: LoadBalancer # Exposes the service on a port on each Node's IP diff --git a/k8s/production/notes-db-deployment.yaml b/k8s/production/notes-db-deployment.yaml new file mode 100644 index 0000000..cd66052 --- /dev/null +++ b/k8s/production/notes-db-deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notes-db-deployment + namespace: staging + labels: + app: notes-db +spec: + replicas: 1 + selector: + matchLabels: + app: notes-db + template: + metadata: + labels: + app: notes-db + spec: + containers: + - name: postgres + image: postgres:15-alpine # Use the same PosgreSQL image as in Docker Compose + ports: + - containerPort: 5432 # Default PosgreSQL port + env: + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config # ConfigMap name matches + key: NOTES_DB_NAME # Point to the database name + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: notes-secrets # Secret name matches + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: notes-secrets # Secret name matches + key: POSTGRES_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" +--- +apiVersion: v1 +kind: Service +metadata: + name: notes-db-service # Internal DNS name for the Order DB + namespace: staging + labels: + app: notes-db +spec: + selector: + app: notes-db # Selects pods with the label app + ports: + - protocol: TCP + port: 5432 # The port the service listens on (default PosgreSQL) + targetPort: 5432 # The port on the Pod (containerPort) + type: ClusterIP # Only accessible from within the cluster diff --git a/k8s/production/notes-service-deployment.yaml b/k8s/production/notes-service-deployment.yaml new file mode 100644 index 0000000..31ebb7b --- /dev/null +++ b/k8s/production/notes-service-deployment.yaml @@ -0,0 +1,82 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notes-service + namespace: staging + labels: + app: notes-service +spec: + replicas: 1 + selector: + matchLabels: + app: notes-service + template: + metadata: + labels: + app: notes-service + spec: + containers: + - name: notes-service-container + image: _IMAGE_NAME_WITH_TAG_ # Placeholder for sit722aliceacr.azurecr.io/users_service: + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: POSTGRES_HOST + valueFrom: + configMapKeyRef: + name: notes-config + key: NOTES_DB_HOST + - name: POSTGRES_PORT + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_PORT + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config + key: NOTES_DB_NAME + - name: ENVIRONMENT + valueFrom: + configMapKeyRef: + name: notes-config + key: ENVIRONMENT + - name: USERS_SERVICE_URL + valueFrom: + configMapKeyRef: + name: notes-config + key: USERS_SERVICE_URL + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: notes-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: notes-secrets + key: POSTGRES_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" +--- +apiVersion: v1 +kind: Service +metadata: + name: notes-service + namespace: staging + labels: + app: notes-service +spec: + selector: + app: notes-service + ports: + - protocol: TCP + port: 5001 + targetPort: 8000 + type: LoadBalancer diff --git a/k8s/production/secrets.yaml b/k8s/production/secrets.yaml new file mode 100644 index 0000000..1089588 --- /dev/null +++ b/k8s/production/secrets.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Secret +metadata: + name: notes-secrets + namespace: staging +type: Opaque # Indicates arbitrary user-defined data +data: + # PostgreSQL Credentials + POSTGRES_USER: "cG9zdGdyZXM=" # Base64 for 'postgres' + POSTGRES_PASSWORD: "cG9zdGdyZXM=" # Base64 for 'postgres' + + # Azure Storage Account Credentials for Product Service image uploads + # REPLACE WITH YOUR ACTUAL BASE64 ENCODED VALUES from your Azure Storage Account + # Example: echo -n 'myblobstorageaccount' | base64 + # AZURE_STORAGE_ACCOUNT_NAME: "" + # Example: echo -n 'your_storage_account_key_string' | base64 + # AZURE_STORAGE_ACCOUNT_KEY: "" diff --git a/k8s/production/users-db-deployment.yaml b/k8s/production/users-db-deployment.yaml new file mode 100644 index 0000000..288857a --- /dev/null +++ b/k8s/production/users-db-deployment.yaml @@ -0,0 +1,61 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: users-db-deployment + namespace: staging + labels: + app: users-db +spec: + replicas: 1 + selector: + matchLabels: + app: users-db + template: + metadata: + labels: + app: users-db + spec: + containers: + - name: postgres + image: postgres:15-alpine # Use the same PosgreSQL image as in Docker Compose + ports: + - containerPort: 5432 # Default PosgreSQL port + env: + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config # ConfigMap name matches + key: USERS_DB_NAME # Point to the database name + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: notes-secrets # Secret name matches + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: notes-secrets # Secret name matches + key: POSTGRES_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "200m" +--- +apiVersion: v1 +kind: Service +metadata: + name: users-db-service # Internal DNS name for the Order DB + namespace: staging + labels: + app: users-db +spec: + selector: + app: users-db # Selects pods with the label app + ports: + - protocol: TCP + port: 5432 # The port the service listens on (default PosgreSQL) + targetPort: 5432 # The port on the Pod (containerPort) + type: ClusterIP # Only accessible from within the cluster diff --git a/k8s/production/users-service-deployment.yaml b/k8s/production/users-service-deployment.yaml new file mode 100644 index 0000000..135586e --- /dev/null +++ b/k8s/production/users-service-deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: users-service # Deployment name matches + namespace: staging + labels: + app: users-service +spec: + replicas: 1 + selector: + matchLabels: + app: users-service + template: + metadata: + labels: + app: users-service + spec: + containers: + - name: users-service-container + image: _IMAGE_NAME_WITH_TAG_ # Placeholder for sit722aliceacr.azurecr.io/users_service: + imagePullPolicy: Always + ports: + - containerPort: 8000 + env: + - name: POSTGRES_HOST + valueFrom: + configMapKeyRef: + name: notes-config + key: USERS_DB_HOST + - name: POSTGRES_PORT + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_PORT + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config + key: USERS_DB_NAME + - name: ENVIRONMENT + valueFrom: + configMapKeyRef: + name: notes-config + key: ENVIRONMENT + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: notes-secrets + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: notes-secrets + key: POSTGRES_PASSWORD + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "250m" +--- +apiVersion: v1 +kind: Service +metadata: + name: users-service + namespace: staging + labels: + app: users-service +spec: + selector: + app: users-service + ports: + - protocol: TCP + port: 5000 + targetPort: 8000 + type: LoadBalancer diff --git a/k8s/staging/frontend-deployment.yaml b/k8s/staging/frontend-deployment.yaml index be36057..7dd040f 100644 --- a/k8s/staging/frontend-deployment.yaml +++ b/k8s/staging/frontend-deployment.yaml @@ -6,7 +6,7 @@ metadata: labels: app: frontend spec: - replicas: 2 # high availability, load distribution, and rolling update capabilities + replicas: 1 selector: matchLabels: app: frontend diff --git a/playwright-python/conftest.py b/playwright-python/conftest.py new file mode 100644 index 0000000..1aa2a9d --- /dev/null +++ b/playwright-python/conftest.py @@ -0,0 +1,18 @@ +"""Pytest configuration for Playwright tests.""" +import pytest +from playwright.sync_api import Page + + +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + """Configure browser context.""" + return { + **browser_context_args, + "viewport": {"width": 1280, "height": 720}, + } + + +@pytest.fixture(scope='session') +def base_url(): + """Base URL for the application.""" + return "http://localhost:80" \ No newline at end of file diff --git a/playwright-python/pytest.ini b/playwright-python/pytest.ini new file mode 100644 index 0000000..9154fe2 --- /dev/null +++ b/playwright-python/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +; addopts = -v --base-url=http://localhost:3000 +markers = + acceptance: acceptance tests + smoke: smoke tests \ No newline at end of file diff --git a/playwright-python/requirements.txt b/playwright-python/requirements.txt new file mode 100644 index 0000000..c44b431 --- /dev/null +++ b/playwright-python/requirements.txt @@ -0,0 +1,3 @@ +pytest +pytest-playwright +pytest-base-url \ No newline at end of file diff --git a/playwright-python/test_example.py b/playwright-python/test_example.py new file mode 100644 index 0000000..1c5d455 --- /dev/null +++ b/playwright-python/test_example.py @@ -0,0 +1,17 @@ +import re +from playwright.sync_api import Page, expect + +def test_has_title(page: Page): + page.goto("https://playwright.dev/") + + # Expect a title "to contain" a substring. + expect(page).to_have_title(re.compile("Playwright")) + +def test_get_started_link(page: Page): + page.goto("https://playwright.dev/") + + # Click the get started link. + page.get_by_role("link", name="Get started").click() + + # Expects page to have a heading with the name of Installation. + expect(page.get_by_role("heading", name="Installation")).to_be_visible() \ No newline at end of file diff --git a/playwright-python/tests/test_acceptance.py b/playwright-python/tests/test_acceptance.py new file mode 100644 index 0000000..d426ee4 --- /dev/null +++ b/playwright-python/tests/test_acceptance.py @@ -0,0 +1,114 @@ +# Acceptance test +# This is an example test file only, for demonstration of successful running the acceptance test +# Real test involves more complex end-to-end user interaction with frontend UI +import pytest +import os +from playwright.sync_api import Page, expect + +FRONTEND_URL = os.getenv('FRONTEND_URL', 'http://localhost:3000') +USERS_SERVICE_URL = os.getenv('USERS_SERVICE_URL', 'http://localhost:5000') +NOTES_SERVICE_URL = os.getenv('NOTES_SERVICE_URL', 'http://localhost:5001') + +# Fixture should be outside the class +@pytest.fixture(scope="session") +def browser_context_args(browser_context_args): + """Configure browser context""" + return { + **browser_context_args, + "ignore_https_errors": True, + } + +@pytest.mark.smoke +class TestEndToEndUserFlow: + """Acceptance testing to verify correct end-to-end user flow.""" + + def test_frontend_loads(self, page: Page): + """Test that frontend page loads successfully""" + # Navigate to frontend + print(FRONTEND_URL) + page.goto(FRONTEND_URL) + + # # Wait for page to load + page.wait_for_load_state('networkidle') + + # Check page content + expect(page.locator('text=Notes Application')).to_be_visible(timeout=5000) + + def test_add_user_workflow(self, page: Page): + """Test complete add note workflow""" + # Navigate to frontend + page.goto(FRONTEND_URL) + + # Wait for page to load + page.wait_for_load_state('networkidle') + + # Fill note form (adjust selectors to match your actual form) + page.fill('input[id="user-username"]', 'User') + page.fill('input[id="user-email"]', 'anotheruser@gmail.com') + + # Submit form + page.click('button:has-text("Register User")') + + # Wait for response + page.wait_for_timeout(1000) + + # Verify note appears in list (adjust selector based on your HTML) + expect(page.locator('text=anotheruser@gmail.com')).to_be_visible(timeout=5000) + + def test_add_note_workflow(self, page: Page): + """Test complete add note workflow""" + # Navigate to frontend + page.goto(FRONTEND_URL) + + # Wait for page to load + page.wait_for_load_state('networkidle') + + # Fill note form (adjust selectors to match your actual form) + page.fill('input[id="note-user-id"]', '1') + page.fill('input[id="note-title"]', 'Test Note') + page.fill('textarea[id="note-content"]', 'Test note content for acceptance testing') + + # Submit form + page.click('button:has-text("Create Note")') + # Wait for response + page.wait_for_timeout(1000) + + page.fill('input[id="filter-user-id"]', '1') + page.click('button[id="filter-btn"]') + # Wait for response + page.wait_for_timeout(1000) + + # Verify note appears in list (adjust selector based on your HTML) + expect(page.locator('h3:has-text("Test Note")')).to_be_visible(timeout=5000) + + def test_notes_api_health_check(self, page: Page): + """Test Notes API endpoint is accessible""" + response = page.request.get(f"{NOTES_SERVICE_URL}/") + assert response.status == 200 + + data = response.json() + assert 'message' in data or 'status' in data + + def test_users_api_health_check(self, page: Page): + """Test Users API endpoint is accessible""" + response = page.request.get(f"{USERS_SERVICE_URL}/") + assert response.status == 200 + + data = response.json() + assert 'message' in data or 'status' in data + + def test_notes_service_health_endpoint(self, page: Page): + """Test Notes service health endpoint""" + response = page.request.get(f"{NOTES_SERVICE_URL}/health") + assert response.status == 200 + + data = response.json() + assert data.get('status') == 'ok' + + def test_users_service_health_endpoint(self, page: Page): + """Test Users service health endpoint""" + response = page.request.get(f"{USERS_SERVICE_URL}/health") + assert response.status == 200 + + data = response.json() + assert data.get('status') == 'ok' \ No newline at end of file diff --git a/playwright-python/tests/test_service_availability.py b/playwright-python/tests/test_service_availability.py new file mode 100644 index 0000000..b6ef0ba --- /dev/null +++ b/playwright-python/tests/test_service_availability.py @@ -0,0 +1,39 @@ +"""Service availability smoke tests.""" +import pytest +from playwright.sync_api import Page, expect + + +@pytest.mark.smoke +class TestServiceAvailability: + """Quick smoke tests to verify all services are running.""" + + def test_frontend_loads(self, page: Page, base_url: str): + """Test frontend is accessible.""" + page.goto(base_url) + expect(page).to_have_title("Notes Application") + expect(page.locator("h1")).to_contain_text("Notes Application") + + def test_users_service_accessible(self, page: Page, base_url: str): + """Test Users Service is responding.""" + page.goto(base_url) + + # Check that user list loads (not showing error) + user_list = page.locator("#user-list") + expect(user_list).not_to_contain_text("An error occurred", timeout=10000) + + def test_notes_service_accessible(self, page: Page, base_url: str): + """Test Notes Service is responding.""" + page.goto(base_url) + + # Check that note list loads (not showing error) + note_list = page.locator("#note-list") + expect(note_list).not_to_contain_text("An error occurred", timeout=10000) + + def test_all_sections_visible(self, page: Page, base_url: str): + """Test all major sections are rendered.""" + page.goto(base_url) + + expect(page.locator("h2:has-text('User Management')")).to_be_visible() + expect(page.locator("h2:has-text('Notes Management')")).to_be_visible() + expect(page.locator("#user-form")).to_be_visible() + expect(page.locator("#note-form")).to_be_visible() \ No newline at end of file