diff --git a/.github/workflows/00-terraform.yml b/.github/workflows/00-terraform.yml new file mode 100644 index 00000000..b1d1d3bf --- /dev/null +++ b/.github/workflows/00-terraform.yml @@ -0,0 +1,154 @@ +name: 00 - Terraform Infrastructure + +on: + # Plan automatically whenever the infrastructure code changes + push: + branches: + - main + paths: + - "terraform/**" + - ".github/workflows/00-terraform.yml" + + pull_request: + paths: + - "terraform/**" + + # Manual run: "plan" (drift check) or "apply" (make the change) + workflow_dispatch: + inputs: + action: + description: "Terraform action to run" + required: true + type: choice + default: plan + options: + - plan + - apply + +permissions: + contents: read + +env: + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + +jobs: + + # ========================================================= + # Format, validate and plan + # ========================================================= + plan: + name: Terraform fmt / validate / plan + runs-on: ubuntu-latest + + defaults: + run: + working-directory: terraform + + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} + ARM_ACCESS_KEY: ${{ secrets.TF_STATE_ACCESS_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Export service principal for Terraform + run: | + CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .clientId) + CLIENT_SECRET=$(echo "$AZURE_CREDENTIALS" | jq -r .clientSecret) + TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .tenantId) + SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .subscriptionId) + echo "::add-mask::$CLIENT_SECRET" + echo "ARM_CLIENT_ID=$CLIENT_ID" >> "$GITHUB_ENV" + echo "ARM_CLIENT_SECRET=$CLIENT_SECRET" >> "$GITHUB_ENV" + echo "ARM_TENANT_ID=$TENANT_ID" >> "$GITHUB_ENV" + echo "ARM_SUBSCRIPTION_ID=$SUBSCRIPTION_ID" >> "$GITHUB_ENV" + + - name: Terraform fmt + run: terraform fmt -check -recursive + + - name: Terraform init + run: terraform init + + - name: Terraform validate + run: terraform validate + + - name: Terraform plan + id: plan + run: | + set +e + terraform plan -no-color -detailed-exitcode -out=tfplan > plan.txt + EXIT_CODE=$? + set -e + cat plan.txt + { + echo "## Terraform plan" + echo "" + if [ "$EXIT_CODE" -eq 0 ]; then + echo "No changes - live Azure infrastructure matches the code." + elif [ "$EXIT_CODE" -eq 2 ]; then + echo "Changes detected:" + fi + echo '```' + grep -E "Plan:|No changes|# azurerm|will be|must be" plan.txt || true + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + if [ "$EXIT_CODE" -eq 1 ]; then + exit 1 + fi + + # ========================================================= + # Apply (manual only) + # ========================================================= + apply: + name: Terraform apply + runs-on: ubuntu-latest + needs: + - plan + + if: github.event_name == 'workflow_dispatch' && inputs.action == 'apply' + + # Add a required reviewer on this environment for an approval gate + environment: + name: infrastructure + + defaults: + run: + working-directory: terraform + + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} + ARM_ACCESS_KEY: ${{ secrets.TF_STATE_ACCESS_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Export service principal for Terraform + run: | + CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .clientId) + CLIENT_SECRET=$(echo "$AZURE_CREDENTIALS" | jq -r .clientSecret) + TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .tenantId) + SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .subscriptionId) + echo "::add-mask::$CLIENT_SECRET" + echo "ARM_CLIENT_ID=$CLIENT_ID" >> "$GITHUB_ENV" + echo "ARM_CLIENT_SECRET=$CLIENT_SECRET" >> "$GITHUB_ENV" + echo "ARM_TENANT_ID=$TENANT_ID" >> "$GITHUB_ENV" + echo "ARM_SUBSCRIPTION_ID=$SUBSCRIPTION_ID" >> "$GITHUB_ENV" + + - name: Terraform init + run: terraform init + + - name: Terraform apply + run: terraform apply -auto-approve -no-color diff --git a/.github/workflows/00b-terraform-apply.yml b/.github/workflows/00b-terraform-apply.yml new file mode 100644 index 00000000..5b28c845 --- /dev/null +++ b/.github/workflows/00b-terraform-apply.yml @@ -0,0 +1,54 @@ +name: 00b - Terraform Apply (manual) + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + TF_IN_AUTOMATION: "true" + TF_INPUT: "false" + +jobs: + apply: + name: Terraform plan + apply + runs-on: ubuntu-latest + + environment: + name: infrastructure + + defaults: + run: + working-directory: terraform + + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} + ARM_ACCESS_KEY: ${{ secrets.TF_STATE_ACCESS_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_wrapper: false + + - name: Export service principal for Terraform + run: | + echo "ARM_CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .clientId)" >> "$GITHUB_ENV" + SECRET=$(echo "$AZURE_CREDENTIALS" | jq -r .clientSecret) + echo "::add-mask::$SECRET" + echo "ARM_CLIENT_SECRET=$SECRET" >> "$GITHUB_ENV" + echo "ARM_TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .tenantId)" >> "$GITHUB_ENV" + echo "ARM_SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r .subscriptionId)" >> "$GITHUB_ENV" + + - name: Terraform init + run: terraform init + + - name: Terraform plan + run: terraform plan -no-color -out=tfplan + + - name: Terraform apply + run: terraform apply -auto-approve -no-color tfplan diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..0c2ea4b7 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -160,6 +160,26 @@ jobs: -t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \ ./${{ matrix.service }} + # --------------------------------------------------------- + # DevSecOps: scan the freshly built image BEFORE it is pushed + # --------------------------------------------------------- + - name: Login to Docker Hub (required by Docker Scout) + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Scout - quickview, CVEs and recommendations + uses: docker/scout-action@v1 + with: + command: quickview,cves,recommendations + image: local://${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + only-severities: critical,high + # Report-only for now; set to true to block the push on findings + exit-code: false + write-comment: false + summary: true + - 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..dafc03b4 100644 --- a/.github/workflows/04-deploy-production.yml +++ b/.github/workflows/04-deploy-production.yml @@ -1,11 +1,21 @@ name: 04 - Deploy to Production on: + # Automatic: runs after "03 - Test Staging" completes on main + workflow_run: + workflows: + - "03 - Test Staging" + types: + - completed + branches: + - main + + # Manual fallback / rollback workflow_dispatch: inputs: image_tag: - description: "Tested image SHA to deploy" - required: true + description: "Image SHA to deploy (manual runs only)" + required: false type: string jobs: @@ -13,12 +23,21 @@ jobs: name: Deploy to Production runs-on: ubuntu-latest + if: > + ${{ github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' }} + environment: name: production + env: + IMAGE_TAG: ${{ inputs.image_tag || github.event.workflow_run.head_sha }} + steps: - - name: Checkout repository + - name: Checkout tested commit uses: actions/checkout@v4 + with: + ref: ${{ env.IMAGE_TAG }} - name: Login to Azure uses: azure/login@v3 @@ -69,37 +88,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 +161,4 @@ 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 diff --git a/.github/workflows/05-monitoring.yml b/.github/workflows/05-monitoring.yml new file mode 100644 index 00000000..0baf3512 --- /dev/null +++ b/.github/workflows/05-monitoring.yml @@ -0,0 +1,128 @@ +name: 05 - Deploy and Validate Monitoring + +on: + # Runs after every successful staging deployment + workflow_run: + workflows: + - "02 - Deploy to Staging" + types: + - completed + branches: + - main + + workflow_dispatch: + +jobs: + monitoring: + name: Prometheus + Grafana + runs-on: ubuntu-latest + + if: > + ${{ github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' }} + + environment: + name: staging + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + + - 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: Set up Helm + uses: azure/setup-helm@v4 + + - name: Deploy kube-prometheus-stack + run: | + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts + helm repo update + helm upgrade --install prometheus prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --create-namespace \ + --values monitoring/kube-prometheus-stack-values.yaml \ + --set grafana.adminPassword="${{ secrets.GRAFANA_ADMIN_PASSWORD }}" \ + --wait \ + --timeout 10m + + - name: Apply ServiceMonitor and Grafana dashboard + run: | + kubectl apply -f monitoring/servicemonitor-koalatech.yaml + kubectl apply -f monitoring/dashboard-koalatech.yaml + + - name: Wait for monitoring pods + run: | + kubectl wait --for=condition=Ready pods --all \ + --field-selector=status.phase!=Succeeded \ + -n monitoring \ + --timeout=300s + kubectl get pods -n monitoring + + - name: Validate Prometheus is scraping the application + run: | + kubectl -n monitoring port-forward svc/prometheus-kube-prometheus-prometheus 9090:9090 > /dev/null 2>&1 & + sleep 5 + + for i in $(seq 1 24); do + COUNT=$(curl -sG http://localhost:9090/api/v1/query \ + --data-urlencode 'query=up{job=~".+-service"} == 1' \ + | jq '.data.result | length') + + echo "Healthy KoalaTech targets: ${COUNT:-0}" + + if [ "${COUNT:-0}" -ge 5 ]; then + { + echo "## Prometheus targets (up == 1)" + echo "" + echo "| namespace | service | pod |" + echo "|---|---|---|" + curl -sG http://localhost:9090/api/v1/query \ + --data-urlencode 'query=up{job=~".+-service"} == 1' \ + | jq -r '.data.result[] | "| \(.metric.namespace) | \(.metric.service) | \(.metric.pod) |"' + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + sleep 10 + done + + echo "Prometheus is not scraping the KoalaTech services." + exit 1 + + - name: Validate Grafana and the KoalaTech dashboard + run: | + kubectl -n monitoring port-forward svc/prometheus-grafana 3000:80 > /dev/null 2>&1 & + sleep 5 + + curl --fail -s http://localhost:3000/api/health | tee grafana-health.json + + for i in $(seq 1 12); do + TITLE=$(curl -s -u "admin:${{ secrets.GRAFANA_ADMIN_PASSWORD }}" \ + "http://localhost:3000/api/search?query=KoalaTech" | jq -r '.[0].title // empty') + if [ -n "$TITLE" ]; then + echo "Dashboard found: $TITLE" + { + echo "## Grafana" + echo "" + echo "- Health: $(jq -r .database grafana-health.json)" + echo "- Dashboard provisioned: $TITLE" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + sleep 10 + done + + echo "KoalaTech dashboard was not provisioned." + exit 1 diff --git a/.gitignore b/.gitignore index 4299cd1a..7255e74a 100644 --- a/.gitignore +++ b/.gitignore @@ -196,4 +196,10 @@ cython_debug/ .cursorignore .cursorindexingignore -node_modules/ \ No newline at end of file +node_modules/ +# Terraform (Task 10.2D) - never commit state or plans +.terraform/ +*.tfstate +*.tfstate.* +tfplan +crash.log diff --git a/README.md b/README.md index adf1b194..428c545c 100644 --- a/README.md +++ b/README.md @@ -310,4 +310,1090 @@ After the production deployment completes: - Find the external IP of the production frontend service. - Access the production application. - Confirm that the application is working correctly. -- Verify that production is running the same image SHA that was tested in staging. \ No newline at end of file +- Verify that production is running the same image SHA that was tested in staging.# CI trigger 09/20/2026 16:26:12 +# CI trigger 09/20/2026 16:27:34 + + + +--- + +# Cheat Sheet: Docker + Terraform + Azure + AKS + GitHub Actions + +Quick reference for the SIT722 6.2P / 9.2P viva. Replace `<...>` placeholders with your own values. + +**Pipeline in one sentence:** Git/GitHub holds the code → GitHub Actions tests it → Docker builds images → images are pushed to ACR (tagged with the commit SHA) → AKS pulls and runs them → Terraform creates the Azure infrastructure. + +| Tool | Role | Remember | +| --- | --- | --- | +| Git / GitHub | Version control / hosting + Actions | Git = tool, GitHub = service | +| Docker | Packages the app into images | image = template, container = running instance | +| Docker Compose | Runs multi-container apps locally | one YAML, one command | +| ACR | Private image registry | stores images, does NOT run them | +| AKS / Kubernetes | Runs and manages containers | Deployment = replicas, Service = stable access | +| Terraform | Infrastructure as Code | declarative: describe the end state | +| GitHub Actions | CI/CD automation | trigger → workflow → jobs → steps | + +--- + +## 1. Azure CLI basics + +```bash +az login # interactive login +az account show # active subscription +az account list -o table +az account set --subscription "" + +az group list -o table # resource groups +az acr list -o table # container registries +az aks list -o table # AKS clusters +az storage account list -o table +``` + +Create the base resources manually (only needed if not using Terraform): + +```bash +az group create --name --location australiaeast +az acr create --resource-group --name --sku Basic +``` + +Clean up (saves CloudLabs credits, deletes everything in the group): + +```bash +az group delete --name --yes --no-wait +``` + +--- + +## 2. Docker + +### Dockerfile (from `student-service/Dockerfile`) + +```dockerfile +FROM python:3.12-slim # base image +WORKDIR /app # working directory +COPY requirements.txt . # copy deps first (layer caching) +RUN pip install --no-cache-dir -r requirements.txt # build-time command +COPY . . # copy the app code +RUN useradd -m appuser && chown -R appuser:appuser /app +USER appuser # do not run as root +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] # start command +``` + +### Build / run / inspect + +```bash +docker build -t user-service:v1 ./user-service # build image (NOT a container yet) +docker images # prove the image exists +docker run -d --name user-service -p 8000:8000 user-service:v1 +docker ps # running containers +docker ps -a # including stopped +docker logs user-service +docker exec -it user-service sh # shell inside container +docker stop user-service +docker rm user-service +docker rmi user-service:v1 +``` + +### Docker problem seen in the unit: container-name conflict + +```bash +docker ps -a # find the old container using that name +docker rm -f # remove it +docker compose up -d # start again +``` + +--- + +## 3. Docker Compose + +```bash +docker compose config # validate + show the resolved file (safe to demo) +docker compose up -d # create and start in background +docker compose up -d --build # rebuild images first +docker compose ps # status +docker compose logs -f # follow logs +docker compose down # stop + remove containers and networks +docker compose down -v # also remove volumes (wipes DB data) +``` + +Key parts of `docker-compose.yml` (services / ports / env / volumes): + +```yaml +services: + user-service: + build: + context: ./user-service + dockerfile: Dockerfile + image: koalatech-user-service:v1 + ports: + - "8000:8000" # host:container + env_file: + - ./user-service/.env # environment variables + depends_on: + user-db: + condition: service_healthy + networks: + - koalatech-network + + user-db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: users + volumes: + - user-db-data:/var/lib/postgresql/data # persistent storage + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d users"] +``` + +Frontend: http://localhost:3000 + +--- + +## 4. Push an image to ACR + +Order: **build → tag → login → push → verify**. + +```bash +# 1. Build +docker build -t koalatech-user-service:v1 ./user-service + +# 2. Tag with the ACR login server (.azurecr.io) +docker tag koalatech-user-service:v1 .azurecr.io/koalatech-user-service:v1 + +# 3. Authenticate +az acr login --name + +# 4. Push +docker push .azurecr.io/koalatech-user-service:v1 + +# 5. Verify +az acr repository list --name -o table +az acr repository show-tags --name --repository koalatech-user-service -o table +``` + +Tag with the Git commit SHA (traceable, used by the pipeline): + +```bash +SHA=$(git rev-parse HEAD) +docker tag koalatech-user-service:v1 .azurecr.io/koalatech-user-service:$SHA +docker push .azurecr.io/koalatech-user-service:$SHA +``` + +Get the login server / admin credentials if needed: + +```bash +az acr show --name --query loginServer -o tsv +az acr credential show --name +``` + +--- + +## 5. Terraform + +### Commands (in order) + +```bash +terraform init # download providers (azurerm), first command in a project +terraform fmt # format files (fmt -check = only report) +terraform validate # syntax/consistency check, creates nothing (safe viva demo) +terraform plan # preview create/change/destroy +terraform apply # make the changes (type yes, or -auto-approve) +terraform output # show output values +terraform state list # resources tracked in state +terraform destroy # tear everything down (cleanup) +``` + +Safe viva demo: `terraform fmt -check` then `terraform validate`. +Running `apply` twice → "No changes" because Terraform compares desired config with state. + +### Suggested file layout + +```text +terraform/ +├── providers.tf +├── variables.tf +├── main.tf +└── outputs.tf +``` + +### providers.tf + +```hcl +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.100" + } + } +} + +provider "azurerm" { + features {} + subscription_id = var.subscription_id +} +``` + +### variables.tf + +```hcl +variable "subscription_id" { + type = string +} + +variable "location" { + type = string + default = "australiaeast" +} + +variable "resource_group_name" { + type = string + default = "rg-koalatech" +} + +variable "acr_name" { + type = string + description = "Globally unique, lowercase letters and digits only" +} + +variable "aks_name" { + type = string + default = "aks-koalatech" +} + +variable "storage_account_name" { + type = string + description = "Globally unique, 3-24 lowercase letters and digits" +} +``` + +### main.tf + +```hcl +resource "azurerm_resource_group" "rg" { + name = var.resource_group_name + location = var.location +} + +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 +} + +resource "azurerm_kubernetes_cluster" "aks" { + name = var.aks_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + dns_prefix = var.aks_name + + default_node_pool { + name = "default" + node_count = 3 # Week 08: 3 nodes for the staging + production PostgreSQL workloads + vm_size = "Standard_DS2_v2" + } + + identity { + type = "SystemAssigned" + } +} + +# Allow AKS to pull images from ACR (prevents ImagePullBackOff) +resource "azurerm_role_assignment" "aks_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 +} + +resource "azurerm_storage_account" "storage" { + 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" +} + +resource "azurerm_storage_container" "photos" { + name = "photos" + storage_account_name = azurerm_storage_account.storage.name + container_access_type = "private" +} +``` + +### outputs.tf + +```hcl +output "resource_group_name" { + value = azurerm_resource_group.rg.name +} + +output "acr_login_server" { + value = azurerm_container_registry.acr.login_server +} + +output "aks_cluster_name" { + value = azurerm_kubernetes_cluster.aks.name +} + +output "storage_connection_string" { + value = azurerm_storage_account.storage.primary_connection_string + sensitive = true +} +``` + +Read the sensitive value (goes into the `AZURE_STORAGE_CONNECTION_STRING` environment secret): + +```bash +terraform output -raw storage_connection_string +``` + +Pass values on the command line instead of a hard-coded default: + +```bash +terraform apply -var="subscription_id=" -var="acr_name=" -var="storage_account_name=" +``` + +> Version numbers and VM size are examples, so check what your subscription/region allows. `AuthorizationFailed` = wrong subscription or missing permission, not an app bug. Check `az account show` first. + +--- + +## 6. Connect kubectl to AKS + +```bash +az aks get-credentials --resource-group --name --overwrite-existing +kubectl get nodes # Week 08: must show 3 nodes +kubectl config current-context +``` + +Attach ACR to AKS if you did not create the role assignment in Terraform: + +```bash +az aks update --resource-group --name --attach-acr +``` + +--- + +## 7. Azure Service Principal (for GitHub Actions) + +```bash +SUB_ID=$(az account show --query id -o tsv) + +az ad sp create-for-rbac \ + --name "sp-koalatech-github" \ + --role Contributor \ + --scopes /subscriptions/$SUB_ID/resourceGroups/ \ + --sdk-auth +``` + +The JSON output goes into the repository secret `AZURE_CREDENTIALS`: + +```json +{ + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "subscriptionId": "YOUR_SUBSCRIPTION_ID", + "tenantId": "YOUR_TENANT_ID" +} +``` + +If the SP also needs to push to ACR and deploy to AKS, add roles: + +```bash +az role assignment create --assignee --role AcrPush --scope $(az acr show -n --query id -o tsv) +az role assignment create --assignee --role "Azure Kubernetes Service Cluster User Role" --scope $(az aks show -g -n --query id -o tsv) +``` + +Never commit this JSON. If the CLI rejects `--sdk-auth`, build the JSON by hand from `appId`, `password`, `tenant` and your subscription id. + +--- + +## 8. GitHub configuration + +**Repository → Settings → Secrets and variables → Actions** + +| Type | Name | Value | +| --- | --- | --- | +| Secret | `AZURE_CREDENTIALS` | Service Principal JSON | +| Variable | `ACR_NAME` | ACR name | +| Variable | `ACR_LOGIN_SERVER` | `.azurecr.io` | +| Variable | `AKS_RESOURCE_GROUP` | Resource group of AKS | +| Variable | `AKS_CLUSTER_NAME` | AKS cluster name | + +**Settings → Environments → `staging` and `production`** (same secret names in both): + +`POSTGRES_USER`, `POSTGRES_PASSWORD`, `JWT_SECRET_KEY`, `DEFAULT_ADMIN_USERNAME`, `DEFAULT_ADMIN_EMAIL`, `DEFAULT_ADMIN_PASSWORD`, `AZURE_STORAGE_CONNECTION_STRING` + +Secret = sensitive (masked in logs). Variable = normal config. + +Optional, using the GitHub CLI: + +```bash +gh secret set AZURE_CREDENTIALS < sp.json +gh variable set ACR_NAME --body "" +gh variable set ACR_LOGIN_SERVER --body ".azurecr.io" +gh variable set AKS_RESOURCE_GROUP --body "" +gh variable set AKS_CLUSTER_NAME --body "" +gh secret set POSTGRES_PASSWORD --env staging --body "postgres" +``` + +--- + +## 9. GitHub Actions concepts and snippets + +| Term | Meaning | +| --- | --- | +| Workflow | The whole YAML automation | +| Trigger (`on:`) | Event that starts it: `push`, `workflow_dispatch`, `workflow_run` | +| Job | Group of steps on one runner | +| Step | One action or command | +| Runner | Machine running the job (`ubuntu-latest`) | +| Matrix | Same job repeated for several values (one per microservice) | +| `needs` | Job waits for another job to succeed | +| `environment` | Links a job to a GitHub Environment (secrets, approvals) | + +### The four workflows in this repo + +| File | Trigger | What it does | +| --- | --- | --- | +| `01-ci.yml` | push to `main` / manual | Test each service (matrix) → build and push images to ACR tagged with the commit SHA | +| `02-deploy-staging.yml` | after 01 succeeds (`workflow_run`) | Apply manifests to `staging`, `kubectl set image` with the SHA, wait for rollouts | +| `03-staging-test.yml` | after staging deploy | Smoke-test the staging app | +| `04-deploy-production.yml` | **manual** (`workflow_dispatch`, input `image_tag`) | Deploy the already-tested SHA to `production`, no rebuild | + +### Trigger + matrix + needs + +```yaml +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + backend-test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + service: [user-service, student-service, lecturer-service, course-service, enrollment-service] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - working-directory: ${{ matrix.service }} + run: | + pip install -r requirements.txt + pytest -v + + build-and-push: + needs: [backend-test] # skipped if tests fail + runs-on: ubuntu-latest + steps: [...] +``` + +### Login to Azure, then build and push tagged with the SHA + +```yaml +- uses: actions/checkout@v4 + +- name: Login to Azure + uses: azure/login@v3 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + +- name: Login to ACR + run: az acr login --name ${{ vars.ACR_NAME }} + +- name: Build and push + run: | + IMAGE=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ github.sha }} + docker build -t $IMAGE ./user-service + docker push $IMAGE +``` + +### Staging deploy pattern (from `02-deploy-staging.yml`) + +```yaml +environment: + name: staging +steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} # the tested commit + - uses: azure/login@v3 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + - run: az aks get-credentials --resource-group ${{ vars.AKS_RESOURCE_GROUP }} --name ${{ vars.AKS_CLUSTER_NAME }} --overwrite-existing + - run: kubectl create namespace staging --dry-run=client -o yaml | kubectl apply -f - + - run: | + kubectl create secret generic application-secret \ + --namespace staging \ + --from-literal=JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" \ + --dry-run=client -o yaml | kubectl apply -f - + - run: kubectl apply -f kubernetes/staging/ + - run: | + kubectl set image deployment/user-service \ + user-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + - run: kubectl rollout status deployment/user-service -n staging --timeout=300s +``` + +### Manual production trigger + +```yaml +on: + workflow_dispatch: + inputs: + image_tag: + description: "Tested image SHA to deploy" + required: true + type: string +# use ${{ inputs.image_tag }} in the kubectl set image commands +``` + +CI failing demo: break a frontend test → `frontend-test` fails → `build-and-push` is **skipped** because of `needs`. +Frontend CI failure seen earlier: missing `VITE_*` service URL env vars in the test job. Add test values in the job `env:`. + +--- + +## 10. Kubernetes cheat sheet + +```bash +# Inspect +kubectl get nodes +kubectl get ns +kubectl get pods -n staging +kubectl get deploy,svc,pvc -n staging +kubectl get all -n staging + +# Deploy +kubectl apply -f kubernetes/staging/ # apply a folder of manifests +kubectl set image deployment/frontend frontend=/koalatech-frontend: -n staging +kubectl rollout status deployment/frontend -n staging +kubectl rollout history deployment/frontend -n staging +kubectl rollout undo deployment/frontend -n staging # rollback + +# Scale +kubectl scale deployment/frontend --replicas=3 -n staging + +# Troubleshoot +kubectl describe pod -n staging # events: image pull, scheduling, probes +kubectl logs -n staging +kubectl logs -n staging --previous # logs of the crashed container +kubectl exec -it -n staging -- sh + +# Public IP of the frontend +kubectl get svc frontend -n staging -w # wait for EXTERNAL-IP +kubectl get svc frontend -n production +``` + +Verify which image is running (should equal the tested SHA): + +```bash +kubectl get deployment frontend -n production -o jsonpath='{.spec.template.spec.containers[0].image}' +``` + +Minimal Deployment + Service example: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: frontend + namespace: staging +spec: + replicas: 2 + selector: + matchLabels: { app: frontend } + template: + metadata: + labels: { app: frontend } + spec: + containers: + - name: frontend + image: /koalatech-frontend: + ports: + - containerPort: 80 +--- +apiVersion: v1 +kind: Service +metadata: + name: frontend + namespace: staging +spec: + type: LoadBalancer # ClusterIP = internal only + selector: + app: frontend + ports: + - port: 80 + targetPort: 80 +``` + +### Errors + +| Error | Meaning | Check | +| --- | --- | --- | +| `ImagePullBackOff` | Cannot pull the image | Image name/tag correct? Image in ACR? AKS has `AcrPull` on ACR? | +| `CrashLoopBackOff` | Container starts then crashes repeatedly | `kubectl logs --previous`, `describe pod`, env vars/secrets | +| `Pending` pod | Not scheduled | Not enough node CPU/memory (Week 08 needs 3 nodes), PVC unbound | +| `AuthorizationFailed` (Azure) | Missing permissions | `az account show`, correct RG/subscription, role assignments | + +--- + +## 11. Full Week 08 run order + +```bash +# 1. Infrastructure +cd terraform +terraform init && terraform fmt && terraform validate +terraform plan +terraform apply +terraform output + +# 2. Cluster access +az aks get-credentials -g -n --overwrite-existing +kubectl get nodes # expect 3 nodes + +# 3. Service Principal + GitHub secrets/variables/environments (sections 7 and 8) + +# 4. Push code -> 01 CI -> 02 Deploy Staging -> 03 Staging Test run automatically +git add . && git commit -m "Trigger CI" && git push origin main + +# 5. Verify staging +kubectl get pods,svc,pvc -n staging +kubectl get svc frontend -n staging # open EXTERNAL-IP in the browser + +# 6. Promote to production (manual) +git rev-parse HEAD # SHA that passed staging +# GitHub -> Actions -> 04 - Deploy to Production -> Run workflow -> image_tag = + +# 7. Verify production +kubectl get pods,svc,pvc -n production +kubectl get svc frontend -n production + +# 8. Clean up when finished (saves credits) +terraform destroy +# or: az group delete --name --yes --no-wait +``` + +--- + +## 12. Viva quick answers + +- **Image vs container:** image is a read-only template; a container is a running instance of it. +- **ACR:** Azure private registry. It stores images and does not run them; AKS pulls from it and runs them. +- **Push to ACR:** build → tag with the login server → `az acr login` → `docker push` → verify. +- **Why commit SHA as tag:** it traces an image to exact code, and the same tested image is promoted to production (rollback = redeploy an older SHA). +- **Pod / Deployment / Service:** Pod = smallest unit; Deployment keeps N replicas running; Service = stable network access. +- **ClusterIP vs LoadBalancer:** internal only vs public IP. +- **ConfigMap vs Secret:** non-sensitive config vs sensitive data. +- **plan vs apply:** plan previews, apply changes real infrastructure. +- **Terraform state:** maps config to real resources so Terraform knows what exists. +- **Provider:** plugin that talks to a platform (`azurerm` for Azure). +- **Declarative:** describe the desired end state, and Terraform works out the steps. +- **CI:** automatic build and test on every change. +- **Continuous Delivery vs Deployment:** Delivery is always release-ready with a manual production step; Deployment auto-releases every passing change. This repo is **Delivery** (manual step 04). +- **Why staging:** finds deployment, config and network problems that unit tests miss. +- **`needs`:** a job waits for other jobs to succeed, so failed tests stop the image push. +- **Secret vs variable:** a secret is sensitive and masked; a variable is plain config (`ACR_NAME`). +- **Why delete resources:** cloud resources keep using CloudLabs credits. + + +# SIT722 Viva: Commands From Start to Finish (matched to this machine's directories) + +Shell: Git Bash (bash). Run block by block. +Legend: ✅ safe (read/check/validate) | 💲 may create Azure cost | ⚠️ changes or deletes things (only if the tutor asks). + +## Directory map (verified 2026-09-24) + +| What | Path | +| --- | --- | +| App repo (Docker, Compose, K8s manifests, workflows) | `/d/Github/SIT722-week08` (Windows: `D:\Github\SIT722-week08`) | +| Terraform (real Week 08 infra) | `/d/Github/week08/terraform` (separate folder, **not** inside the repo above) | +| Week 06 examples | `/d/Github/week06` | + +Repo facts that shape the commands: + +- Workflows: `.github/workflows/01-ci.yml`, `02-deploy-staging.yml`, `03-staging-test.yml`, `04-deploy-production.yml`. +- `01-ci.yml` has two jobs: `backend-test` (matrix of 5 services) and `build-and-push` (`needs: backend-test`). **There is no frontend-test job in this repo.** +- Compose host ports: frontend `3000`; services `8000` user, `8001` student, `8002` lecturer, `8003` course, `8004` enrollment; databases `5433`–`5437`. +- Each service only has `.env.test`. Compose needs `.env` (gitignored), so Section 4 creates them. +- K8s manifests: `kubernetes/staging/` and `kubernetes/production/`. +- Git remotes: `origin` = `RSainarayan/SIT722-week08`, `upstream` = `sit722-devops/week08`. + +Real Azure resources (verify with Section 2 first): + +| Item | Value | +| --- | --- | +| Subscription | Deakin labs DS - 1069 | +| Resource group | koalatech-week08-rg | +| ACR | acrs225404454w08x (acrs225404454w08x.azurecr.io) | +| AKS | aks-koalatech-s225404454-w08 | + +Never delete: `deakinuni`, `NetworkWatcherRG`, `DefaultResourceGroup-EAU`. + +--- + +## 1. Go to the project ✅ + +```bash +cd /d/Github/SIT722-week08 +pwd +ls +git status +git log --oneline -5 +git remote -v +``` + +Say: "Git manages my code. These commands show the project, its state, and my GitHub fork." + +--- + +## 2. Check Azure (always before using any resource) ✅ + +```bash +az account show -o table +az group list -o table +az acr list -o table +az aks list -o table +az resource list --query "[].{Name:name,Type:type,ResourceGroup:resourceGroup,Location:location}" -o table +kubectl config current-context +``` + +Say: "Before using any Azure resource I check what really exists in my subscription." +If `az acr list` or `az aks list` is empty, the resource has been deleted. Do not recreate it; explain the manifests or Terraform instead. + +--- + +## 3. Docker ✅ + +```bash +cd /d/Github/SIT722-week08 +cat user-service/Dockerfile +docker build -t viva-user-service:v1 ./user-service +docker images | grep viva-user-service +``` + +Optional: run it alone (no database, so it may exit; `docker logs` shows why): + +```bash +docker run -d --name viva-user -p 8000:8000 viva-user-service:v1 +docker ps +docker logs viva-user +docker rm -f viva-user +``` + +Say: "The Dockerfile is the recipe, and `docker build` makes an image. An image is the template. A container is a running instance." +Base image `python:3.12-slim`, runs as non-root `appuser`, starts with `uvicorn` on port 8000. + +--- + +## 4. Docker Compose + +Compose reads `.//.env`, and only `.env.test` exists. Create the `.env` files from it. The test files use `POSTGRES_HOST=localhost`, but inside Compose the host must be the database container name: + +```bash +cd /d/Github/SIT722-week08 +for s in user student lecturer course enrollment; do + sed -e "s/^POSTGRES_HOST=.*/POSTGRES_HOST=$s-db/" \ + -e "s/^POSTGRES_PORT=.*/POSTGRES_PORT=5432/" \ + $s-service/.env.test > $s-service/.env +done +ls -a user-service +cat user-service/.env +``` + +`.env` is in `.gitignore`, so it won't be committed. If the services still cannot reach their database, check `.env.test` for other variables that need changing. + +```bash +cat docker-compose.yml +docker compose config # ✅ validate only +docker compose up -d --build # builds and starts all 5 services, 5 databases and the frontend +docker compose ps +docker compose logs --tail=50 +``` + +Open: http://localhost:3000 (frontend). Service APIs: http://localhost:8000 to :8004. +Login (from the env file): `admin` / `AdminPassword123!`. + +```bash +docker compose down # cleanup (add -v to also wipe DB volumes) +``` + +Container-name conflict (containers are named `user-service`, `user-db` and so on): + +```bash +docker ps -a +docker rm -f user-service # use the name that docker ps -a shows +docker compose up -d +``` + +Say: "Compose defines the services, ports, environment variables, volumes and network in one YAML and starts everything together." + +--- + +## 5. Push an image to ACR 💲 (small: storage only) + +```bash +ACR_NAME=$(az acr list --query "[0].name" -o tsv) +echo "$ACR_NAME" + +ACR_LOGIN_SERVER=$(az acr show --name "$ACR_NAME" --query loginServer -o tsv) +echo "$ACR_LOGIN_SERVER" + +az acr login --name "$ACR_NAME" + +docker tag viva-user-service:v1 "$ACR_LOGIN_SERVER/viva-user-service:v1" +docker push "$ACR_LOGIN_SERVER/viva-user-service:v1" + +az acr repository list --name "$ACR_NAME" --output table +az acr repository show-tags --name "$ACR_NAME" --repository viva-user-service --output table +``` + +The pipeline names images `koalatech-` tagged with the commit SHA. To show them: + +```bash +az acr repository show-tags --name "$ACR_NAME" --repository koalatech-user-service --output table +``` + +Say: "The sequence is build, tag, login, push, verify. ACR only stores images. It does not run them." +Portal: Container registries → your registry → Overview (login server) → Services → Repositories. + +--- + +## 6. Kubernetes / AKS ✅ + +```bash +RG=$(az aks list --query "[0].resourceGroup" -o tsv) +AKS=$(az aks list --query "[0].name" -o tsv) +echo "$RG $AKS" + +az aks get-credentials --resource-group "$RG" --name "$AKS" --overwrite-existing +kubectl config current-context +kubectl get nodes # Week 08 expects 3 nodes +kubectl get ns +kubectl get pods -n staging +kubectl get deployments -n staging +kubectl get services -n staging +kubectl get pvc -n staging +kubectl get pods -n production +kubectl get services -n production +``` + +If a namespace is not found, that environment has not been deployed. Say so. + +Manifests in the repo: + +```bash +cd /d/Github/SIT722-week08 +ls kubernetes/staging kubernetes/production +cat kubernetes/staging/12-frontend.yaml +``` + +Which image is running (should be the commit SHA): + +```bash +kubectl get deployment frontend -n staging -o jsonpath='{.spec.template.spec.containers[0].image}'; echo +kubectl get svc frontend -n staging # EXTERNAL-IP = the public address +``` + +Troubleshooting (take a real pod name from `kubectl get pods -n staging`): + +```bash +kubectl describe pod -n staging +kubectl logs -n staging +``` + +Say: "Pods run the containers, the Deployment keeps the replicas running, and the Service gives stable access. ClusterIP is internal and LoadBalancer is external. The database Pods use PVCs so data survives restarts." +ImagePullBackOff = cannot pull the image (check name, tag, ACR permission). CrashLoopBackOff = the container keeps crashing (check logs). + +--- + +## 7. Terraform ✅ (real folder: `/d/Github/week08/terraform`) + +```bash +cd /d/Github/week08/terraform +pwd +ls +``` + +Files present: `resource_group.tf`, `container_registry.tf`, `kubernetes_service.tf`, `storage_account.tf`, `variables.tf`, `versions.tf`, `output.tf`, `terraform.tfvars`, `setup-terraform.ps1`, plus existing state files. + +```bash +cat versions.tf +cat variables.tf +cat kubernetes_service.tf # node_count = var.aks_node_count (default 3) +cat terraform.tfvars + +terraform version +terraform init +terraform fmt -check +terraform validate +``` + +`plan` only previews changes; nothing is created. Since the infrastructure already exists and is in state, it should report "No changes": + +```bash +terraform plan +terraform state list +terraform output +``` + +Say: "`init` downloads the azurerm provider, `fmt -check` checks the formatting, `validate` checks the syntax, and `plan` previews the changes without creating anything. It says no changes because the state already matches the real resources." + +Do not run `terraform apply` or `terraform destroy` unless the tutor asks. `destroy` would delete the live ACR, AKS and storage, and `terraform.tfstate` here is the record of them. Do not delete the state files. + +### ⚠️ 💲 Only if the tutor asks + +```bash +terraform apply # type: yes +terraform destroy # type: yes. This removes the real week08 infrastructure +``` + +Fallback if the tutor wants to see a new resource group created from scratch (separate from the real state): + +```bash +mkdir -p ~/viva-terraform && cd ~/viva-terraform +cat > main.tf <<'TF' +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} +} + +resource "azurerm_resource_group" "viva" { + name = "viva-terraform-rg" + location = "Australia East" +} +TF +export ARM_SUBSCRIPTION_ID=$(az account show --query id -o tsv) +terraform init && terraform fmt && terraform validate && terraform plan +# only if asked: terraform apply, then az group show --name viva-terraform-rg -o table, then terraform destroy +``` + +--- + +## 8. GitHub Actions / CI ✅ + +```bash +cd /d/Github/SIT722-week08 +ls -la .github/workflows +cat .github/workflows/01-ci.yml +grep -n "uses:" .github/workflows/*.yml +grep -n -A3 "needs:" .github/workflows/*.yml +grep -n "secrets\." .github/workflows/*.yml +grep -n "vars\." .github/workflows/*.yml +gh run list --limit 10 +gh secret list +gh variable list +``` + +Say: "A push to main triggers 01-CI. `backend-test` runs as a matrix, one job per service, each with its own PostgreSQL service container. `build-and-push` has `needs: backend-test`, so images are only built and pushed after the tests pass, tagged with the commit SHA. 02 deploys to staging, 03 tests staging, and 04 is the manual production deployment." + +Backend tests locally (example for one service; the CI does this per service): + +```bash +cd /d/Github/SIT722-week08/user-service +pip install -r requirements.txt +pytest -v # needs PostgreSQL on localhost:5433 (see .env.test) +``` + +Frontend tests exist locally (Vitest) but are not part of this repo's CI: + +```bash +cd /d/Github/SIT722-week08/frontend +npm ci +VITE_USER_SERVICE_URL=http://localhost:8000 \ +VITE_STUDENT_SERVICE_URL=http://localhost:8001 \ +VITE_LECTURER_SERVICE_URL=http://localhost:8002 \ +VITE_COURSE_SERVICE_URL=http://localhost:8003 \ +VITE_ENROLLMENT_SERVICE_URL=http://localhost:8004 \ +npm run test:run +``` + +Say: "A secret is sensitive, like `AZURE_CREDENTIALS`. A variable is normal configuration, like `ACR_NAME`." + +To demonstrate "failed test → build skipped": break one backend test on a throwaway branch, push, and show `build-and-push` as skipped in the Actions tab. Do not do this on `main` unless asked. + +--- + +## 9. Staging → production (Continuous Delivery) + +```bash +cd /d/Github/SIT722-week08 +gh run list --workflow "02 - Deploy to Staging" --limit 5 +gh run list --workflow "03 - Staging Test" --limit 5 +git rev-parse HEAD +``` + +Use the SHA from the successful staging run, not just `HEAD` if newer commits exist. + +⚠️ 💲 Only if asked to run the production deployment: + +```bash +gh workflow run "04 - Deploy to Production" -f image_tag= +gh run watch +kubectl get svc frontend -n production +``` + +Say: "Delivery keeps the release ready but production is a manual step. Deployment would send every passing change to production automatically. The same tested image is promoted, with no rebuild." + +--- + +## 10. Monitoring ✅ (only if it is installed) + +```bash +kubectl get pods -n monitoring +helm list -n monitoring +``` + +If the namespace does not exist, monitoring is not installed. Explain it instead. + +```bash +kubectl port-forward -n monitoring service/prometheus-kube-prometheus-prometheus 9090:9090 +# http://localhost:9090 queries: up | kube_pod_status_phase{namespace="staging", phase="Running"} + +kubectl port-forward -n monitoring service/prometheus-grafana 3000:80 +# http://localhost:3000 (do not show passwords; stop Compose first, since it also uses port 3000) +``` + +Say: "Prometheus collects metrics. Grafana shows them in dashboards." + +--- + +## 11. Cleanup ⚠️ (saves credits; only for resources you created) + +The week08 ACR and AKS are running and cost credits. Preferably tear them down with Terraform, since it owns them: + +```bash +cd /d/Github/week08/terraform +terraform destroy # type: yes +``` + +Or delete the whole group: + +```bash +az group list -o table +az group delete --name koalatech-week08-rg --yes --no-wait +``` + +If you use `az group delete`, Terraform state becomes stale. Do not delete `deakinuni`, `NetworkWatcherRG` or `DefaultResourceGroup-EAU`. + +--- + +## 12. One-line pipeline answer + +"Git manages the code, GitHub Actions runs the tests, Docker packages the application, ACR stores the images, Kubernetes/AKS runs the containers, Terraform creates the Azure infrastructure, and monitoring watches it after deployment." diff --git a/course-service/app/main.py b/course-service/app/main.py index 7c8cbfb2..2a050130 100644 --- a/course-service/app/main.py +++ b/course-service/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from sqlalchemy.exc import OperationalError from app.db import Base, engine @@ -81,4 +82,11 @@ def health_check() -> dict[str, str]: return { "status": "healthy", "service": "course-service", - } \ No newline at end of file + } + + +# Expose Prometheus metrics at /metrics (Task 10.2D) +Instrumentator().instrument(app).expose( + app, + include_in_schema=False, +) diff --git a/course-service/requirements.txt b/course-service/requirements.txt index 58a1178e..c73be3ac 100644 --- a/course-service/requirements.txt +++ b/course-service/requirements.txt @@ -6,4 +6,5 @@ pydantic==2.11.7 PyJWT==2.10.1 python-dotenv==1.0.1 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==7.1.0 diff --git a/enrollment-service/app/main.py b/enrollment-service/app/main.py index 868063be..e9482d73 100644 --- a/enrollment-service/app/main.py +++ b/enrollment-service/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from sqlalchemy.exc import OperationalError from app.db import Base, engine @@ -81,4 +82,11 @@ def health_check() -> dict[str, str]: return { "status": "healthy", "service": "enrollment-service", - } \ No newline at end of file + } + + +# Expose Prometheus metrics at /metrics (Task 10.2D) +Instrumentator().instrument(app).expose( + app, + include_in_schema=False, +) diff --git a/enrollment-service/requirements.txt b/enrollment-service/requirements.txt index 58a1178e..c73be3ac 100644 --- a/enrollment-service/requirements.txt +++ b/enrollment-service/requirements.txt @@ -6,4 +6,5 @@ pydantic==2.11.7 PyJWT==2.10.1 python-dotenv==1.0.1 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==7.1.0 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6a1bcec6..ae258e47 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,7 +22,7 @@ ENV VITE_ENROLLMENT_SERVICE_URL=${VITE_ENROLLMENT_SERVICE_URL} RUN npm run build -FROM nginx:1.27-alpine +FROM nginx:1.31-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0b874fce..a3d405c0 100755 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -7,6 +7,11 @@ server { client_max_body_size 10M; + # Prometheus scrapes /metrics inside the cluster; never expose it publicly + location ~ ^/api/[^/]+/metrics { + return 404; + } + location /api/users/ { proxy_pass http://user-service:8000/; proxy_http_version 1.1; diff --git a/kubernetes/production/07-user-service.yaml b/kubernetes/production/07-user-service.yaml index 17fd2726..d8abd1bf 100644 --- a/kubernetes/production/07-user-service.yaml +++ b/kubernetes/production/07-user-service.yaml @@ -67,9 +67,12 @@ kind: Service metadata: name: user-service namespace: production + labels: + app: user-service spec: selector: app: user-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/production/08-student-service.yaml b/kubernetes/production/08-student-service.yaml index 5e66dfb7..f5f124cd 100644 --- a/kubernetes/production/08-student-service.yaml +++ b/kubernetes/production/08-student-service.yaml @@ -57,9 +57,12 @@ kind: Service metadata: name: student-service namespace: production + labels: + app: student-service spec: selector: app: student-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/production/09-lecturer-service.yaml b/kubernetes/production/09-lecturer-service.yaml index ad5f588a..65a6f3ec 100644 --- a/kubernetes/production/09-lecturer-service.yaml +++ b/kubernetes/production/09-lecturer-service.yaml @@ -57,9 +57,12 @@ kind: Service metadata: name: lecturer-service namespace: production + labels: + app: lecturer-service spec: selector: app: lecturer-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/production/10-course-service.yaml b/kubernetes/production/10-course-service.yaml index 3cdc7844..72163aa8 100644 --- a/kubernetes/production/10-course-service.yaml +++ b/kubernetes/production/10-course-service.yaml @@ -50,9 +50,12 @@ kind: Service metadata: name: course-service namespace: production + labels: + app: course-service spec: selector: app: course-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/production/11-enrollment-service.yaml b/kubernetes/production/11-enrollment-service.yaml index 16e23bde..353c35f2 100644 --- a/kubernetes/production/11-enrollment-service.yaml +++ b/kubernetes/production/11-enrollment-service.yaml @@ -50,9 +50,12 @@ kind: Service metadata: name: enrollment-service namespace: production + labels: + app: enrollment-service spec: selector: app: enrollment-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/staging/07-user-service.yaml b/kubernetes/staging/07-user-service.yaml index 7984533d..e37e867f 100644 --- a/kubernetes/staging/07-user-service.yaml +++ b/kubernetes/staging/07-user-service.yaml @@ -67,9 +67,12 @@ kind: Service metadata: name: user-service namespace: staging + labels: + app: user-service spec: selector: app: user-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/staging/08-student-service.yaml b/kubernetes/staging/08-student-service.yaml index 244ed083..78caaf6d 100644 --- a/kubernetes/staging/08-student-service.yaml +++ b/kubernetes/staging/08-student-service.yaml @@ -57,9 +57,12 @@ kind: Service metadata: name: student-service namespace: staging + labels: + app: student-service spec: selector: app: student-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/staging/09-lecturer-service.yaml b/kubernetes/staging/09-lecturer-service.yaml index 48a611f6..954aa48f 100644 --- a/kubernetes/staging/09-lecturer-service.yaml +++ b/kubernetes/staging/09-lecturer-service.yaml @@ -57,9 +57,12 @@ kind: Service metadata: name: lecturer-service namespace: staging + labels: + app: lecturer-service spec: selector: app: lecturer-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/staging/10-course-service.yaml b/kubernetes/staging/10-course-service.yaml index 652cd06b..c0f67580 100644 --- a/kubernetes/staging/10-course-service.yaml +++ b/kubernetes/staging/10-course-service.yaml @@ -50,9 +50,12 @@ kind: Service metadata: name: course-service namespace: staging + labels: + app: course-service spec: selector: app: course-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/kubernetes/staging/11-enrollment-service.yaml b/kubernetes/staging/11-enrollment-service.yaml index 87a21d4f..03e4afe3 100644 --- a/kubernetes/staging/11-enrollment-service.yaml +++ b/kubernetes/staging/11-enrollment-service.yaml @@ -50,9 +50,12 @@ kind: Service metadata: name: enrollment-service namespace: staging + labels: + app: enrollment-service spec: selector: app: enrollment-service ports: - - port: 8000 + - name: http + port: 8000 targetPort: 8000 \ No newline at end of file diff --git a/lecturer-service/app/main.py b/lecturer-service/app/main.py index 62972d48..de4b7a27 100644 --- a/lecturer-service/app/main.py +++ b/lecturer-service/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from sqlalchemy.exc import OperationalError from app.db import Base, engine @@ -95,4 +96,11 @@ def health_check() -> dict[str, str]: return { "status": "healthy", "service": "lecturer-service", - } \ No newline at end of file + } + + +# Expose Prometheus metrics at /metrics (Task 10.2D) +Instrumentator().instrument(app).expose( + app, + include_in_schema=False, +) diff --git a/lecturer-service/requirements.txt b/lecturer-service/requirements.txt index feeedd2d..74c51f02 100644 --- a/lecturer-service/requirements.txt +++ b/lecturer-service/requirements.txt @@ -8,4 +8,5 @@ python-multipart==0.0.20 python-dotenv==1.0.1 azure-storage-blob==12.26.0 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==7.1.0 diff --git a/monitoring/dashboard-koalatech.yaml b/monitoring/dashboard-koalatech.yaml new file mode 100644 index 00000000..d54cacea --- /dev/null +++ b/monitoring/dashboard-koalatech.yaml @@ -0,0 +1,465 @@ +# Grafana's sidecar loads any ConfigMap labelled grafana_dashboard="1" +apiVersion: v1 +kind: ConfigMap +metadata: + name: koalatech-dashboard + namespace: monitoring + labels: + grafana_dashboard: "1" +data: + koalatech-overview.json: | + { + "uid": "koalatech-overview", + "title": "KoalaTech - Application & Cluster Overview", + "tags": [ + "koalatech", + "sit722" + ], + "timezone": "browser", + "schemaVersion": 39, + "refresh": "30s", + "time": { + "from": "now-1h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus", + "label": "Data source", + "current": {} + }, + { + "name": "namespace", + "type": "custom", + "label": "Namespace", + "query": "production,staging", + "current": { + "text": "production", + "value": "production" + }, + "options": [ + { + "text": "production", + "value": "production", + "selected": true + }, + { + "text": "staging", + "value": "staging", + "selected": false + } + ] + } + ] + }, + "panels": [ + { + "id": 1, + "type": "stat", + "title": "Healthy service targets", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "none", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 5 + } + ] + } + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(up{namespace=\"$namespace\",job=~\".+-service\"})" + } + ] + }, + { + "id": 2, + "type": "stat", + "title": "Requests / sec (all services)", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(rate(http_requests_total{namespace=\"$namespace\"}[5m]))" + } + ] + }, + { + "id": 3, + "type": "stat", + "title": "5xx error ratio", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 6, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.05 + } + ] + } + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(rate(http_requests_total{namespace=\"$namespace\",status=\"5xx\"}[5m])) / clamp_min(sum(rate(http_requests_total{namespace=\"$namespace\"}[5m])), 1e-9)" + } + ] + }, + { + "id": 4, + "type": "stat", + "title": "Pod restarts (namespace)", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 18, + "y": 0, + "w": 6, + "h": 4 + }, + "fieldConfig": { + "defaults": { + "unit": "none", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + } + ] + } + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum(kube_pod_container_status_restarts_total{namespace=\"$namespace\"})" + } + ] + }, + { + "id": 5, + "type": "timeseries", + "title": "Request rate by service", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (service) (rate(http_requests_total{namespace=\"$namespace\"}[5m]))", + "legendFormat": "{{service}}" + } + ] + }, + { + "id": 6, + "type": "timeseries", + "title": "p95 latency by service", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 4, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "histogram_quantile(0.95, sum by (le, service) (rate(http_request_duration_seconds_bucket{namespace=\"$namespace\"}[5m])))", + "legendFormat": "{{service}}" + } + ] + }, + { + "id": 7, + "type": "timeseries", + "title": "Pod CPU usage", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 12, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "cores" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace=\"$namespace\",container!=\"\",container!=\"POD\"}[5m]))", + "legendFormat": "{{pod}}" + } + ] + }, + { + "id": 8, + "type": "timeseries", + "title": "Pod memory (working set)", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 12, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "bytes" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "sum by (pod) (container_memory_working_set_bytes{namespace=\"$namespace\",container!=\"\",container!=\"POD\"})", + "legendFormat": "{{pod}}" + } + ] + }, + { + "id": 9, + "type": "timeseries", + "title": "Node CPU utilisation", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 0, + "y": 20, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))", + "legendFormat": "{{instance}}" + } + ] + }, + { + "id": 10, + "type": "timeseries", + "title": "Node memory utilisation", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "gridPos": { + "x": 12, + "y": 20, + "w": 12, + "h": 8 + }, + "fieldConfig": { + "defaults": { + "unit": "percent" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "expr": "100 * (1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", + "legendFormat": "{{instance}}" + } + ] + } + ] + } diff --git a/monitoring/kube-prometheus-stack-values.yaml b/monitoring/kube-prometheus-stack-values.yaml new file mode 100644 index 00000000..a8ffe92a --- /dev/null +++ b/monitoring/kube-prometheus-stack-values.yaml @@ -0,0 +1,30 @@ +# Values for the kube-prometheus-stack Helm chart (Task 10.2D) +# Sized for a 3 x Standard_D2s_v3 AKS cluster that also runs staging + production. + +alertmanager: + enabled: false # alert routing is out of scope; saves node capacity + +grafana: + defaultDashboardsTimezone: Australia/Melbourne + service: + type: ClusterIP # accessed via kubectl port-forward, no extra public IP + sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + searchNamespace: ALL + +prometheus: + prometheusSpec: + retention: 2d + scrapeInterval: 30s + # Pick up my ServiceMonitor even without the Helm release label + serviceMonitorSelectorNilUsesHelmValues: false + podMonitorSelectorNilUsesHelmValues: false + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1536Mi diff --git a/monitoring/servicemonitor-koalatech.yaml b/monitoring/servicemonitor-koalatech.yaml new file mode 100644 index 00000000..a132cca9 --- /dev/null +++ b/monitoring/servicemonitor-koalatech.yaml @@ -0,0 +1,27 @@ +# Tells Prometheus to scrape /metrics on every KoalaTech backend Service +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: koalatech-services + namespace: monitoring + labels: + release: prometheus +spec: + namespaceSelector: + matchNames: + - staging + - production + selector: + matchExpressions: + - key: app + operator: In + values: + - user-service + - student-service + - lecturer-service + - course-service + - enrollment-service + endpoints: + - port: http + path: /metrics + interval: 15s diff --git a/student-service/app/main.py b/student-service/app/main.py index 0d85f2ad..bb8cece8 100644 --- a/student-service/app/main.py +++ b/student-service/app/main.py @@ -3,6 +3,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from sqlalchemy.exc import OperationalError from app.db import Base, engine @@ -102,4 +103,11 @@ def health_check() -> dict[str, str]: return { "status": "healthy", "service": "student-service", - } \ No newline at end of file + } + + +# Expose Prometheus metrics at /metrics (Task 10.2D) +Instrumentator().instrument(app).expose( + app, + include_in_schema=False, +) diff --git a/student-service/requirements.txt b/student-service/requirements.txt index feeedd2d..74c51f02 100644 --- a/student-service/requirements.txt +++ b/student-service/requirements.txt @@ -8,4 +8,5 @@ python-multipart==0.0.20 python-dotenv==1.0.1 azure-storage-blob==12.26.0 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==7.1.0 diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..487dc661 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# 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:4BEgtU/crlkbMduT7tm8Sp3+C+1RBfrpUIlTBM/wtMc=", + "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3", + "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae", + "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e", + "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151", + "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e", + "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea", + "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b", + "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b", + "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff", + "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 00000000..37bbf374 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,72 @@ +resource "azurerm_resource_group" "main" { + name = var.resource_group_name + location = var.location + tags = var.tags +} + +resource "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + sku = "Basic" + admin_enabled = false + tags = var.tags +} + +resource "azurerm_storage_account" "photos" { + name = var.storage_account_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + account_tier = "Standard" + account_replication_type = "LRS" + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + tags = var.tags +} + +# Names match AZURE_STORAGE_CONTAINER_NAME in kubernetes/*/08 and 09 manifests +resource "azurerm_storage_container" "student_photos" { + name = "student-profile-photo" + storage_account_id = azurerm_storage_account.photos.id + container_access_type = "private" +} + +resource "azurerm_storage_container" "lecturer_photos" { + name = "lecturer-profile-photo" + storage_account_id = azurerm_storage_account.photos.id + container_access_type = "private" +} + +resource "azurerm_kubernetes_cluster" "aks" { + name = var.aks_cluster_name + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + dns_prefix = "koalatech-w08" + + default_node_pool { + name = "system" + node_count = var.node_count + vm_size = var.node_vm_size + + upgrade_settings { + drain_timeout_in_minutes = 0 + max_surge = "10%" + node_soak_duration_in_minutes = 0 + } + } + + identity { + type = "SystemAssigned" + } + + tags = var.tags +} + +# Lets AKS pull images from ACR. Created once from my own account +# (the pipeline principal only has Contributor and cannot write role assignments). +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 +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 00000000..15c80bbe --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,16 @@ +output "resource_group_name" { + value = azurerm_resource_group.main.name +} + +output "acr_login_server" { + value = azurerm_container_registry.acr.login_server +} + +output "aks_cluster_name" { + value = azurerm_kubernetes_cluster.aks.name +} + +output "storage_connection_string" { + value = azurerm_storage_account.photos.primary_connection_string + sensitive = true +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 00000000..c75a56db --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,29 @@ +terraform { + required_version = ">= 1.6.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + # Remote state so the GitHub Actions runner and my laptop share one state file. + # Authentication to the state container uses ARM_ACCESS_KEY (never committed). + backend "azurerm" { + resource_group_name = "koalatech-tfstate-rg" + storage_account_name = "tfstates225404454w08" + container_name = "tfstate" + key = "week10.terraform.tfstate" + } +} + +provider "azurerm" { + features {} + + # The pipeline service principal is scoped to one resource group, so it cannot + # register subscription-level resource providers. They are already registered. + resource_provider_registrations = "none" + + # subscription_id is read from ARM_SUBSCRIPTION_ID +} diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 00000000..2b1ab8fa --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,8 @@ +# Task 10.2D - personal Azure for Students subscription +location = "australiaeast" +resource_group_name = "koalatech-week08-rg" +acr_name = "acrs225404454w10" +storage_account_name = "koalatechs225404454w10" +aks_cluster_name = "aks-koalatech-s225404454-w08" +node_count = 3 +node_vm_size = "Standard_B2s_v2" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..513e5611 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,42 @@ +variable "location" { + description = "Azure region" + type = string + default = "australiaeast" +} + +variable "resource_group_name" { + type = string +} + +variable "acr_name" { + type = string +} + +variable "storage_account_name" { + type = string +} + +variable "aks_cluster_name" { + type = string +} + +variable "node_count" { + description = "Staging + production + monitoring needs 3 nodes" + type = number + default = 3 +} + +variable "node_vm_size" { + type = string + default = "Standard_B2s_v2" +} + +variable "tags" { + type = map(string) + default = { + project = "koalatech" + unit = "SIT722" + managed_by = "terraform" + task = "10.2D" + } +} diff --git a/user-service/app/main.py b/user-service/app/main.py index 1b4769a6..d3bfb7c4 100644 --- a/user-service/app/main.py +++ b/user-service/app/main.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator from sqlalchemy import select from sqlalchemy.exc import OperationalError from sqlalchemy.orm import Session @@ -138,4 +139,11 @@ def health_check() -> dict[str, str]: return { "status": "healthy", "service": "user-service", - } \ No newline at end of file + } + + +# Expose Prometheus metrics at /metrics (Task 10.2D) +Instrumentator().instrument(app).expose( + app, + include_in_schema=False, +) diff --git a/user-service/requirements.txt b/user-service/requirements.txt index 02679ad8..cb236985 100644 --- a/user-service/requirements.txt +++ b/user-service/requirements.txt @@ -8,4 +8,5 @@ pwdlib[argon2]==0.2.1 python-multipart==0.0.20 pytest==8.4.1 httpx==0.28.1 -python-dotenv==1.0.1 \ No newline at end of file +python-dotenv==1.0.1 +prometheus-fastapi-instrumentator==7.1.0