From 0d92a930b35fae63e1fe200625171ae678325693 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 11 Sep 2026 00:23:19 +1000 Subject: [PATCH 01/12] Trigger CI pipeline for Task 8.1P --- .gitignore | 7 ++- terraform/.terraform.lock.hcl | 22 +++++++++ terraform/container_registry.tf | 15 ++++++ terraform/kubernetes_service.tf | 34 ++++++++++++++ terraform/output.tf | 47 +++++++++++++++++++ terraform/resource_group.tf | 11 +++++ terraform/storage_account.tf | 30 ++++++++++++ terraform/terraform.tfvars | 24 ++++++++++ terraform/variables.tf | 83 +++++++++++++++++++++++++++++++++ terraform/versions.tf | 14 ++++++ user-service/app/main.py | 2 + 11 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/container_registry.tf create mode 100644 terraform/kubernetes_service.tf create mode 100644 terraform/output.tf create mode 100644 terraform/resource_group.tf create mode 100644 terraform/storage_account.tf create mode 100644 terraform/terraform.tfvars create mode 100644 terraform/variables.tf create mode 100644 terraform/versions.tf 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/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/container_registry.tf b/terraform/container_registry.tf new file mode 100644 index 00000000..9bc9a1c5 --- /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 + } + ) +} diff --git a/terraform/kubernetes_service.tf b/terraform/kubernetes_service.tf new file mode 100644 index 00000000..df83060b --- /dev/null +++ b/terraform/kubernetes_service.tf @@ -0,0 +1,34 @@ +resource "azurerm_kubernetes_cluster" "aks" { + name = var.aks_cluster_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + dns_prefix = var.aks_dns_prefix + kubernetes_version = var.kubernetes_version + + default_node_pool { + name = "default" + node_count = var.aks_node_count + vm_size = var.aks_node_vm_size + } + + identity { + type = "SystemAssigned" + } + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +# +# Grant AKS permission to pull images from your ACR +# +resource "azurerm_role_assignment" "acr_pull" { + principal_id = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id + role_definition_name = "AcrPull" + scope = azurerm_container_registry.acr.id + skip_service_principal_aad_check = true +} diff --git a/terraform/output.tf b/terraform/output.tf new file mode 100644 index 00000000..d3044b52 --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,47 @@ +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 "aks_cluster_name" { + description = "Name of the AKS cluster" + value = azurerm_kubernetes_cluster.aks.name +} + +output "aks_get_credentials_command" { + description = "Azure CLI command used to configure kubectl" + value = join(" ", [ + "az aks get-credentials", + "--resource-group", + azurerm_resource_group.rg.name, + "--name", + azurerm_kubernetes_cluster.aks.name, + "--overwrite-existing" + ]) +} + +output "acr_login_command" { + description = "Azure CLI command used to log in to ACR" + value = "az acr login --name ${azurerm_container_registry.acr.name}" +} diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf new file mode 100644 index 00000000..b4709dbe --- /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 + } + ) +} diff --git a/terraform/storage_account.tf b/terraform/storage_account.tf new file mode 100644 index 00000000..bde8b1af --- /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" +} diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars new file mode 100644 index 00000000..e10cdbef --- /dev/null +++ b/terraform/terraform.tfvars @@ -0,0 +1,24 @@ +location = "Australia East" +resource_group_name = "koalatech-week08-rg" + +# Must be globally unique. If Azure rejects it as taken, add a digit and retry. +acr_name = "sit722acrtf2306357w08" + +# Must be globally unique, 3-24 lowercase letters/digits. If Azure rejects it as taken, add a digit and retry. +storage_account_name = "sit722storagetf230635708" + +aks_cluster_name = "aks-sit722-week08" +aks_dns_prefix = "koalatechw08" + +# Task 8.1P requires 3 nodes: staging and production both run persistent PostgreSQL workloads. +aks_node_count = 3 +aks_node_vm_size = "Standard_D2s_v3" + +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..28175dab --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,83 @@ +variable "location" { + description = "Azure region where the resources will be created" + type = string + default = "Australia East" +} + +variable "resource_group_name" { + description = "Name of the Azure Resource Group" + type = string +} + +variable "acr_name" { + description = "Globally unique name of the Azure Container Registry" + type = string + + validation { + condition = can(regex("^[a-zA-Z0-9]+$", var.acr_name)) + error_message = "The ACR name must contain only alphanumeric characters." + } +} + +variable "storage_account_name" { + description = "Globally unique name of the Azure Storage Account" + type = string + + validation { + condition = ( + length(var.storage_account_name) >= 3 && + length(var.storage_account_name) <= 24 && + can(regex("^[a-z0-9]+$", var.storage_account_name)) + ) + + error_message = "The storage account name must contain 3-24 lowercase letters and numbers." + } +} + +variable "aks_cluster_name" { + description = "Name of the Azure Kubernetes Service cluster" + type = string +} + +variable "aks_dns_prefix" { + description = "DNS prefix used by the AKS cluster" + type = string +} + +variable "aks_node_count" { + description = "Number of nodes in the default AKS node pool" + type = number + default = 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" + } +} diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 00000000..49749feb --- /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/app/main.py b/user-service/app/main.py index 1b4769a6..fbaf04d3 100644 --- a/user-service/app/main.py +++ b/user-service/app/main.py @@ -21,6 +21,8 @@ logger = logging.getLogger(__name__) +# kaustubh Ranjan + def initialise_database() -> None: maximum_attempts = 10 From 325a8bca295dc5ca2172a5e07515bf606a2c37f0 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 18 Sep 2026 06:40:28 +1000 Subject: [PATCH 02/12] Add GitHub Actions CI/CD workflow for AKS deployment --- .github/workflows/deploy.yml | 117 +++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..03ef2936 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,117 @@ +name: Build and Deploy to AKS + +on: + push: + branches: + - main + - master + +env: + REGISTRY_LOGIN_SERVER: ${{ secrets.ACR_LOGIN_SERVER }} + REGISTRY_USERNAME: ${{ secrets.ACR_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.ACR_PASSWORD }} + IMAGE_NAME: sit722-app + KUBE_NAMESPACE: default + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to ACR + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY_LOGIN_SERVER }} + username: ${{ env.REGISTRY_USERNAME }} + password: ${{ env.REGISTRY_PASSWORD }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest + ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + cache-from: type=registry,ref=${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + + - name: Set up kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.36.1' + + - name: Configure kubectl with AKS credentials + uses: azure/aks-set-context@v3 + with: + resource-group: ${{ secrets.AKS_RESOURCE_GROUP }} + cluster-name: ${{ secrets.AKS_CLUSTER_NAME }} + admin: 'false' + env: + AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + + - name: Create deployment manifest + run: | + cat > deployment.yaml << EOF + apiVersion: apps/v1 + kind: Deployment + metadata: + name: sit722-app + namespace: default + spec: + replicas: 2 + selector: + matchLabels: + app: sit722-app + template: + metadata: + labels: + app: sit722-app + spec: + containers: + - name: sit722-app + image: ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} + ports: + - containerPort: 80 + resources: + requests: + memory: "64Mi" + cpu: "250m" + limits: + memory: "128Mi" + cpu: "500m" + --- + apiVersion: v1 + kind: Service + metadata: + name: sit722-app-service + namespace: default + spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 80 + selector: + app: sit722-app + EOF + + - name: Deploy to AKS + run: | + kubectl apply -f deployment.yaml + kubectl rollout status deployment/sit722-app -n default + + - name: Verify deployment + run: | + echo "Deployment Status:" + kubectl get deployments -n default + kubectl get services -n default + kubectl get pods -n default \ No newline at end of file From 34720afd7f2d4d666206b41c2bfebba93536180a Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 18 Sep 2026 07:01:42 +1000 Subject: [PATCH 03/12] Test CI/CD pipeline automation --- frontend/src/index.css | 2 + student-service/app/db.py | 1 + terraform/terraform.tfstate.1789060423.backup | 391 ++++++++++++++++++ 3 files changed, 394 insertions(+) create mode 100644 terraform/terraform.tfstate.1789060423.backup 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/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/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 + } + ] +} From 7ae85db8f3fbe6696c851361067f63e4546aa23e Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 18 Sep 2026 07:07:33 +1000 Subject: [PATCH 04/12] Add root Dockerfile for frontend containerization --- Dockerfile | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 Dockerfile 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;"] From d39149b6a5952e1822f58329dee1ca8963e65027 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 18 Sep 2026 07:16:02 +1000 Subject: [PATCH 05/12] Test CI/CD pipeline automation --- .github/workflows/deploy.yml | 137 ++++++++--------------------------- 1 file changed, 32 insertions(+), 105 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 03ef2936..3c417bf3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -4,114 +4,41 @@ on: push: branches: - main - - master - -env: - REGISTRY_LOGIN_SERVER: ${{ secrets.ACR_LOGIN_SERVER }} - REGISTRY_USERNAME: ${{ secrets.ACR_USERNAME }} - REGISTRY_PASSWORD: ${{ secrets.ACR_PASSWORD }} - IMAGE_NAME: sit722-app - KUBE_NAMESPACE: default jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to ACR - uses: docker/login-action@v2 - with: - registry: ${{ env.REGISTRY_LOGIN_SERVER }} - username: ${{ env.REGISTRY_USERNAME }} - password: ${{ env.REGISTRY_PASSWORD }} - - - name: Build and push Docker image - uses: docker/build-push-action@v4 - with: - context: . - push: true - tags: | - ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:latest - ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - cache-from: type=registry,ref=${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:buildcache - cache-to: type=registry,ref=${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:buildcache,mode=max - - - name: Set up kubectl - uses: azure/setup-kubectl@v3 - with: - version: 'v1.36.1' - - - name: Configure kubectl with AKS credentials - uses: azure/aks-set-context@v3 - with: - resource-group: ${{ secrets.AKS_RESOURCE_GROUP }} - cluster-name: ${{ secrets.AKS_CLUSTER_NAME }} - admin: 'false' - env: - AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - - - name: Create deployment manifest - run: | - cat > deployment.yaml << EOF - apiVersion: apps/v1 - kind: Deployment - metadata: - name: sit722-app - namespace: default - spec: - replicas: 2 - selector: - matchLabels: - app: sit722-app - template: - metadata: - labels: - app: sit722-app - spec: - containers: - - name: sit722-app - image: ${{ env.REGISTRY_LOGIN_SERVER }}/${{ env.IMAGE_NAME }}:${{ github.sha }} - ports: - - containerPort: 80 - resources: - requests: - memory: "64Mi" - cpu: "250m" - limits: - memory: "128Mi" - cpu: "500m" - --- - apiVersion: v1 - kind: Service - metadata: - name: sit722-app-service - namespace: default - spec: - type: LoadBalancer - ports: - - port: 80 - targetPort: 80 - selector: - app: sit722-app - EOF - - - name: Deploy to AKS - run: | - kubectl apply -f deployment.yaml - kubectl rollout status deployment/sit722-app -n default - - - name: Verify deployment - run: | - echo "Deployment Status:" - kubectl get deployments -n default - kubectl get services -n default - kubectl get pods -n default \ No newline at end of file + - 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 From 80b268be6f28052d492b4942b3700f13c46b9e36 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 25 Sep 2026 16:14:50 +1000 Subject: [PATCH 06/12] Task 10.2D: Terraform, Docker Scout and Prometheus/Grafana in the pipeline --- .github/workflows/01-ci.yml | 197 +++++- .github/workflows/02-deploy-staging.yml | 360 ++++++---- .github/workflows/03-staging-test.yml | 71 +- .github/workflows/04-deploy-production.yml | 41 +- .../workflows/99-destroy-infrastructure.yml | 58 ++ .github/workflows/deploy.yml | 88 +-- Task_9.3C_Report.docx | Bin 0 -> 11052 bytes course-service/app/main.py | 178 ++--- course-service/requirements.txt | 6 +- enrollment-service/app/main.py | 178 ++--- enrollment-service/requirements.txt | 6 +- frontend/Dockerfile | 66 +- kubernetes/production/07-user-service.yaml | 151 ++-- kubernetes/production/08-student-service.yaml | 131 ++-- .../production/09-lecturer-service.yaml | 131 ++-- kubernetes/production/10-course-service.yaml | 117 +-- .../production/11-enrollment-service.yaml | 117 +-- kubernetes/staging/07-user-service.yaml | 151 ++-- kubernetes/staging/08-student-service.yaml | 131 ++-- kubernetes/staging/09-lecturer-service.yaml | 131 ++-- kubernetes/staging/10-course-service.yaml | 117 +-- kubernetes/staging/11-enrollment-service.yaml | 117 +-- lecturer-service/app/main.py | 206 +++--- lecturer-service/requirements.txt | 8 +- monitoring/grafana-dashboard.yaml | 664 ++++++++++++++++++ monitoring/kube-prometheus-stack-values.yaml | 58 ++ monitoring/podmonitor.yaml | 29 + student-service/app/main.py | 220 +++--- student-service/requirements.txt | 8 +- terraform/backend.tf | 12 + terraform/container_registry.tf | 22 +- terraform/kubernetes_service.tf | 51 +- terraform/output.tf | 4 +- terraform/resource_group.tf | 34 +- terraform/storage_account.tf | 38 +- terraform/terraform.tfvars | 25 +- terraform/variables.tf | 112 +-- terraform/versions.tf | 14 +- user-service/app/main.py | 296 ++++---- user-service/requirements.txt | 8 +- 40 files changed, 2805 insertions(+), 1547 deletions(-) create mode 100644 .github/workflows/99-destroy-infrastructure.yml create mode 100644 Task_9.3C_Report.docx create mode 100644 monitoring/grafana-dashboard.yaml create mode 100644 monitoring/kube-prometheus-stack-values.yaml create mode 100644 monitoring/podmonitor.yaml create mode 100644 terraform/backend.tf diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..c62617d8 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -5,15 +5,117 @@ on: push: branches: - main - + # Manual trigger for the workflow workflow_dispatch: +permissions: + contents: read jobs: # ========================================================= - # Backend Tests + # Stage 1: Infrastructure as Code (Terraform) + # Provisions / reconciles the resource group, ACR, AKS and + # storage account BEFORE any image is built or deployed. + # ========================================================= + terraform: + name: Terraform (fmt, validate, plan, apply) + runs-on: ubuntu-latest + + 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: Login to Azure + uses: azure/login@v2 + with: + creds: ${{ secrets.AZURE_CREDENTIALS }} + + # Create the Terraform state storage account on first run + # (idempotent - does nothing if it already exists). + - name: Bootstrap Terraform state storage + run: | + RG="${{ vars.TFSTATE_RESOURCE_GROUP }}" + SA="${{ vars.TFSTATE_STORAGE_ACCOUNT }}" + if ! az storage account show --name "$SA" --resource-group "$RG" >/dev/null 2>&1; then + az storage account create \ + --name "$SA" \ + --resource-group "$RG" \ + --location "$(az group show --name "$RG" --query location -o tsv)" \ + --sku Standard_LRS \ + --min-tls-version TLS1_2 \ + --allow-blob-public-access false + fi + KEY=$(az storage account keys list --account-name "$SA" --resource-group "$RG" --query "[0].value" -o tsv) + echo "::add-mask::$KEY" + az storage container create --name tfstate --account-name "$SA" --account-key "$KEY" --output none + + - name: Set up Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.8 + terraform_wrapper: false + + - name: Terraform format check + run: terraform fmt -check -recursive + + - name: Terraform init (remote state in Azure Storage) + 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 validate + run: terraform validate -no-color + + - name: Terraform plan + run: | + terraform plan -input=false -no-color -out=tfplan | tee plan.txt + { + echo "## Terraform plan" + echo '```' + grep -E "^(Plan:|No changes)" plan.txt || tail -n 5 plan.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Terraform apply + if: github.ref == 'refs/heads/main' + run: terraform apply -input=false -auto-approve tfplan + + - name: Terraform outputs + if: github.ref == 'refs/heads/main' + run: | + { + echo "## Terraform outputs" + echo '```' + terraform output -no-color + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + + # ========================================================= + # Stage 2: Backend Tests # ========================================================= backend-test: name: Test ${{ matrix.service }} @@ -103,14 +205,18 @@ jobs: # ========================================================= - # Build and Push Docker Images + # Stage 3: 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 + # Infrastructure must exist and all backend tests must pass needs: + - terraform - backend-test # Build and push when code is pushed to main or manually triggered @@ -139,6 +245,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 +262,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..12e16260 100644 --- a/.github/workflows/04-deploy-production.yml +++ b/.github/workflows/04-deploy-production.yml @@ -32,12 +32,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 +89,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 - @@ -142,4 +174,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 index 3c417bf3..e5c59d92 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,44 +1,44 @@ -name: Build and Deploy to AKS - -on: - push: - branches: - - main - -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 +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/Task_9.3C_Report.docx b/Task_9.3C_Report.docx new file mode 100644 index 0000000000000000000000000000000000000000..c92c89ed1b4ce3948217e51d0f3f6dbb2e7ed38f GIT binary patch literal 11052 zcmc(FWmKHYvi9Ka1b2s^K?e&ST!KT;Fu1z~4el1)-8~RIIKdr)1a~KBkV~?6b`s7$ zcYWWl+iSh^&RWw?O?6duch%D^F9iV!5BT2nidnRNeEItW>f!BVWoy7B|6jWx{OqD* z3o^I+rz7kSS=~u;pzDVYq>umr!Ji!stX?}RyYw=wu3IP z;L&Hg(mb_2?X62bdnz5gB7!4^t>*3{c;em52Z#0*Am;apyKQE>gfm!w+if}4LzOQO zq*eY_j_)#;dagG5n^xme36T3TqHe!Ip`&^d20~m_B~YLTPRL%V1t$6?zbp#{dwQBl zs;tL%);GfGhLTGP7&sS=np*qz-56_1E+mN=L~W>OqJhSm4USI9p0AL3waeiJ{Kx6vgSo;3GV_JgQ9-A00f8%sOGn~qrHn* zkLNfw;&di!qXHs(IqXIzyH>5Y+|_)`hNK}Ba9ZDN@q*rlLtJ_op7##$QdH0E)xyF? zymZ5Aq-N0hG{X@(ai%Ul?%2B^lA}*HR|bp3l4u=|%NphEKgq(gqm=?1pG!-EJGPaU z=U0D}$%a-9+maGUk%P_{Q;0UU#u$Pk;3`c?eCpK?>^=C3z_OAPM;VnG!bJnN%TOzM4!p(TnJeP z@nxLT&s*%SMx?shUsPVirH308tA{@FZS0H5HvGc8yjQ0ci2yTo0p2%>HjL;|U2$x8 z&rIS+wP+Eh-67OJMC~d{=|FcMo4hI$xcKJUZrdvC!q;(TLNBejuIpUhdO*`)q#H8WrLN<2$;;@FPqPQ~8scw1+}VMAS0 z@RxF~9^|wtXvs)T@sc~^6XjCoSVOwaSSuT(Td+aHYR-1d_>iq|7xcF%W)k&Kgc%zB}Q(0T@Ych`TcV|~Ss zAET&RNp@m{*X>D}|GesL;Z<9bJ{)c(tsLl?C)O>*+o^uEG=)C%l?t^gYee0F>^jK# z;x7aPSQG2L1HPK(C*FpI1Ks?%qg(h|7OIrSDQ#-DMA1Zt#c!3F#M%eQH^S<=gCdu<&%q(}^ zuh>g*p^km(;UJ7i*~r*pzyqmIdymF&(xMW2=m9#=s7mOGN86Z=8jvJy$RSZ&Qg7b1 zP_Bl;5QQ?%^Bl1e`Fvbbyk%HHv5D6 z0{4Z6iY{z1j`*c4E=FDREFQiIqne!)1)S3eW5Ei{~AZ z#}-*}ia`O91&38%H{{Cg0tG7TiP&yAABwNeIn-s1eL;hraRBWNTnl7?kQHf$uKeL$ zZE}ZFLYno$8!#ve4F19BPnXCLR$jVb_%EV`Ccx{Dz`9?_LZFYq=J}S*5!hHg4K#Z? zU97KEIj_u1oA(uC6SDYLyn;`Lgj+nYh}nCC&hjl7xijB0IT1hzA0_4@s85hCZgpp2 z^VFsY&B`=(fB>o!5+1zbRkJHPS|on+mQzVC6Mw%QW{y%^An&W*1gT0}p>DXaSHu{! z92fa`RT@Fq$LBAhs+@4FbUw25&qHWp zTp$MuQN?A&QZj$sl=f!KgBVmk3b{>Am3ituwGYTYk3CWYyu6`hgpC*rEWWutF(YQsYvz@mIf75w~uq<4s0tb+cD z=ovMgNV?gOd4-x_*z#CzN~S8e;x8HE#XAi@Um8<#BcmGO4v71>K+wkov;lHIt&o6f z1wFzk<}H|d7MT3t5wvT3lLX1ruL)P;Wj-rbh;VSgBjk&T_Uf==b&QqID6bPGaV_CE zRWyHmO@+2^VHZ>F!)v)?0buOXs9J<6Io(R5CWBYrUvW$kea6$PDj6+0O2w8p+I@Pc z-xS2xP)ud%2#qAB5_An98}fG4NOl)ebjNs2=C$VoZ5_9+jt_y^dFK;zV6z^V*%n3b z;r+B+iyV(^^dnah%@75eUYOW{v-3>2JEo#|j@*WQ>L6+5v|V3#lwhbztX-6E!H6Qc zWu%K!X!i6Jm}o?V241)$@0woY=d>7}-jOmyOwq0qS!Hj%9@uLO=9Ufg2!$ZR)1++2 zp*%zxRTb8_^n}-5U$~Nn8Rg;Kg6>*`u*$3Klj5BX5kq*Cg#y;ov%&9GL?TM5R*SPU z31F^5pdqbVls-Fw5EumWuWviN=@ZS(a;Kp5K+&+baAiikJb@0M7 zIDlATXp&^w69kFn-JW6WcvYR$(^2Bh;%A*>km=Z0Y8)+uZ|0$HGzB~yuD9LsN4#n2 z_g7di>0HN7lF3{79a5OmUl=tC5{0tQ<*fF|(Hn_f7 zTpq7!L(3k=hM#ZQ{(Al8tEd7voex)rm5z4HnW7^8C8)9NVVR##NIr?cx}XABP)y@2Yk9mXJc^kn zZt2`>ej>+8u|cX5LGh+#=v*=vmIOe{wK?<@eaF6DJDSGg#bQG8O#bm!ZE&Zx68?f~ z+$gaM;SMTvxzDm-SEi-!U9o_*SCR10U_VOjF%Oek_AZJiSV!?EVP%^FJuY|1j^U`o z>;0~5#iRAoi?h-IgRX<+(2vv~J#01Lz%RTwBtGy~(eF^%>O1h<9xRWRu+E^AWzCz$ ztJ@QE)ilnJ6v{LmRL=4cU2GpOXLb#EgJh6lw037+57=)n)qerqc7!{KEGkWoG}joL z3>dk?yF*FuDDGBRY4$W{6S4NBvYbI%wsOAbHFaH0nu?LDyX?aEJXO-S7+z$2bknXleK?ik~qT8(BN#HZ|+_4o7OQIYf!5sofJrt)5i$Xn&mD#>;Zy0}GJ%Z-_A%oE6;o-!s!IG5)=*W*8-r_B z;sc`Ec<$C|%jN~bsA870Ov?}RBzGKm$6uquz%4z3%jEXvO5ejv6K`WB2cl({QLu&k zHlg}xLo8--h`U52ob)}!v*&GmY^v^|{V59B8vyh3yaw=3pYQTC3GbF{rw8qy@b*(p zPc$p`E4*W1bMO*?q~VxyNz{m4GyRl(7>9Jel_9l{nh#Q4FDng+D%de8;6@JSIH~dA zeidSAaRMY;0`tHXj>kJa7qUKL<2=Ms+BchtM{J~V$P2lAZ(a5c++O9Ipm6;8Xb~l& zFD6>wXSiK{YsWb#8`<41wY+hiQW%Svn3?#PKy9~WX-vVQw`r^6n{kW5R7KM=Nny@? zfQ_|9FbvoP&V`cxMumwc$kZnt>k3hQ3fwJ~^>y@Q})V=hJQ>P?@$3SU1D8u7}^y(RJNn*aYYi;QHW{wgESsxI+^@CnH zTF zUl6hnK;D!)0ta&%_AooNK+8yLE)G!sJ$DG0aN4eexd>j3w?fKY6QL_EX4%)oMjqdu z|5>@zY*JROf&&2Ar~m-U&&tKl-o+eb_r1znSJ9N4W5&6Cs<22Jp9e(;{~7*?__#lg z@o?EH8&-JgEOPG6^=we;ySz+4XetYQ+0T4QB`xXJ8AA?ddzImX1G$JXBZ27%DwB&a9hL)xHQ z(Tf_lenIp?g!CER%M^qVjxSIaL(VC7&{3#G=(vSP&%8{KFhr0t5QT>zrPeahU*p8d z@0_+Vd4de!Mm+HajKS=$UPCj88&ZG0R8XxlgyjL6`77ELoa#nM7G92p_Aj z*%S&mV0&#CFJ_U7?__0M8m$Yn=lo@c-=3AXhbzxslnYyGivqo_?+y7OgLZO~;2B;J zhJ965#IN)7u=f>urbuw=vTE_<8o!LbHN>STw_J6Sii^8LUBzhA#`LMZQO9}I`iGq3 z-8uiFx1Kh$q32x!O8{? z^a}dV1-FmGuD=SciQTDgNLKakmGKGmb)~^5V&%BzO)z#@6T|AbLE0T;hcQ~(#IsK zBqtgbB_P*(Zd5FSN>vx+)|`|Yj~^xrfrtX;>aw`e#91_=iN_Pc5hF*~k9`YQGDvmE zIG9GgF&Atc?A}jmw~N>}zbORiCRa_Mfqh~@&4SqwcN`<*<3Kj&1`+uQ2o`^ehr6h@AN>*8aG7~sr}eK3`9IfXjfhmz#4XIx)foL585 zrZO+L96Im0l-)6V_$E+vL~qNV=NA!+trG2&Gp#Kl=8qHYtRet`B<};RNBvS#E*s<~ z!x;VFI9J4!KAbjC z9?ra1aAqp;c*hT1yyCCFh4~qu|G8j5Kg8$1QL4n*(eE}Mej9p- zAy`lYbSMX!dFE6^a!iVCU1{R)NfZ5BCanSm`4 zYr}`Us0D0^<@6_ntU8j;CoJ*AKTY=6l+Fkb&rNz*Jh@;20E`E7XbM|d+CR|BI!Z3q zAUmz^bGlZgU$%o8^(Kx+b)w!{0Je6$Z55Va3F4Jo44!^D$O`_3Z}%%HRMN|g4_=1QN1_N||l6M`^S3w^J9#&LIPiw8s5jC4}STE57$*$mLfzp)u z(fBfA7$15cf-W#Ber~D@t8%P5)1lGiMB;npW^? zxj$X0<}ybX{oum2hv&O1f9-<~cJ@{l-(8tHBnaQmjOw>s96LLwgiV%f!nyKU)PZ=+ z1mDGF-TnrLr>EyVh$E+btd1V;)rhK0zOn;9o<~v$p%2HTX9B zSq&(CHArl%q!?0eZsa~N)>oIytF^aSSd~Khas}&^MY({QHC|Z_e*d{;GAJY8A^Nu& z>&W!q%9^QzBdcJrpaiCDhkzWCm8qmSWs==5#UwwjJsZz4CTRA1I;#p8*io7`g3^Sx zUqU{H`BXkra-}m7b$heAS<$mnc<^WdDM5WvLl4ub^kMn=XNNSjva+|dvi~cKpNt)p zU1dh?(V;wPc6bSG7Q2BdQa|P%^JA&}Yw&x-QuvUKQ|B0F__R=h(%_ zMQ{hzT()qtc4UC5b`68rHdGOTT@(dQeJ=!UxSZ?lR>B!C+VB~q zamZd$W10e1%}h4gTBJHUt^p=;l%uAic~}Oj@n=hmmB<0O&fzz<_G6|>9!UwgM)o-` zq=`1gFF7{^gAB(8yjzu%vR4eJbs$Rk!S@cTHh8&>_oQRuN7Dx8I(L}PvDGU3dJ3=l zJI&1ukD*P|p6?@aofpZYy{G?JM15-|6P})u)8Da}<%j(ah}DKR9}N=3n$YBRq~yf54sTgTJt+XUBZjA!?3zKY6R8kd7Qe}?av(@mYl zL)f}KJcJL=kE6f8HkdzR;Kyzw6Do)dPKqd6F4W)@%oxhjF<^Cr!e~x91JdD_yM({KJWWOcrH!ax@22w{lgHvnbYW zCAxl+^08-UEShbScQW3`2K%^k_b^K*5P$0VTrxcD<3p52KSb$2M`R#NgMWf_Fm}#z zl?2sar)!1(V|c^lr{M7rB9-Riqy?A^oSuR~_#Leig%LIa-g%(Si+G-; zFf1xWv_cGccCX{w8aup)>|gJVS51DjaV=RT)a)dPuimbtLk~KQypqL(hnHqlIth3bFKMSVzt^(~D6RuEHkJTe3D_L-`udF{HrT5|FEPzbPi#!@ zp0Ug&sW*8iZAS1wgg4VDy=9Q9*pax`jLF8P-ae_QGhXrog+p0G))}ZrigL`%E}yPl zf7I+|0pL~8!8XmCSd|#0pX|^vrX}-}nSrA=sE=>)d*oel>?spBgNjHlvJ;FH-~tY_ z6K<$9(d6>?VMDT3!3Z(Ox4tSA5lBbo;Swe6&((|{rk@2|%PHxmeH(m4Ec_mqkB*Ul z_xk^JSjwb-`jq()mUa)v=KqAH9mwAPp}6?LfaJzLoZK=a`ss9yydi5;3;9@}Y5#QU zOn*kS4n~)97K=>5hhnaOE+Txj{<%ODn`*S+G0%&fuH8A$DT8Ks1)dVM$|3bUd9K3B z!kI76pR4GoV_{@KuF5N$7n3zMe<1o)@G?u7g&fdrdz7VbA=$FV8?2%pXjUw0JvwQ^ zkg{G@Rx9srEqD^r)a%4UoFdPHzQv=9618u0&Nt}dhgqUk5xK?6HZL?|?;&cBBJtcT zd;HzZ)nus6lU?w4Zk;>2#?rLZ!rwqcusvI{v=_4WDte!3Pcjf)tZ_8=hq(ZQ*1%d~IbV-87+kw~tsg}^8ii)l%hla(HaHsu6&7-oFL|0( z-?eVy6;cQIk}{nVu*BUu^3|p0#XN!9Y9qgQt<_0?ao6bpZ|%}Hl!gL7#}+s*zO4vD zf5Fb|rDnCwia24j_dX|TvcIXfZG3l6#O4!co`S zjK6iqZf+RRdEWjuvFfhBx4J#RlE<@@0Ppi9V{A>R+1zwu(NzdNNl!YK4W>-}0}I}{ zKSn3q&{9=y^_fPx^o)>Sr--19_PG2uWFz{^Zq*3bV!gtaL=17%v{%LJum`TYZy_91s9{{{o z1UY|sX`+s}^u5_i*uoRkllygd^9xn$y&lM_t*0#-hAFt^@|vC{mlxAL`W?8U+qL#v zGF?{wmmYP^vFz94^tpU-8FY)CGw*~VO%cU4^v&F_OIW-@w{gS44Y*S0{Dh1_H88v8 zO+mc<@OQfHPvNI(l{W|{aYG~CXhW_>2d3f8jb&(Qk22!eM9Ggb2BQkMg+Ukb*ZX*LR`R2Qr95Z)pJaLl2962%?{~8vaHYFTZShu^%MB-cenmV zKD?y=1pa4%9POL}~x>6aw0hYc@!xpdEkV|5NLS9QW_` zU2C@|KPjJIn7>qhObq=*0RZX$VE)xTk13R2z%s0VB~%{&^W(z(mjD->-)#K)pECZj zfX8L?F9E!`zX|xeh<=QIT(kZ{-#(y!82gXv z!++lmAH)CqIPS>)7{~8I{&gIW_qkt+&))gB WDo{VB9u@!#;Qo-&CsBU?^Zx-R&a$BZ literal 0 HcmV?d00001 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..2897bfd6 100644 --- a/course-service/requirements.txt +++ b/course-service/requirements.txt @@ -1,4 +1,5 @@ -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 @@ -6,4 +7,5 @@ pydantic==2.11.7 PyJWT==2.10.1 python-dotenv==1.0.1 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==8.1.0 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..2897bfd6 100644 --- a/enrollment-service/requirements.txt +++ b/enrollment-service/requirements.txt @@ -1,4 +1,5 @@ -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 @@ -6,4 +7,5 @@ pydantic==2.11.7 PyJWT==2.10.1 python-dotenv==1.0.1 pytest==8.4.1 -httpx==0.28.1 \ No newline at end of file +httpx==0.28.1 +prometheus-fastapi-instrumentator==8.1.0 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6a1bcec6..a1024e76 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,33 +1,33 @@ -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 + +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/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/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..16325d07 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 +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/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..16325d07 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 +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/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 index 9bc9a1c5..13c9396d 100644 --- a/terraform/container_registry.tf +++ b/terraform/container_registry.tf @@ -1,15 +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 + name = var.acr_name + resource_group_name = local.resource_group_name + location = local.location - sku = "Basic" - admin_enabled = true + sku = "Basic" + admin_enabled = true - tags = merge( - var.tags, - { - Environment = var.environment - } - ) + tags = merge( + var.tags, + { + Environment = var.environment + } + ) } diff --git a/terraform/kubernetes_service.tf b/terraform/kubernetes_service.tf index df83060b..30a762c7 100644 --- a/terraform/kubernetes_service.tf +++ b/terraform/kubernetes_service.tf @@ -1,34 +1,39 @@ 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 + 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 - } + default_node_pool { + name = "default" + node_count = var.aks_node_count + vm_size = var.aks_node_vm_size + } - identity { - type = "SystemAssigned" - } + identity { + type = "SystemAssigned" + } - tags = merge( - var.tags, - { - Environment = var.environment - } - ) + tags = merge( + var.tags, + { + Environment = var.environment + } + ) } # # Grant AKS permission to pull images from your ACR # resource "azurerm_role_assignment" "acr_pull" { - principal_id = azurerm_kubernetes_cluster.aks.kubelet_identity[0].object_id - role_definition_name = "AcrPull" - scope = azurerm_container_registry.acr.id - skip_service_principal_aad_check = true + # 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 index d3044b52..534424db 100644 --- a/terraform/output.tf +++ b/terraform/output.tf @@ -1,6 +1,6 @@ output "resource_group_name" { description = "Name of the resource group" - value = azurerm_resource_group.rg.name + value = local.resource_group_name } output "acr_name" { @@ -34,7 +34,7 @@ output "aks_get_credentials_command" { value = join(" ", [ "az aks get-credentials", "--resource-group", - azurerm_resource_group.rg.name, + local.resource_group_name, "--name", azurerm_kubernetes_cluster.aks.name, "--overwrite-existing" diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf index b4709dbe..f5b5a9c1 100644 --- a/terraform/resource_group.tf +++ b/terraform/resource_group.tf @@ -1,11 +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" { - name = var.resource_group_name - location = var.location - - tags = merge( - var.tags, - { - Environment = var.environment - } - ) + 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 index bde8b1af..5c303e01 100644 --- a/terraform/storage_account.tf +++ b/terraform/storage_account.tf @@ -1,30 +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 + name = var.storage_account_name + resource_group_name = local.resource_group_name + location = local.location - account_tier = "Standard" - account_replication_type = "LRS" + account_tier = "Standard" + account_replication_type = "LRS" - min_tls_version = "TLS1_2" - allow_nested_items_to_be_public = false + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false - tags = merge( - var.tags, - { - Environment = var.environment - } - ) + 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" + 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" + name = "lecturer-profile-photo" + storage_account_id = azurerm_storage_account.storage_account.id + container_access_type = "private" } diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars index e10cdbef..773b40a6 100644 --- a/terraform/terraform.tfvars +++ b/terraform/terraform.tfvars @@ -1,8 +1,14 @@ -location = "Australia East" -resource_group_name = "koalatech-week08-rg" +# 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 = "sit722acrtf2306357w08" +acr_name = "sit722acrtf2306357w08" # Must be globally unique, 3-24 lowercase letters/digits. If Azure rejects it as taken, add a digit and retry. storage_account_name = "sit722storagetf230635708" @@ -10,15 +16,16 @@ storage_account_name = "sit722storagetf230635708" aks_cluster_name = "aks-sit722-week08" aks_dns_prefix = "koalatechw08" -# Task 8.1P requires 3 nodes: staging and production both run persistent PostgreSQL workloads. -aks_node_count = 3 +# 2 nodes keeps within the Azure for Students vCPU quota (2 x 2 vCPU) +# and still runs staging, production and monitoring. +aks_node_count = 2 aks_node_vm_size = "Standard_D2s_v3" environment = "staging-production" tags = { - Project = "KoalaTech Course Platform" - ManagedBy = "Terraform" - Practical = "Week08" - Environment = "Week08-CD" + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week08" + Environment = "Week08-CD" } diff --git a/terraform/variables.tf b/terraform/variables.tf index 28175dab..a02c6367 100644 --- a/terraform/variables.tf +++ b/terraform/variables.tf @@ -1,83 +1,95 @@ variable "location" { - description = "Azure region where the resources will be created" - type = string - default = "Australia East" + 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 + description = "Name of the Azure Resource Group" + type = string } variable "acr_name" { - description = "Globally unique name of the Azure Container Registry" - type = string + 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." - } + 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." - } + 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 + description = "Name of the Azure Kubernetes Service cluster" + type = string } variable "aks_dns_prefix" { - description = "DNS prefix used by the AKS cluster" - type = string + 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." - } + 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" + 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" + description = "Environment name applied to resource tags" + type = string + default = "development" } variable "kubernetes_version" { - default = "1.36.1" + default = "1.36.1" } variable "tags" { - description = "Tags applied to Azure resources" - type = map(string) - - default = { - Project = "KoalaTech Course Platform" - ManagedBy = "Terraform" - Practical = "Week08" - } + 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 index 49749feb..44545772 100644 --- a/terraform/versions.tf +++ b/terraform/versions.tf @@ -1,14 +1,14 @@ terraform { - required_version = ">= 1.7.0" + required_version = ">= 1.7.0" - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = "~> 4.0" - } + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" } + } } provider "azurerm" { - features {} + features {} } diff --git a/user-service/app/main.py b/user-service/app/main.py index fbaf04d3..fe9fdeb2 100644 --- a/user-service/app/main.py +++ b/user-service/app/main.py @@ -1,143 +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__) - -# 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, -) - - -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..73f62e38 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 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 From 87800dec925c938298be85aa569c1159bc715697 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 25 Sep 2026 16:28:56 +1000 Subject: [PATCH 07/12] Use unique ACR/storage names and an allowed AKS VM size --- terraform/terraform.tfvars | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars index 773b40a6..6a2d16aa 100644 --- a/terraform/terraform.tfvars +++ b/terraform/terraform.tfvars @@ -8,10 +8,10 @@ use_existing_resource_group = false grant_acr_pull_role = false # Must be globally unique. If Azure rejects it as taken, add a digit and retry. -acr_name = "sit722acrtf2306357w08" +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 = "sit722storagetf230635708" +storage_account_name = "sit722app2306357w10" aks_cluster_name = "aks-sit722-week08" aks_dns_prefix = "koalatechw08" @@ -19,7 +19,7 @@ aks_dns_prefix = "koalatechw08" # 2 nodes keeps within the Azure for Students vCPU quota (2 x 2 vCPU) # and still runs staging, production and monitoring. aks_node_count = 2 -aks_node_vm_size = "Standard_D2s_v3" +aks_node_vm_size = "Standard_D2s_v5" environment = "staging-production" From 0a5205895f5127762a636dacd8b43bed1530922a Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 25 Sep 2026 16:36:06 +1000 Subject: [PATCH 08/12] Use Standard_B2s_v2 nodes to fit Azure for Students quota --- terraform/terraform.tfvars | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/terraform/terraform.tfvars b/terraform/terraform.tfvars index 6a2d16aa..03f20e0a 100644 --- a/terraform/terraform.tfvars +++ b/terraform/terraform.tfvars @@ -16,10 +16,11 @@ storage_account_name = "sit722app2306357w10" aks_cluster_name = "aks-sit722-week08" aks_dns_prefix = "koalatechw08" -# 2 nodes keeps within the Azure for Students vCPU quota (2 x 2 vCPU) -# and still runs staging, production and monitoring. +# 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_D2s_v5" +aks_node_vm_size = "Standard_B2s_v2" environment = "staging-production" From 27bd8395371246fbfab6c7335ca9ff1ea1789661 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 25 Sep 2026 16:47:12 +1000 Subject: [PATCH 09/12] Upgrade PyJWT to 2.15.0 to fix CVE-2026-32597 and CVE-2026-48526 --- course-service/requirements.txt | 2 +- enrollment-service/requirements.txt | 2 +- lecturer-service/requirements.txt | 2 +- student-service/requirements.txt | 2 +- user-service/requirements.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/course-service/requirements.txt b/course-service/requirements.txt index 2897bfd6..b44098f2 100644 --- a/course-service/requirements.txt +++ b/course-service/requirements.txt @@ -4,7 +4,7 @@ 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 diff --git a/enrollment-service/requirements.txt b/enrollment-service/requirements.txt index 2897bfd6..b44098f2 100644 --- a/enrollment-service/requirements.txt +++ b/enrollment-service/requirements.txt @@ -4,7 +4,7 @@ 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 diff --git a/lecturer-service/requirements.txt b/lecturer-service/requirements.txt index 16325d07..3dc31b17 100644 --- a/lecturer-service/requirements.txt +++ b/lecturer-service/requirements.txt @@ -4,7 +4,7 @@ uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic[email]==2.11.7 -PyJWT==2.10.1 +PyJWT==2.15.0 python-multipart==0.0.32 python-dotenv==1.0.1 azure-storage-blob==12.26.0 diff --git a/student-service/requirements.txt b/student-service/requirements.txt index 16325d07..3dc31b17 100644 --- a/student-service/requirements.txt +++ b/student-service/requirements.txt @@ -4,7 +4,7 @@ uvicorn[standard]==0.35.0 SQLAlchemy==2.0.43 psycopg2-binary==2.9.10 pydantic[email]==2.11.7 -PyJWT==2.10.1 +PyJWT==2.15.0 python-multipart==0.0.32 python-dotenv==1.0.1 azure-storage-blob==12.26.0 diff --git a/user-service/requirements.txt b/user-service/requirements.txt index 73f62e38..f236fb2e 100644 --- a/user-service/requirements.txt +++ b/user-service/requirements.txt @@ -4,7 +4,7 @@ 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.32 pytest==8.4.1 From f672fdfe08074fca81073a32ad6a4ba5794b347a Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Fri, 25 Sep 2026 16:55:45 +1000 Subject: [PATCH 10/12] Patch Alpine packages in frontend image (CVE-2026-93990) --- frontend/Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a1024e76..96798374 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -24,6 +24,10 @@ 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 From bfa5286b6838d717f18159e1323d1e806edcc623 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Sun, 4 Oct 2026 17:14:13 +1100 Subject: [PATCH 11/12] Enable continuous deployment: auto-promote to production after staging tests pass --- .github/workflows/01-ci.yml | 116 +++------------------ .github/workflows/04-deploy-production.yml | 38 +++++-- 2 files changed, 42 insertions(+), 112 deletions(-) diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index c62617d8..26baf125 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -2,10 +2,17 @@ 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: @@ -15,107 +22,7 @@ permissions: jobs: # ========================================================= - # Stage 1: Infrastructure as Code (Terraform) - # Provisions / reconciles the resource group, ACR, AKS and - # storage account BEFORE any image is built or deployed. - # ========================================================= - terraform: - name: Terraform (fmt, validate, plan, apply) - runs-on: ubuntu-latest - - 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: Login to Azure - uses: azure/login@v2 - with: - creds: ${{ secrets.AZURE_CREDENTIALS }} - - # Create the Terraform state storage account on first run - # (idempotent - does nothing if it already exists). - - name: Bootstrap Terraform state storage - run: | - RG="${{ vars.TFSTATE_RESOURCE_GROUP }}" - SA="${{ vars.TFSTATE_STORAGE_ACCOUNT }}" - if ! az storage account show --name "$SA" --resource-group "$RG" >/dev/null 2>&1; then - az storage account create \ - --name "$SA" \ - --resource-group "$RG" \ - --location "$(az group show --name "$RG" --query location -o tsv)" \ - --sku Standard_LRS \ - --min-tls-version TLS1_2 \ - --allow-blob-public-access false - fi - KEY=$(az storage account keys list --account-name "$SA" --resource-group "$RG" --query "[0].value" -o tsv) - echo "::add-mask::$KEY" - az storage container create --name tfstate --account-name "$SA" --account-key "$KEY" --output none - - - name: Set up Terraform - uses: hashicorp/setup-terraform@v3 - with: - terraform_version: 1.9.8 - terraform_wrapper: false - - - name: Terraform format check - run: terraform fmt -check -recursive - - - name: Terraform init (remote state in Azure Storage) - 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 validate - run: terraform validate -no-color - - - name: Terraform plan - run: | - terraform plan -input=false -no-color -out=tfplan | tee plan.txt - { - echo "## Terraform plan" - echo '```' - grep -E "^(Plan:|No changes)" plan.txt || tail -n 5 plan.txt - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - name: Terraform apply - if: github.ref == 'refs/heads/main' - run: terraform apply -input=false -auto-approve tfplan - - - name: Terraform outputs - if: github.ref == 'refs/heads/main' - run: | - { - echo "## Terraform outputs" - echo '```' - terraform output -no-color - echo '```' - } >> "$GITHUB_STEP_SUMMARY" - - - # ========================================================= - # Stage 2: Backend Tests + # Stage 1: Backend Tests # ========================================================= backend-test: name: Test ${{ matrix.service }} @@ -205,7 +112,7 @@ jobs: # ========================================================= - # Stage 3: Build, Security Scan (Docker Scout) and Push + # 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. @@ -214,9 +121,10 @@ jobs: name: Build, scan and push ${{ matrix.image }} runs-on: ubuntu-latest - # Infrastructure must exist and all backend tests must pass + # 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: - - terraform - backend-test # Build and push when code is pushed to main or manually triggered diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml index 12e16260..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 @@ -101,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 From 32e43300a2b5be065a8931204ef4954248936886 Mon Sep 17 00:00:00 2001 From: Kaustubh Date: Sun, 4 Oct 2026 17:29:45 +1100 Subject: [PATCH 12/12] Apply Debian security updates in Python service images (CVE-2026-103111) --- course-service/Dockerfile | 6 ++++++ enrollment-service/Dockerfile | 6 ++++++ lecturer-service/Dockerfile | 6 ++++++ student-service/Dockerfile | 6 ++++++ user-service/Dockerfile | 6 ++++++ 5 files changed, 30 insertions(+) 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/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/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/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/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 \