diff --git a/.github/workflows/00-infra.yml b/.github/workflows/00-infra.yml
new file mode 100644
index 00000000..3afe1696
--- /dev/null
+++ b/.github/workflows/00-infra.yml
@@ -0,0 +1,50 @@
+name: 00 - Infrastructure (Terraform)
+
+# Runs Terraform validation and planning in CI whenever the Terraform code
+# changes, and can be triggered manually. This is the "Terraform integrated into
+# the pipeline" evidence. `apply` is performed locally with elevated credentials
+# (least privilege: the CI service principal is Contributor only).
+
+on:
+ push:
+ branches: [ main ]
+ paths:
+ - "terraform/**"
+ - ".github/workflows/00-infra.yml"
+ pull_request:
+ branches: [ main ]
+ paths:
+ - "terraform/**"
+ workflow_dispatch:
+
+jobs:
+ terraform:
+ name: Terraform validate & plan
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: terraform
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Setup Terraform
+ uses: hashicorp/setup-terraform@v3
+
+ - name: Terraform init (no backend)
+ run: terraform init -backend=false
+
+ - name: Terraform format check
+ run: terraform fmt -check -recursive
+ continue-on-error: true
+
+ - name: Terraform validate
+ run: terraform validate
+
+ - name: Terraform plan
+ run: terraform plan -input=false
\ No newline at end of file
diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml
index 8206db43..2144c1d2 100644
--- a/.github/workflows/01-ci.yml
+++ b/.github/workflows/01-ci.yml
@@ -5,7 +5,7 @@ on:
push:
branches:
- main
-
+
# Manual trigger for the workflow
workflow_dispatch:
@@ -103,7 +103,7 @@ jobs:
# =========================================================
- # Build and Push Docker Images
+ # Build, Scan and Push Docker Images
# =========================================================
build-and-push:
name: Build and Push ${{ matrix.image }}
@@ -160,7 +160,31 @@ jobs:
-t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \
./${{ matrix.service }}
+ # ---------------------------------------------------------------
+ # SECURITY SCANNING (Docker Scout) — runs AFTER build, BEFORE push
+ # so a vulnerable image is analysed before it enters the registry.
+ # Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN repo secrets.
+ # ---------------------------------------------------------------
+ - name: Scan image with Docker Scout
+ uses: docker/scout-action@v1
+ continue-on-error: true # report-only: never blocks the pipeline
+ with:
+ command: cves,recommendations
+ image: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }}
+ only-severities: critical,high
+ sarif-file: scout-${{ matrix.image }}.sarif
+ dockerhub-user: ${{ secrets.DOCKERHUB_USERNAME }}
+ dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }}
+ # exit-code: true # uncomment to FAIL the build on critical/high CVEs
+
+ - name: Upload Docker Scout SARIF (optional)
+ if: always()
+ uses: github/codeql-action/upload-sarif@v3
+ continue-on-error: true
+ with:
+ sarif_file: scout-${{ matrix.image }}.sarif
+
- name: Push Docker image with commit SHA
run: |
docker push \
- ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }}
+ ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }}
\ No newline at end of file
diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml
index 969d646b..84a6c9d6 100644
--- a/.github/workflows/04-deploy-production.yml
+++ b/.github/workflows/04-deploy-production.yml
@@ -1,6 +1,16 @@
name: 04 - Deploy to Production
on:
+ # Continuous Deployment: run automatically after staging tests complete
+ workflow_run:
+ workflows:
+ - "03 - Test Staging"
+ types:
+ - completed
+ branches:
+ - main
+
+ # Manual trigger kept for redeploying or rolling back a specific version
workflow_dispatch:
inputs:
image_tag:
@@ -8,15 +18,30 @@ on:
required: true
type: string
+# Prevent two production deployments from running at the same time
+concurrency:
+ group: production
+ cancel-in-progress: false
+
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
+ # Only deploy if the staging tests passed, or if run manually
+ if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
+
environment:
name: production
+ # Use the SHA tested in staging, or the manual input if run by hand
+ env:
+ IMAGE_TAG: ${{ inputs.image_tag || github.event.workflow_run.head_sha }}
+
steps:
+ - name: Show image tag being deployed
+ run: echo "Deploying image tag ${{ env.IMAGE_TAG }} to production"
+
- name: Checkout repository
uses: actions/checkout@v4
@@ -69,37 +94,37 @@ jobs:
- name: Update frontend image
run: |
kubectl set image deployment/frontend \
- frontend=${{ vars.ACR_LOGIN_SERVER }}/koalatech-frontend:${{ inputs.image_tag }} \
+ frontend=${{ vars.ACR_LOGIN_SERVER }}/koalatech-frontend:${{ env.IMAGE_TAG }} \
-n production
- name: Update user-service image
run: |
kubectl set image deployment/user-service \
- user-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ inputs.image_tag }} \
+ user-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ env.IMAGE_TAG }} \
-n production
- name: Update student-service image
run: |
kubectl set image deployment/student-service \
- student-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-student-service:${{ inputs.image_tag }} \
+ student-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-student-service:${{ env.IMAGE_TAG }} \
-n production
- name: Update lecturer-service image
run: |
kubectl set image deployment/lecturer-service \
- lecturer-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-lecturer-service:${{ inputs.image_tag }} \
+ lecturer-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-lecturer-service:${{ env.IMAGE_TAG }} \
-n production
- name: Update course-service image
run: |
kubectl set image deployment/course-service \
- course-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-course-service:${{ inputs.image_tag }} \
+ course-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-course-service:${{ env.IMAGE_TAG }} \
-n production
- name: Update enrollment-service image
run: |
kubectl set image deployment/enrollment-service \
- enrollment-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-enrollment-service:${{ inputs.image_tag }} \
+ enrollment-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-enrollment-service:${{ env.IMAGE_TAG }} \
-n production
- name: Wait for frontend rollout
@@ -142,4 +167,46 @@ jobs:
run: |
kubectl get pods -n production
kubectl get services -n production
- kubectl get pvc -n production
\ No newline at end of file
+ kubectl get pvc -n production
+
+ # =========================================================
+ # MONITORING & OBSERVABILITY (Prometheus + Grafana)
+ # Runs after a successful production deployment.
+ # =========================================================
+ monitoring:
+ name: Deploy and validate monitoring
+ runs-on: ubuntu-latest
+ needs: deploy-production
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Login to Azure
+ uses: azure/login@v2
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ - name: Get AKS credentials
+ run: |
+ az aks get-credentials \
+ --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \
+ --name ${{ vars.AKS_CLUSTER_NAME }} \
+ --overwrite-existing
+
+ - name: Install / upgrade kube-prometheus-stack
+ run: |
+ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
+ helm repo update
+ helm upgrade --install monitoring prometheus-community/kube-prometheus-stack \
+ --namespace monitoring --create-namespace \
+ -f monitoring/values-monitoring.yaml \
+ --wait --timeout 10m
+
+ - name: Apply application ServiceMonitor
+ run: kubectl apply -f monitoring/servicemonitor-user-service.yaml
+
+ - name: Validate monitoring is running
+ run: |
+ kubectl get pods -n monitoring
+ kubectl get servicemonitors -A
\ No newline at end of file
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index 1132741c..1ebf13a5 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -103,6 +103,14 @@ const Login = () => {
Sign in to continue
+
+ Release 2.0 – deployed automatically via Continuous Deployment
+
+
{error && (
= 3 &&
+ length(var.storage_account_name) <= 24 &&
+ can(regex("^[a-z0-9]+$", var.storage_account_name))
+ )
+
+ error_message = "The storage account name must contain 3–24 lowercase letters and numbers."
+ }
+}
+
+variable "aks_cluster_name" {
+ description = "Name of the Azure Kubernetes Service cluster"
+ type = string
+}
+
+variable "aks_dns_prefix" {
+ description = "DNS prefix used by the AKS cluster"
+ type = string
+}
+
+variable "aks_node_count" {
+ description = "Number of nodes in the default AKS node pool"
+ type = number
+
+ # Week 08 requires 3 nodes: staging and production both run
+ # persistent PostgreSQL workloads on the same cluster.
+ default = 3
+
+ validation {
+ condition = var.aks_node_count >= 1
+ error_message = "The AKS node count must be at least 1."
+ }
+}
+
+variable "aks_node_vm_size" {
+ description = "Virtual machine size used by the AKS nodes"
+ type = string
+ default = "Standard_D2s_v3"
+}
+
+variable "environment" {
+ description = "Environment name applied to resource tags"
+ type = string
+ default = "development"
+}
+
+variable "kubernetes_version" {
+ description = "AKS Kubernetes version. Leave null to let Azure choose the default for the region. Check available versions with: az aks get-versions --location australiaeast --output table"
+ type = string
+ default = null
+}
+
+variable "tags" {
+ description = "Tags applied to Azure resources"
+ type = map(string)
+
+ default = {
+ Project = "KoalaTech Course Platform"
+ ManagedBy = "Terraform"
+ Practical = "Week08"
+ }
+}
+variable "subscription_id" {
+ description = "Azure subscription ID"
+ type = string
+}
\ No newline at end of file
diff --git a/terraform/versions.tf b/terraform/versions.tf
new file mode 100644
index 00000000..85cb5a9a
--- /dev/null
+++ b/terraform/versions.tf
@@ -0,0 +1,15 @@
+terraform {
+ required_version = ">= 1.5.0"
+
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 4.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+ subscription_id = var.subscription_id
+}
\ No newline at end of file