diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..5bfd26f4 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -1,20 +1,25 @@ name: 01 - CI on: - # Trigger the workflow on push to main branch + push: branches: - main + + + pull_request: + branches: + - main - # Manual trigger for the workflow + workflow_dispatch: jobs: - # ========================================================= + # Backend Tests - # ========================================================= + backend-test: name: Test ${{ matrix.service }} runs-on: ubuntu-latest @@ -102,9 +107,47 @@ jobs: pytest -v - # ========================================================= + + # Terraform Infrastructure Validation and Plan + + terraform: + name: Terraform Validate and Plan + runs-on: ubuntu-latest + + needs: + - backend-test + + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + + - name: Initialize Terraform + working-directory: terraform + run: terraform init -input=false + + - name: Validate Terraform + working-directory: terraform + run: terraform validate + + - name: Login to Azure for Terraform plan + if: github.event_name != 'pull_request' + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Plan Terraform changes + if: github.event_name != 'pull_request' + working-directory: terraform + run: terraform plan -input=false -var-file=terraform.tfvars + + + # Build and Push Docker Images - # ========================================================= + build-and-push: name: Build and Push ${{ matrix.image }} runs-on: ubuntu-latest @@ -112,6 +155,7 @@ jobs: # All backend tests must pass before this job starts needs: - backend-test + - terraform # Build and push when code is pushed to main or manually triggered if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' @@ -160,6 +204,17 @@ jobs: -t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \ ./${{ matrix.service }} + - name: Scan image with Docker Scout + continue-on-error: true + uses: docker/scout-action@v1 + with: + command: cves + image: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + only-severities: critical,high + dockerhub-user: ${{ secrets.DOCKERHUB_USERNAME }} + dockerhub-password: ${{ secrets.DOCKERHUB_TOKEN }} + exit-code: false + - name: Push Docker image with commit SHA run: | docker push \ diff --git a/.github/workflows/02-deploy-staging.yml b/.github/workflows/02-deploy-staging.yml index 006bb70d..f63b9250 100644 --- a/.github/workflows/02-deploy-staging.yml +++ b/.github/workflows/02-deploy-staging.yml @@ -11,7 +11,7 @@ on: jobs: deploy-staging: - name: Deploy to Staging + name: Deploy frontend to App Service staging slot runs-on: ubuntu-latest if: > @@ -20,6 +20,15 @@ jobs: environment: name: staging + env: + APP_SERVICE_NAME: koalatech-week08-webapp-226035073 + RESOURCE_GROUP: koalatech-week08-rg + SLOT_NAME: staging + ACR_NAME: koalatech8acr226035073 + ACR_LOGIN_SERVER: koalatech8acr226035073.azurecr.io + IMAGE_TAG: ${{ github.event.workflow_run.head_sha }} + IMAGE_NAME: koalatech-frontend + steps: - name: Checkout tested commit uses: actions/checkout@v4 @@ -27,125 +36,35 @@ jobs: ref: ${{ github.event.workflow_run.head_sha }} - name: Login to Azure - uses: azure/login@v3 + uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - - name: Get AKS credentials - run: | - az aks get-credentials \ - --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ - --name ${{ vars.AKS_CLUSTER_NAME }} \ - --overwrite-existing - - - name: 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 + - name: Configure App Service staging image 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 - + SUBSCRIPTION_ID=$(az account show --query id --output tsv) + IMAGE_REF="${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${IMAGE_TAG}" + SITE_CONFIG_URL="https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RESOURCE_GROUP}/providers/Microsoft.Web/sites/${APP_SERVICE_NAME}/slots/${SLOT_NAME}/config/web?api-version=2022-03-01" - - name: Apply Kubernetes manifests - run: | - kubectl apply \ - -f kubernetes/staging/ + echo "Deploying ${IMAGE_REF} to slot ${SLOT_NAME}" - - 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 + az rest \ + --method put \ + --url "$SITE_CONFIG_URL" \ + --body "{\"properties\":{\"linuxFxVersion\":\"DOCKER|${IMAGE_REF}\"}}" - - name: Wait for enrollment-service rollout + - name: Restart staging slot run: | - kubectl rollout status deployment/enrollment-service \ - -n staging \ - --timeout=300s + az webapp restart \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --slot "$SLOT_NAME" - - name: Show staging resources + - name: Show App Service staging status run: | - kubectl get pods -n staging - kubectl get services -n staging - kubectl get pvc -n staging \ No newline at end of file + az webapp show \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --slot "$SLOT_NAME" \ + --query '{name:name, state:state, defaultHostName:defaultHostName, slot:slotSwapStatus}' \ + -o table \ No newline at end of file diff --git a/.github/workflows/03-staging-test.yml b/.github/workflows/03-staging-test.yml index 01daf5fa..ca00f95e 100644 --- a/.github/workflows/03-staging-test.yml +++ b/.github/workflows/03-staging-test.yml @@ -20,44 +20,56 @@ jobs: environment: name: staging + env: + APP_SERVICE_NAME: koalatech-week08-webapp-226035073 + RESOURCE_GROUP: koalatech-week08-rg + SLOT_NAME: staging + steps: - name: Login to Azure - uses: azure/login@v3 + uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - - name: Get AKS credentials + - name: Get staging hostname + id: staging run: | - az aks get-credentials \ - --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ - --name ${{ vars.AKS_CLUSTER_NAME }} \ - --overwrite-existing + STAGING_HOSTNAME=$(az webapp show \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --slot "$SLOT_NAME" \ + --query defaultHostName \ + --output tsv) - - name: Wait for staging frontend IP - run: | - for i in {1..30}; do + if [ -z "$STAGING_HOSTNAME" ]; then + echo "Staging hostname not available." + exit 1 + fi - FRONTEND_IP=$(kubectl get service frontend \ - -n staging \ - -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo "STAGING_HOSTNAME=$STAGING_HOSTNAME" >> "$GITHUB_ENV" + + - name: Wait for staging app to respond + run: | + echo "Checking https://${STAGING_HOSTNAME}" - if [ -n "$FRONTEND_IP" ]; then - echo "FRONTEND_IP=$FRONTEND_IP" >> "$GITHUB_ENV" + for i in {1..30}; do + if curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --connect-timeout 10 \ + --max-time 30 \ + "https://${STAGING_HOSTNAME}" \ + > /tmp/staging-response.html 2>/dev/null; then + echo "Staging app responded successfully." + grep -Eiq " + ${{ github.event.workflow_run.conclusion == 'success' }} + environment: name: production + env: + APP_SERVICE_NAME: koalatech-week08-webapp-226035073 + RESOURCE_GROUP: koalatech-week08-rg + STAGING_SLOT: staging + PRODUCTION_SLOT: production + steps: - name: Checkout repository uses: actions/checkout@v4 + with: + ref: ${{ github.event.workflow_run.head_sha }} - name: Login to Azure - uses: azure/login@v3 + uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - - name: Get AKS credentials - run: | - az aks get-credentials \ - --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ - --name ${{ vars.AKS_CLUSTER_NAME }} \ - --overwrite-existing - - - name: Create production namespace - run: | - kubectl create namespace production \ - --dry-run=client \ - -o yaml | kubectl apply -f - - - - name: Create PostgreSQL secret - run: | - kubectl create secret generic postgres-secret \ - --namespace production \ - --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 production \ - --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/production/ - - - name: Update frontend image - run: | - kubectl set image deployment/frontend \ - frontend=${{ vars.ACR_LOGIN_SERVER }}/koalatech-frontend:${{ inputs.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 }} \ - -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 }} \ - -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 }} \ - -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 }} \ - -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 }} \ - -n production - - - name: Wait for frontend rollout - run: | - kubectl rollout status deployment/frontend \ - -n production \ - --timeout=300s - - - name: Wait for user-service rollout - run: | - kubectl rollout status deployment/user-service \ - -n production \ - --timeout=300s - - - name: Wait for student-service rollout - run: | - kubectl rollout status deployment/student-service \ - -n production \ - --timeout=300s - - - name: Wait for lecturer-service rollout - run: | - kubectl rollout status deployment/lecturer-service \ - -n production \ - --timeout=300s - - - name: Wait for course-service rollout - run: | - kubectl rollout status deployment/course-service \ - -n production \ - --timeout=300s - - - name: Wait for enrollment-service rollout - run: | - kubectl rollout status deployment/enrollment-service \ - -n production \ - --timeout=300s - - - name: Show production resources - run: | - kubectl get pods -n production - kubectl get services -n production - kubectl get pvc -n production \ No newline at end of file + - name: Swap staging into production + run: | + echo "Swapping ${STAGING_SLOT} slot into ${PRODUCTION_SLOT} slot" + timeout 600s az webapp deployment slot swap \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --slot "$STAGING_SLOT" \ + --target-slot "$PRODUCTION_SLOT" + + - name: Verify production endpoint + run: | + PROD_HOSTNAME=$(az webapp show \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --query defaultHostName \ + --output tsv) + + echo "Checking https://${PROD_HOSTNAME}" + + if ! curl \ + --fail \ + --silent \ + --show-error \ + --location \ + --connect-timeout 10 \ + --max-time 30 \ + "https://${PROD_HOSTNAME}" \ + > /tmp/production-response.html; then + + echo "Production verification failed: the live endpoint did not respond successfully." + echo "Rolling back to the previous production version using the slot swap." + + timeout 600s az webapp deployment slot swap \ + --name "$APP_SERVICE_NAME" \ + --resource-group "$RESOURCE_GROUP" \ + --slot "$STAGING_SLOT" \ + --target-slot "$PRODUCTION_SLOT" + + echo "Rollback swap completed." + exit 1 + fi + + if ! grep -Eiq " { variant="h4" fontWeight={600} > - Dashboard + KoalaTech University — Continuous Deployment Demo diff --git a/kubernetes/monitoring/00-namespace.yaml b/kubernetes/monitoring/00-namespace.yaml new file mode 100644 index 00000000..d3252360 --- /dev/null +++ b/kubernetes/monitoring/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: monitoring diff --git a/kubernetes/monitoring/01-kube-state-metrics.yaml b/kubernetes/monitoring/01-kube-state-metrics.yaml new file mode 100644 index 00000000..b9eec99d --- /dev/null +++ b/kubernetes/monitoring/01-kube-state-metrics.yaml @@ -0,0 +1,70 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: kube-state-metrics + namespace: monitoring + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: kube-state-metrics +rules: + - apiGroups: ["*"] + resources: ["*"] + verbs: ["list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: kube-state-metrics +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: kube-state-metrics +subjects: + - kind: ServiceAccount + name: kube-state-metrics + namespace: monitoring + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kube-state-metrics + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: kube-state-metrics + template: + metadata: + labels: + app: kube-state-metrics + spec: + serviceAccountName: kube-state-metrics + containers: + - name: kube-state-metrics + image: registry.k8s.io/kube-state-metrics/kube-state-metrics:v2.15.0 + imagePullPolicy: IfNotPresent + args: + - --port=8080 + ports: + - name: metrics + containerPort: 8080 + +--- +apiVersion: v1 +kind: Service +metadata: + name: kube-state-metrics + namespace: monitoring +spec: + selector: + app: kube-state-metrics + ports: + - name: metrics + port: 8080 + targetPort: metrics diff --git a/kubernetes/monitoring/02-node-exporter.yaml b/kubernetes/monitoring/02-node-exporter.yaml new file mode 100644 index 00000000..36369e39 --- /dev/null +++ b/kubernetes/monitoring/02-node-exporter.yaml @@ -0,0 +1,67 @@ +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: node-exporter + namespace: monitoring +spec: + selector: + matchLabels: + app: node-exporter + template: + metadata: + labels: + app: node-exporter + spec: + hostNetwork: true + hostPID: true + dnsPolicy: ClusterFirstWithHostNet + containers: + - name: node-exporter + image: quay.io/prometheus/node-exporter:v1.8.2 + imagePullPolicy: IfNotPresent + args: + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.rootfs=/host/root + - --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($|/) + ports: + - name: metrics + containerPort: 9100 + hostPort: 9100 + securityContext: + runAsNonRoot: true + runAsUser: 65534 + volumeMounts: + - name: proc + mountPath: /host/proc + readOnly: true + - name: sys + mountPath: /host/sys + readOnly: true + - name: root + mountPath: /host/root + readOnly: true + volumes: + - name: proc + hostPath: + path: /proc + - name: sys + hostPath: + path: /sys + - name: root + hostPath: + path: / + +--- +apiVersion: v1 +kind: Service +metadata: + name: node-exporter + namespace: monitoring +spec: + selector: + app: node-exporter + ports: + - name: metrics + port: 9100 + targetPort: metrics diff --git a/kubernetes/monitoring/03-prometheus.yaml b/kubernetes/monitoring/03-prometheus.yaml new file mode 100644 index 00000000..f3568ee1 --- /dev/null +++ b/kubernetes/monitoring/03-prometheus.yaml @@ -0,0 +1,133 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: prometheus + namespace: monitoring + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: prometheus +rules: + - apiGroups: [""] + resources: + - nodes + - nodes/proxy + - nodes/metrics + - services + - endpoints + - pods + - namespaces + verbs: ["get", "list", "watch"] + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: prometheus +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: prometheus +subjects: + - kind: ServiceAccount + name: prometheus + namespace: monitoring + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: prometheus-config + namespace: monitoring +data: + prometheus.yml: | + global: + scrape_interval: 15s + evaluation_interval: 15s + + scrape_configs: + - job_name: prometheus + static_configs: + - targets: ["localhost:9090"] + + - job_name: kube-state-metrics + static_configs: + - targets: ["kube-state-metrics:8080"] + + - job_name: node-exporter + static_configs: + - targets: ["node-exporter:9100"] + + - job_name: kubernetes-cadvisor + scheme: https + bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token + tls_config: + ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt + insecure_skip_verify: true + kubernetes_sd_configs: + - role: node + relabel_configs: + - action: labelmap + regex: __meta_kubernetes_node_label_(.+) + - target_label: __address__ + replacement: kubernetes.default.svc:443 + - source_labels: [__meta_kubernetes_node_name] + target_label: __metrics_path__ + regex: (.+) + replacement: /api/v1/nodes/$1/proxy/metrics/cadvisor + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + serviceAccountName: prometheus + containers: + - name: prometheus + image: prom/prometheus:v2.55.1 + imagePullPolicy: IfNotPresent + args: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.enable-lifecycle + ports: + - name: web + containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + - name: data + mountPath: /prometheus + volumes: + - name: config + configMap: + name: prometheus-config + - name: data + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + namespace: monitoring +spec: + selector: + app: prometheus + ports: + - name: web + port: 9090 + targetPort: web diff --git a/kubernetes/monitoring/04-grafana.yaml b/kubernetes/monitoring/04-grafana.yaml new file mode 100644 index 00000000..a2a6411f --- /dev/null +++ b/kubernetes/monitoring/04-grafana.yaml @@ -0,0 +1,86 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-datasources + namespace: monitoring +data: + prometheus.yaml: | + apiVersion: 1 + datasources: + - name: Prometheus + uid: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: grafana + namespace: monitoring +spec: + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:11.4.0 + imagePullPolicy: IfNotPresent + env: + - name: GF_SECURITY_ADMIN_USER + value: admin + - name: GF_SECURITY_ADMIN_PASSWORD + value: local-demo-admin + ports: + - name: http + containerPort: 3000 + volumeMounts: + - name: datasources + mountPath: /etc/grafana/provisioning/datasources + - name: dashboards + mountPath: /etc/grafana/provisioning/dashboards + - name: dashboard-files + mountPath: /var/lib/grafana/dashboards + - name: data + mountPath: /var/lib/grafana + volumes: + - name: datasources + configMap: + name: grafana-datasources + - name: dashboards + configMap: + name: grafana-dashboard-provider + items: + - key: dashboards.yaml + path: dashboards.yaml + - name: dashboard-files + configMap: + name: grafana-dashboard-provider + items: + - key: koalatech-monitoring.json + path: koalatech-monitoring.json + - name: data + emptyDir: {} + +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + namespace: monitoring +spec: + selector: + app: grafana + ports: + - name: http + port: 3000 + targetPort: http diff --git a/kubernetes/monitoring/05-grafana-dashboard.yaml b/kubernetes/monitoring/05-grafana-dashboard.yaml new file mode 100644 index 00000000..61f52180 --- /dev/null +++ b/kubernetes/monitoring/05-grafana-dashboard.yaml @@ -0,0 +1,74 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: grafana-dashboard-provider + namespace: monitoring +data: + dashboards.yaml: | + apiVersion: 1 + providers: + - name: KoalaTech + orgId: 1 + folder: KoalaTech + type: file + disableDeletion: true + editable: false + options: + path: /var/lib/grafana/dashboards + koalatech-monitoring.json: | + { + "annotations": {"list": []}, + "editable": false, + "graphTooltip": 0, + "panels": [ + { + "type": "timeseries", + "title": "Production Available Replicas", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 0}, + "targets": [{"refId": "A", "expr": "sum by (deployment) (kube_deployment_status_replicas_available{namespace=\"production\"})", "legendFormat": "{{deployment}}"}], + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []} + }, + { + "type": "stat", + "title": "Ready Production Pods", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 0}, + "targets": [{"refId": "A", "expr": "sum(kube_pod_status_ready{namespace=\"production\",condition=\"true\"})", "legendFormat": "Ready pods"}], + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []} + }, + { + "type": "timeseries", + "title": "Container CPU Usage", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 8}, + "targets": [{"refId": "A", "expr": "sum by (pod) (rate(container_cpu_usage_seconds_total{namespace=\"production\",container!=\"\",image!=\"\"}[5m]))", "legendFormat": "{{pod}}"}], + "fieldConfig": {"defaults": {"unit": "cores"}, "overrides": []} + }, + { + "type": "timeseries", + "title": "Container Memory Working Set", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 8}, + "targets": [{"refId": "A", "expr": "sum by (pod) (container_memory_working_set_bytes{namespace=\"production\",container!=\"\",image!=\"\"})", "legendFormat": "{{pod}}"}], + "fieldConfig": {"defaults": {"unit": "bytes"}, "overrides": []} + }, + { + "type": "gauge", + "title": "Node CPU Utilisation", + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 16}, + "targets": [{"refId": "A", "expr": "100 * (1 - avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])))", "legendFormat": "{{instance}}"}], + "fieldConfig": {"defaults": {"unit": "percent", "min": 0, "max": 100}, "overrides": []} + }, + { + "type": "timeseries", + "title": "Container Restarts", + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 16}, + "targets": [{"refId": "A", "expr": "sum by (pod) (kube_pod_container_status_restarts_total{namespace=\"production\"})", "legendFormat": "{{pod}}"}], + "fieldConfig": {"defaults": {"unit": "short"}, "overrides": []} + } + ], + "schemaVersion": 39, + "tags": ["koalatech", "kubernetes", "prometheus"], + "templating": {"list": []}, + "time": {"from": "now-15m", "to": "now"}, + "title": "KoalaTech Kubernetes Monitoring", + "uid": "koalatech-monitoring", + "version": 1 + } diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 00000000..1fc0fcb5 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.81.0" + constraints = "~> 4.0" + hashes = [ + "h1:XhToZua4gtih1Kv8RdStcfND83G4Tmb6GZFT4jEUhDU=", + "zh:0732e7b74264ddfa2b90ba69d01c283d3cbae9f72ed3e506c6ac92529fed7fd3", + "zh:12afb524e232fe4e3d6161927724af5dfa4831d71edd9c174917ca9b7377bfae", + "zh:169d619ae202c4145e02fb706fb7c3679445ab3e3ff722edbf89597517a8c92e", + "zh:6beb95a3ef2f2d9c76abaa48e5450e90686a3fb6a47f1cb0ff7c5e94b6960151", + "zh:705e075fb5ffc4bf66fd7cbabf1a65007a41621e80030a2c158a4c83b6046216", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:79a8d17fefe647040fcb9ee8821a4f09f395427c4fd49493489b9a93a9a1038e", + "zh:8cc3f900b3774c0ae37ae42365c4579a199cf9e5edf88e476fdf5ab1048f84ea", + "zh:dec373b9390fa95e257291acd018ed65a7d512b428645d35e22cdbe8b245a08b", + "zh:e60f1e9fb45df6defade2855ed6e68547409ea75d30655c556adb0c08579749b", + "zh:f901d12ec82f3f8b5880a27b5cbcd7bd0d97e60c9367a2d7ed82fdd1157b39ff", + "zh:facf68ea5bf0f2b8ba720e7fba5f86492e1d4c591100460bb91c3f79f391f4b6", + ] +} + +provider "registry.terraform.io/hashicorp/local" { + version = "2.9.0" + constraints = "~> 2.5" + hashes = [ + "h1:m24fjcInWvTVZ1XSo2MaNuKPe+X/gfG8SIi09rA7a7M=", + "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", + "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", + "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", + "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", + "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", + "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", + "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", + "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", + "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", + "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", + "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + ] +} \ No newline at end of file diff --git a/terraform/app_service.tf b/terraform/app_service.tf new file mode 100644 index 00000000..506d7a6e --- /dev/null +++ b/terraform/app_service.tf @@ -0,0 +1,80 @@ +resource "azurerm_service_plan" "app_service_plan" { + name = var.app_service_plan_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + os_type = "Linux" + sku_name = var.app_service_plan_sku + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +resource "azurerm_linux_web_app" "web_app" { + name = var.app_service_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + service_plan_id = azurerm_service_plan.app_service_plan.id + + https_only = true + + app_settings = { + WEBSITES_PORT = "80" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = "false" + } + + site_config { + always_on = true + + application_stack { + docker_image_name = "${azurerm_container_registry.acr.login_server}/koalatech-frontend:latest" + docker_registry_url = "https://${azurerm_container_registry.acr.login_server}" + docker_registry_username = azurerm_container_registry.acr.admin_username + docker_registry_password = azurerm_container_registry.acr.admin_password + } + } + + identity { + type = "SystemAssigned" + } + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +resource "azurerm_linux_web_app_slot" "staging" { + name = var.app_service_staging_slot_name + app_service_id = azurerm_linux_web_app.web_app.id + + https_only = true + + app_settings = { + WEBSITES_PORT = "80" + WEBSITES_ENABLE_APP_SERVICE_STORAGE = "false" + } + + site_config { + always_on = true + + application_stack { + docker_image_name = "${azurerm_container_registry.acr.login_server}/koalatech-frontend:latest" + docker_registry_url = "https://${azurerm_container_registry.acr.login_server}" + docker_registry_username = azurerm_container_registry.acr.admin_username + docker_registry_password = azurerm_container_registry.acr.admin_password + } + } + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} diff --git a/terraform/container_registry.tf b/terraform/container_registry.tf new file mode 100644 index 00000000..1a5f350f --- /dev/null +++ b/terraform/container_registry.tf @@ -0,0 +1,15 @@ +resource "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + sku = "Basic" + admin_enabled = true + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} \ No newline at end of file diff --git a/terraform/kubernetes_service.tf b/terraform/kubernetes_service.tf new file mode 100644 index 00000000..65f37e1e --- /dev/null +++ b/terraform/kubernetes_service.tf @@ -0,0 +1,24 @@ +resource "azurerm_kubernetes_cluster" "aks" { + name = var.aks_cluster_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + dns_prefix = var.aks_dns_prefix + kubernetes_version = var.kubernetes_version + + default_node_pool { + name = "default" + node_count = var.aks_node_count + vm_size = var.aks_node_vm_size + } + + identity { + type = "SystemAssigned" + } + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} \ No newline at end of file diff --git a/terraform/output.tf b/terraform/output.tf new file mode 100644 index 00000000..5719d5c1 --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,93 @@ +output "resource_group_name" { + description = "Name of the resource group" + value = azurerm_resource_group.rg.name +} + +output "acr_name" { + description = "Name of the Azure Container Registry" + value = azurerm_container_registry.acr.name +} + +output "acr_login_server" { + description = "Login server of the Azure Container Registry" + value = azurerm_container_registry.acr.login_server +} + +output "storage_account_name" { + description = "Name of the Azure Storage Account" + value = azurerm_storage_account.storage_account.name +} + +output "storage_connection_string" { + description = "Connection string used by the application to access Blob Storage" + value = azurerm_storage_account.storage_account.primary_connection_string + sensitive = true +} + +output "student_profile_container" { + description = "Student profile photo Blob container" + value = azurerm_storage_container.student_profile_photo.name +} + +output "lecturer_profile_container" { + description = "Lecturer profile photo Blob container" + value = azurerm_storage_container.lecturer_profile_photo.name +} + +output "aks_cluster_name" { + description = "Name of the AKS cluster" + value = azurerm_kubernetes_cluster.aks.name +} + +output "app_service_plan_name" { + description = "Name of the App Service plan" + value = azurerm_service_plan.app_service_plan.name +} + +output "app_service_name" { + description = "Name of the production App Service" + value = azurerm_linux_web_app.web_app.name +} + +output "app_service_default_hostname" { + description = "Default hostname of the production App Service" + value = "https://${azurerm_linux_web_app.web_app.default_hostname}" +} + +output "staging_slot_name" { + description = "Name of the App Service staging slot" + value = azurerm_linux_web_app_slot.staging.name +} + +output "staging_slot_hostname" { + description = "Default hostname of the App Service staging slot" + value = "https://${azurerm_linux_web_app_slot.staging.default_hostname}" +} + +output "aks_get_credentials_command" { + description = "Azure CLI command used to configure kubectl" + value = join(" ", [ + "az aks get-credentials", + "--resource-group", + azurerm_resource_group.rg.name, + "--name", + azurerm_kubernetes_cluster.aks.name, + "--overwrite-existing" + ]) +} + +output "acr_login_command" { + description = "Azure CLI command used to log in to ACR" + value = "az acr login --name ${azurerm_container_registry.acr.name}" +} + +output "app_service_swap_command" { + description = "Command used to swap the staging slot into production" + value = "az webapp deployment slot swap --name ${azurerm_linux_web_app.web_app.name} --resource-group ${azurerm_resource_group.rg.name} --slot ${azurerm_linux_web_app_slot.staging.name} --target-slot production" +} + +output "app_service_staging_configure_command" { + description = "Command used to configure the staging slot container image" + value = "az webapp config container set --name ${azurerm_linux_web_app.web_app.name} --resource-group ${azurerm_resource_group.rg.name} --slot ${azurerm_linux_web_app_slot.staging.name} --docker-custom-image-name ${azurerm_container_registry.acr.login_server}/koalatech-frontend:latest --docker-registry-server-url https://${azurerm_container_registry.acr.login_server} --docker-registry-server-user ${azurerm_container_registry.acr.admin_username} --docker-registry-server-password ${azurerm_container_registry.acr.admin_password}" + sensitive = true +} diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf new file mode 100644 index 00000000..0fbe75d3 --- /dev/null +++ b/terraform/resource_group.tf @@ -0,0 +1,11 @@ +resource "azurerm_resource_group" "rg" { + name = var.resource_group_name + location = var.location + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} \ No newline at end of file diff --git a/terraform/storage_account.tf b/terraform/storage_account.tf new file mode 100644 index 00000000..8b3a6b6f --- /dev/null +++ b/terraform/storage_account.tf @@ -0,0 +1,30 @@ +resource "azurerm_storage_account" "storage_account" { + name = var.storage_account_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + account_tier = "Standard" + account_replication_type = "LRS" + + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +resource "azurerm_storage_container" "student_profile_photo" { + name = "student-profile-photo" + storage_account_id = azurerm_storage_account.storage_account.id + container_access_type = "private" +} + +resource "azurerm_storage_container" "lecturer_profile_photo" { + name = "lecturer-profile-photo" + storage_account_id = azurerm_storage_account.storage_account.id + container_access_type = "private" +} \ No newline at end of file diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 00000000..c6b106f7 --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,24 @@ +location = "Australia East" +resource_group_name = "koalatech-week08-rg" + +acr_name = "koalatech8acr226035073" +storage_account_name = "koalatech8st226035073" + +app_service_plan_name = "koalatech-week08-plan" +app_service_plan_sku = "S1" +app_service_name = "koalatech-week08-webapp-226035073" + +aks_cluster_name = "koalatech-week08-aks" +aks_dns_prefix = "koalatech-week08" + +aks_node_count = 3 +aks_node_vm_size = "Standard_D2s_v3" + +environment = "week08" + +tags = { + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week08" + Environment = "Week08" +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..7e4f7f04 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,105 @@ +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 "app_service_plan_name" { + description = "Name of the Azure App Service Plan" + type = string +} + +variable "app_service_plan_sku" { + description = "SKU for the Azure App Service Plan" + type = string + default = "S1" +} + +variable "app_service_name" { + description = "Globally unique name of the Azure App Service web app" + type = string +} + +variable "app_service_staging_slot_name" { + description = "Name of the App Service staging deployment slot" + type = string + default = "staging" +} + +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" + } +} \ No newline at end of file diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 00000000..4c60616f --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,19 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + } +} + +provider "azurerm" { + features {} +} \ No newline at end of file