diff --git a/.github/workflows/00-terraform.yml b/.github/workflows/00-terraform.yml
new file mode 100644
index 00000000..d04dc9ba
--- /dev/null
+++ b/.github/workflows/00-terraform.yml
@@ -0,0 +1,76 @@
+name: 00 - Terraform
+
+on:
+ pull_request:
+ paths:
+ - "terraform/**"
+ push:
+ branches:
+ - main
+ paths:
+ - "terraform/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+env:
+ TF_IN_AUTOMATION: "true"
+ TF_WORKING_DIR: terraform
+
+jobs:
+ terraform:
+ name: Terraform validate, plan and apply
+ runs-on: ubuntu-latest
+
+ defaults:
+ run:
+ working-directory: terraform
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Login to Azure
+ uses: azure/login@v3
+ with:
+ creds: ${{ secrets.AZURE_CREDENTIALS }}
+
+ # The azurerm provider reads ARM_* variables; azure/login does not
+ # export them, so pull them out of the same service principal JSON.
+ - name: Export ARM credentials for Terraform
+ env:
+ CREDS: ${{ secrets.AZURE_CREDENTIALS }}
+ run: |
+ for key in clientId clientSecret tenantId subscriptionId; do
+ echo "::add-mask::$(echo "$CREDS" | jq -r .$key)"
+ done
+ echo "ARM_CLIENT_ID=$(echo "$CREDS" | jq -r .clientId)" >> "$GITHUB_ENV"
+ echo "ARM_CLIENT_SECRET=$(echo "$CREDS" | jq -r .clientSecret)" >> "$GITHUB_ENV"
+ echo "ARM_TENANT_ID=$(echo "$CREDS" | jq -r .tenantId)" >> "$GITHUB_ENV"
+ echo "ARM_SUBSCRIPTION_ID=$(echo "$CREDS" | jq -r .subscriptionId)" >> "$GITHUB_ENV"
+
+ - name: Set up Terraform
+ uses: hashicorp/setup-terraform@v3
+ with:
+ terraform_version: "~1.9"
+
+ - name: Terraform format check
+ run: terraform fmt -check -diff
+
+ - name: Terraform init
+ run: |
+ terraform init \
+ -backend-config="resource_group_name=${{ vars.TFSTATE_RESOURCE_GROUP }}" \
+ -backend-config="storage_account_name=${{ vars.TFSTATE_STORAGE_ACCOUNT }}"
+
+ - name: Terraform validate
+ run: terraform validate
+
+ - name: Terraform plan
+ run: terraform plan -input=false -out=tfplan
+
+ # Only changes merged to main are applied. Pull requests stop at plan.
+ - name: Terraform apply
+ if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
+ run: terraform apply -input=false -auto-approve tfplan
diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml
index 8206db43..52a9faf9 100644
--- a/.github/workflows/01-ci.yml
+++ b/.github/workflows/01-ci.yml
@@ -160,6 +160,36 @@ jobs:
-t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \
./${{ matrix.service }}
+ - name: Build Docker image
+ run: |
+ docker build \
+ --platform linux/amd64 \
+ -t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \
+ ./${{ matrix.service }}
+
+ - name: Login to Docker Hub (for Docker Scout)
+ uses: docker/login-action@v3
+ with:
+ username: ${{ vars.DOCKER_HUB_USER }}
+ password: ${{ secrets.DOCKER_HUB_PAT }}
+
+ - name: Docker Scout scan
+ uses: docker/scout-action@v1
+ with:
+ command: quickview,cves,recommendations
+ image: local://${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }}
+ only-severities: critical,high
+ sarif-file: scout-${{ matrix.service }}.sarif
+ # Report-only until the first findings are fixed, then flip to true
+ exit-code: false
+
+ - name: Upload Scout report
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: scout-${{ matrix.service }}
+ path: scout-${{ matrix.service }}.sarif
+
- name: Push Docker image with commit SHA
run: |
docker push \
diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml
index 969d646b..5d1ac3de 100644
--- a/.github/workflows/04-deploy-production.yml
+++ b/.github/workflows/04-deploy-production.yml
@@ -1,24 +1,30 @@
name: 04 - Deploy to Production
on:
- workflow_dispatch:
- inputs:
- image_tag:
- description: "Tested image SHA to deploy"
- required: true
- type: string
+ workflow_run:
+ workflows:
+ - "03 - Test Staging"
+ types:
+ - completed
+ branches:
+ - main
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
+ if: >
+ ${{ github.event.workflow_run.conclusion == 'success' }}
+
environment:
name: production
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.workflow_run.head_sha }}
- name: Login to Azure
uses: azure/login@v3
@@ -69,37 +75,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:${{ github.event.workflow_run.head_sha }} \
-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:${{ github.event.workflow_run.head_sha }} \
-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:${{ github.event.workflow_run.head_sha }} \
-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:${{ github.event.workflow_run.head_sha }} \
-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:${{ github.event.workflow_run.head_sha }} \
-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:${{ github.event.workflow_run.head_sha }} \
-n production
- name: Wait for frontend rollout
diff --git a/.gitignore b/.gitignore
index 4299cd1a..d51bfe2b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -196,4 +196,10 @@ cython_debug/
.cursorignore
.cursorindexingignore
-node_modules/
\ No newline at end of file
+node_modules/
+
+# terraform
+terraform/.terraform/
+terraform/*.tfstate
+terraform/*.tfstate.*
+terraform/tfplan
\ No newline at end of file
diff --git a/frontend/index.html b/frontend/index.html
index 3176fb0b..da1126ad 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
KoalaTech University
+ KoalaTech University - Live
diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl
new file mode 100644
index 00000000..ec22119d
--- /dev/null
+++ b/terraform/.terraform.lock.hcl
@@ -0,0 +1,43 @@
+# This file is maintained automatically by "terraform init".
+# Manual edits may be lost in future updates.
+
+provider "registry.terraform.io/hashicorp/azurerm" {
+ version = "4.81.0"
+ constraints = "~> 4.0"
+ hashes = [
+ "h1:XhToZua4gtih1Kv8RdStcfND83G4Tmb6GZFT4jEUhDU=",
+ "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3",
+ "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae",
+ "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e",
+ "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151",
+ "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e",
+ "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea",
+ "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b",
+ "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b",
+ "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff",
+ "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6",
+ ]
+}
+
+provider "registry.terraform.io/hashicorp/local" {
+ version = "2.9.1"
+ constraints = "~> 2.0"
+ hashes = [
+ "h1:OZGJN0LSSat5QIxZPxDXtVr4XpHb2oG7cVKJhSqBFIE=",
+ "zh:25606c7a5e308144fb627f6e31611bb52ff72bb9ae2d27af39673ab1a6b3c1bf",
+ "zh:2568c4ef4dab31821f6f7040af0d1a2aa2b9455b8d9cc546598b791b8ada34cd",
+ "zh:25ac210f136042047975896e5e11fe6012425301c826d6b7ce22a49f96a1e0e8",
+ "zh:55d3a7bf01eced8e1f259b548020ef1221c5687e0fe6b5b30f81ad0b4121ff12",
+ "zh:6168d6934777b853c57815baa18a54101c604a6f1124ca47cd23d016c6f2f1e5",
+ "zh:63c0aa17c8373f761123376226debffd1f2259901ff259d4547c520def9a6787",
+ "zh:6d1f1572db6c85bd6d6b1b1deef247084e189816875f67e6bcc5d4736794e0a9",
+ "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
+ "zh:a8ea1c155bd5e0d695a28d3c0e2f542de8844e1791ea021f015a3f08e1c059e1",
+ "zh:ccd790f97269509ade0574ef63c955d3943cb1d812c59177bd85ce4c1d17ca0f",
+ "zh:dcfc17bb666e659a9daa9ff52fd8f6ec9ea519868726015e645955c372296332",
+ "zh:eb83abbfa7f52cba609b48cbb9a6a5cab07780a672b2020fc2ba190e699a2516",
+ "zh:eedffca70074ae153790fb23c4ace8d1637a60ceba4e3c42c8eaeda7c1f9d10f",
+ ]
+}
diff --git a/terraform/container_registry.tf b/terraform/container_registry.tf
new file mode 100644
index 00000000..1a5f350f
--- /dev/null
+++ b/terraform/container_registry.tf
@@ -0,0 +1,15 @@
+resource "azurerm_container_registry" "acr" {
+ name = var.acr_name
+ resource_group_name = azurerm_resource_group.rg.name
+ location = azurerm_resource_group.rg.location
+
+ sku = "Basic"
+ admin_enabled = true
+
+ tags = merge(
+ var.tags,
+ {
+ Environment = var.environment
+ }
+ )
+}
\ No newline at end of file
diff --git a/terraform/kubernetes_serivce.tf b/terraform/kubernetes_serivce.tf
new file mode 100644
index 00000000..ebfd7692
--- /dev/null
+++ b/terraform/kubernetes_serivce.tf
@@ -0,0 +1,34 @@
+resource "azurerm_kubernetes_cluster" "aks" {
+ name = var.aks_cluster_name
+ location = azurerm_resource_group.rg.location
+ resource_group_name = azurerm_resource_group.rg.name
+ dns_prefix = var.aks_dns_prefix
+ kubernetes_version = var.kubernetes_version
+
+ default_node_pool {
+ name = "default"
+ node_count = var.aks_node_count
+ vm_size = var.aks_node_vm_size
+ }
+
+ identity {
+ type = "SystemAssigned"
+ }
+
+ tags = merge(
+ var.tags,
+ {
+ Environment = var.environment
+ }
+ )
+}
+
+#
+# Grant AKS permission to pull images from your ACR
+#
+resource "azurerm_role_assignment" "acr_pull" {
+ principal_id = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id
+ role_definition_name = "AcrPull"
+ scope = azurerm_container_registry.acr.id
+ skip_service_principal_aad_check = true
+}
\ No newline at end of file
diff --git a/terraform/output.tf b/terraform/output.tf
new file mode 100644
index 00000000..36e88828
--- /dev/null
+++ b/terraform/output.tf
@@ -0,0 +1,57 @@
+output "resource_group_name" {
+ description = "Name of the resource group"
+ value = azurerm_resource_group.rg.name
+}
+
+output "acr_name" {
+ description = "Name of the Azure Container Registry"
+ value = azurerm_container_registry.acr.name
+}
+
+output "acr_login_server" {
+ description = "Login server of the Azure Container Registry"
+ value = azurerm_container_registry.acr.login_server
+}
+
+output "storage_account_name" {
+ description = "Name of the Azure Storage Account"
+ value = azurerm_storage_account.storage_account.name
+}
+
+output "storage_connection_string" {
+ description = "Connection string used by the application to access Blob Storage"
+ value = azurerm_storage_account.storage_account.primary_connection_string
+ sensitive = true
+}
+
+output "student_profile_container" {
+ description = "Student profile photo Blob container"
+ value = azurerm_storage_container.student_profile_photo.name
+}
+
+output "lecturer_profile_container" {
+ description = "Lecturer profile photo Blob container"
+ value = azurerm_storage_container.lecturer_profile_photo.name
+}
+
+output "aks_cluster_name" {
+ description = "Name of the AKS cluster"
+ value = azurerm_kubernetes_cluster.aks.name
+}
+
+output "aks_get_credentials_command" {
+ description = "Azure CLI command used to configure kubectl"
+ value = join(" ", [
+ "az aks get-credentials",
+ "--resource-group",
+ azurerm_resource_group.rg.name,
+ "--name",
+ azurerm_kubernetes_cluster.aks.name,
+ "--overwrite-existing"
+ ])
+}
+
+output "acr_login_command" {
+ description = "Azure CLI command used to log in to ACR"
+ value = "az acr login --name ${azurerm_container_registry.acr.name}"
+}
\ No newline at end of file
diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf
new file mode 100644
index 00000000..0fbe75d3
--- /dev/null
+++ b/terraform/resource_group.tf
@@ -0,0 +1,11 @@
+resource "azurerm_resource_group" "rg" {
+ name = var.resource_group_name
+ location = var.location
+
+ tags = merge(
+ var.tags,
+ {
+ Environment = var.environment
+ }
+ )
+}
\ No newline at end of file
diff --git a/terraform/storage_account.tf b/terraform/storage_account.tf
new file mode 100644
index 00000000..8b3a6b6f
--- /dev/null
+++ b/terraform/storage_account.tf
@@ -0,0 +1,30 @@
+resource "azurerm_storage_account" "storage_account" {
+ name = var.storage_account_name
+ resource_group_name = azurerm_resource_group.rg.name
+ location = azurerm_resource_group.rg.location
+
+ account_tier = "Standard"
+ account_replication_type = "LRS"
+
+ min_tls_version = "TLS1_2"
+ allow_nested_items_to_be_public = false
+
+ tags = merge(
+ var.tags,
+ {
+ Environment = var.environment
+ }
+ )
+}
+
+resource "azurerm_storage_container" "student_profile_photo" {
+ name = "student-profile-photo"
+ storage_account_id = azurerm_storage_account.storage_account.id
+ container_access_type = "private"
+}
+
+resource "azurerm_storage_container" "lecturer_profile_photo" {
+ name = "lecturer-profile-photo"
+ storage_account_id = azurerm_storage_account.storage_account.id
+ container_access_type = "private"
+}
\ No newline at end of file
diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars
new file mode 100644
index 00000000..a2724352
--- /dev/null
+++ b/terraform/terraform.tfvars
@@ -0,0 +1,24 @@
+location = "Australia East"
+resource_group_name = "225294805-koalatech-week09-rg"
+
+# Replace with a unique name for your Azure Container Registry
+acr_name = "225294805acrweek09"
+
+# Replace with a unique name for your Azure Storage Account
+storage_account_name = "225294805week09"
+
+# Replace with a unique name for your Azure Kubernetes Service cluster
+aks_cluster_name = "225294805week09"
+aks_dns_prefix = "koalatech"
+
+aks_node_count = 3
+aks_node_vm_size = "Standard_D2s_v3"
+
+environment = "development"
+
+tags = {
+ Project = "KoalaTech Course Platform"
+ ManagedBy = "Terraform"
+ Practical = "Week10"
+ Environment = "Development"
+}
\ No newline at end of file
diff --git a/terraform/variables.tf b/terraform/variables.tf
new file mode 100644
index 00000000..d9c95973
--- /dev/null
+++ b/terraform/variables.tf
@@ -0,0 +1,83 @@
+variable "location" {
+ description = "Azure region where the resources will be created"
+ type = string
+ default = "Australia East"
+}
+
+variable "resource_group_name" {
+ description = "Name of the Azure Resource Group"
+ type = string
+}
+
+variable "acr_name" {
+ description = "Globally unique name of the Azure Container Registry"
+ type = string
+
+ validation {
+ condition = can(regex("^[a-zA-Z0-9]+$", var.acr_name))
+ error_message = "The ACR name must contain only alphanumeric characters."
+ }
+}
+
+variable "storage_account_name" {
+ description = "Globally unique name of the Azure Storage Account"
+ type = string
+
+ validation {
+ condition = (
+ length(var.storage_account_name) >= 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
+ default = 2
+
+ 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" {
+ default = "1.36.1"
+}
+
+variable "tags" {
+ description = "Tags applied to Azure resources"
+ type = map(string)
+
+ default = {
+ Project = "KoalaTech Course Platform"
+ ManagedBy = "Terraform"
+ Practical = "Week06"
+ }
+}
\ No newline at end of file
diff --git a/terraform/versions.tf b/terraform/versions.tf
new file mode 100644
index 00000000..c1a6ec16
--- /dev/null
+++ b/terraform/versions.tf
@@ -0,0 +1,18 @@
+terraform {
+ required_version = ">= 1.7.0"
+
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 4.0"
+ }
+ local = {
+ source = "hashicorp/local"
+ version = "~> 2.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+}
\ No newline at end of file