diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..26baf125 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -2,18 +2,27 @@ name: 01 - CI on: # Trigger the workflow on push to main branch + # (merging a pull request into main is a push to main) push: branches: - main - + + # Run the tests on every pull request into main, so changes are + # validated before they are merged and deployed (Task 9.3C) + pull_request: + branches: + - main + # Manual trigger for the workflow workflow_dispatch: +permissions: + contents: read jobs: # ========================================================= - # Backend Tests + # Stage 1: Backend Tests # ========================================================= backend-test: name: Test ${{ matrix.service }} @@ -103,13 +112,18 @@ jobs: # ========================================================= - # Build and Push Docker Images + # Stage 2: Build, Security Scan (Docker Scout) and Push + # Images are scanned BEFORE they are pushed to ACR, so an + # image with fixable critical/high CVEs never reaches the + # registry or the cluster. # ========================================================= - build-and-push: - name: Build and Push ${{ matrix.image }} + build-scan-push: + name: Build, scan and push ${{ matrix.image }} runs-on: ubuntu-latest - # All backend tests must pass before this job starts + # All backend tests must pass. The Azure infrastructure (AKS, ACR, + # storage) is already provisioned, so the Terraform stage was removed + # for Task 9.3C, which focuses on the deployment stages. needs: - backend-test @@ -139,6 +153,9 @@ jobs: - service: enrollment-service image: koalatech-enrollment-service + env: + IMAGE_REF: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + steps: - name: Checkout repository @@ -153,14 +170,70 @@ jobs: run: | az acr login --name ${{ vars.ACR_NAME }} - - name: Build Docker image - run: | - docker build \ - --platform linux/amd64 \ - -t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \ - ./${{ matrix.service }} + # Docker Scout requires a Docker Hub account + - name: Login to Docker Hub (for Docker Scout) + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push Docker image with commit SHA - run: | - docker push \ - ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (load locally for scanning) + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.service }} + platforms: linux/amd64 + load: true + push: false + tags: ${{ env.IMAGE_REF }} + + - name: Docker Scout - vulnerability report (all severities) + uses: docker/scout-action@v1 + with: + command: quickview,cves + image: local://${{ env.IMAGE_REF }} + sarif-file: scout-${{ matrix.service }}.sarif + summary: true + exit-code: false + + - name: Docker Scout - base image recommendations + uses: docker/scout-action@v1 + with: + command: recommendations + image: local://${{ env.IMAGE_REF }} + summary: true + + - name: Upload Docker Scout report + if: always() + uses: actions/upload-artifact@v4 + with: + name: docker-scout-${{ matrix.service }} + path: scout-${{ matrix.service }}.sarif + if-no-files-found: ignore + + # Security gate: fail the build if the image contains + # critical/high vulnerabilities that already have a fix. + - name: Docker Scout - security gate + uses: docker/scout-action@v1 + with: + command: cves + image: local://${{ env.IMAGE_REF }} + only-severities: ${{ vars.SCOUT_FAIL_SEVERITIES || 'critical,high' }} + only-fixed: true + summary: false + exit-code: true + + # Only reached when the gate passes. Rebuilds from the build + # cache and pushes with an SBOM and provenance attestation + # (supply-chain policy finding from Task 10.1P). + - name: Push image with SBOM and provenance + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.service }} + platforms: linux/amd64 + push: true + sbom: true + provenance: mode=max + tags: ${{ env.IMAGE_REF }} diff --git a/.github/workflows/02-deploy-staging.yml b/.github/workflows/02-deploy-staging.yml index 006bb70d..41a3e6a5 100644 --- a/.github/workflows/02-deploy-staging.yml +++ b/.github/workflows/02-deploy-staging.yml @@ -1,151 +1,209 @@ -name: 02 - Deploy to Staging - -on: - workflow_run: - workflows: - - "01 - CI" - types: - - completed - branches: - - main - -jobs: - deploy-staging: - name: Deploy to Staging - runs-on: ubuntu-latest - - if: > - ${{ github.event.workflow_run.conclusion == 'success' }} - - environment: - name: staging - - steps: - - name: Checkout tested commit - uses: actions/checkout@v4 - with: - ref: ${{ github.event.workflow_run.head_sha }} - - - name: Login to Azure - uses: azure/login@v3 - 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: Create staging namespace - run: | - kubectl create namespace staging \ - --dry-run=client \ - -o yaml | kubectl apply -f - - - - name: Create PostgreSQL secret - run: | - kubectl create secret generic postgres-secret \ - --namespace staging \ - --from-literal=POSTGRES_USER="${{ secrets.POSTGRES_USER }}" \ - --from-literal=POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}" \ - --dry-run=client \ - -o yaml | kubectl apply -f - - - - name: Create application secret - run: | - kubectl create secret generic application-secret \ - --namespace staging \ - --from-literal=POSTGRES_USER="${{ secrets.POSTGRES_USER }}" \ - --from-literal=POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}" \ - --from-literal=JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" \ - --from-literal=DEFAULT_ADMIN_USERNAME="${{ secrets.DEFAULT_ADMIN_USERNAME }}" \ - --from-literal=DEFAULT_ADMIN_EMAIL="${{ secrets.DEFAULT_ADMIN_EMAIL }}" \ - --from-literal=DEFAULT_ADMIN_PASSWORD="${{ secrets.DEFAULT_ADMIN_PASSWORD }}" \ - --from-literal=AZURE_STORAGE_CONNECTION_STRING="${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ - --dry-run=client \ - -o yaml | kubectl apply -f - - - - name: Apply Kubernetes manifests - run: | - kubectl apply \ - -f kubernetes/staging/ - - - name: Update frontend image - run: | - kubectl set image deployment/frontend \ - frontend=${{ vars.ACR_LOGIN_SERVER }}/koalatech-frontend:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Update user-service image - run: | - kubectl set image deployment/user-service \ - user-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Update student-service image - run: | - kubectl set image deployment/student-service \ - student-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-student-service:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Update lecturer-service image - run: | - kubectl set image deployment/lecturer-service \ - lecturer-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-lecturer-service:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Update course-service image - run: | - kubectl set image deployment/course-service \ - course-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-course-service:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Update enrollment-service image - run: | - kubectl set image deployment/enrollment-service \ - enrollment-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-enrollment-service:${{ github.event.workflow_run.head_sha }} \ - -n staging - - - name: Wait for frontend rollout - run: | - kubectl rollout status deployment/frontend \ - -n staging \ - --timeout=300s - - - name: Wait for user-service rollout - run: | - kubectl rollout status deployment/user-service \ - -n staging \ - --timeout=300s - - - name: Wait for student-service rollout - run: | - kubectl rollout status deployment/student-service \ - -n staging \ - --timeout=300s - - - name: Wait for lecturer-service rollout - run: | - kubectl rollout status deployment/lecturer-service \ - -n staging \ - --timeout=300s - - - name: Wait for course-service rollout - run: | - kubectl rollout status deployment/course-service \ - -n staging \ - --timeout=300s - - - name: Wait for enrollment-service rollout - run: | - kubectl rollout status deployment/enrollment-service \ - -n staging \ - --timeout=300s - - - name: Show staging resources - run: | - kubectl get pods -n staging - kubectl get services -n staging - kubectl get pvc -n staging \ No newline at end of file +name: 02 - Deploy to Staging + +on: + workflow_run: + workflows: + - "01 - CI" + types: + - completed + branches: + - main + +jobs: + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + + if: >- + ${{ github.event.workflow_run.conclusion == 'success' }} + + environment: + name: staging + + steps: + - name: Checkout tested commit + uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Login to Azure + uses: azure/login@v3 + 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 + + # The storage account is (re)created by Terraform, so read its + # connection string at deploy time instead of storing it as a secret. + - name: Get storage connection string from Azure + run: | + STORAGE_ACCOUNT=$(az storage account list \ + --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ + --query "[?tags.ManagedBy=='Terraform'] | [0].name" -o tsv) + CONNECTION_STRING=$(az storage account show-connection-string \ + --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ + --name "$STORAGE_ACCOUNT" \ + --query connectionString -o tsv) + echo "::add-mask::$CONNECTION_STRING" + echo "AZURE_STORAGE_CONNECTION_STRING=$CONNECTION_STRING" >> "$GITHUB_ENV" + + # ===================================================== + # Monitoring: Prometheus + Grafana (kube-prometheus-stack) + # Installed before the application so new pods are + # scraped from the moment they start. + # ===================================================== + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Deploy Prometheus and Grafana + run: | + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts + helm repo update + helm upgrade --install monitoring prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --create-namespace \ + --values monitoring/kube-prometheus-stack-values.yaml \ + --set-string grafana.adminPassword="${{ secrets.GRAFANA_ADMIN_PASSWORD }}" \ + --wait \ + --timeout 10m + + - name: Configure application scraping and Grafana dashboard + run: | + kubectl apply -f monitoring/podmonitor.yaml + kubectl apply -f monitoring/grafana-dashboard.yaml + kubectl get pods -n monitoring + + - name: Create staging namespace + run: | + kubectl create namespace staging \ + --dry-run=client \ + -o yaml | kubectl apply -f - + + # Lets AKS pull images from ACR using the registry credentials + # (used because the lab cannot create an AcrPull role assignment). + - name: Create ACR image pull secret + run: | + ACR_USERNAME=$(az acr credential show --name ${{ vars.ACR_NAME }} --query username -o tsv) + ACR_PASSWORD=$(az acr credential show --name ${{ vars.ACR_NAME }} --query "passwords[0].value" -o tsv) + echo "::add-mask::$ACR_PASSWORD" + kubectl create secret docker-registry acr-pull-secret \ + --namespace staging \ + --docker-server=${{ vars.ACR_LOGIN_SERVER }} \ + --docker-username="$ACR_USERNAME" \ + --docker-password="$ACR_PASSWORD" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + kubectl patch serviceaccount default \ + --namespace staging \ + -p '{"imagePullSecrets":[{"name":"acr-pull-secret"}]}' + + - name: Create PostgreSQL secret + run: | + kubectl create secret generic postgres-secret \ + --namespace staging \ + --from-literal=POSTGRES_USER="${{ secrets.POSTGRES_USER }}" \ + --from-literal=POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + + - name: Create application secret + run: | + kubectl create secret generic application-secret \ + --namespace staging \ + --from-literal=POSTGRES_USER="${{ secrets.POSTGRES_USER }}" \ + --from-literal=POSTGRES_PASSWORD="${{ secrets.POSTGRES_PASSWORD }}" \ + --from-literal=JWT_SECRET_KEY="${{ secrets.JWT_SECRET_KEY }}" \ + --from-literal=DEFAULT_ADMIN_USERNAME="${{ secrets.DEFAULT_ADMIN_USERNAME }}" \ + --from-literal=DEFAULT_ADMIN_EMAIL="${{ secrets.DEFAULT_ADMIN_EMAIL }}" \ + --from-literal=DEFAULT_ADMIN_PASSWORD="${{ secrets.DEFAULT_ADMIN_PASSWORD }}" \ + --from-literal=AZURE_STORAGE_CONNECTION_STRING="$AZURE_STORAGE_CONNECTION_STRING" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + + - name: Apply Kubernetes manifests + run: | + kubectl apply \ + -f kubernetes/staging/ + + - name: Update frontend image + run: | + kubectl set image deployment/frontend \ + frontend=${{ vars.ACR_LOGIN_SERVER }}/koalatech-frontend:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Update user-service image + run: | + kubectl set image deployment/user-service \ + user-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-user-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Update student-service image + run: | + kubectl set image deployment/student-service \ + student-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-student-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Update lecturer-service image + run: | + kubectl set image deployment/lecturer-service \ + lecturer-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-lecturer-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Update course-service image + run: | + kubectl set image deployment/course-service \ + course-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-course-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Update enrollment-service image + run: | + kubectl set image deployment/enrollment-service \ + enrollment-service=${{ vars.ACR_LOGIN_SERVER }}/koalatech-enrollment-service:${{ github.event.workflow_run.head_sha }} \ + -n staging + + - name: Wait for frontend rollout + run: | + kubectl rollout status deployment/frontend \ + -n staging \ + --timeout=300s + + - name: Wait for user-service rollout + run: | + kubectl rollout status deployment/user-service \ + -n staging \ + --timeout=300s + + - name: Wait for student-service rollout + run: | + kubectl rollout status deployment/student-service \ + -n staging \ + --timeout=300s + + - name: Wait for lecturer-service rollout + run: | + kubectl rollout status deployment/lecturer-service \ + -n staging \ + --timeout=300s + + - name: Wait for course-service rollout + run: | + kubectl rollout status deployment/course-service \ + -n staging \ + --timeout=300s + + - name: Wait for enrollment-service rollout + run: | + kubectl rollout status deployment/enrollment-service \ + -n staging \ + --timeout=300s + + - name: Show staging resources + run: | + kubectl get pods -n staging + kubectl get services -n staging + kubectl get pvc -n staging \ No newline at end of file diff --git a/.github/workflows/03-staging-test.yml b/.github/workflows/03-staging-test.yml index 01daf5fa..c2f04231 100644 --- a/.github/workflows/03-staging-test.yml +++ b/.github/workflows/03-staging-test.yml @@ -14,7 +14,7 @@ jobs: name: Staging Smoke Test runs-on: ubuntu-latest - if: > + if: >- ${{ github.event.workflow_run.conclusion == 'success' }} environment: @@ -60,4 +60,71 @@ jobs: --fail \ --retry 10 \ --retry-delay 5 \ - http://${{ env.FRONTEND_IP }} \ No newline at end of file + http://${{ env.FRONTEND_IP }} + + - name: Test backend services through the frontend proxy + run: | + for service in users students lecturers courses enrollments; do + echo "Checking /api/$service/health" + curl --fail --silent --show-error \ + --retry 10 --retry-delay 5 --retry-all-errors \ + "http://${{ env.FRONTEND_IP }}/api/$service/health" + echo + done + + - name: Generate traffic for monitoring + run: | + for i in $(seq 1 30); do + for service in users students lecturers courses enrollments; do + curl --silent --output /dev/null "http://${{ env.FRONTEND_IP }}/api/$service/" + done + done + + # ===================================================== + # Monitoring validation + # ===================================================== + - name: Verify Prometheus is scraping the application + run: | + kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090 >/dev/null 2>&1 & + sleep 5 + + # Wait up to 5 minutes for all 5 staging services to be scraped and up + for i in $(seq 1 30); do + RESULT=$(curl -s --get http://localhost:9090/api/v1/query \ + --data-urlencode 'query=up{namespace="staging",container=~".+-service"}') + UP=$(jq '[.data.result[] | select(.value[1]=="1")] | length' <<< "$RESULT") + echo "Staging services up in Prometheus: $UP/5" + if [ "$UP" -ge 5 ]; then break; fi + sleep 10 + done + + { + echo "## Prometheus scrape targets (staging)" + echo "| Service | Pod | Up |" + echo "|---|---|---|" + jq -r '.data.result[] | "| \(.metric.container) | \(.metric.pod) | \(.value[1]) |"' <<< "$RESULT" + } >> "$GITHUB_STEP_SUMMARY" + + REQUESTS=$(curl -s --get http://localhost:9090/api/v1/query \ + --data-urlencode 'query=sum(http_requests_total{namespace="staging"})' \ + | jq -r '.data.result[0].value[1] // "0"') + echo "Total HTTP requests recorded in staging: $REQUESTS" + echo "Total HTTP requests recorded by Prometheus (staging): **$REQUESTS**" >> "$GITHUB_STEP_SUMMARY" + + [ "$UP" -ge 5 ] || { echo "Prometheus is not scraping all services"; exit 1; } + + - name: Verify Grafana and the KoalaTech dashboard + run: | + kubectl port-forward -n monitoring svc/kps-grafana 3000:80 >/dev/null 2>&1 & + sleep 5 + + curl --fail --silent --show-error http://localhost:3000/api/health + echo + + DASHBOARDS=$(curl --fail --silent --show-error \ + -u "admin:${{ secrets.GRAFANA_ADMIN_PASSWORD }}" \ + "http://localhost:3000/api/search?query=KoalaTech") + echo "$DASHBOARDS" | jq -r '.[].title' + [ "$(jq length <<< "$DASHBOARDS")" -ge 1 ] || { echo "Dashboard not found"; exit 1; } + + echo "Grafana is healthy and the **KoalaTech Services Overview** dashboard is provisioned." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml index 969d646b..f564f5fc 100644 --- a/.github/workflows/04-deploy-production.yml +++ b/.github/workflows/04-deploy-production.yml @@ -1,10 +1,21 @@ name: 04 - Deploy to Production on: + # Continuous Deployment (Task 9.3C): run automatically as soon as + # the staging smoke tests ("03 - Test Staging") finish on main. + workflow_run: + workflows: + - "03 - Test Staging" + types: + - completed + branches: + - main + + # Kept for manual rollback to an older, already-tested image workflow_dispatch: inputs: image_tag: - description: "Tested image SHA to deploy" + description: "Tested image SHA to deploy (rollback)" required: true type: string @@ -13,12 +24,23 @@ jobs: name: Deploy to Production runs-on: ubuntu-latest + # Only promote to production if the staging tests passed + if: >- + ${{ github.event_name == 'workflow_dispatch' || + github.event.workflow_run.conclusion == 'success' }} + environment: name: production + env: + # Same image SHA that was deployed and tested in staging (no rebuild) + IMAGE_TAG: ${{ github.event.workflow_run.head_sha || inputs.image_tag }} + 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 @@ -32,12 +54,44 @@ jobs: --name ${{ vars.AKS_CLUSTER_NAME }} \ --overwrite-existing + # The storage account is (re)created by Terraform, so read its + # connection string at deploy time instead of storing it as a secret. + - name: Get storage connection string from Azure + run: | + STORAGE_ACCOUNT=$(az storage account list \ + --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ + --query "[?tags.ManagedBy=='Terraform'] | [0].name" -o tsv) + CONNECTION_STRING=$(az storage account show-connection-string \ + --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ + --name "$STORAGE_ACCOUNT" \ + --query connectionString -o tsv) + echo "::add-mask::$CONNECTION_STRING" + echo "AZURE_STORAGE_CONNECTION_STRING=$CONNECTION_STRING" >> "$GITHUB_ENV" + - name: Create production namespace run: | kubectl create namespace production \ --dry-run=client \ -o yaml | kubectl apply -f - + # Lets AKS pull images from ACR using the registry credentials + # (used because the lab cannot create an AcrPull role assignment). + - name: Create ACR image pull secret + run: | + ACR_USERNAME=$(az acr credential show --name ${{ vars.ACR_NAME }} --query username -o tsv) + ACR_PASSWORD=$(az acr credential show --name ${{ vars.ACR_NAME }} --query "passwords[0].value" -o tsv) + echo "::add-mask::$ACR_PASSWORD" + kubectl create secret docker-registry acr-pull-secret \ + --namespace production \ + --docker-server=${{ vars.ACR_LOGIN_SERVER }} \ + --docker-username="$ACR_USERNAME" \ + --docker-password="$ACR_PASSWORD" \ + --dry-run=client \ + -o yaml | kubectl apply -f - + kubectl patch serviceaccount default \ + --namespace production \ + -p '{"imagePullSecrets":[{"name":"acr-pull-secret"}]}' + - name: Create PostgreSQL secret run: | kubectl create secret generic postgres-secret \ @@ -57,7 +111,7 @@ jobs: --from-literal=DEFAULT_ADMIN_USERNAME="${{ secrets.DEFAULT_ADMIN_USERNAME }}" \ --from-literal=DEFAULT_ADMIN_EMAIL="${{ secrets.DEFAULT_ADMIN_EMAIL }}" \ --from-literal=DEFAULT_ADMIN_PASSWORD="${{ secrets.DEFAULT_ADMIN_PASSWORD }}" \ - --from-literal=AZURE_STORAGE_CONNECTION_STRING="${{ secrets.AZURE_STORAGE_CONNECTION_STRING }}" \ + --from-literal=AZURE_STORAGE_CONNECTION_STRING="$AZURE_STORAGE_CONNECTION_STRING" \ --dry-run=client \ -o yaml | kubectl apply -f - @@ -69,37 +123,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 +196,9 @@ 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 + + - name: Verify monitoring stack is running + run: | + kubectl get pods -n monitoring + kubectl get podmonitor -n monitoring diff --git a/.github/workflows/99-destroy-infrastructure.yml b/.github/workflows/99-destroy-infrastructure.yml new file mode 100644 index 00000000..4168b02b --- /dev/null +++ b/.github/workflows/99-destroy-infrastructure.yml @@ -0,0 +1,58 @@ +name: 99 - Destroy Infrastructure + +# Manually tear down all Azure resources managed by Terraform +# (resource group, ACR, AKS, storage) to stop Azure charges. +on: + workflow_dispatch: + inputs: + confirm: + description: 'Type "destroy" to confirm' + required: true + type: string + +permissions: + contents: read + +jobs: + destroy: + name: Terraform destroy + runs-on: ubuntu-latest + if: inputs.confirm == 'destroy' + + defaults: + run: + working-directory: terraform + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Export Azure service principal for Terraform + env: + AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} + run: | + CLIENT_SECRET=$(jq -r '.clientSecret' <<< "$AZURE_CREDENTIALS") + echo "::add-mask::$CLIENT_SECRET" + { + echo "ARM_CLIENT_ID=$(jq -r '.clientId' <<< "$AZURE_CREDENTIALS")" + echo "ARM_CLIENT_SECRET=$CLIENT_SECRET" + echo "ARM_SUBSCRIPTION_ID=$(jq -r '.subscriptionId' <<< "$AZURE_CREDENTIALS")" + echo "ARM_TENANT_ID=$(jq -r '.tenantId' <<< "$AZURE_CREDENTIALS")" + } >> "$GITHUB_ENV" + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.8 + terraform_wrapper: false + + - name: Terraform init + run: | + terraform init -input=false \ + -backend-config="resource_group_name=${{ vars.TFSTATE_RESOURCE_GROUP }}" \ + -backend-config="storage_account_name=${{ vars.TFSTATE_STORAGE_ACCOUNT }}" \ + -backend-config="container_name=tfstate" \ + -backend-config="key=week08.terraform.tfstate" + + - name: Terraform destroy + run: terraform destroy -input=false -auto-approve diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..e5c59d92 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,44 @@ +name: Build and Deploy to AKS + +# Legacy single-service workflow (Task 9.3C). Superseded by the +# 01-04 pipeline, so it now only runs when triggered manually. +on: + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to Azure Container Registry + run: | + docker login ${{ secrets.ACR_LOGIN_SERVER }} \ + -u ${{ secrets.ACR_USERNAME }} \ + -p ${{ secrets.ACR_PASSWORD }} + + - name: Build Docker image + run: | + docker build -t ${{ secrets.ACR_LOGIN_SERVER }}/frontend:${{ github.sha }} . + + - name: Push image to ACR + run: | + docker push ${{ secrets.ACR_LOGIN_SERVER }}/frontend:${{ github.sha }} + + - name: Set up kubectl + run: | + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + sudo mv kubectl /usr/local/bin/ + + - name: Get AKS credentials + run: | + az login --service-principal -u ${{ secrets.AZURE_CLIENT_ID }} -p ${{ secrets.AZURE_CLIENT_SECRET }} --tenant ${{ secrets.AZURE_TENANT_ID }} || true + az aks get-credentials --resource-group ${{ secrets.AKS_RESOURCE_GROUP }} --name ${{ secrets.AKS_CLUSTER_NAME }} || true + + - name: Deploy to AKS + run: | + kubectl set image deployment/frontend frontend=${{ secrets.ACR_LOGIN_SERVER }}/frontend:${{ github.sha }} -n default || kubectl create deployment frontend --image=${{ secrets.ACR_LOGIN_SERVER }}/frontend:${{ github.sha }} -n default + kubectl rollout status deployment/frontend -n default || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4299cd1a..83dc6cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -196,4 +196,9 @@ cython_debug/ .cursorignore .cursorindexingignore -node_modules/ \ No newline at end of file +node_modules/ + +terraform/.terraform/ +terraform/terraform.tfstate +terraform/terraform.tfstate.backup +terraform/tfplan \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..75c9d5a0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY frontend/package*.json ./ + +RUN npm ci + +COPY frontend . . + +ARG VITE_USER_SERVICE_URL=/api/users +ARG VITE_STUDENT_SERVICE_URL=/api/students +ARG VITE_LECTURER_SERVICE_URL=/api/lecturers +ARG VITE_COURSE_SERVICE_URL=/api/courses +ARG VITE_ENROLLMENT_SERVICE_URL=/api/enrollments + +ENV VITE_USER_SERVICE_URL=${VITE_USER_SERVICE_URL} +ENV VITE_STUDENT_SERVICE_URL=${VITE_STUDENT_SERVICE_URL} +ENV VITE_LECTURER_SERVICE_URL=${VITE_LECTURER_SERVICE_URL} +ENV VITE_COURSE_SERVICE_URL=${VITE_COURSE_SERVICE_URL} +ENV VITE_ENROLLMENT_SERVICE_URL=${VITE_ENROLLMENT_SERVICE_URL} + +RUN npm run build + +FROM nginx:1.27-alpine + +COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/Task_9.3C_Report.docx b/Task_9.3C_Report.docx new file mode 100644 index 00000000..c92c89ed Binary files /dev/null and b/Task_9.3C_Report.docx differ diff --git a/course-service/Dockerfile b/course-service/Dockerfile index d750db8d..0ec0b6fe 100644 --- a/course-service/Dockerfile +++ b/course-service/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.12-slim WORKDIR /app +# Apply Debian security updates from the base image (fixes pcre2 +# CVE-2026-103111 flagged by the Docker Scout security gate) +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install \ diff --git a/course-service/app/main.py b/course-service/app/main.py index 7c8cbfb2..57d1a490 100644 --- a/course-service/app/main.py +++ b/course-service/app/main.py @@ -1,84 +1,94 @@ -import logging -import time -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from sqlalchemy.exc import OperationalError - -from app.db import Base, engine -from app.routers import courses - - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def initialise_database() -> None: - maximum_attempts = 10 - retry_delay_seconds = 5 - - for attempt in range(1, maximum_attempts + 1): - try: - Base.metadata.create_all(bind=engine) - - logger.info( - "Database connection established successfully." - ) - - return - - except OperationalError: - logger.warning( - "Database connection failed. Attempt %s of %s.", - attempt, - maximum_attempts, - ) - - if attempt == maximum_attempts: - logger.exception( - "Unable to connect to the database." - ) - raise - - time.sleep(retry_delay_seconds) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - initialise_database() - yield - - -app = FastAPI( - title="KoalaTech University Course Service", - description=( - "Manages courses and lecturer assignments " - "for KoalaTech University." - ), - version="1.0.0", - lifespan=lifespan, -) - - -app.include_router(courses.router) - - -@app.get("/", tags=["Health"]) -def root() -> dict[str, str]: - return { - "message": ( - "KoalaTech University Course Service is running." - ) - } - - -@app.get("/health", tags=["Health"]) -def health_check() -> dict[str, str]: - return { - "status": "healthy", - "service": "course-service", - } \ No newline at end of file +import logging +import time +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 +from app.routers import courses + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + + +def initialise_database() -> None: + maximum_attempts = 10 + retry_delay_seconds = 5 + + for attempt in range(1, maximum_attempts + 1): + try: + Base.metadata.create_all(bind=engine) + + logger.info( + "Database connection established successfully." + ) + + return + + except OperationalError: + logger.warning( + "Database connection failed. Attempt %s of %s.", + attempt, + maximum_attempts, + ) + + if attempt == maximum_attempts: + logger.exception( + "Unable to connect to the database." + ) + raise + + time.sleep(retry_delay_seconds) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialise_database() + yield + + +app = FastAPI( + title="KoalaTech University Course Service", + description=( + "Manages courses and lecturer assignments " + "for KoalaTech University." + ), + version="1.0.0", + lifespan=lifespan, +) + + +# Expose Prometheus metrics at /metrics so Prometheus can scrape +# request counts, latencies and error rates for this service. +Instrumentator( + excluded_handlers=["/metrics", "/health"], +).instrument(app).expose( + app, + include_in_schema=False, +) + +app.include_router(courses.router) + + +@app.get("/", tags=["Health"]) +def root() -> dict[str, str]: + return { + "message": ( + "KoalaTech University Course Service is running." + ) + } + + +@app.get("/health", tags=["Health"]) +def health_check() -> dict[str, str]: + return { + "status": "healthy", + "service": "course-service", + } \ No newline at end of file diff --git a/course-service/requirements.txt b/course-service/requirements.txt index 58a1178e..b44098f2 100644 --- a/course-service/requirements.txt +++ b/course-service/requirements.txt @@ -1,9 +1,11 @@ -fastapi==0.116.1 +fastapi==0.141.1 +starlette==1.7.0 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic==2.11.7 -PyJWT==2.10.1 +PyJWT==2.15.0 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==8.1.0 diff --git a/enrollment-service/Dockerfile b/enrollment-service/Dockerfile index d750db8d..0ec0b6fe 100644 --- a/enrollment-service/Dockerfile +++ b/enrollment-service/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.12-slim WORKDIR /app +# Apply Debian security updates from the base image (fixes pcre2 +# CVE-2026-103111 flagged by the Docker Scout security gate) +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install \ diff --git a/enrollment-service/app/main.py b/enrollment-service/app/main.py index 868063be..972ccecc 100644 --- a/enrollment-service/app/main.py +++ b/enrollment-service/app/main.py @@ -1,84 +1,94 @@ -import logging -import time -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from sqlalchemy.exc import OperationalError - -from app.db import Base, engine -from app.routers import enrollments - - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def initialise_database() -> None: - maximum_attempts = 10 - retry_delay_seconds = 5 - - for attempt in range(1, maximum_attempts + 1): - try: - Base.metadata.create_all(bind=engine) - - logger.info( - "Database connection established successfully." - ) - - return - - except OperationalError: - logger.warning( - "Database connection failed. Attempt %s of %s.", - attempt, - maximum_attempts, - ) - - if attempt == maximum_attempts: - logger.exception( - "Unable to connect to the database." - ) - raise - - time.sleep(retry_delay_seconds) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - initialise_database() - yield - - -app = FastAPI( - title="KoalaTech University Enrollment Service", - description=( - "Manages student course enrollments " - "for KoalaTech University." - ), - version="1.0.0", - lifespan=lifespan, -) - - -app.include_router(enrollments.router) - - -@app.get("/", tags=["Health"]) -def root() -> dict[str, str]: - return { - "message": ( - "KoalaTech University Enrollment Service is running." - ) - } - - -@app.get("/health", tags=["Health"]) -def health_check() -> dict[str, str]: - return { - "status": "healthy", - "service": "enrollment-service", - } \ No newline at end of file +import logging +import time +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 +from app.routers import enrollments + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + + +def initialise_database() -> None: + maximum_attempts = 10 + retry_delay_seconds = 5 + + for attempt in range(1, maximum_attempts + 1): + try: + Base.metadata.create_all(bind=engine) + + logger.info( + "Database connection established successfully." + ) + + return + + except OperationalError: + logger.warning( + "Database connection failed. Attempt %s of %s.", + attempt, + maximum_attempts, + ) + + if attempt == maximum_attempts: + logger.exception( + "Unable to connect to the database." + ) + raise + + time.sleep(retry_delay_seconds) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialise_database() + yield + + +app = FastAPI( + title="KoalaTech University Enrollment Service", + description=( + "Manages student course enrollments " + "for KoalaTech University." + ), + version="1.0.0", + lifespan=lifespan, +) + + +# Expose Prometheus metrics at /metrics so Prometheus can scrape +# request counts, latencies and error rates for this service. +Instrumentator( + excluded_handlers=["/metrics", "/health"], +).instrument(app).expose( + app, + include_in_schema=False, +) + +app.include_router(enrollments.router) + + +@app.get("/", tags=["Health"]) +def root() -> dict[str, str]: + return { + "message": ( + "KoalaTech University Enrollment Service is running." + ) + } + + +@app.get("/health", tags=["Health"]) +def health_check() -> dict[str, str]: + return { + "status": "healthy", + "service": "enrollment-service", + } \ No newline at end of file diff --git a/enrollment-service/requirements.txt b/enrollment-service/requirements.txt index 58a1178e..b44098f2 100644 --- a/enrollment-service/requirements.txt +++ b/enrollment-service/requirements.txt @@ -1,9 +1,11 @@ -fastapi==0.116.1 +fastapi==0.141.1 +starlette==1.7.0 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic==2.11.7 -PyJWT==2.10.1 +PyJWT==2.15.0 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==8.1.0 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6a1bcec6..96798374 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,33 +1,37 @@ -FROM node:22-alpine AS build - -WORKDIR /app - -COPY package*.json ./ - -RUN npm ci - -COPY . . - -ARG VITE_USER_SERVICE_URL=/api/users -ARG VITE_STUDENT_SERVICE_URL=/api/students -ARG VITE_LECTURER_SERVICE_URL=/api/lecturers -ARG VITE_COURSE_SERVICE_URL=/api/courses -ARG VITE_ENROLLMENT_SERVICE_URL=/api/enrollments - -ENV VITE_USER_SERVICE_URL=${VITE_USER_SERVICE_URL} -ENV VITE_STUDENT_SERVICE_URL=${VITE_STUDENT_SERVICE_URL} -ENV VITE_LECTURER_SERVICE_URL=${VITE_LECTURER_SERVICE_URL} -ENV VITE_COURSE_SERVICE_URL=${VITE_COURSE_SERVICE_URL} -ENV VITE_ENROLLMENT_SERVICE_URL=${VITE_ENROLLMENT_SERVICE_URL} - -RUN npm run build - -FROM nginx:1.27-alpine - -COPY nginx.conf /etc/nginx/conf.d/default.conf - -COPY --from=build /app/dist /usr/share/nginx/html - -EXPOSE 80 - -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +ARG VITE_USER_SERVICE_URL=/api/users +ARG VITE_STUDENT_SERVICE_URL=/api/students +ARG VITE_LECTURER_SERVICE_URL=/api/lecturers +ARG VITE_COURSE_SERVICE_URL=/api/courses +ARG VITE_ENROLLMENT_SERVICE_URL=/api/enrollments + +ENV VITE_USER_SERVICE_URL=${VITE_USER_SERVICE_URL} +ENV VITE_STUDENT_SERVICE_URL=${VITE_STUDENT_SERVICE_URL} +ENV VITE_LECTURER_SERVICE_URL=${VITE_LECTURER_SERVICE_URL} +ENV VITE_COURSE_SERVICE_URL=${VITE_COURSE_SERVICE_URL} +ENV VITE_ENROLLMENT_SERVICE_URL=${VITE_ENROLLMENT_SERVICE_URL} + +RUN npm run build + +FROM nginx:stable-alpine + +# Apply the latest Alpine security patches to the base image +# (fixes CVE-2026-93990 in expat reported by Docker Scout). +RUN apk upgrade --no-cache + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index 7f0d5636..637232fb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -9,6 +9,8 @@ body { background: #f5f5f5; } +/* checking */ + a { text-decoration: none; color: inherit; diff --git a/kubernetes/production/07-user-service.yaml b/kubernetes/production/07-user-service.yaml index 17fd2726..e097d692 100644 --- a/kubernetes/production/07-user-service.yaml +++ b/kubernetes/production/07-user-service.yaml @@ -1,75 +1,76 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: user-service - namespace: production -spec: - replicas: 1 - selector: - matchLabels: - app: user-service - template: - metadata: - labels: - app: user-service - spec: - containers: - - name: user-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: users - - name: POSTGRES_HOST - value: user-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: ACCESS_TOKEN_EXPIRE_MINUTES - value: "30" - - name: DEFAULT_ADMIN_USERNAME - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_USERNAME - - name: DEFAULT_ADMIN_EMAIL - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_EMAIL - - name: DEFAULT_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_PASSWORD - ---- -apiVersion: v1 -kind: Service -metadata: - name: user-service - namespace: production -spec: - selector: - app: user-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: users + - name: POSTGRES_HOST + value: user-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: ACCESS_TOKEN_EXPIRE_MINUTES + value: "30" + - name: DEFAULT_ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_USERNAME + - name: DEFAULT_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_EMAIL + - name: DEFAULT_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_PASSWORD + +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service + namespace: production +spec: + selector: + app: user-service + ports: + - 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..f84fd737 100644 --- a/kubernetes/production/08-student-service.yaml +++ b/kubernetes/production/08-student-service.yaml @@ -1,65 +1,66 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: student-service - namespace: production -spec: - replicas: 1 - selector: - matchLabels: - app: student-service - template: - metadata: - labels: - app: student-service - spec: - containers: - - name: student-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: students - - name: POSTGRES_HOST - value: student-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: AZURE_STORAGE_CONNECTION_STRING - valueFrom: - secretKeyRef: - name: application-secret - key: AZURE_STORAGE_CONNECTION_STRING - - name: AZURE_STORAGE_CONTAINER_NAME - value: student-profile-photo - ---- -apiVersion: v1 -kind: Service -metadata: - name: student-service - namespace: production -spec: - selector: - app: student-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: student-service + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: student-service + template: + metadata: + labels: + app: student-service + spec: + containers: + - name: student-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: students + - name: POSTGRES_HOST + value: student-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: AZURE_STORAGE_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: application-secret + key: AZURE_STORAGE_CONNECTION_STRING + - name: AZURE_STORAGE_CONTAINER_NAME + value: student-profile-photo + +--- +apiVersion: v1 +kind: Service +metadata: + name: student-service + namespace: production +spec: + selector: + app: student-service + ports: + - 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..1d1aabb0 100644 --- a/kubernetes/production/09-lecturer-service.yaml +++ b/kubernetes/production/09-lecturer-service.yaml @@ -1,65 +1,66 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: lecturer-service - namespace: production -spec: - replicas: 1 - selector: - matchLabels: - app: lecturer-service - template: - metadata: - labels: - app: lecturer-service - spec: - containers: - - name: lecturer-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: lecturers - - name: POSTGRES_HOST - value: lecturer-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: AZURE_STORAGE_CONNECTION_STRING - valueFrom: - secretKeyRef: - name: application-secret - key: AZURE_STORAGE_CONNECTION_STRING - - name: AZURE_STORAGE_CONTAINER_NAME - value: lecturer-profile-photo - ---- -apiVersion: v1 -kind: Service -metadata: - name: lecturer-service - namespace: production -spec: - selector: - app: lecturer-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lecturer-service + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: lecturer-service + template: + metadata: + labels: + app: lecturer-service + spec: + containers: + - name: lecturer-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: lecturers + - name: POSTGRES_HOST + value: lecturer-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: AZURE_STORAGE_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: application-secret + key: AZURE_STORAGE_CONNECTION_STRING + - name: AZURE_STORAGE_CONTAINER_NAME + value: lecturer-profile-photo + +--- +apiVersion: v1 +kind: Service +metadata: + name: lecturer-service + namespace: production +spec: + selector: + app: lecturer-service + ports: + - 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..1a220c91 100644 --- a/kubernetes/production/10-course-service.yaml +++ b/kubernetes/production/10-course-service.yaml @@ -1,58 +1,59 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: course-service - namespace: production -spec: - replicas: 1 - selector: - matchLabels: - app: course-service - template: - metadata: - labels: - app: course-service - spec: - containers: - - name: course-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: courses - - name: POSTGRES_HOST - value: course-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - ---- -apiVersion: v1 -kind: Service -metadata: - name: course-service - namespace: production -spec: - selector: - app: course-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: course-service + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: course-service + template: + metadata: + labels: + app: course-service + spec: + containers: + - name: course-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: courses + - name: POSTGRES_HOST + value: course-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + +--- +apiVersion: v1 +kind: Service +metadata: + name: course-service + namespace: production +spec: + selector: + app: course-service + ports: + - 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..96fa7a46 100644 --- a/kubernetes/production/11-enrollment-service.yaml +++ b/kubernetes/production/11-enrollment-service.yaml @@ -1,58 +1,59 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: enrollment-service - namespace: production -spec: - replicas: 1 - selector: - matchLabels: - app: enrollment-service - template: - metadata: - labels: - app: enrollment-service - spec: - containers: - - name: enrollment-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: enrollments - - name: POSTGRES_HOST - value: enrollment-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - ---- -apiVersion: v1 -kind: Service -metadata: - name: enrollment-service - namespace: production -spec: - selector: - app: enrollment-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: enrollment-service + namespace: production +spec: + replicas: 1 + selector: + matchLabels: + app: enrollment-service + template: + metadata: + labels: + app: enrollment-service + spec: + containers: + - name: enrollment-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: enrollments + - name: POSTGRES_HOST + value: enrollment-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + +--- +apiVersion: v1 +kind: Service +metadata: + name: enrollment-service + namespace: production +spec: + selector: + app: enrollment-service + ports: + - 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..1d69aafa 100644 --- a/kubernetes/staging/07-user-service.yaml +++ b/kubernetes/staging/07-user-service.yaml @@ -1,75 +1,76 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: user-service - namespace: staging -spec: - replicas: 1 - selector: - matchLabels: - app: user-service - template: - metadata: - labels: - app: user-service - spec: - containers: - - name: user-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: users - - name: POSTGRES_HOST - value: user-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: ACCESS_TOKEN_EXPIRE_MINUTES - value: "30" - - name: DEFAULT_ADMIN_USERNAME - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_USERNAME - - name: DEFAULT_ADMIN_EMAIL - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_EMAIL - - name: DEFAULT_ADMIN_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: DEFAULT_ADMIN_PASSWORD - ---- -apiVersion: v1 -kind: Service -metadata: - name: user-service - namespace: staging -spec: - selector: - app: user-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: user-service + namespace: staging +spec: + replicas: 1 + selector: + matchLabels: + app: user-service + template: + metadata: + labels: + app: user-service + spec: + containers: + - name: user-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: users + - name: POSTGRES_HOST + value: user-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: ACCESS_TOKEN_EXPIRE_MINUTES + value: "30" + - name: DEFAULT_ADMIN_USERNAME + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_USERNAME + - name: DEFAULT_ADMIN_EMAIL + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_EMAIL + - name: DEFAULT_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: DEFAULT_ADMIN_PASSWORD + +--- +apiVersion: v1 +kind: Service +metadata: + name: user-service + namespace: staging +spec: + selector: + app: user-service + ports: + - 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..69a65bdb 100644 --- a/kubernetes/staging/08-student-service.yaml +++ b/kubernetes/staging/08-student-service.yaml @@ -1,65 +1,66 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: student-service - namespace: staging -spec: - replicas: 1 - selector: - matchLabels: - app: student-service - template: - metadata: - labels: - app: student-service - spec: - containers: - - name: student-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: students - - name: POSTGRES_HOST - value: student-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: AZURE_STORAGE_CONNECTION_STRING - valueFrom: - secretKeyRef: - name: application-secret - key: AZURE_STORAGE_CONNECTION_STRING - - name: AZURE_STORAGE_CONTAINER_NAME - value: student-profile-photo - ---- -apiVersion: v1 -kind: Service -metadata: - name: student-service - namespace: staging -spec: - selector: - app: student-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: student-service + namespace: staging +spec: + replicas: 1 + selector: + matchLabels: + app: student-service + template: + metadata: + labels: + app: student-service + spec: + containers: + - name: student-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: students + - name: POSTGRES_HOST + value: student-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: AZURE_STORAGE_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: application-secret + key: AZURE_STORAGE_CONNECTION_STRING + - name: AZURE_STORAGE_CONTAINER_NAME + value: student-profile-photo + +--- +apiVersion: v1 +kind: Service +metadata: + name: student-service + namespace: staging +spec: + selector: + app: student-service + ports: + - 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..5bb0249b 100644 --- a/kubernetes/staging/09-lecturer-service.yaml +++ b/kubernetes/staging/09-lecturer-service.yaml @@ -1,65 +1,66 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: lecturer-service - namespace: staging -spec: - replicas: 1 - selector: - matchLabels: - app: lecturer-service - template: - metadata: - labels: - app: lecturer-service - spec: - containers: - - name: lecturer-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: lecturers - - name: POSTGRES_HOST - value: lecturer-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - - name: AZURE_STORAGE_CONNECTION_STRING - valueFrom: - secretKeyRef: - name: application-secret - key: AZURE_STORAGE_CONNECTION_STRING - - name: AZURE_STORAGE_CONTAINER_NAME - value: lecturer-profile-photo - ---- -apiVersion: v1 -kind: Service -metadata: - name: lecturer-service - namespace: staging -spec: - selector: - app: lecturer-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: lecturer-service + namespace: staging +spec: + replicas: 1 + selector: + matchLabels: + app: lecturer-service + template: + metadata: + labels: + app: lecturer-service + spec: + containers: + - name: lecturer-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: lecturers + - name: POSTGRES_HOST + value: lecturer-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + - name: AZURE_STORAGE_CONNECTION_STRING + valueFrom: + secretKeyRef: + name: application-secret + key: AZURE_STORAGE_CONNECTION_STRING + - name: AZURE_STORAGE_CONTAINER_NAME + value: lecturer-profile-photo + +--- +apiVersion: v1 +kind: Service +metadata: + name: lecturer-service + namespace: staging +spec: + selector: + app: lecturer-service + ports: + - 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..4391922b 100644 --- a/kubernetes/staging/10-course-service.yaml +++ b/kubernetes/staging/10-course-service.yaml @@ -1,58 +1,59 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: course-service - namespace: staging -spec: - replicas: 1 - selector: - matchLabels: - app: course-service - template: - metadata: - labels: - app: course-service - spec: - containers: - - name: course-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: courses - - name: POSTGRES_HOST - value: course-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - ---- -apiVersion: v1 -kind: Service -metadata: - name: course-service - namespace: staging -spec: - selector: - app: course-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: course-service + namespace: staging +spec: + replicas: 1 + selector: + matchLabels: + app: course-service + template: + metadata: + labels: + app: course-service + spec: + containers: + - name: course-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: courses + - name: POSTGRES_HOST + value: course-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + +--- +apiVersion: v1 +kind: Service +metadata: + name: course-service + namespace: staging +spec: + selector: + app: course-service + ports: + - 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..cbadedaf 100644 --- a/kubernetes/staging/11-enrollment-service.yaml +++ b/kubernetes/staging/11-enrollment-service.yaml @@ -1,58 +1,59 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: enrollment-service - namespace: staging -spec: - replicas: 1 - selector: - matchLabels: - app: enrollment-service - template: - metadata: - labels: - app: enrollment-service - spec: - containers: - - name: enrollment-service - image: placeholder - imagePullPolicy: Always - ports: - - containerPort: 8000 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: application-secret - key: POSTGRES_PASSWORD - - name: POSTGRES_DB - value: enrollments - - name: POSTGRES_HOST - value: enrollment-db - - name: POSTGRES_PORT - value: "5432" - - name: JWT_SECRET_KEY - valueFrom: - secretKeyRef: - name: application-secret - key: JWT_SECRET_KEY - - name: JWT_ALGORITHM - value: HS256 - ---- -apiVersion: v1 -kind: Service -metadata: - name: enrollment-service - namespace: staging -spec: - selector: - app: enrollment-service - ports: - - port: 8000 - targetPort: 8000 \ No newline at end of file +apiVersion: apps/v1 +kind: Deployment +metadata: + name: enrollment-service + namespace: staging +spec: + replicas: 1 + selector: + matchLabels: + app: enrollment-service + template: + metadata: + labels: + app: enrollment-service + spec: + containers: + - name: enrollment-service + image: placeholder + imagePullPolicy: Always + ports: + - name: http + containerPort: 8000 + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: application-secret + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: enrollments + - name: POSTGRES_HOST + value: enrollment-db + - name: POSTGRES_PORT + value: "5432" + - name: JWT_SECRET_KEY + valueFrom: + secretKeyRef: + name: application-secret + key: JWT_SECRET_KEY + - name: JWT_ALGORITHM + value: HS256 + +--- +apiVersion: v1 +kind: Service +metadata: + name: enrollment-service + namespace: staging +spec: + selector: + app: enrollment-service + ports: + - port: 8000 + targetPort: 8000 \ No newline at end of file diff --git a/lecturer-service/Dockerfile b/lecturer-service/Dockerfile index d750db8d..0ec0b6fe 100644 --- a/lecturer-service/Dockerfile +++ b/lecturer-service/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.12-slim WORKDIR /app +# Apply Debian security updates from the base image (fixes pcre2 +# CVE-2026-103111 flagged by the Docker Scout security gate) +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install \ diff --git a/lecturer-service/app/main.py b/lecturer-service/app/main.py index 62972d48..17afc039 100644 --- a/lecturer-service/app/main.py +++ b/lecturer-service/app/main.py @@ -1,98 +1,108 @@ -import logging -import time -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from sqlalchemy.exc import OperationalError - -from app.db import Base, engine -from app.routers import lecturers -from app.storage import ensure_container_exists - - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def initialise_database() -> None: - maximum_attempts = 10 - retry_delay_seconds = 5 - - for attempt in range(1, maximum_attempts + 1): - try: - Base.metadata.create_all(bind=engine) - - logger.info( - "Database connection established successfully." - ) - - return - - except OperationalError: - logger.warning( - "Database connection failed. Attempt %s of %s.", - attempt, - maximum_attempts, - ) - - if attempt == maximum_attempts: - logger.exception( - "Unable to connect to the database." - ) - raise - - time.sleep(retry_delay_seconds) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - initialise_database() - - try: - ensure_container_exists() - - logger.info( - "Azure Blob Storage container is ready." - ) - except Exception: - logger.warning( - "Azure Blob Storage is unavailable. " - "Profile-photo uploads may fail." - ) - - yield - - -app = FastAPI( - title="KoalaTech University Lecturer Service", - description=( - "Manages lecturer records and lecturer profile photos " - "for KoalaTech University." - ), - version="1.0.0", - lifespan=lifespan, -) - - -app.include_router(lecturers.router) - - -@app.get("/", tags=["Health"]) -def root() -> dict[str, str]: - return { - "message": ( - "KoalaTech University Lecturer Service is running." - ) - } - - -@app.get("/health", tags=["Health"]) -def health_check() -> dict[str, str]: - return { - "status": "healthy", - "service": "lecturer-service", - } \ No newline at end of file +import logging +import time +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 +from app.routers import lecturers +from app.storage import ensure_container_exists + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + + +def initialise_database() -> None: + maximum_attempts = 10 + retry_delay_seconds = 5 + + for attempt in range(1, maximum_attempts + 1): + try: + Base.metadata.create_all(bind=engine) + + logger.info( + "Database connection established successfully." + ) + + return + + except OperationalError: + logger.warning( + "Database connection failed. Attempt %s of %s.", + attempt, + maximum_attempts, + ) + + if attempt == maximum_attempts: + logger.exception( + "Unable to connect to the database." + ) + raise + + time.sleep(retry_delay_seconds) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialise_database() + + try: + ensure_container_exists() + + logger.info( + "Azure Blob Storage container is ready." + ) + except Exception: + logger.warning( + "Azure Blob Storage is unavailable. " + "Profile-photo uploads may fail." + ) + + yield + + +app = FastAPI( + title="KoalaTech University Lecturer Service", + description=( + "Manages lecturer records and lecturer profile photos " + "for KoalaTech University." + ), + version="1.0.0", + lifespan=lifespan, +) + + +# Expose Prometheus metrics at /metrics so Prometheus can scrape +# request counts, latencies and error rates for this service. +Instrumentator( + excluded_handlers=["/metrics", "/health"], +).instrument(app).expose( + app, + include_in_schema=False, +) + +app.include_router(lecturers.router) + + +@app.get("/", tags=["Health"]) +def root() -> dict[str, str]: + return { + "message": ( + "KoalaTech University Lecturer Service is running." + ) + } + + +@app.get("/health", tags=["Health"]) +def health_check() -> dict[str, str]: + return { + "status": "healthy", + "service": "lecturer-service", + } \ No newline at end of file diff --git a/lecturer-service/requirements.txt b/lecturer-service/requirements.txt index feeedd2d..3dc31b17 100644 --- a/lecturer-service/requirements.txt +++ b/lecturer-service/requirements.txt @@ -1,11 +1,13 @@ -fastapi==0.116.1 +fastapi==0.141.1 +starlette==1.7.0 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic[email]==2.11.7 -PyJWT==2.10.1 -python-multipart==0.0.20 +PyJWT==2.15.0 +python-multipart==0.0.32 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==8.1.0 diff --git a/monitoring/grafana-dashboard.yaml b/monitoring/grafana-dashboard.yaml new file mode 100644 index 00000000..3c05a55a --- /dev/null +++ b/monitoring/grafana-dashboard.yaml @@ -0,0 +1,664 @@ +# Grafana dashboard, loaded automatically by the Grafana sidecar +# (it watches ConfigMaps 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 Services Overview", + "tags": [ + "koalatech", + "sit722" + ], + "timezone": "browser", + "schemaVersion": 39, + "version": 1, + "refresh": "30s", + "time": { + "from": "now-1h", + "to": "now" + }, + "templating": { + "list": [ + { + "name": "namespace", + "label": "Environment", + "type": "custom", + "query": "staging,production", + "current": { + "text": "staging", + "value": "staging" + }, + "options": [ + { + "text": "staging", + "value": "staging", + "selected": true + }, + { + "text": "production", + "value": "production", + "selected": false + } + ] + } + ] + }, + "panels": [ + { + "id": 1, + "title": "Services up", + "type": "stat", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(up{namespace=\"$namespace\",container=~\".+-service\"})", + "legendFormat": "Up" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "green", + "value": 5 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value_and_name" + } + }, + { + "id": 2, + "title": "Total request rate", + "type": "stat", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 6, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(http_requests_total{namespace=\"$namespace\"}[5m]))", + "legendFormat": "req/s" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps" + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value_and_name" + } + }, + { + "id": 3, + "title": "5xx error ratio", + "type": "stat", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(rate(http_requests_total{namespace=\"$namespace\",status=\"5xx\"}[5m])) / clamp_min(sum(rate(http_requests_total{namespace=\"$namespace\"}[5m])), 1e-9)", + "legendFormat": "errors" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.01 + }, + { + "color": "red", + "value": 0.05 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value_and_name" + } + }, + { + "id": 4, + "title": "Pod restarts (1h)", + "type": "stat", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 18, + "y": 0, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum(increase(kube_pod_container_status_restarts_total{namespace=\"$namespace\"}[1h]))", + "legendFormat": "restarts" + } + ], + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value_and_name" + } + }, + { + "id": 5, + "title": "Request rate by service", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 4, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (container) (rate(http_requests_total{namespace=\"$namespace\"}[5m]))", + "legendFormat": "{{container}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 6, + "title": "p95 latency by service", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 4, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, sum by (le, container) (rate(http_request_duration_seconds_bucket{namespace=\"$namespace\"}[5m])))", + "legendFormat": "{{container}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 7, + "title": "Requests by status class", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 12, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (status) (rate(http_requests_total{namespace=\"$namespace\"}[5m]))", + "legendFormat": "{{status}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 8, + "title": "Scrape target health", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 12, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "up{namespace=\"$namespace\",container=~\".+-service\"}", + "legendFormat": "{{container}}" + } + ], + "fieldConfig": { + "defaults": { + "min": 0, + "max": 1 + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 9, + "title": "Pod CPU usage", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 20, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace=\"$namespace\",container!=\"\"}[5m]))", + "legendFormat": "{{pod}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "cores", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 10, + "title": "Pod memory (working set)", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 20, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "sum by (pod) (container_memory_working_set_bytes{namespace=\"$namespace\",container!=\"\"})", + "legendFormat": "{{pod}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 11, + "title": "Node CPU utilisation", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 28, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "1 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m]))", + "legendFormat": "{{instance}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + }, + { + "id": 12, + "title": "Node memory utilisation", + "type": "timeseries", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 12, + "y": 28, + "w": 12, + "h": 8 + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)", + "legendFormat": "{{instance}}" + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "custom": { + "lineWidth": 2, + "fillOpacity": 8 + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "calcs": [ + "lastNotNull", + "max" + ] + }, + "tooltip": { + "mode": "multi" + } + } + } + ] + } diff --git a/monitoring/kube-prometheus-stack-values.yaml b/monitoring/kube-prometheus-stack-values.yaml new file mode 100644 index 00000000..9a9908cd --- /dev/null +++ b/monitoring/kube-prometheus-stack-values.yaml @@ -0,0 +1,58 @@ +# Helm values for prometheus-community/kube-prometheus-stack +# Installed by .github/workflows/02-deploy-staging.yml into the "monitoring" namespace. +# +# Prometheus scrapes: +# - Kubernetes/node metrics (kube-state-metrics, node-exporter, kubelet/cAdvisor) +# - KoalaTech FastAPI services via monitoring/podmonitor.yaml (/metrics) +# Grafana loads the KoalaTech dashboard from monitoring/grafana-dashboard.yaml. + +fullnameOverride: kps + +# Alertmanager is not needed for this task and would use extra cluster capacity. +alertmanager: + enabled: false + +# AKS manages the control plane, so these components cannot be scraped. +# Disabling them avoids permanently "down" targets. +kubeControllerManager: + enabled: false +kubeScheduler: + enabled: false +kubeEtcd: + enabled: false +kubeProxy: + enabled: false + +prometheus: + prometheusSpec: + scrapeInterval: 30s + retention: 2d + # Select every PodMonitor/ServiceMonitor in the cluster, not only + # those labelled with this Helm release. + podMonitorSelectorNilUsesHelmValues: false + serviceMonitorSelectorNilUsesHelmValues: false + ruleSelectorNilUsesHelmValues: false + resources: + requests: + cpu: 200m + memory: 512Mi + limits: + memory: 1536Mi + +grafana: + fullnameOverride: kps-grafana + # adminPassword is supplied by the pipeline from the GRAFANA_ADMIN_PASSWORD secret. + service: + type: ClusterIP + sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + searchNamespace: monitoring + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + memory: 512Mi diff --git a/monitoring/podmonitor.yaml b/monitoring/podmonitor.yaml new file mode 100644 index 00000000..20978fc5 --- /dev/null +++ b/monitoring/podmonitor.yaml @@ -0,0 +1,29 @@ +# Tells Prometheus to scrape the /metrics endpoint exposed by each +# KoalaTech FastAPI service (prometheus-fastapi-instrumentator) +# in both the staging and production namespaces. +apiVersion: monitoring.coreos.com/v1 +kind: PodMonitor +metadata: + name: koalatech-services + namespace: monitoring + labels: + app.kubernetes.io/part-of: koalatech +spec: + namespaceSelector: + matchNames: + - staging + - production + selector: + matchExpressions: + - key: app + operator: In + values: + - user-service + - student-service + - lecturer-service + - course-service + - enrollment-service + podMetricsEndpoints: + - port: http + path: /metrics + interval: 15s diff --git a/student-service/Dockerfile b/student-service/Dockerfile index d750db8d..0ec0b6fe 100644 --- a/student-service/Dockerfile +++ b/student-service/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.12-slim WORKDIR /app +# Apply Debian security updates from the base image (fixes pcre2 +# CVE-2026-103111 flagged by the Docker Scout security gate) +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install \ diff --git a/student-service/app/db.py b/student-service/app/db.py index 08674b94..4e20937d 100644 --- a/student-service/app/db.py +++ b/student-service/app/db.py @@ -40,6 +40,7 @@ class Base(DeclarativeBase): ) + def get_db() -> Generator[Session, None, None]: db = SessionLocal() diff --git a/student-service/app/main.py b/student-service/app/main.py index 0d85f2ad..2d49d651 100644 --- a/student-service/app/main.py +++ b/student-service/app/main.py @@ -1,105 +1,115 @@ -import logging -import time -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from sqlalchemy.exc import OperationalError - -from app.db import Base, engine -from app.routers import students -from app.storage import ensure_container_exists - - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def initialise_database() -> None: - maximum_attempts = 10 - retry_delay_seconds = 5 - - for attempt in range(1, maximum_attempts + 1): - try: - Base.metadata.create_all(bind=engine) - - logger.info( - "Database connection established successfully." - ) - - return - - except OperationalError: - logger.warning( - "Database connection failed. Attempt %s of %s.", - attempt, - maximum_attempts, - ) - - if attempt == maximum_attempts: - logger.exception( - "Unable to connect to the database." - ) - raise - - time.sleep(retry_delay_seconds) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - initialise_database() - - try: - ensure_container_exists() - - logger.info( - "Azure Blob Storage container is ready." - ) - except Exception: - logger.warning( - "Azure Blob Storage is unavailable. " - "The service will run, but profile-photo " - "uploads may fail." - ) - - yield - - -app = FastAPI( - title="KoalaTech University Student Service", - description=( - "Manages student records and student profile photos " - "for KoalaTech University." - ), - version="1.0.0", - lifespan=lifespan, -) - - -app.include_router(students.router) - - -@app.get( - "/", - tags=["Health"], -) -def root() -> dict[str, str]: - return { - "message": ( - "KoalaTech University Student Service is running." - ) - } - - -@app.get( - "/health", - tags=["Health"], -) -def health_check() -> dict[str, str]: - return { - "status": "healthy", - "service": "student-service", - } \ No newline at end of file +import logging +import time +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 +from app.routers import students +from app.storage import ensure_container_exists + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + + +def initialise_database() -> None: + maximum_attempts = 10 + retry_delay_seconds = 5 + + for attempt in range(1, maximum_attempts + 1): + try: + Base.metadata.create_all(bind=engine) + + logger.info( + "Database connection established successfully." + ) + + return + + except OperationalError: + logger.warning( + "Database connection failed. Attempt %s of %s.", + attempt, + maximum_attempts, + ) + + if attempt == maximum_attempts: + logger.exception( + "Unable to connect to the database." + ) + raise + + time.sleep(retry_delay_seconds) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialise_database() + + try: + ensure_container_exists() + + logger.info( + "Azure Blob Storage container is ready." + ) + except Exception: + logger.warning( + "Azure Blob Storage is unavailable. " + "The service will run, but profile-photo " + "uploads may fail." + ) + + yield + + +app = FastAPI( + title="KoalaTech University Student Service", + description=( + "Manages student records and student profile photos " + "for KoalaTech University." + ), + version="1.0.0", + lifespan=lifespan, +) + + +# Expose Prometheus metrics at /metrics so Prometheus can scrape +# request counts, latencies and error rates for this service. +Instrumentator( + excluded_handlers=["/metrics", "/health"], +).instrument(app).expose( + app, + include_in_schema=False, +) + +app.include_router(students.router) + + +@app.get( + "/", + tags=["Health"], +) +def root() -> dict[str, str]: + return { + "message": ( + "KoalaTech University Student Service is running." + ) + } + + +@app.get( + "/health", + tags=["Health"], +) +def health_check() -> dict[str, str]: + return { + "status": "healthy", + "service": "student-service", + } \ No newline at end of file diff --git a/student-service/requirements.txt b/student-service/requirements.txt index feeedd2d..3dc31b17 100644 --- a/student-service/requirements.txt +++ b/student-service/requirements.txt @@ -1,11 +1,13 @@ -fastapi==0.116.1 +fastapi==0.141.1 +starlette==1.7.0 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic[email]==2.11.7 -PyJWT==2.10.1 -python-multipart==0.0.20 +PyJWT==2.15.0 +python-multipart==0.0.32 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==8.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/backend.tf b/terraform/backend.tf new file mode 100644 index 00000000..ae7a9dcd --- /dev/null +++ b/terraform/backend.tf @@ -0,0 +1,12 @@ +# Remote state in Azure Storage so the GitHub Actions pipeline (and you, +# locally) share one Terraform state file. +# +# The backend settings are passed at init time, e.g. +# terraform init \ +# -backend-config="resource_group_name=" \ +# -backend-config="storage_account_name=" \ +# -backend-config="container_name=tfstate" \ +# -backend-config="key=week08.terraform.tfstate" +terraform { + backend "azurerm" {} +} diff --git a/terraform/container_registry.tf b/terraform/container_registry.tf new file mode 100644 index 00000000..13c9396d --- /dev/null +++ b/terraform/container_registry.tf @@ -0,0 +1,15 @@ +resource "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = local.resource_group_name + location = local.location + + sku = "Basic" + admin_enabled = true + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} diff --git a/terraform/kubernetes_service.tf b/terraform/kubernetes_service.tf new file mode 100644 index 00000000..30a762c7 --- /dev/null +++ b/terraform/kubernetes_service.tf @@ -0,0 +1,39 @@ +resource "azurerm_kubernetes_cluster" "aks" { + name = var.aks_cluster_name + location = local.location + resource_group_name = local.resource_group_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" { + # Creating role assignments needs Owner / User Access Administrator. + # When that is not available (lab environment) the pipeline uses an + # ACR image pull secret instead. + count = var.grant_acr_pull_role ? 1 : 0 + + 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/output.tf b/terraform/output.tf new file mode 100644 index 00000000..534424db --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,47 @@ +output "resource_group_name" { + description = "Name of the resource group" + value = local.resource_group_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 "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", + local.resource_group_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}" +} diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf new file mode 100644 index 00000000..f5b5a9c1 --- /dev/null +++ b/terraform/resource_group.tf @@ -0,0 +1,27 @@ +# Either create a new resource group (own subscription, e.g. Azure for +# Students) or reuse an existing, pre-provisioned one (Deakin lab +# environment, where only the "deakinuni" resource group is available). +resource "azurerm_resource_group" "rg" { + count = var.use_existing_resource_group ? 0 : 1 + + name = var.resource_group_name + location = var.location + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +data "azurerm_resource_group" "existing" { + count = var.use_existing_resource_group ? 1 : 0 + + name = var.resource_group_name +} + +locals { + resource_group_name = var.use_existing_resource_group ? data.azurerm_resource_group.existing[0].name : azurerm_resource_group.rg[0].name + location = var.use_existing_resource_group ? data.azurerm_resource_group.existing[0].location : azurerm_resource_group.rg[0].location +} diff --git a/terraform/storage_account.tf b/terraform/storage_account.tf new file mode 100644 index 00000000..5c303e01 --- /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 = local.resource_group_name + location = local.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" +} diff --git a/terraform/terraform.tfstate.1789060423.backup b/terraform/terraform.tfstate.1789060423.backup new file mode 100644 index 00000000..66939be6 --- /dev/null +++ b/terraform/terraform.tfstate.1789060423.backup @@ -0,0 +1,391 @@ +{ + "version": 4, + "terraform_version": "1.15.8", + "serial": 13, + "lineage": "62729c0d-830c-8d0f-92d5-7b9c92401edf", + "outputs": {}, + "resources": [ + { + "mode": "managed", + "type": "azurerm_container_registry", + "name": "acr", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 2, + "attributes": { + "admin_enabled": true, + "admin_password": "8vPXZcXtO8ydPmesu6CvKMOFEquvSp0Z9Z7AMOVH8378aQ7zwW8JJQQJ99CIACL93NaEqg7NAAACAZCRkZPj", + "admin_username": "sit722acrtf2306357w08", + "anonymous_pull_enabled": false, + "azuread_authentication_as_arm_policy_enabled": true, + "data_endpoint_enabled": false, + "data_endpoint_host_names": [], + "encryption": [], + "export_policy_enabled": true, + "georeplications": [], + "id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/koalatech-week08-rg/providers/Microsoft.ContainerRegistry/registries/sit722acrtf2306357w08", + "identity": [], + "location": "australiaeast", + "login_server": "sit722acrtf2306357w08.azurecr.io", + "name": "sit722acrtf2306357w08", + "network_rule_bypass_for_tasks_enabled": false, + "network_rule_bypass_option": "AzureServices", + "network_rule_set": [], + "public_network_access_enabled": true, + "quarantine_policy_enabled": false, + "resource_group_name": "koalatech-week08-rg", + "retention_policy_in_days": 0, + "role_assignment_mode": "LegacyRegistryPermissions", + "sku": "Basic", + "tags": { + "Environment": "staging-production", + "ManagedBy": "Terraform", + "Practical": "Week08", + "Project": "KoalaTech Course Platform" + }, + "timeouts": null, + "trust_policy_enabled": false, + "zone_redundancy_enabled": false + }, + "sensitive_attributes": [ + [ + { + "type": "get_attr", + "value": "admin_password" + } + ] + ], + "identity_schema_version": 0, + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxODAwMDAwMDAwMDAwLCJkZWxldGUiOjE4MDAwMDAwMDAwMDAsInJlYWQiOjMwMDAwMDAwMDAwMCwidXBkYXRlIjoxODAwMDAwMDAwMDAwfSwic2NoZW1hX3ZlcnNpb24iOiIyIn0=", + "dependencies": [ + "azurerm_resource_group.rg" + ] + } + ] + }, + { + "mode": "managed", + "type": "azurerm_kubernetes_cluster", + "name": "aks", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 2, + "attributes": { + "aci_connector_linux": [], + "ai_toolchain_operator_enabled": false, + "api_server_access_profile": [], + "auto_scaler_profile": [], + "automatic_upgrade_channel": "", + "azure_active_directory_role_based_access_control": [], + "azure_policy_enabled": false, + "bootstrap_profile": [ + { + "artifact_source": "Direct", + "container_registry_id": "" + } + ], + "confidential_computing": [], + "cost_analysis_enabled": false, + "current_kubernetes_version": "1.36.1", + "custom_ca_trust_certificates_base64": [], + "default_node_pool": [ + { + "auto_scaling_enabled": false, + "capacity_reservation_group_id": "", + "fips_enabled": false, + "gpu_driver": "", + "gpu_instance": "", + "host_encryption_enabled": false, + "host_group_id": "", + "kubelet_config": [], + "kubelet_disk_type": "OS", + "linux_os_config": [], + "max_count": 0, + "max_pods": 250, + "min_count": 0, + "name": "default", + "node_count": 3, + "node_labels": {}, + "node_network_profile": [], + "node_public_ip_enabled": false, + "node_public_ip_prefix_id": "", + "only_critical_addons_enabled": false, + "orchestrator_version": "1.36.1", + "os_disk_size_gb": 128, + "os_disk_type": "Managed", + "os_sku": "Ubuntu", + "pod_subnet_id": "", + "proximity_placement_group_id": "", + "scale_down_mode": "Delete", + "snapshot_id": "", + "tags": {}, + "temporary_name_for_rotation": "", + "type": "VirtualMachineScaleSets", + "ultra_ssd_enabled": false, + "upgrade_settings": [ + { + "drain_timeout_in_minutes": 0, + "max_surge": "10%", + "node_soak_duration_in_minutes": 0, + "undrainable_node_behavior": "" + } + ], + "vm_size": "Standard_D2s_v3", + "vnet_subnet_id": "", + "workload_runtime": "", + "zones": [] + } + ], + "disk_encryption_set_id": "", + "dns_prefix": "koalatechw08", + "dns_prefix_private_cluster": "", + "edge_zone": "", + "fqdn": "koalatechw08-r3vr6md6.hcp.australiaeast.azmk8s.io", + "http_application_routing_enabled": false, + "http_application_routing_zone_name": "", + "http_proxy_config": [], + "id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/koalatech-week08-rg/providers/Microsoft.ContainerService/managedClusters/aks-sit722-week08", + "identity": [ + { + "identity_ids": [], + "principal_id": "c4a7a2c7-5573-4b46-aa90-7baa7d927a52", + "tenant_id": "2625129d-99a2-4df5-988e-5c5d07e7d0fb", + "type": "SystemAssigned" + } + ], + "image_cleaner_enabled": null, + "image_cleaner_interval_hours": null, + "ingress_application_gateway": [], + "key_management_service": [], + "key_vault_secrets_provider": [], + "kube_admin_config": [], + "kube_admin_config_raw": "", + "kube_config": [ + { + "client_certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZIVENDQXdXZ0F3SUJBZ0lRTzBZZlVqMDFhYkozTVk0TFdIbHk5ekFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TmpBNU1UQXhNekF6TVRaYUZ3MHlPREE1TVRBeE16RXpNVFphTURBeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1SVXdFd1lEVlFRREV3eHRZWE4wWlhKamJHbGxiblF3CmdnSWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUNEd0F3Z2dJS0FvSUNBUUROY0NpWXp0Wjk2a0lCNmVtd0NuaWgKR1UrNmJDZ0tYZUFkNENEUTRWN3ZyY0RxaU9DeFVUMHluQUFoc3JKb0x6Y0VpeXZGYWgvMThXOEJuaHF3d1BJdApXaW1aOUFnOXVpQmxDWFBhZFplYk9iWG9jdWp3UEVkRG0zbjJoWEQvcU9KQnVGeERMWUljcCtTYXE1RkxhL2tpCjJMQklmN0JDZllFWVFJcVZQS3pBdTNZVFJ4Wk03RkFjWlhJOWtuSnpPNWNmN1hzTnFlY1E2V0xwcHNmdkprS3oKVlRNR01WZ0kvaGZRRmFnc1ZwWnpTQS9Callqa0ViSUVnMVY5MkxJSVAvWDhqSTRleFI0dWNoM3BPNnJzbktGRApoK2pQeS9tZTJ5S2liMk5qb3ptRWR5MUpZSi9jTFBxWko2RCt5NjdDNFBvSXhYUGE5aGRKOHlRbnYydVNZTk9mCnZTaHpUZExKSnQrbm1tYU5EUTMyZUtodVU1c3JWUGY5eDV3NWVnMENDWUhZdXJzdFMxcUJ3WGUvTFFYVVlaVmoKdHJRbitMZ0kxOUg2blZTb2FGM2NPWWluY2ZFeEpVRWVkRTM1T093dTUydmx1VHFlTXBZaUwxVHBWNysrd1NjUQpBUDdRMkMxdDZXSHJ3OVFEVTQ3UUNSZWVBSDUranRrM1UzelR5L3o2MWxTa0hPOW1UcFNENzJ4aFRza3dEY1JKCk1XMlBDbHlMenBVQWJpZzk4aEQ2MGxIdmM3OHNDOVhjQzRXUk5WWXZyZHNmRGp1aEp4MDFhbzk3TUlFRENPa0cKcUhDbTcvSHQ5bzNvV29QZUo5QnVEa2lKMlpPQ0xMMnRQQVhUc0FreWpHWUR1K1JaZElBQUZoK01aQ2dNWGRtagpHbk1rQVdSTUFnLy9LbVNBaFoydU1RSURBUUFCbzFZd1ZEQU9CZ05WSFE4QkFmOEVCQU1DQmFBd0V3WURWUjBsCkJBd3dDZ1lJS3dZQkJRVUhBd0l3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlFNaHhoRWRVcFcKWmVmRXBrcWFrcW5XNk91dDBEQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FnRUFwSXpsRmxwaFpZNkxna2F0bFc1MwpSeEMyWTV4M2xPV1pTWHVYbk9VemRtcmViQWJIZ1VrSmU5MHNhNWxxWGt5WEszM3dBZ0F3NGdTRm5JMklSTEZSCjM0VThvSVYvQ1ZzWk1BU3VNNVVlZlI1eDdpYk10S2xZZE8rcnRSbHI2VysxcUh2R29MZFZxUmdVNGdlb3hMYXQKZjhYRjZNbUFiNmVicmpUTUR2NUxHTXovNWN3RWdHQlhtSkRHbFV4N3F5MEdHUVg1VGJXQy90Q0tlZHZsUDJacgplNFo4WjdidS83MldWVmN0ZVNncVJNU2NteVhTSTl5Y1Y1eUU3T3p1Z2JXbUJ4N2QvNmFvZTNBMTR1S2o2Ykh3CjVyaWJ4aWlpU2czTm9oYkQ2S2hXTjRNQml5Nkc0Mm1ZbEIxd2duaG95ZC9iS05aamp4TkNXNUhsRENYNU9TTGkKTmpuOHpHc050aTYwMVNQdHg4NVZRL1BiNjVwaU5oTDdEZjZRQmI2Q3N5R01VeGNBT05mZzBMK0VKN1puV1JjOQpDcjZ5bnVrZFNTNzRoZEx2S241WG13NVhHdlJuNnQ0LzMrVFR3aDltVDRUWmxJZlFRYjdxNXdMR1RiTFhKL1lICmtMVFBFSVN3YjVFa1FLRi9FYlppby9FbUxZK01odVhXdUViTyszcytTUlVNbE5HTVdkME4vNWIzTzlsd3M0aXAKSmFlRFdjUTJXRW13L0NJcmQ1Y0pNMVRheEQrSE8vd05MZytaN1FKMHdqY3VVenN3NjZiQXF0R1AzdkpVR1RWMQpwc3hkS2RRdXA2V2hOMjZXWlptbUtWaE1aTmZocTVDMmxOSTJOQ3UvOWJoY3ZTZjZzOElUbFU4bUd3dm5GdnUxCjhGNW9PdHZzOWRQdWwvalNkekl4eGVvPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==", + "client_key": "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS2dJQkFBS0NBZ0VBelhBb21NN1dmZXBDQWVucHNBcDRvUmxQdW13b0NsM2dIZUFnME9GZTc2M0E2b2pnCnNWRTlNcHdBSWJLeWFDODNCSXNyeFdvZjlmRnZBWjRhc01EeUxWb3BtZlFJUGJvZ1pRbHoybldYbXptMTZITG8KOER4SFE1dDU5b1Z3LzZqaVFiaGNReTJDSEtma21xdVJTMnY1SXRpd1NIK3dRbjJCR0VDS2xUeXN3THQyRTBjVwpUT3hRSEdWeVBaSnljenVYSCsxN0Rhbm5FT2xpNmFiSDd5WkNzMVV6QmpGWUNQNFgwQldvTEZhV2MwZ1B3WTJJCjVCR3lCSU5WZmRpeUNELzEvSXlPSHNVZUxuSWQ2VHVxN0p5aFE0Zm96OHY1bnRzaW9tOWpZNk01aEhjdFNXQ2YKM0N6Nm1TZWcvc3V1d3VENkNNVnoydllYU2ZNa0o3OXJrbURUbjcwb2MwM1N5U2JmcDVwbWpRME45bmlvYmxPYgpLMVQzL2NlY09Yb05BZ21CMkxxN0xVdGFnY0YzdnkwRjFHR1ZZN2EwSi9pNENOZlIrcDFVcUdoZDNEbUlwM0h4Ck1TVkJIblJOK1Rqc0x1ZHI1Yms2bmpLV0lpOVU2VmUvdnNFbkVBRCswTmd0YmVsaDY4UFVBMU9PMEFrWG5nQisKZm83Wk4xTjgwOHY4K3RaVXBCenZaazZVZys5c1lVN0pNQTNFU1RGdGp3cGNpODZWQUc0b1BmSVErdEpSNzNPLwpMQXZWM0F1RmtUVldMNjNiSHc0N29TY2ROV3FQZXpDQkF3anBCcWh3cHUveDdmYU42RnFEM2lmUWJnNUlpZG1UCmdpeTlyVHdGMDdBSk1veG1BN3ZrV1hTQUFCWWZqR1FvREYzWm94cHpKQUZrVEFJUC95cGtnSVdkcmpFQ0F3RUEKQVFLQ0FnQVRUWXBscFVCT2VpQXo5Q25DelR1OVkxYWU4bXo4Q3dzS1hrQ25HbTM1cTVsTit0NGw1YmRsZ0hhYgpYcUl0eGpYdld0dWFMb08zUlFxdThROUZTVjZDb3JuNFNib3IxcDBXOFVHWXBydVZMYmNwRXF4T3hQM1YzVG9qClR0d3B0dEYwNXd1SzU2L3JNbDZqKzgwOTlCOW5jS0IyWE1KbEtrd05jODhIQVE3NFdGb3dBSVREWmVHTWNYOFUKOHZtSEEwazFYNTBwZTZ2Y084UlJVUEdiUjd5S1dNM2ZzMERpMk1FZHRQM2xhMGl5cDBtTkN3S2E3MkMxMU5pZApFbFhVRHZKMWhZZ1FYYjdJRXNLUVkzZGNIbkYybWhnUmQ1M0tROVo5YmJyb2UwYzdGTzd2NUpZbUJzeVFieGYzCnFPVmxwbzd5Y1FuQmxWQTdOZzhtZEViT3dsaWxPc3BCVVhpWFNMTkRFcStqRi92WWZGQTdzTk5EUTVFaGJZencKY2hsdzNBL1pXWHo4Mk5vSkExaUR1YklEYXBjUm1YQittQVA5cDMrVTBUZ3pMblJlN3ByZlBkd2IwRlp3NmRYYgp2L0RaNGJad0ZQTFZwZ2dyZFNFN2N6UThUSlVWekhTbUxkUEwzaTRWZVdTSlZVelo1dkRZMUVzSlFoU3I3amZKCkx4ZUdjV0hOWHFtYmxhbTY3aVRORVZzZm1ybWI3VWJNNHlURS9oaHh5N2VQV01pSnhJM1lBWTl3eUxMZExOS0QKYTNMeEljQmVGSjZZTUUzSUs2ZW9zZlF4UktBKzF2MnFXdE1KZEFuTnhhVjB5VlVSU1A0ZlBNYmlDY2FmZkpNRwo5M1dWMTVPNy8zVVdEU1U3UGxkRnc0TFo5VUxWa0UxTkladlp1TlB4RU12dzk1MXY4d0tDQVFFQTVqVlJYTURhCkpFcEhiWUQvZ094YWJreWYyanErek94Szl3MmQwQ2l3ZDE3NkJ1cDNTWnBHVFo0U29pTi9GUW9mWXRwajJkSUkKakJ1RldwZFNlb28wMGRkQlY4RDRUdlVpbXJhTjFkYi9yTDNVclFWbzZTR1p5UW1uQ3F1bDR0cHJ3RmRBQ1oyeAprLzF5YTJIWWlYeCtlVGJ3L3N0eW1xb2NtUUdmZ0VtTGRTbkliSDU3THBMc2s1R3FBNklEZlhWTzFtTUYvd1FtCjBMSkZubFRXTVJUR0pXQklwU2drd1h2cVVYQkNwaklNdnhLa2F5Rnk2TDFuYVhJMkYvbHhQbnFtQklPR0RFVDIKWUhSelZsTmdwQzJZSnBRVHdwVUlQWTJKSElZdm9hUkpqYjVJcU1nQnYwcS9VYml0NEZkZHZBbmFUbk1QamNSTgpHK0RKZHlUT2RHcmt4d0tDQVFFQTVIUm1SMFRHR05aSDJiRjk1Mm9XSmRZVVpjaW9ZODRvSHBGSVlsN0F4WkU0CjdmMWNRQlp5VUNwa1hyQmZJSEZLS3hCR0lFMHRyaDBCZEE3eW5Ja1FEV2xOdk42M3ZiazA0amFwWEN1bDg4cWcKLzhmdDU1UTRCc0lVRXhIR25kOTBkS2hlNkFUaFpaMXFiNEw3SWZNSWpDNWZIdUFjR2FGaG9RUUV0T0VHZ2lqSQphK3cwKzIvQmlISWFPVmhKa1RVU1ZZME4yRWw4SWZ6UWZnejNyQ1hqTjF0WlBoa2FiQSthWGdGQ014eHZaZnJrCnRGTzRBNXJoajJlOWpKQ056T1B0UUZ2TG95VVlSZ01aUW45NW1DYktYM3phekQ5aThCVUVneXRnb2RoQmhKRFgKZDgrZ1J5R3ZBazkxanhBVkZZOUl1M2xFcFVBSUJGd1NUOFZuaEhUdFJ3S0NBUUVBbjc2ZDMvUS9LM0dUMFN2egpvTjNoL3FvVFB0Y0c4YUtDZnA4blJId0h3RDNhMkJPNWhReVYvbVJUWDhtcjlNaWFjUGxFMHJESmw1ZTlXTmlxCnRqZlRZVTJ5blJkZDkwUnBwOXhIM2Z5Y1hKMDV5SHA1aTFUeXB5dVlpM0lDNGNGWmhoWEZiU2JOUS9sYWZhTmYKSkVDTWJzYXVRdENnalN6QVBYaC8yd0N0MGphVlNwd1dGSUtGd2YwUmNnWHAwQlFvQ2VLYkZaUW03eWpWNElIVQpSQ2k3T3RKdWRwUzl0TG10SnFmMHgranhLV0hoVUNuYzJCNGVkWEI0a3ZoWUdkZjMvaWZhVEFkS3hESHFteHV1CmN4N2tkdGpJUVgwelE2dEVXdFBPbDZjdUZ6clVwckpyYS9lTmZVTGUvVThDejJveTM3OCtLc3p1Q1F0YUllQWIKbDNOWm53S0NBUUVBemJ2Ulc3OGdrcjl1SnlxRFVzS2p2V0VrTDJGYjI3Mk5QYkhpT014UjE5bm5KZE96bnZkWAo3TCt3Y2VGdFdWOE14dWs1N2Q3ekUwaUZ4WW1zakxHV0ppMjkxeWZPMm1tT3FXVkdjWDNSVGxJZFdSc29NUk8yCnZNZE1vcnV3WFA0ZDl1eWx3MWFaMFJQSkJURTBqYWM2L0ZyS1NKalVWWFNTNGhrblQra1NIbWN4enBjQ1E2aXAKNWdHemdnN3krWFNNMVVSWXduMnk3enlvV2N1OXcyYS9UMXZSYTIzWjhKVEFsSVdYbzBXcFFIS0lRYWpmSDBmOApkMU9hVEpybFNDMit1K1d1NW5ZQ2ZSSlBUaVpVVDJRbmZZU0gyY1gvaUd1MUM1Y2x4N3BqamttTjhSU21wMGc3ClBkNW01UFk3dHBXNWp6TXZVWTN2YWxXc0cvQ1BQNmE1QXdLQ0FRRUFscWVtZmZVd3Y2OUI5d2QveFNQc3dVRjMKd3picjc2SWNQRlUzUWIva204aTkzcXBHZnBxdENpZFUrUVg4RGcyeTY3Zmtqc3p1ZEhrK3c1ZzV5K3UwWXNhOApTSm1FUGsxelpPaWtETzRrTnhLMGFBRXdVeFgwZkcwWTN0TVN1Z3FwM3pYcFZyMHZSTXdSeDl4SlhJT2xzNGphCktiU0Y4ZVYvb1p4aG04ckR2WkhZWjR5eDNBWmI3Wkg0M2prY3lBWmRFYkVGTHlyNFQ5ZUw0K0lBNi9KTHhqaGYKY3hGcmJWOVU5ejF0VUQwdEx5ODBucXg1OXA0WGN2cGdNeThjQSt3c2JMb2Fyemxrc1dMTVRLSDVUd3M2SHdoago3bHQwbGRlVWNKVTlycDBYSUZwZlpzMysrSWkvZ3BZVjBkNGNjRWI3RkI4SFV3QWNCWTlzQnh3c1JWekkwZz09Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==", + "cluster_ca_certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUU2RENDQXRDZ0F3SUJBZ0lRUXZ2K3dkYklRVVF2WGhqQlZFdTF3ekFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWdGdzB5TmpBNU1UQXhNekF6TVRWYUdBOHlNRFUyTURreE1ERXpNVE14TlZvdwpEVEVMTUFrR0ExVUVBeE1DWTJFd2dnSWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUNEd0F3Z2dJS0FvSUNBUURMCkF4WlBkZUk0aW0vL0FyNkdLUzdxODZUcEdHSlVKSHhpYVpMcXRCd0dra1ErNkpweENXbmpNdnBPRkN1SXJuR2YKMUNzdlFwMmt0Z0lBbHBjNjN0OC93Sk9xN0xjS3N4UDFlNHpDTUdlZkZqeGdJZGhHNGR6RFJ5NkZMNlFmMW1XYgp1SCtybWhpM1JoRkdQTGhmR0dvQ1ZDbCtaYjZSaEJzQ0VBdGNpVjIrM2xZWHNpSmhkUTNRdkNIUnlrMElZdFRRCklIU2hmdEwveEl6ZHdjeng0UnRWUU1MSEpWeFhsTkw4RnE3Q09GY1JoUzVaM0NEeDBPVG5hVW96UmQzYk50ODAKS1pYWFV5MHR5NVNTbERKYkVRTFdzU2dvWEU3U0lPL01VOXdWSWdZcTdQemlrSDdHTnpNWHJwalR0S3B6RXJjNwpVLzNFdEJwWklRZTlDcm82NlVsckJqOE1RSGJZY1hBSzFmZXZxSDV1ZEpySzh1NUJNM3ozakRzTGFEVm5yR2Y3CkxoaWJUbnRGTHZ4K3Z1VXNuVnNhL1ZlaUFjUEd2N0s2L09qbUNYd3VBZ3FUSVhOdHlEdWFsUGJ1bGUxWnF6aUkKNk00Y0d2ZXlIRTlFWEt2NnQ2U2xSOWNadFFPWjQ2d3o5UWNJRWRoODZYUjVHQ1Z3L3VJOWFVM1hIZHFzK2FZagpRaExwSVNBQlBwRjRGNVJ5em9Ta0l6dlBQUXNnbFJWcms5MlpuTWpGQStDWVh6NmZZTWRtYnFmVTN3SExMS2lOCnhNQS8za2RrQzJ5NFhZdkVIdXFBdnBOUVY3KzJkeEhtUFg2dFNhVG10bjdqTDdPL0N1akpDMkJlaXh2R3lTc0MKZFV6SC8xN09ObEU5TXJXdFZOcEFVYlhJcU0xUzM5Yjh6cWJPV1Q4MmJ3SURBUUFCbzBJd1FEQU9CZ05WSFE4QgpBZjhFQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVVESWNZUkhWS1ZtWG54S1pLCm1wS3AxdWpycmRBd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dJQkFCaDlsZG5pSnRweThhQVV1ZCtzbXZvRjJ4bmkKQ3IwNnhtelhBUWFtT0lNa0VrMWdLeW5VQ3RhNXJ4UXBFQWxNYzdUNlZ1cHNudjk2czU4dEtneTZsOVpZMDV4NAptVHEvc3AxUHFyeTZNRVBsRWI4aHp0WE5nQnU5Z1lhVXVOcHQrUjEyS2hRTFJrU2R1MlIwaTZaRVZkRzAyWW55CnNSQ3UvamxWeUlaN1RYeFp1TlY3Z2pRNmtqbk9ZUkJNTG54VWpQRWVJUHZQeEJQN3YzQ1JGT2xKRjZxZGl5cEsKQmhMNDdHNlIrNlI2aHR3RFFsZjZxTUYvZUp1STg4OTQwblNwc2p2b2NlNjhJQ1FXTWVlR2ZUOU8zRVlkWWFvVgppRHZNbXR3MWhFUi9WWGlreTY1eEg2QjFHVE0xdmxBWmRVTTRXcWZrKzZUYWJwakRQZGhEcmJDazN4eDhlUjllCk1Ibkg3VGpqdG9KQURER2dSSGNSVUs2U1hFU0ZISXRoSjd1Ukt3TDFhK1ZUWjdkSExBbSs5N05HUG9icFQxTzIKYURVRHRwWlNaUGQ4Y2RQMmNsRGJlUlI3amxTdGZGNHBreVgza0ljMjM2M1ZDYnN6dzcvSXBBbmhIU0hKMDhidAptbkVsUGk2aEQ5S3NvRjQ2bEpnNm80dlFjM2g4c0ZjMnVkTFJFRGVsdmN3OG15MHg4RDJmaXJMbVJxWEVtdU8yCkxnRmw0L2wxWm9HRWNkemVUVTd0ZndDeUhiVWowQkVaV1REVzl0R1AwYmMxWitGdGREVGliaDNUWlV2ZTRSRG0KcG1Oa2o2bzl6QjFkZktnL0VjMFpwWncwd1lRdm9PeEovMU5yT3MvaWhRdHQ3N25UQUxKeEl6Z3UwSmRBRURRMQppWWlFaVdrdGNQNnl6LzFZCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", + "host": "https://koalatechw08-r3vr6md6.hcp.australiaeast.azmk8s.io:443", + "password": "mwaisxf3rvz9w7nnkxmd8l4wa4vl2vu68yj5nsine0tkq760bhqtzl76fxx8tchb7smfi6dkkitbovmbmoqh5w7zvpve1z6rjhmqwon2k7thm5mbtg7m87ji2ehqaipq", + "username": "clusterUser_koalatech-week08-rg_aks-sit722-week08" + } + ], + "kube_config_raw": "apiVersion: v1\nclusters:\n- cluster:\n certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUU2RENDQXRDZ0F3SUJBZ0lRUXZ2K3dkYklRVVF2WGhqQlZFdTF3ekFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWdGdzB5TmpBNU1UQXhNekF6TVRWYUdBOHlNRFUyTURreE1ERXpNVE14TlZvdwpEVEVMTUFrR0ExVUVBeE1DWTJFd2dnSWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUNEd0F3Z2dJS0FvSUNBUURMCkF4WlBkZUk0aW0vL0FyNkdLUzdxODZUcEdHSlVKSHhpYVpMcXRCd0dra1ErNkpweENXbmpNdnBPRkN1SXJuR2YKMUNzdlFwMmt0Z0lBbHBjNjN0OC93Sk9xN0xjS3N4UDFlNHpDTUdlZkZqeGdJZGhHNGR6RFJ5NkZMNlFmMW1XYgp1SCtybWhpM1JoRkdQTGhmR0dvQ1ZDbCtaYjZSaEJzQ0VBdGNpVjIrM2xZWHNpSmhkUTNRdkNIUnlrMElZdFRRCklIU2hmdEwveEl6ZHdjeng0UnRWUU1MSEpWeFhsTkw4RnE3Q09GY1JoUzVaM0NEeDBPVG5hVW96UmQzYk50ODAKS1pYWFV5MHR5NVNTbERKYkVRTFdzU2dvWEU3U0lPL01VOXdWSWdZcTdQemlrSDdHTnpNWHJwalR0S3B6RXJjNwpVLzNFdEJwWklRZTlDcm82NlVsckJqOE1RSGJZY1hBSzFmZXZxSDV1ZEpySzh1NUJNM3ozakRzTGFEVm5yR2Y3CkxoaWJUbnRGTHZ4K3Z1VXNuVnNhL1ZlaUFjUEd2N0s2L09qbUNYd3VBZ3FUSVhOdHlEdWFsUGJ1bGUxWnF6aUkKNk00Y0d2ZXlIRTlFWEt2NnQ2U2xSOWNadFFPWjQ2d3o5UWNJRWRoODZYUjVHQ1Z3L3VJOWFVM1hIZHFzK2FZagpRaExwSVNBQlBwRjRGNVJ5em9Ta0l6dlBQUXNnbFJWcms5MlpuTWpGQStDWVh6NmZZTWRtYnFmVTN3SExMS2lOCnhNQS8za2RrQzJ5NFhZdkVIdXFBdnBOUVY3KzJkeEhtUFg2dFNhVG10bjdqTDdPL0N1akpDMkJlaXh2R3lTc0MKZFV6SC8xN09ObEU5TXJXdFZOcEFVYlhJcU0xUzM5Yjh6cWJPV1Q4MmJ3SURBUUFCbzBJd1FEQU9CZ05WSFE4QgpBZjhFQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVVESWNZUkhWS1ZtWG54S1pLCm1wS3AxdWpycmRBd0RRWUpLb1pJaHZjTkFRRUxCUUFEZ2dJQkFCaDlsZG5pSnRweThhQVV1ZCtzbXZvRjJ4bmkKQ3IwNnhtelhBUWFtT0lNa0VrMWdLeW5VQ3RhNXJ4UXBFQWxNYzdUNlZ1cHNudjk2czU4dEtneTZsOVpZMDV4NAptVHEvc3AxUHFyeTZNRVBsRWI4aHp0WE5nQnU5Z1lhVXVOcHQrUjEyS2hRTFJrU2R1MlIwaTZaRVZkRzAyWW55CnNSQ3UvamxWeUlaN1RYeFp1TlY3Z2pRNmtqbk9ZUkJNTG54VWpQRWVJUHZQeEJQN3YzQ1JGT2xKRjZxZGl5cEsKQmhMNDdHNlIrNlI2aHR3RFFsZjZxTUYvZUp1STg4OTQwblNwc2p2b2NlNjhJQ1FXTWVlR2ZUOU8zRVlkWWFvVgppRHZNbXR3MWhFUi9WWGlreTY1eEg2QjFHVE0xdmxBWmRVTTRXcWZrKzZUYWJwakRQZGhEcmJDazN4eDhlUjllCk1Ibkg3VGpqdG9KQURER2dSSGNSVUs2U1hFU0ZISXRoSjd1Ukt3TDFhK1ZUWjdkSExBbSs5N05HUG9icFQxTzIKYURVRHRwWlNaUGQ4Y2RQMmNsRGJlUlI3amxTdGZGNHBreVgza0ljMjM2M1ZDYnN6dzcvSXBBbmhIU0hKMDhidAptbkVsUGk2aEQ5S3NvRjQ2bEpnNm80dlFjM2g4c0ZjMnVkTFJFRGVsdmN3OG15MHg4RDJmaXJMbVJxWEVtdU8yCkxnRmw0L2wxWm9HRWNkemVUVTd0ZndDeUhiVWowQkVaV1REVzl0R1AwYmMxWitGdGREVGliaDNUWlV2ZTRSRG0KcG1Oa2o2bzl6QjFkZktnL0VjMFpwWncwd1lRdm9PeEovMU5yT3MvaWhRdHQ3N25UQUxKeEl6Z3UwSmRBRURRMQppWWlFaVdrdGNQNnl6LzFZCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K\n server: https://koalatechw08-r3vr6md6.hcp.australiaeast.azmk8s.io:443\n name: aks-sit722-week08\ncontexts:\n- context:\n cluster: aks-sit722-week08\n user: clusterUser_koalatech-week08-rg_aks-sit722-week08\n name: aks-sit722-week08\ncurrent-context: aks-sit722-week08\nkind: Config\nusers:\n- name: clusterUser_koalatech-week08-rg_aks-sit722-week08\n user:\n client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZIVENDQXdXZ0F3SUJBZ0lRTzBZZlVqMDFhYkozTVk0TFdIbHk5ekFOQmdrcWhraUc5dzBCQVFzRkFEQU4KTVFzd0NRWURWUVFERXdKallUQWVGdzB5TmpBNU1UQXhNekF6TVRaYUZ3MHlPREE1TVRBeE16RXpNVFphTURBeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1SVXdFd1lEVlFRREV3eHRZWE4wWlhKamJHbGxiblF3CmdnSWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUNEd0F3Z2dJS0FvSUNBUUROY0NpWXp0Wjk2a0lCNmVtd0NuaWgKR1UrNmJDZ0tYZUFkNENEUTRWN3ZyY0RxaU9DeFVUMHluQUFoc3JKb0x6Y0VpeXZGYWgvMThXOEJuaHF3d1BJdApXaW1aOUFnOXVpQmxDWFBhZFplYk9iWG9jdWp3UEVkRG0zbjJoWEQvcU9KQnVGeERMWUljcCtTYXE1RkxhL2tpCjJMQklmN0JDZllFWVFJcVZQS3pBdTNZVFJ4Wk03RkFjWlhJOWtuSnpPNWNmN1hzTnFlY1E2V0xwcHNmdkprS3oKVlRNR01WZ0kvaGZRRmFnc1ZwWnpTQS9Callqa0ViSUVnMVY5MkxJSVAvWDhqSTRleFI0dWNoM3BPNnJzbktGRApoK2pQeS9tZTJ5S2liMk5qb3ptRWR5MUpZSi9jTFBxWko2RCt5NjdDNFBvSXhYUGE5aGRKOHlRbnYydVNZTk9mCnZTaHpUZExKSnQrbm1tYU5EUTMyZUtodVU1c3JWUGY5eDV3NWVnMENDWUhZdXJzdFMxcUJ3WGUvTFFYVVlaVmoKdHJRbitMZ0kxOUg2blZTb2FGM2NPWWluY2ZFeEpVRWVkRTM1T093dTUydmx1VHFlTXBZaUwxVHBWNysrd1NjUQpBUDdRMkMxdDZXSHJ3OVFEVTQ3UUNSZWVBSDUranRrM1UzelR5L3o2MWxTa0hPOW1UcFNENzJ4aFRza3dEY1JKCk1XMlBDbHlMenBVQWJpZzk4aEQ2MGxIdmM3OHNDOVhjQzRXUk5WWXZyZHNmRGp1aEp4MDFhbzk3TUlFRENPa0cKcUhDbTcvSHQ5bzNvV29QZUo5QnVEa2lKMlpPQ0xMMnRQQVhUc0FreWpHWUR1K1JaZElBQUZoK01aQ2dNWGRtagpHbk1rQVdSTUFnLy9LbVNBaFoydU1RSURBUUFCbzFZd1ZEQU9CZ05WSFE4QkFmOEVCQU1DQmFBd0V3WURWUjBsCkJBd3dDZ1lJS3dZQkJRVUhBd0l3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlFNaHhoRWRVcFcKWmVmRXBrcWFrcW5XNk91dDBEQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FnRUFwSXpsRmxwaFpZNkxna2F0bFc1MwpSeEMyWTV4M2xPV1pTWHVYbk9VemRtcmViQWJIZ1VrSmU5MHNhNWxxWGt5WEszM3dBZ0F3NGdTRm5JMklSTEZSCjM0VThvSVYvQ1ZzWk1BU3VNNVVlZlI1eDdpYk10S2xZZE8rcnRSbHI2VysxcUh2R29MZFZxUmdVNGdlb3hMYXQKZjhYRjZNbUFiNmVicmpUTUR2NUxHTXovNWN3RWdHQlhtSkRHbFV4N3F5MEdHUVg1VGJXQy90Q0tlZHZsUDJacgplNFo4WjdidS83MldWVmN0ZVNncVJNU2NteVhTSTl5Y1Y1eUU3T3p1Z2JXbUJ4N2QvNmFvZTNBMTR1S2o2Ykh3CjVyaWJ4aWlpU2czTm9oYkQ2S2hXTjRNQml5Nkc0Mm1ZbEIxd2duaG95ZC9iS05aamp4TkNXNUhsRENYNU9TTGkKTmpuOHpHc050aTYwMVNQdHg4NVZRL1BiNjVwaU5oTDdEZjZRQmI2Q3N5R01VeGNBT05mZzBMK0VKN1puV1JjOQpDcjZ5bnVrZFNTNzRoZEx2S241WG13NVhHdlJuNnQ0LzMrVFR3aDltVDRUWmxJZlFRYjdxNXdMR1RiTFhKL1lICmtMVFBFSVN3YjVFa1FLRi9FYlppby9FbUxZK01odVhXdUViTyszcytTUlVNbE5HTVdkME4vNWIzTzlsd3M0aXAKSmFlRFdjUTJXRW13L0NJcmQ1Y0pNMVRheEQrSE8vd05MZytaN1FKMHdqY3VVenN3NjZiQXF0R1AzdkpVR1RWMQpwc3hkS2RRdXA2V2hOMjZXWlptbUtWaE1aTmZocTVDMmxOSTJOQ3UvOWJoY3ZTZjZzOElUbFU4bUd3dm5GdnUxCjhGNW9PdHZzOWRQdWwvalNkekl4eGVvPQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg==\n client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlKS2dJQkFBS0NBZ0VBelhBb21NN1dmZXBDQWVucHNBcDRvUmxQdW13b0NsM2dIZUFnME9GZTc2M0E2b2pnCnNWRTlNcHdBSWJLeWFDODNCSXNyeFdvZjlmRnZBWjRhc01EeUxWb3BtZlFJUGJvZ1pRbHoybldYbXptMTZITG8KOER4SFE1dDU5b1Z3LzZqaVFiaGNReTJDSEtma21xdVJTMnY1SXRpd1NIK3dRbjJCR0VDS2xUeXN3THQyRTBjVwpUT3hRSEdWeVBaSnljenVYSCsxN0Rhbm5FT2xpNmFiSDd5WkNzMVV6QmpGWUNQNFgwQldvTEZhV2MwZ1B3WTJJCjVCR3lCSU5WZmRpeUNELzEvSXlPSHNVZUxuSWQ2VHVxN0p5aFE0Zm96OHY1bnRzaW9tOWpZNk01aEhjdFNXQ2YKM0N6Nm1TZWcvc3V1d3VENkNNVnoydllYU2ZNa0o3OXJrbURUbjcwb2MwM1N5U2JmcDVwbWpRME45bmlvYmxPYgpLMVQzL2NlY09Yb05BZ21CMkxxN0xVdGFnY0YzdnkwRjFHR1ZZN2EwSi9pNENOZlIrcDFVcUdoZDNEbUlwM0h4Ck1TVkJIblJOK1Rqc0x1ZHI1Yms2bmpLV0lpOVU2VmUvdnNFbkVBRCswTmd0YmVsaDY4UFVBMU9PMEFrWG5nQisKZm83Wk4xTjgwOHY4K3RaVXBCenZaazZVZys5c1lVN0pNQTNFU1RGdGp3cGNpODZWQUc0b1BmSVErdEpSNzNPLwpMQXZWM0F1RmtUVldMNjNiSHc0N29TY2ROV3FQZXpDQkF3anBCcWh3cHUveDdmYU42RnFEM2lmUWJnNUlpZG1UCmdpeTlyVHdGMDdBSk1veG1BN3ZrV1hTQUFCWWZqR1FvREYzWm94cHpKQUZrVEFJUC95cGtnSVdkcmpFQ0F3RUEKQVFLQ0FnQVRUWXBscFVCT2VpQXo5Q25DelR1OVkxYWU4bXo4Q3dzS1hrQ25HbTM1cTVsTit0NGw1YmRsZ0hhYgpYcUl0eGpYdld0dWFMb08zUlFxdThROUZTVjZDb3JuNFNib3IxcDBXOFVHWXBydVZMYmNwRXF4T3hQM1YzVG9qClR0d3B0dEYwNXd1SzU2L3JNbDZqKzgwOTlCOW5jS0IyWE1KbEtrd05jODhIQVE3NFdGb3dBSVREWmVHTWNYOFUKOHZtSEEwazFYNTBwZTZ2Y084UlJVUEdiUjd5S1dNM2ZzMERpMk1FZHRQM2xhMGl5cDBtTkN3S2E3MkMxMU5pZApFbFhVRHZKMWhZZ1FYYjdJRXNLUVkzZGNIbkYybWhnUmQ1M0tROVo5YmJyb2UwYzdGTzd2NUpZbUJzeVFieGYzCnFPVmxwbzd5Y1FuQmxWQTdOZzhtZEViT3dsaWxPc3BCVVhpWFNMTkRFcStqRi92WWZGQTdzTk5EUTVFaGJZencKY2hsdzNBL1pXWHo4Mk5vSkExaUR1YklEYXBjUm1YQittQVA5cDMrVTBUZ3pMblJlN3ByZlBkd2IwRlp3NmRYYgp2L0RaNGJad0ZQTFZwZ2dyZFNFN2N6UThUSlVWekhTbUxkUEwzaTRWZVdTSlZVelo1dkRZMUVzSlFoU3I3amZKCkx4ZUdjV0hOWHFtYmxhbTY3aVRORVZzZm1ybWI3VWJNNHlURS9oaHh5N2VQV01pSnhJM1lBWTl3eUxMZExOS0QKYTNMeEljQmVGSjZZTUUzSUs2ZW9zZlF4UktBKzF2MnFXdE1KZEFuTnhhVjB5VlVSU1A0ZlBNYmlDY2FmZkpNRwo5M1dWMTVPNy8zVVdEU1U3UGxkRnc0TFo5VUxWa0UxTkladlp1TlB4RU12dzk1MXY4d0tDQVFFQTVqVlJYTURhCkpFcEhiWUQvZ094YWJreWYyanErek94Szl3MmQwQ2l3ZDE3NkJ1cDNTWnBHVFo0U29pTi9GUW9mWXRwajJkSUkKakJ1RldwZFNlb28wMGRkQlY4RDRUdlVpbXJhTjFkYi9yTDNVclFWbzZTR1p5UW1uQ3F1bDR0cHJ3RmRBQ1oyeAprLzF5YTJIWWlYeCtlVGJ3L3N0eW1xb2NtUUdmZ0VtTGRTbkliSDU3THBMc2s1R3FBNklEZlhWTzFtTUYvd1FtCjBMSkZubFRXTVJUR0pXQklwU2drd1h2cVVYQkNwaklNdnhLa2F5Rnk2TDFuYVhJMkYvbHhQbnFtQklPR0RFVDIKWUhSelZsTmdwQzJZSnBRVHdwVUlQWTJKSElZdm9hUkpqYjVJcU1nQnYwcS9VYml0NEZkZHZBbmFUbk1QamNSTgpHK0RKZHlUT2RHcmt4d0tDQVFFQTVIUm1SMFRHR05aSDJiRjk1Mm9XSmRZVVpjaW9ZODRvSHBGSVlsN0F4WkU0CjdmMWNRQlp5VUNwa1hyQmZJSEZLS3hCR0lFMHRyaDBCZEE3eW5Ja1FEV2xOdk42M3ZiazA0amFwWEN1bDg4cWcKLzhmdDU1UTRCc0lVRXhIR25kOTBkS2hlNkFUaFpaMXFiNEw3SWZNSWpDNWZIdUFjR2FGaG9RUUV0T0VHZ2lqSQphK3cwKzIvQmlISWFPVmhKa1RVU1ZZME4yRWw4SWZ6UWZnejNyQ1hqTjF0WlBoa2FiQSthWGdGQ014eHZaZnJrCnRGTzRBNXJoajJlOWpKQ056T1B0UUZ2TG95VVlSZ01aUW45NW1DYktYM3phekQ5aThCVUVneXRnb2RoQmhKRFgKZDgrZ1J5R3ZBazkxanhBVkZZOUl1M2xFcFVBSUJGd1NUOFZuaEhUdFJ3S0NBUUVBbjc2ZDMvUS9LM0dUMFN2egpvTjNoL3FvVFB0Y0c4YUtDZnA4blJId0h3RDNhMkJPNWhReVYvbVJUWDhtcjlNaWFjUGxFMHJESmw1ZTlXTmlxCnRqZlRZVTJ5blJkZDkwUnBwOXhIM2Z5Y1hKMDV5SHA1aTFUeXB5dVlpM0lDNGNGWmhoWEZiU2JOUS9sYWZhTmYKSkVDTWJzYXVRdENnalN6QVBYaC8yd0N0MGphVlNwd1dGSUtGd2YwUmNnWHAwQlFvQ2VLYkZaUW03eWpWNElIVQpSQ2k3T3RKdWRwUzl0TG10SnFmMHgranhLV0hoVUNuYzJCNGVkWEI0a3ZoWUdkZjMvaWZhVEFkS3hESHFteHV1CmN4N2tkdGpJUVgwelE2dEVXdFBPbDZjdUZ6clVwckpyYS9lTmZVTGUvVThDejJveTM3OCtLc3p1Q1F0YUllQWIKbDNOWm53S0NBUUVBemJ2Ulc3OGdrcjl1SnlxRFVzS2p2V0VrTDJGYjI3Mk5QYkhpT014UjE5bm5KZE96bnZkWAo3TCt3Y2VGdFdWOE14dWs1N2Q3ekUwaUZ4WW1zakxHV0ppMjkxeWZPMm1tT3FXVkdjWDNSVGxJZFdSc29NUk8yCnZNZE1vcnV3WFA0ZDl1eWx3MWFaMFJQSkJURTBqYWM2L0ZyS1NKalVWWFNTNGhrblQra1NIbWN4enBjQ1E2aXAKNWdHemdnN3krWFNNMVVSWXduMnk3enlvV2N1OXcyYS9UMXZSYTIzWjhKVEFsSVdYbzBXcFFIS0lRYWpmSDBmOApkMU9hVEpybFNDMit1K1d1NW5ZQ2ZSSlBUaVpVVDJRbmZZU0gyY1gvaUd1MUM1Y2x4N3BqamttTjhSU21wMGc3ClBkNW01UFk3dHBXNWp6TXZVWTN2YWxXc0cvQ1BQNmE1QXdLQ0FRRUFscWVtZmZVd3Y2OUI5d2QveFNQc3dVRjMKd3picjc2SWNQRlUzUWIva204aTkzcXBHZnBxdENpZFUrUVg4RGcyeTY3Zmtqc3p1ZEhrK3c1ZzV5K3UwWXNhOApTSm1FUGsxelpPaWtETzRrTnhLMGFBRXdVeFgwZkcwWTN0TVN1Z3FwM3pYcFZyMHZSTXdSeDl4SlhJT2xzNGphCktiU0Y4ZVYvb1p4aG04ckR2WkhZWjR5eDNBWmI3Wkg0M2prY3lBWmRFYkVGTHlyNFQ5ZUw0K0lBNi9KTHhqaGYKY3hGcmJWOVU5ejF0VUQwdEx5ODBucXg1OXA0WGN2cGdNeThjQSt3c2JMb2Fyemxrc1dMTVRLSDVUd3M2SHdoago3bHQwbGRlVWNKVTlycDBYSUZwZlpzMysrSWkvZ3BZVjBkNGNjRWI3RkI4SFV3QWNCWTlzQnh3c1JWekkwZz09Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg==\n token: mwaisxf3rvz9w7nnkxmd8l4wa4vl2vu68yj5nsine0tkq760bhqtzl76fxx8tchb7smfi6dkkitbovmbmoqh5w7zvpve1z6rjhmqwon2k7thm5mbtg7m87ji2ehqaipq\n", + "kubelet_identity": [ + { + "client_id": "c08369d0-1dcb-4135-80be-a1c7afd263ac", + "object_id": "dad8b647-4f52-41fb-a904-1c6dcbb9b978", + "user_assigned_identity_id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/MC_koalatech-week08-rg_aks-sit722-week08_australiaeast/providers/Microsoft.ManagedIdentity/userAssignedIdentities/aks-sit722-week08-agentpool" + } + ], + "kubernetes_version": "1.36.1", + "linux_profile": [], + "local_account_disabled": false, + "location": "australiaeast", + "maintenance_window": [], + "maintenance_window_auto_upgrade": [], + "maintenance_window_node_os": [], + "microsoft_defender": [], + "monitor_metrics": [], + "name": "aks-sit722-week08", + "network_profile": [ + { + "advanced_networking": [], + "dns_service_ip": "10.0.0.10", + "ip_versions": [ + "IPv4" + ], + "load_balancer_profile": [ + { + "backend_pool_type": "NodeIPConfiguration", + "effective_outbound_ips": [ + "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/MC_koalatech-week08-rg_aks-sit722-week08_australiaeast/providers/Microsoft.Network/publicIPAddresses/70edc46e-b074-433c-8faf-4bf0e5538c9f" + ], + "idle_timeout_in_minutes": 0, + "managed_outbound_ip_count": 1, + "managed_outbound_ipv6_count": 0, + "outbound_ip_address_ids": [], + "outbound_ip_prefix_ids": [], + "outbound_ports_allocated": 0 + } + ], + "load_balancer_sku": "standard", + "nat_gateway_profile": [], + "network_data_plane": "azure", + "network_mode": "", + "network_plugin": "azure", + "network_plugin_mode": "overlay", + "network_policy": "", + "outbound_type": "loadBalancer", + "pod_cidr": "10.244.0.0/16", + "pod_cidrs": [ + "10.244.0.0/16" + ], + "service_cidr": "10.0.0.0/16", + "service_cidrs": [ + "10.0.0.0/16" + ] + } + ], + "node_os_upgrade_channel": "NodeImage", + "node_provisioning_profile": [ + { + "default_node_pools": "", + "mode": "Manual" + } + ], + "node_resource_group": "MC_koalatech-week08-rg_aks-sit722-week08_australiaeast", + "node_resource_group_id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/MC_koalatech-week08-rg_aks-sit722-week08_australiaeast", + "oidc_issuer_enabled": true, + "oidc_issuer_url": "https://australiaeast.oic.prod-aks.azure.com/2625129d-99a2-4df5-988e-5c5d07e7d0fb/849150df-84ac-4f23-b4ff-a5a6f8d241c6/", + "oms_agent": [], + "open_service_mesh_enabled": false, + "portal_fqdn": "koalatechw08-r3vr6md6.portal.hcp.australiaeast.azmk8s.io", + "private_cluster_enabled": false, + "private_cluster_public_fqdn_enabled": false, + "private_dns_zone_id": "", + "private_fqdn": "", + "resource_group_name": "koalatech-week08-rg", + "role_based_access_control_enabled": true, + "run_command_enabled": true, + "service_mesh_profile": [], + "service_principal": [], + "sku_tier": "Free", + "storage_profile": [], + "support_plan": "KubernetesOfficial", + "tags": { + "Environment": "staging-production", + "ManagedBy": "Terraform", + "Practical": "Week08", + "Project": "KoalaTech Course Platform" + }, + "timeouts": null, + "upgrade_override": [], + "web_app_routing": [], + "windows_profile": [], + "workload_autoscaler_profile": [], + "workload_identity_enabled": false + }, + "sensitive_attributes": [ + [ + { + "type": "get_attr", + "value": "kube_admin_config" + } + ], + [ + { + "type": "get_attr", + "value": "kube_admin_config_raw" + } + ], + [ + { + "type": "get_attr", + "value": "kube_config" + } + ], + [ + { + "type": "get_attr", + "value": "kube_config_raw" + } + ] + ], + "identity_schema_version": 0, + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo1NDAwMDAwMDAwMDAwLCJkZWxldGUiOjU0MDAwMDAwMDAwMDAsInJlYWQiOjMwMDAwMDAwMDAwMCwidXBkYXRlIjo1NDAwMDAwMDAwMDAwfSwic2NoZW1hX3ZlcnNpb24iOiIyIn0=", + "dependencies": [ + "azurerm_resource_group.rg" + ] + } + ] + }, + { + "mode": "managed", + "type": "azurerm_resource_group", + "name": "rg", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/koalatech-week08-rg", + "location": "australiaeast", + "managed_by": "", + "name": "koalatech-week08-rg", + "tags": { + "Environment": "staging-production", + "ManagedBy": "Terraform", + "Practical": "Week08", + "Project": "KoalaTech Course Platform" + }, + "timeouts": null + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "identity": { + "name": "koalatech-week08-rg", + "subscription_id": "054515f7-f4ff-4a29-b19f-a64d375a1937" + }, + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjo1NDAwMDAwMDAwMDAwLCJkZWxldGUiOjU0MDAwMDAwMDAwMDAsInJlYWQiOjMwMDAwMDAwMDAwMCwidXBkYXRlIjo1NDAwMDAwMDAwMDAwfX0=" + } + ] + }, + { + "mode": "managed", + "type": "azurerm_role_assignment", + "name": "acr_pull", + "provider": "provider[\"registry.terraform.io/hashicorp/azurerm\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "condition": "", + "condition_version": "", + "delegated_managed_identity_resource_id": "", + "description": "", + "id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/koalatech-week08-rg/providers/Microsoft.ContainerRegistry/registries/sit722acrtf2306357w08/providers/Microsoft.Authorization/roleAssignments/51b278a1-cc6b-0b3b-df21-47593dad2bf9", + "name": "51b278a1-cc6b-0b3b-df21-47593dad2bf9", + "principal_id": "dad8b647-4f52-41fb-a904-1c6dcbb9b978", + "principal_type": "ServicePrincipal", + "role_definition_id": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/providers/Microsoft.Authorization/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d", + "role_definition_name": "AcrPull", + "scope": "/subscriptions/054515f7-f4ff-4a29-b19f-a64d375a1937/resourceGroups/koalatech-week08-rg/providers/Microsoft.ContainerRegistry/registries/sit722acrtf2306357w08", + "skip_service_principal_aad_check": true, + "timeouts": null + }, + "sensitive_attributes": [], + "identity_schema_version": 0, + "private": "eyJlMmJmYjczMC1lY2FhLTExZTYtOGY4OC0zNDM2M2JjN2M0YzAiOnsiY3JlYXRlIjoxODAwMDAwMDAwMDAwLCJkZWxldGUiOjE4MDAwMDAwMDAwMDAsInJlYWQiOjMwMDAwMDAwMDAwMH19", + "dependencies": [ + "azurerm_container_registry.acr", + "azurerm_kubernetes_cluster.aks", + "azurerm_resource_group.rg" + ] + } + ] + } + ], + "check_results": [ + { + "object_kind": "var", + "config_addr": "var.aks_node_count", + "status": "unknown", + "objects": null + }, + { + "object_kind": "var", + "config_addr": "var.acr_name", + "status": "unknown", + "objects": null + }, + { + "object_kind": "var", + "config_addr": "var.storage_account_name", + "status": "unknown", + "objects": null + } + ] +} diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 00000000..03f20e0a --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,32 @@ +# Azure for Students: Terraform creates the resource group itself. +location = "Australia East" +resource_group_name = "koalatech-week08-rg" +use_existing_resource_group = false + +# The GitHub service principal only has Contributor, which cannot create +# role assignments, so AKS pulls from ACR with an image pull secret instead. +grant_acr_pull_role = false + +# Must be globally unique. If Azure rejects it as taken, add a digit and retry. +acr_name = "sit722acr2306357w10" + +# Must be globally unique, 3-24 lowercase letters/digits. If Azure rejects it as taken, add a digit and retry. +storage_account_name = "sit722app2306357w10" + +aks_cluster_name = "aks-sit722-week08" +aks_dns_prefix = "koalatechw08" + +# 2 x Standard_B2s_v2 (2 vCPU, 8 GB each) fits the Azure for Students quota: +# Bsv2 family limit 10 vCPUs, total regional limit 6 vCPUs. +# This is enough to run staging, production and monitoring. +aks_node_count = 2 +aks_node_vm_size = "Standard_B2s_v2" + +environment = "staging-production" + +tags = { + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week08" + Environment = "Week08-CD" +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..a02c6367 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,95 @@ +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 = 3 + + validation { + condition = var.aks_node_count >= 1 + error_message = "The AKS node count must be at least 1." + } +} + +variable "aks_node_vm_size" { + description = "Virtual machine size used by the AKS nodes" + type = string + default = "Standard_D2s_v3" +} + +variable "environment" { + description = "Environment name applied to resource tags" + type = string + default = "development" +} + +variable "kubernetes_version" { + default = "1.36.1" +} + +variable "tags" { + description = "Tags applied to Azure resources" + type = map(string) + + default = { + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week08" + } +} + +variable "use_existing_resource_group" { + description = "Reuse an existing resource group instead of creating one" + type = bool + default = false +} + +variable "grant_acr_pull_role" { + description = "Create the AcrPull role assignment for AKS (requires Owner or User Access Administrator)" + type = bool + default = true +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 00000000..44545772 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } +} + +provider "azurerm" { + features {} +} diff --git a/user-service/Dockerfile b/user-service/Dockerfile index d29ccfcf..f20e1fbf 100644 --- a/user-service/Dockerfile +++ b/user-service/Dockerfile @@ -2,6 +2,12 @@ FROM python:3.12-slim WORKDIR /app +# Apply Debian security updates from the base image (fixes pcre2 +# CVE-2026-103111 flagged by the Docker Scout security gate) +RUN apt-get update \ + && apt-get upgrade -y --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . RUN pip install \ diff --git a/user-service/app/main.py b/user-service/app/main.py index 1b4769a6..fe9fdeb2 100644 --- a/user-service/app/main.py +++ b/user-service/app/main.py @@ -1,141 +1,153 @@ -import logging -import os -import time -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from sqlalchemy import select -from sqlalchemy.exc import OperationalError -from sqlalchemy.orm import Session - -from app.db import Base, engine -from app.models import User, UserRole -from app.routers import auth, users -from app.security import hash_password - - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -logger = logging.getLogger(__name__) - - -def initialise_database() -> None: - maximum_attempts = 10 - retry_delay_seconds = 5 - - for attempt in range(1, maximum_attempts + 1): - try: - Base.metadata.create_all(bind=engine) - - logger.info( - "Database connection established successfully." - ) - - return - - except OperationalError: - logger.warning( - "Database connection failed. Attempt %s of %s.", - attempt, - maximum_attempts, - ) - - if attempt == maximum_attempts: - logger.exception( - "Unable to connect to the database." - ) - raise - - time.sleep(retry_delay_seconds) - - -def create_default_admin() -> None: - admin_username = os.getenv( - "DEFAULT_ADMIN_USERNAME", - "admin", - ) - - admin_email = os.getenv( - "DEFAULT_ADMIN_EMAIL", - "admin@koalatech.edu.au", - ) - - admin_password = os.getenv( - "DEFAULT_ADMIN_PASSWORD", - "AdminPassword123!", - ) - - with Session(engine) as db: - existing_admin = db.scalar( - select(User).where( - User.username == admin_username - ) - ) - - if existing_admin is not None: - logger.info( - "Default administrator account already exists." - ) - return - - admin = User( - username=admin_username, - email=admin_email, - hashed_password=hash_password(admin_password), - role=UserRole.ADMIN, - is_active=True, - ) - - db.add(admin) - db.commit() - - logger.info( - "Default administrator account created." - ) - - -@asynccontextmanager -async def lifespan(_: FastAPI): - initialise_database() - create_default_admin() - - yield - - -app = FastAPI( - title="KoalaTech University User Service", - description=( - "Manages user accounts, authentication and " - "role-based access for KoalaTech University." - ), - version="1.0.0", - lifespan=lifespan, -) - - -app.include_router(auth.router) -app.include_router(users.router) - - -@app.get( - "/", - tags=["Health"], -) -def root() -> dict[str, str]: - return { - "message": "KoalaTech University User Service is running." - } - - -@app.get( - "/health", - tags=["Health"], -) -def health_check() -> dict[str, str]: - return { - "status": "healthy", - "service": "user-service", - } \ No newline at end of file +import logging +import os +import time +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 + +from app.db import Base, engine +from app.models import User, UserRole +from app.routers import auth, users +from app.security import hash_password + + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s", +) + +logger = logging.getLogger(__name__) + +# kaustubh Ranjan + + +def initialise_database() -> None: + maximum_attempts = 10 + retry_delay_seconds = 5 + + for attempt in range(1, maximum_attempts + 1): + try: + Base.metadata.create_all(bind=engine) + + logger.info( + "Database connection established successfully." + ) + + return + + except OperationalError: + logger.warning( + "Database connection failed. Attempt %s of %s.", + attempt, + maximum_attempts, + ) + + if attempt == maximum_attempts: + logger.exception( + "Unable to connect to the database." + ) + raise + + time.sleep(retry_delay_seconds) + + +def create_default_admin() -> None: + admin_username = os.getenv( + "DEFAULT_ADMIN_USERNAME", + "admin", + ) + + admin_email = os.getenv( + "DEFAULT_ADMIN_EMAIL", + "admin@koalatech.edu.au", + ) + + admin_password = os.getenv( + "DEFAULT_ADMIN_PASSWORD", + "AdminPassword123!", + ) + + with Session(engine) as db: + existing_admin = db.scalar( + select(User).where( + User.username == admin_username + ) + ) + + if existing_admin is not None: + logger.info( + "Default administrator account already exists." + ) + return + + admin = User( + username=admin_username, + email=admin_email, + hashed_password=hash_password(admin_password), + role=UserRole.ADMIN, + is_active=True, + ) + + db.add(admin) + db.commit() + + logger.info( + "Default administrator account created." + ) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + initialise_database() + create_default_admin() + + yield + + +app = FastAPI( + title="KoalaTech University User Service", + description=( + "Manages user accounts, authentication and " + "role-based access for KoalaTech University." + ), + version="1.0.0", + lifespan=lifespan, +) + + +# Expose Prometheus metrics at /metrics so Prometheus can scrape +# request counts, latencies and error rates for this service. +Instrumentator( + excluded_handlers=["/metrics", "/health"], +).instrument(app).expose( + app, + include_in_schema=False, +) + +app.include_router(auth.router) +app.include_router(users.router) + + +@app.get( + "/", + tags=["Health"], +) +def root() -> dict[str, str]: + return { + "message": "KoalaTech University User Service is running." + } + + +@app.get( + "/health", + tags=["Health"], +) +def health_check() -> dict[str, str]: + return { + "status": "healthy", + "service": "user-service", + } \ No newline at end of file diff --git a/user-service/requirements.txt b/user-service/requirements.txt index 02679ad8..f236fb2e 100644 --- a/user-service/requirements.txt +++ b/user-service/requirements.txt @@ -1,11 +1,13 @@ -fastapi==0.116.1 +fastapi==0.141.1 +starlette==1.7.0 uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic[email]==2.11.7 -PyJWT +PyJWT==2.15.0 pwdlib[argon2]==0.2.1 -python-multipart==0.0.20 +python-multipart==0.0.32 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==8.1.0