diff --git a/.github/scripts/get_backend_ip.sh b/.github/scripts/get_backend_ip.sh new file mode 100644 index 0000000..638e4e1 --- /dev/null +++ b/.github/scripts/get_backend_ip.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +# Exit immediately if any command fails +set -e + +echo "Waiting for LoadBalancer IPs to be assigned (up to 5 minutes)..." +NOTES_IP="" +USERS_IP="" + +NOTES_PORT="" +USERS_PORT="" + +for i in $(seq 1 60); do + echo "Attempt $i/60 to get IPs..." + NOTES_IP=$(kubectl get service notes-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + NOTES_PORT=$(kubectl get service notes-service -o jsonpath='{.spec.ports[0].port}') + + USERS_IP=$(kubectl get service users-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + USERS_PORT=$(kubectl get service users-service -o jsonpath='{.spec.ports[0].port}') + + if [[ -n "$NOTES_IP" && -n "$NOTES_PORT" && -n "$USERS_IP" && -n "$USERS_PORT" ]]; then + echo "All backend LoadBalancer IPs assigned!" + echo "NOTE Service IP: $NOTES_IP:$NOTES_PORT" + echo "USER Service IP: $USERS_IP:$USERS_PORT" + break + fi + sleep 5 # Wait 5 seconds before next attempt +done + +if [[ -z "$NOTES_IP" || -z "$NOTES_PORT" || -z "$USERS_IP" || -z "$USERS_PORT" ]]; then + echo "Error: One or more LoadBalancer IPs not assigned after timeout." + exit 1 # Fail the job if IPs are not obtained +fi + +# These are environment variables for subsequent steps in the *same job* +# And used to set the job outputs +echo "NOTES_IP=$NOTES_IP" >> $GITHUB_ENV +echo "NOTES_PORT=$NOTES_PORT" >> $GITHUB_ENV +echo "USERS_IP=$USERS_IP" >> $GITHUB_ENV +echo "USERS_PORT=$USERS_PORT" >> $GITHUB_ENV \ No newline at end of file diff --git a/.github/scripts/smoke_tests.sh b/.github/scripts/smoke_tests.sh new file mode 100644 index 0000000..3e645dd --- /dev/null +++ b/.github/scripts/smoke_tests.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +set -e + +NOTES_IP=$NOTES_SERVICE_IP +NOTES_PORT=$NOTES_SERVICE_PORT + +USERS_IP=$USERS_SERVICE_IP +USERS_PORT=$USERS_SERVICE_PORT + +echo "Running smoke tests against staging environment" +echo "Notes Service: http://${NOTES_IP}:${NOTES_PORT}" +echo "Users Service: http://${USERS_IP}:${USERS_PORT}" + +echo "Done!" \ No newline at end of file diff --git a/.github/workflows/acceptance_test_cd.yml b/.github/workflows/acceptance_test_cd.yml new file mode 100644 index 0000000..1d333a8 --- /dev/null +++ b/.github/workflows/acceptance_test_cd.yml @@ -0,0 +1,71 @@ +name: CD - Staging Tests on PR + +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' + +env: + PYTHON_VERSION: "3.10" + +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 + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: notesdb + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Playwright + run: | + echo "Installing Playwright..." + + - name: Start Users Service + run: | + echo "Starting users service..." + + - name: Start Notes Service + run: | + echo "Starting notes service..." + + - name: Start Frontend + run: | + echo "Starting frontend service..." + + - name: Run acceptance tests + run: | + echo "Runing acceptance tests with Playwright..." \ No newline at end of file diff --git a/.github/workflows/cd-staging-deploy.yml b/.github/workflows/cd-staging-deploy.yml new file mode 100644 index 0000000..08f4420 --- /dev/null +++ b/.github/workflows/cd-staging-deploy.yml @@ -0,0 +1,271 @@ +name: Develop Branch CD - Deploy to Staging Environment + +on: + # Manual trigger + workflow_dispatch: + + # Run the workflow when the new PR to develop is approved and merged + push: + branches: + - develop + paths: + - 'backend/**' + - 'frontend/**' + - 'k8s/staging/**' + - 'infrastructure/staging/**' + +env: + SHARED_ACR_LOGIN_SERVER: ${{ secrets.SHARED_ACR_LOGIN_SERVER }} + + RESOURCE_GROUP_STAGING: sit722alice-staging-rg + AKS_CLUSTER_STAGING: sit722alice-staging-aks + AZURE_LOCATION: australiaeast + +jobs: + # Build images + build-images: + name: Build Docker images for all services + runs-on: ubuntu-latest + + outputs: + GIT_SHA: ${{ steps.vars.outputs.GIT_SHA }} + IMAGE_TAG: ${{ steps.vars.outputs.IMAGE_TAG }} + NOTES_SERVICE_IMAGE: ${{ steps.backend_images.outputs.notes_service_image }} + USERS_SERVICE_IMAGE: ${{ steps.backend_images.outputs.users_service_image }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Log in to ACR + run: | + az acr login --name ${{ env.SHARED_ACR_LOGIN_SERVER }} + + - name: Set variables (Short Git SHA and Image tag) + id: vars + run: | + echo "GIT_SHA=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + echo "IMAGE_TAG=staging-$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT + + - name: Build Backend Images (Notes Service) + id: backend_images + run: | + NOTES_SERVICE_IMAGE="notes_service:${{ steps.vars.outputs.IMAGE_TAG }}" + USERS_SERVICE_IMAGE="users_service:${{ steps.vars.outputs.IMAGE_TAG }}" + + docker build -t ${{ env.SHARED_ACR_LOGIN_SERVER }}/$NOTES_SERVICE_IMAGE ./backend/notes_service + docker build -t ${{ env.SHARED_ACR_LOGIN_SERVER }}/$USERS_SERVICE_IMAGE ./backend/users_service + + echo "notes_service=$NOTES_SERVICE_IMAGE" >> $GITHUB_OUTPUT + echo "users_service=$USERS_SERVICE_IMAGE" >> $GITHUB_OUTPUT + + # Image Vulnerability Scan with Trivy + security-scan: + name: Image Vulnerability scan with Trivy + runs-on: ubuntu-latest + needs: build-images + + # Matrix strategy defining the images to be scan + strategy: + matrix: + service: + - name: Notes Service + image_with_tag: ${{ needs.build-images.outputs.NOTES_SERVICE_IMAGE }} + - name: Users Service + image_with_tag: ${{ needs.build-images.outputs.USERS_SERVICE_IMAGE }} + + steps: + - name: Trivy security scan on ${{ matrix.service.name }} + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.SHARED_ACR_LOGIN_SERVER }}/${{ matrix.service.image_with_tag }} + format: 'table' + severity: 'CRITICAL,HIGH' + exit-code: '1' + + - name: Security check passed + run: | + echo "${{ matrix.service.name }} passed security scan" + echo "Safe to push to registry" + + # Push ONLY if security scan passes + push-images: + name: Push Images to shared ACR + runs-on: ubuntu-latest + needs: [build-images, security-scan] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Log in to ACR + run: | + az acr login --name ${{ env.SHARED_ACR_LOGIN_SERVER }} + + - name: Push All Images to ACR + run: | + docker push ${{ env.SHARED_ACR_LOGIN_SERVER }}/${{ needs.build-images.outputs.NOTES_SERVICE_IMAGE }} + docker push ${{ env.SHARED_ACR_LOGIN_SERVER }}/${{ needs.build-images.outputs.USERS_SERVICE_IMAGE }} + + # Provision staging infrastructure with OpenTofu + provision-infrastructure: + name: Provision staging infrastructure with OpenTofu + runs-on: ubuntu-latest + needs: [build-images, security-scan] + + defaults: + run: + working-directory: ./infrastructure/staging + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Provisioning Infrastructure + run: | + echo "Provisioning... placeholder during development..." + echo "Done." + # - 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: OpenTofu Init + # run: tofu init + + # - name: OpenTofu Plan + # run: | + # tofu plan \ + # -var="git_sha={{ github.sha }}" \ + # -out=staging.tfplan + + # - name: OpenTofu Apply + # run: tofu apply -auto-approve staging.tfplan + + # Deploy services to staging AKS + deploy-to-staging: + name: Deploy to staging environment + runs-on: ubuntu-latest + needs: [build-images, provision-infrastructure] + + outputs: + NOTES_SERVICE_IP: ${{ steps.get_backend_ips.outputs.notes_ip }} + NOTES_SERVICE_PORT: ${{ steps.get_backend_ips.outputs.notes_port }} + USERS_SERVICE_IP: ${{ steps.get_backend_ips.outputs.users_ip }} + USERS_SERVICE_PORT: ${{ steps.get_backend_ips.outputs.users_port }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Azure + uses: azure/login@v1 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + enable-AzPSSession: true + + - name: Set Kubernetes context (get AKS credentials) + run: | + az aks get-credentials \ + --resource-group ${{ env.RESOURCE_GROUP_STAGING }} \ + --name ${{ env.AKS_CLUSTER_STAGING }} \ + --overwrite-existing + + - name: Deploy Backend Infrastructure (Namespace, ConfigMaps, Secrets, Databases) + run: | + echo "Creating Namespace..." + kubectl apply -f k8s/staging/namespace.yaml + + echo "Deploying Configmaps & Secrets..." + kubectl apply -f k8s/staging/configmaps.yaml + kubectl apply -f k8s/staging/secrets.yaml + + echo "Deploying Databases..." + kubectl apply -f k8s/staging/postgres-deployment.yaml + kubectl wait --for=condition=ready pod -l app=postgres -n staging --timeout=300s + + - name: Deploy Backend Microservices + run: | + # Update image tag in deployment manifest, using the specific git SHA version + echo "Updating image tag in deployment manifest..." + sed -i "s|staging-latest|${{ needs.build-images.outputs.IMAGE_TAG }}|g" k8s/staging/users-service-deployment.yaml + sed -i "s|staging-latest|${{ needs.build-images.outputs.IMAGE_TAG }}|g" k8s/staging/notes-service-deployment.yaml + + echo "Deploying backend services to AKS..." + kubectl apply -f k8s/staging/users-service-deployment.yaml + kubectl wait --for=condition=ready pod -l app=users-service -n staging --timeout=300s + kubectl apply -f k8s/staging/notes-service-deployment.yaml + kubectl wait --for=condition=ready pod -l app=notes-service -n staging --timeout=300s + + - name: Wait for Backend LoadBalancer IPs + run: | + chmod +x .github/scripts/get_backend_ip.sh + ./.github/scripts/get_backend_ip.sh + + - name: Capture Backend IPs for Workflow Output + id: get_backend_ips + run: | + echo "notes_ip=${{ env.NOTES_IP }}" >> $GITHUB_OUTPUT + echo "notes_port=${{ env.NOTES_PORT }}" >> $GITHUB_OUTPUT + echo "users_ip=${{ env.USERS_IP }}" >> $GITHUB_OUTPUT + echo "users_port=${{ env.USERS_PORT }}" >> $GITHUB_OUTPUT + + # Run smoke tests + smoke-tests: + runs-on: ubuntu-latest + needs: deploy-to-staging + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run smoke tests + run: | + chmod +x ./scripts/smoke-tests.sh + ./scripts/smoke-tests.sh \ + NOTES_SERVICE_IP=${{ needs.deploy-to-staging.outputs.NOTES_SERVICE_IP }} \ + NOTES_SERVICE_PORT=${{ needs.deploy-to-staging.outputs.NOTES_SERVICE_PORT }} \ + USERS_SERVICE_IP=${{ needs.deploy-to-staging.outputs.USERS_SERVICE_IP }} \ + USERS_SERVICE_PORT=${{ needs.deploy-to-staging.outputs.USERS_SERVICE_PORT }} \ + + # Cleanup staging environment + cleanup-staging: + runs-on: ubuntu-latest + needs: [smoke-tests] + if: always() + + defaults: + run: + working-directory: ./infrastructure/staging + + steps: + - name: OpenTofu Init + run: | + echo "Init OpenTofu..." + + - name: OpenTofu Destroy + run: | + echo "Destroying staging infrastructure..." + + - name: Deployment summary + if: success() + run: | + echo "Staging deployment successful!" + echo "Smoke tests passed!" + echo "Staging environment cleaned up!" \ No newline at end of file diff --git a/.gitignore b/.gitignore index cfa76a7..0490e50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,50 @@ +# ----- Infrastructure files ------ # +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +# Optional: ignore graph output files generated by `terraform graph` +# *.dot + +# Optional: ignore plan files saved before destroying Terraform configuration +# Uncomment the line below if you want to ignore planout files. +# planout + +# ----- Project files ----- # # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/infrastructure/shared/.terraform.lock.hcl b/infrastructure/shared/.terraform.lock.hcl new file mode 100644 index 0000000..9a7d68c --- /dev/null +++ b/infrastructure/shared/.terraform.lock.hcl @@ -0,0 +1,20 @@ +# 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", + ] +} diff --git a/infrastructure/shared/container_registry.tf b/infrastructure/shared/container_registry.tf new file mode 100644 index 0000000..0e99431 --- /dev/null +++ b/infrastructure/shared/container_registry.tf @@ -0,0 +1,14 @@ +# infrastructure/shared/container_registry.tf + +resource "azurerm_container_registry" "acr" { + name = "${var.prefix}acr" + resource_group_name = azurerm_resource_group.shared_rg.name + location = var.location + sku = "Basic" + admin_enabled = true + + tags = { + Environment = "Shared" + ManagedBy = "Terraform" + } +} diff --git a/infrastructure/shared/outputs.tf b/infrastructure/shared/outputs.tf new file mode 100644 index 0000000..e546246 --- /dev/null +++ b/infrastructure/shared/outputs.tf @@ -0,0 +1,38 @@ +# infrastructure/shared/outputs.tf + +output "resource_group_name" { + description = "Shared resource group name" + value = azurerm_resource_group.shared_rg.name +} + +output "acr_name" { + description = "Azure Container Registry name" + value = azurerm_container_registry.acr.name +} + +output "acr_login_server" { + description = "ACR login server" + value = azurerm_container_registry.acr.login_server +} + +output "acr_admin_username" { + description = "ACR admin username" + value = azurerm_container_registry.acr.admin_username + sensitive = true +} + +output "acr_admin_password" { + description = "ACR admin password" + value = azurerm_container_registry.acr.admin_password + sensitive = true +} + +# output "tfstate_storage_account_name" { +# description = "Storage account name for Terraform state" +# value = azurerm_storage_account.tfstate.name +# } + +# output "tfstate_container_name" { +# description = "Container name for Terraform state" +# value = azurerm_storage_container.tfstate.name +# } \ No newline at end of file diff --git a/infrastructure/shared/provider.tf b/infrastructure/shared/provider.tf new file mode 100644 index 0000000..7f028c3 --- /dev/null +++ b/infrastructure/shared/provider.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~>3.0" + } + } + required_version = ">= 1.1.0" +} + +provider "azurerm" { + features {} +} \ No newline at end of file diff --git a/infrastructure/shared/resource_group.tf b/infrastructure/shared/resource_group.tf new file mode 100644 index 0000000..ccb2011 --- /dev/null +++ b/infrastructure/shared/resource_group.tf @@ -0,0 +1,12 @@ +# infrastructure/shared/resource_group.tf + +resource "azurerm_resource_group" "shared_rg" { + name = "${var.prefix}-shared-rg" + location = var.location + + tags = { + Environment = "Shared" + ManagedBy = "Terraform" + Purpose = "Shared resources across all environments" + } +} \ No newline at end of file diff --git a/infrastructure/shared/variables.tf b/infrastructure/shared/variables.tf new file mode 100644 index 0000000..8e38b89 --- /dev/null +++ b/infrastructure/shared/variables.tf @@ -0,0 +1,13 @@ +# infrastructure/shared/variables.tf + +variable "prefix" { + description = "Prefix for all resource names" + type = string + default = "sit722alice" +} + +variable "location" { + description = "Azure region" + type = string + default = "australiaeast" +} diff --git a/infrastructure/staging/.terraform.lock.hcl b/infrastructure/staging/.terraform.lock.hcl new file mode 100644 index 0000000..b5bb53a --- /dev/null +++ b/infrastructure/staging/.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/staging/container_registry.tf b/infrastructure/staging/container_registry.tf new file mode 100644 index 0000000..a5de0dc --- /dev/null +++ b/infrastructure/staging/container_registry.tf @@ -0,0 +1,7 @@ +# infrastructure/staging/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/staging/kubernetes_cluster.tf b/infrastructure/staging/kubernetes_cluster.tf new file mode 100644 index 0000000..73b0021 --- /dev/null +++ b/infrastructure/staging/kubernetes_cluster.tf @@ -0,0 +1,59 @@ +# infrastructure/staging/kubernetes_cluster.tf + +resource "azurerm_kubernetes_cluster" "staging_aks" { + name = "${var.prefix}-${var.environment}-aks" + location = var.location + resource_group_name = azurerm_resource_group.staging_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 your ACR +resource "azurerm_role_assignment" "aks_acr_pull" { + principal_id = azurerm_kubernetes_cluster.staging_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 staging namespace +resource "kubernetes_namespace" "staging" { + metadata { + name = var.environment + labels = { + environment = var.environment + managed-by = "terraform" + } + } + + depends_on = [azurerm_kubernetes_cluster.staging_aks] +} \ No newline at end of file diff --git a/infrastructure/staging/outputs.tf b/infrastructure/staging/outputs.tf new file mode 100644 index 0000000..96480c8 --- /dev/null +++ b/infrastructure/staging/outputs.tf @@ -0,0 +1,27 @@ +# infrastructure/staging/outputs.tf + +output "resource_group_name" { + description = "Resource group name" + value = azurerm_resource_group.staging_rg.name +} + +output "aks_cluster_name" { + description = "AKS cluster name" + value = azurerm_kubernetes_cluster.staging_aks.name +} + +output "aks_kube_config" { + description = "AKS kubeconfig" + value = azurerm_kubernetes_cluster.staging_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 +} \ No newline at end of file diff --git a/infrastructure/staging/provider.tf b/infrastructure/staging/provider.tf new file mode 100644 index 0000000..4298aa4 --- /dev/null +++ b/infrastructure/staging/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" { + # Allow resource delete on staging environment + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} + +# Configure Kubernetes provider to manage namespace +provider "kubernetes" { + host = azurerm_kubernetes_cluster.staging_aks.kube_config[0].host + client_certificate = base64decode(azurerm_kubernetes_cluster.staging_aks.kube_config[0].client_certificate) + client_key = base64decode(azurerm_kubernetes_cluster.staging_aks.kube_config[0].client_key) + cluster_ca_certificate = base64decode(azurerm_kubernetes_cluster.staging_aks.kube_config[0].cluster_ca_certificate) +} \ No newline at end of file diff --git a/infrastructure/staging/resource_group.tf b/infrastructure/staging/resource_group.tf new file mode 100644 index 0000000..54da372 --- /dev/null +++ b/infrastructure/staging/resource_group.tf @@ -0,0 +1,13 @@ +# infrastructure/staging/resource_group.tf + +resource "azurerm_resource_group" "staging_rg" { + name = "${var.prefix}-${var.environment}-rg" + location = var.location + + tags = { + Environment = var.environment + ManagedBy = "Terraform" + GitSHA = var.git_sha + AutoDestroy = "true" + } +} \ No newline at end of file diff --git a/infrastructure/staging/variables.tf b/infrastructure/staging/variables.tf new file mode 100644 index 0000000..f21dea5 --- /dev/null +++ b/infrastructure/staging/variables.tf @@ -0,0 +1,44 @@ +# Specify the environment +variable "environment" { + description = "Environment name" + type = string + default = "staging" +} + +# Specify the prefix, ensuring all resources have unique naming +variable "prefix" { + description = "Prefix for all resource names" + type = string + default = "sit722alice" +} + +# 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/staging/configmaps.yaml b/k8s/staging/configmaps.yaml new file mode 100644 index 0000000..146d886 --- /dev/null +++ b/k8s/staging/configmaps.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: notes-config + namespace: staging +data: + # Database Configuration + 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/staging/namespace.yaml b/k8s/staging/namespace.yaml new file mode 100644 index 0000000..d5d94ac --- /dev/null +++ b/k8s/staging/namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: staging + labels: + environment: staging + managed-by: kubectl \ No newline at end of file diff --git a/k8s/staging/notes-service-deployment.yaml b/k8s/staging/notes-service-deployment.yaml new file mode 100644 index 0000000..1642157 --- /dev/null +++ b/k8s/staging/notes-service-deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notes-service-deployment + namespace: staging + labels: + app: notes-service +spec: + replicas: 1 + selector: + matchLabels: + app: notes-service + template: + metadata: + labels: + app: notes-service + spec:ners: + - name: notes-service-container + image: sit722aliceacr.azurecr.io/notes-service:staging-latest + imagePullPolicy: Always + ports: + - containerPort: 5001 + env: + - name: POSTGRES_HOST + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_HOST + - name: POSTGRES_PORT + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_PORT + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_DB + - 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 +--- +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: 5001 + type: LoadBalancer diff --git a/k8s/staging/postgres-deployment.yaml b/k8s/staging/postgres-deployment.yaml new file mode 100644 index 0000000..9005828 --- /dev/null +++ b/k8s/staging/postgres-deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pvc + namespace: staging +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-deployment + namespace: staging + labels: + app: postgres +spec: + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15-alpine # Use the same PostgreSQL image as in Docker Compose + ports: + - containerPort: 5432 # Default PostgreSQL port + env: + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config # ConfigMap name matches + key: POSTGRES_DB # 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 + volumeMounts: + - name: postgres-storage + mountPath: /var/lib/postgresql/data + subPath: postgres + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-service # Internal DNS name for the Order DB + namespace: staging + labels: + app: postgres +spec: + selector: + app: postgres # Selects pods with the label app + ports: + - protocol: TCP + port: 5432 # The port the service listens on (default PostgreSQL) + targetPort: 5432 # The port on the Pod (containerPort) + type: ClusterIP # Only accessible from within the cluster diff --git a/k8s/staging/secrets.yaml b/k8s/staging/secrets.yaml new file mode 100644 index 0000000..1089588 --- /dev/null +++ b/k8s/staging/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/staging/users-service-deployment.yaml b/k8s/staging/users-service-deployment.yaml new file mode 100644 index 0000000..c4954d2 --- /dev/null +++ b/k8s/staging/users-service-deployment.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: users-service-deployment # 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: sit722aliceacr.azurecr.io/users-service:staging-latest + imagePullPolicy: Always + ports: + - containerPort: 5000 + env: + - name: POSTGRES_HOST + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_HOST + - name: POSTGRES_PORT + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_PORT + - name: POSTGRES_DB + valueFrom: + configMapKeyRef: + name: notes-config + key: POSTGRES_DB + - 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: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" +--- +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: 5000 + type: LoadBalancer