diff --git a/.github/workflows/00-terraform.yml b/.github/workflows/00-terraform.yml new file mode 100644 index 00000000..1c1fdc80 --- /dev/null +++ b/.github/workflows/00-terraform.yml @@ -0,0 +1,88 @@ +name: 00 - Terraform Infrastructure + +on: + workflow_dispatch: + inputs: + action: + description: "Terraform action to execute" + required: true + default: "plan" + type: choice + options: + - plan + - apply + - destroy + + pull_request: + paths: + - "terraform/**" + + push: + branches: + - main + paths: + - "terraform/**" + +jobs: + terraform: + name: Terraform ${{ github.event.inputs.action || 'Validate & Plan' }} + runs-on: ubuntu-latest + + defaults: + run: + working-directory: ./terraform + + env: + ARM_CLIENT_ID: ${{ fromJSON(secrets.AZURE_CREDENTIALS).clientId }} + ARM_CLIENT_SECRET: ${{ fromJSON(secrets.AZURE_CREDENTIALS).clientSecret }} + ARM_SUBSCRIPTION_ID: ${{ fromJSON(secrets.AZURE_CREDENTIALS).subscriptionId }} + ARM_TENANT_ID: ${{ fromJSON(secrets.AZURE_CREDENTIALS).tenantId }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.7.5 + + - name: Login to Azure CLI + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Ensure Remote State Backend Exists + run: | + echo "Ensuring remote state backend resource group and storage account exist..." + az group create --name tfstate-rg --location "Australia East" || true + az storage account create \ + --name tfstate20245 \ + --resource-group tfstate-rg \ + --sku Standard_LRS \ + --encryption-services blob || true + az storage container create \ + --name tfstate \ + --account-name tfstate20245 \ + --auth-mode login || true + + - name: Terraform Format Check + run: terraform fmt -check + + - name: Terraform Init + run: terraform init + + - name: Terraform Validate + run: terraform validate + + - name: Terraform Plan + id: plan + run: terraform plan -no-color -out=tfplan + + - name: Terraform Apply + if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply' + run: terraform apply -auto-approve tfplan + + - name: Terraform Destroy + if: github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'destroy' + run: terraform destroy -auto-approve diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..e8a14075 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -6,6 +6,11 @@ on: branches: - main + # Trigger the workflow on pull requests targeting main branch + pull_request: + branches: + - main + # Manual trigger for the workflow workflow_dispatch: @@ -83,10 +88,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: "3.12" @@ -142,7 +147,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Login to Azure uses: azure/login@v2 @@ -160,6 +165,34 @@ jobs: -t ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} \ ./${{ matrix.service }} + # ========================================================= + # Docker Scout Security Scanning (DevSecOps) + # ========================================================= + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker Scout Quickview + uses: docker/scout-action@v1 + with: + command: quickview + image: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + + - name: Docker Scout CVE Analysis + uses: docker/scout-action@v1 + with: + command: cves + image: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + only-severities: critical,high + + - name: Docker Scout Recommendations + uses: docker/scout-action@v1 + with: + command: recommendations + image: ${{ vars.ACR_LOGIN_SERVER }}/${{ matrix.image }}:${{ github.sha }} + - name: Push Docker image with commit SHA run: | docker push \ diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml index 969d646b..7352f1d0 100644 --- a/.github/workflows/04-deploy-production.yml +++ b/.github/workflows/04-deploy-production.yml @@ -1,11 +1,21 @@ name: 04 - Deploy to Production on: + # Automated trigger: runs automatically when Staging tests pass + workflow_run: + workflows: + - "03 - Test Staging" + types: + - completed + branches: + - main + + # Manual trigger fallback workflow_dispatch: inputs: image_tag: description: "Tested image SHA to deploy" - required: true + required: false type: string jobs: @@ -13,12 +23,29 @@ jobs: name: Deploy to Production runs-on: ubuntu-latest + # Only run if manual trigger OR if staging tests succeeded + if: > + ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} + environment: name: production + env: + IMAGE_TAG: ${{ inputs.image_tag || github.event.workflow_run.head_sha || github.sha }} + steps: - - name: Checkout repository + - name: Checkout tested commit uses: actions/checkout@v4 + with: + ref: ${{ env.IMAGE_TAG }} + + - name: Display deployment metadata + run: | + echo "==========================================" + echo "Starting Continuous Deployment to Production" + echo "Trigger Event: ${{ github.event_name }}" + echo "Deploying Image Tag / Commit SHA: ${{ env.IMAGE_TAG }}" + echo "==========================================" - name: Login to Azure uses: azure/login@v3 @@ -69,37 +96,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 +169,15 @@ 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: Show production frontend URL + run: | + echo "==========================================" + echo "Production Application Deployment Complete" + echo "==========================================" + kubectl get service frontend -n production + PROD_IP=$(kubectl get service frontend -n production -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + echo "Production Application External IP: $PROD_IP" + echo "Production Application URL: http://${PROD_IP}" + echo "Deployed Image Tag: ${{ env.IMAGE_TAG }}" \ No newline at end of file diff --git a/.github/workflows/05-monitoring.yml b/.github/workflows/05-monitoring.yml new file mode 100644 index 00000000..d095e29a --- /dev/null +++ b/.github/workflows/05-monitoring.yml @@ -0,0 +1,79 @@ +name: 05 - Deploy Monitoring (Prometheus & Grafana) + +on: + workflow_run: + workflows: + - "04 - Deploy to Production" + types: + - completed + branches: + - main + + workflow_dispatch: + +jobs: + deploy-monitoring: + name: Deploy & Validate Prometheus and Grafana on AKS + runs-on: ubuntu-latest + + if: > + github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + - name: Get AKS credentials + run: | + az aks get-credentials \ + --resource-group ${{ vars.AKS_RESOURCE_GROUP }} \ + --name ${{ vars.AKS_CLUSTER_NAME }} \ + --overwrite-existing + + - name: Set up Helm + uses: azure/setup-helm@v4 + + - name: Deploy kube-prometheus-stack via Helm + run: | + helm repo add prometheus-community https://prometheus-community.github.io/helm-charts + helm repo update + helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack \ + --namespace monitoring \ + --create-namespace \ + --values kubernetes/monitoring/values.yaml \ + --wait \ + --timeout 10m + + - name: Validate Monitoring Pods and Services + run: | + echo "==========================================" + echo "Monitoring Namespace Pods:" + kubectl get pods -n monitoring + echo "==========================================" + echo "Monitoring Namespace Services:" + kubectl get svc -n monitoring + echo "==========================================" + + - name: Output Grafana Dashboard Access URL + run: | + echo "Waiting for Grafana LoadBalancer External IP..." + for i in {1..30}; do + GRAFANA_IP=$(kubectl get svc kube-prometheus-stack-grafana -n monitoring -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + if [ -n "$GRAFANA_IP" ]; then + echo "====================================================" + echo "Grafana Dashboard is accessible at:" + echo "URL: http://${GRAFANA_IP}" + echo "Username: admin" + echo "Password: AdminPassword123!" + echo "====================================================" + exit 0 + fi + echo "Waiting for external IP assignment ($i/30)..." + sleep 10 + done + echo "Note: If external IP is still pending, use 'kubectl port-forward svc/kube-prometheus-stack-grafana 3000:80 -n monitoring'" diff --git a/.gitignore b/.gitignore index 4299cd1a..aa739d68 100644 --- a/.gitignore +++ b/.gitignore @@ -196,4 +196,17 @@ cython_debug/ .cursorignore .cursorindexingignore -node_modules/ \ No newline at end of file +node_modules/ + +# Terraform +.terraform/ +*.tfstate +*.tfstate.* +*.tfvars.json +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6a1bcec6..8d993cf7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,7 +22,8 @@ ENV VITE_ENROLLMENT_SERVICE_URL=${VITE_ENROLLMENT_SERVICE_URL} RUN npm run build -FROM nginx:1.27-alpine +#FROM nginx:1.27-alpine +FROM nginx:1-alpine-slim COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index d1a76071..af575c60 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -36,15 +36,22 @@ const Header = () => { }} > - - KoalaTech University - + + + KoalaTech University - CD Pipeline (Task 9.3C) + + + = 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 "image_tag" { + description = "Docker Image Tag" + type = string +} + + +variable "aks_node_count" { + description = "Number of nodes in the default AKS node pool" + type = number + default = 2 + + validation { + condition = var.aks_node_count >= 1 + error_message = "The AKS node count must be at least 1." + } +} + +variable "aks_node_vm_size" { + description = "Virtual machine size used by the AKS nodes" + type = string + default = "Standard_D2s_v3" +} + +variable "environment" { + description = "Environment name applied to resource tags" + type = string + default = "development" +} + +variable "kubernetes_version" { + default = "1.36.1" +} + +variable "tags" { + description = "Tags applied to Azure resources" + type = map(string) + + default = { + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week06" + } +} \ No newline at end of file diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 00000000..94549d50 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,27 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + } + + # REMOTE STATE BACKEND + backend "azurerm" { + resource_group_name = "tfstate-rg" + storage_account_name = "tfstate20245" + container_name = "tfstate" + key = "koalatech.tfstate" + } + +} + +provider "azurerm" { + features { + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} \ No newline at end of file