diff --git a/.github/workflows/01-ci.yml b/.github/workflows/01-ci.yml index 8206db43..37deb194 100644 --- a/.github/workflows/01-ci.yml +++ b/.github/workflows/01-ci.yml @@ -5,6 +5,10 @@ on: push: branches: - main + + pull_request: + branches: + - main # Manual trigger for the workflow workflow_dispatch: @@ -83,10 +87,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.12" @@ -142,7 +146,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Login to Azure uses: azure/login@v2 diff --git a/.github/workflows/02-deploy-staging.yml b/.github/workflows/02-deploy-staging.yml index 006bb70d..85ffe45b 100644 --- a/.github/workflows/02-deploy-staging.yml +++ b/.github/workflows/02-deploy-staging.yml @@ -15,7 +15,8 @@ jobs: runs-on: ubuntu-latest if: > - ${{ github.event.workflow_run.conclusion == 'success' }} + ${{ github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event != 'pull_request' }} environment: name: staging diff --git a/.github/workflows/04-deploy-production.yml b/.github/workflows/04-deploy-production.yml index 969d646b..92a6a96b 100644 --- a/.github/workflows/04-deploy-production.yml +++ b/.github/workflows/04-deploy-production.yml @@ -1,18 +1,20 @@ name: 04 - Deploy to Production on: - workflow_dispatch: - inputs: - image_tag: - description: "Tested image SHA to deploy" - required: true - type: string - + workflow_run: + workflows: + - "03 - Test Staging" + types: + - completed + branches: + - main jobs: deploy-production: name: Deploy to Production runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + environment: name: production @@ -69,37 +71,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:${{ github.event.workflow_run.head_sha }} \ -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:${{ github.event.workflow_run.head_sha }} \ -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:${{ github.event.workflow_run.head_sha }} \ -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:${{ github.event.workflow_run.head_sha }} \ -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:${{ github.event.workflow_run.head_sha }} \ -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:${{ github.event.workflow_run.head_sha }} \ -n production - name: Wait for frontend rollout diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 1132741c..d6692eb4 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -87,7 +87,7 @@ const Login = () => { return ( - + {/* background colour change */} { color="text.secondary" sx={{ mb: 3 }} > - Sign in to continue + Sign in to continue (v2 - released by Continuous Deployment) {/* text change */} {error && ( diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 00000000..ef1394e4 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfstate +*.tfstate.* +.terraform.lock.hcl +terraform.tfvars \ No newline at end of file diff --git a/terraform/container_registry.tf b/terraform/container_registry.tf new file mode 100644 index 00000000..dc32f38a --- /dev/null +++ b/terraform/container_registry.tf @@ -0,0 +1,15 @@ +resource "azurerm_container_registry" "acr" { + name = var.acr_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + sku = "Basic" + admin_enabled = true + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} \ No newline at end of file diff --git a/terraform/kubernetes_manifest.tf b/terraform/kubernetes_manifest.tf new file mode 100644 index 00000000..3fd07c08 --- /dev/null +++ b/terraform/kubernetes_manifest.tf @@ -0,0 +1,15 @@ +locals { + k8s_dir = "${path.module}/../kubernetes" + + template_vars = { + acr_login_server = azurerm_container_registry.acr.login_server + storage_connection_string = azurerm_storage_account.storage_account.primary_connection_string + } +} + +resource "local_file" "k8s_manifests" { + for_each = fileset(local.k8s_dir, "*.tpl") + + filename = "${local.k8s_dir}/${trimsuffix(each.value, ".tpl")}" + content = templatefile("${local.k8s_dir}/${each.value}", local.template_vars) +} \ No newline at end of file diff --git a/terraform/kubernetes_serivce.tf b/terraform/kubernetes_serivce.tf new file mode 100644 index 00000000..bee30484 --- /dev/null +++ b/terraform/kubernetes_serivce.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 +} \ No newline at end of file diff --git a/terraform/output.tf b/terraform/output.tf new file mode 100644 index 00000000..36e88828 --- /dev/null +++ b/terraform/output.tf @@ -0,0 +1,57 @@ +output "resource_group_name" { + description = "Name of the resource group" + value = azurerm_resource_group.rg.name +} + +output "acr_name" { + description = "Name of the Azure Container Registry" + value = azurerm_container_registry.acr.name +} + +output "acr_login_server" { + description = "Login server of the Azure Container Registry" + value = azurerm_container_registry.acr.login_server +} + +output "storage_account_name" { + description = "Name of the Azure Storage Account" + value = azurerm_storage_account.storage_account.name +} + +output "storage_connection_string" { + description = "Connection string used by the application to access Blob Storage" + value = azurerm_storage_account.storage_account.primary_connection_string + sensitive = true +} + +output "student_profile_container" { + description = "Student profile photo Blob container" + value = azurerm_storage_container.student_profile_photo.name +} + +output "lecturer_profile_container" { + description = "Lecturer profile photo Blob container" + value = azurerm_storage_container.lecturer_profile_photo.name +} + +output "aks_cluster_name" { + description = "Name of the AKS cluster" + value = azurerm_kubernetes_cluster.aks.name +} + +output "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}" +} \ No newline at end of file diff --git a/terraform/resource_group.tf b/terraform/resource_group.tf new file mode 100644 index 00000000..ff3255ec --- /dev/null +++ b/terraform/resource_group.tf @@ -0,0 +1,11 @@ +resource "azurerm_resource_group" "rg" { + name = var.resource_group_name + location = var.location + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} \ No newline at end of file diff --git a/terraform/storage_account.tf b/terraform/storage_account.tf new file mode 100644 index 00000000..ce33235f --- /dev/null +++ b/terraform/storage_account.tf @@ -0,0 +1,30 @@ +resource "azurerm_storage_account" "storage_account" { + name = var.storage_account_name + resource_group_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + account_tier = "Standard" + account_replication_type = "LRS" + + min_tls_version = "TLS1_2" + allow_nested_items_to_be_public = false + + tags = merge( + var.tags, + { + Environment = var.environment + } + ) +} + +resource "azurerm_storage_container" "student_profile_photo" { + name = "student-profile-photo" + storage_account_id = azurerm_storage_account.storage_account.id + container_access_type = "private" +} + +resource "azurerm_storage_container" "lecturer_profile_photo" { + name = "lecturer-profile-photo" + storage_account_id = azurerm_storage_account.storage_account.id + container_access_type = "private" +} \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 00000000..c6e53382 --- /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 = 2 + + validation { + condition = var.aks_node_count >= 1 + error_message = "The AKS node count must be at least 1." + } +} + +variable "aks_node_vm_size" { + description = "Virtual machine size used by the AKS nodes" + type = string + default = "Standard_D2s_v3" +} + +variable "environment" { + description = "Environment name applied to resource tags" + type = string + default = "development" +} + +variable "kubernetes_version" { + default = "1.36.1" +} + +variable "tags" { + description = "Tags applied to Azure resources" + type = map(string) + + default = { + Project = "KoalaTech Course Platform" + ManagedBy = "Terraform" + Practical = "Week06" + } +} \ No newline at end of file diff --git a/terraform/versions.tf b/terraform/versions.tf new file mode 100644 index 00000000..d70fa886 --- /dev/null +++ b/terraform/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 4.0" + } + local = { + source = "hashicorp/local" + version = "~> 2.5" + } + } +} + +provider "azurerm" { + features {} +}