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