From 8606dcce39e890b601c68a5900bfcf10aa312537 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:01:17 -0400 Subject: [PATCH 1/9] feat(infra): scaffold Terraform structure for demo environment Flat infra/ layout optimised for workshop readability. Providers wired for azurerm (OIDC-ready), kubernetes and helm (from AKS output), azuread and random. Local state by default; backend.tf.example provided for Azure Storage migration. Placeholder files created for all subsequent tasks (aks, acr, identity, kubernetes, argocd, aks-mcp). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/.gitignore | 12 ++++++ infra/acr.tf | 1 + infra/aks-mcp.tf | 1 + infra/aks.tf | 1 + infra/argocd.tf | 1 + infra/backend.tf.example | 10 +++++ infra/identity.tf | 1 + infra/kubernetes.tf | 1 + infra/main.tf | 20 ++++++++++ infra/outputs.tf | 29 ++++++++++++++ infra/providers.tf | 47 ++++++++++++++++++++++ infra/terraform.tfvars.example | 8 ++++ infra/variables.tf | 72 ++++++++++++++++++++++++++++++++++ infra/versions.tf | 26 ++++++++++++ 14 files changed, 230 insertions(+) create mode 100644 infra/.gitignore create mode 100644 infra/acr.tf create mode 100644 infra/aks-mcp.tf create mode 100644 infra/aks.tf create mode 100644 infra/argocd.tf create mode 100644 infra/backend.tf.example create mode 100644 infra/identity.tf create mode 100644 infra/kubernetes.tf create mode 100644 infra/main.tf create mode 100644 infra/outputs.tf create mode 100644 infra/providers.tf create mode 100644 infra/terraform.tfvars.example create mode 100644 infra/variables.tf create mode 100644 infra/versions.tf diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..d963d97 --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1,12 @@ +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +backend.tf diff --git a/infra/acr.tf b/infra/acr.tf new file mode 100644 index 0000000..bd6c64e --- /dev/null +++ b/infra/acr.tf @@ -0,0 +1 @@ +# Azure Container Registry - see task dark-factory-9ho diff --git a/infra/aks-mcp.tf b/infra/aks-mcp.tf new file mode 100644 index 0000000..91bda3b --- /dev/null +++ b/infra/aks-mcp.tf @@ -0,0 +1 @@ +# AKS MCP Server via Helm - see task dark-factory-71d diff --git a/infra/aks.tf b/infra/aks.tf new file mode 100644 index 0000000..30b95d3 --- /dev/null +++ b/infra/aks.tf @@ -0,0 +1 @@ +# AKS cluster - see task dark-factory-dhz diff --git a/infra/argocd.tf b/infra/argocd.tf new file mode 100644 index 0000000..0295a42 --- /dev/null +++ b/infra/argocd.tf @@ -0,0 +1 @@ +# ArgoCD via Helm - see task dark-factory-y4c diff --git a/infra/backend.tf.example b/infra/backend.tf.example new file mode 100644 index 0000000..d4d4cfd --- /dev/null +++ b/infra/backend.tf.example @@ -0,0 +1,10 @@ +# Rename to backend.tf and fill in values to use Azure Storage remote state. +# Run: terraform init -reconfigure +terraform { + backend "azurerm" { + resource_group_name = "rg-terraform-state" + storage_account_name = "" + container_name = "tfstate" + key = "agentic-platform-engineering.tfstate" + } +} diff --git a/infra/identity.tf b/infra/identity.tf new file mode 100644 index 0000000..d1ec434 --- /dev/null +++ b/infra/identity.tf @@ -0,0 +1 @@ +# Managed Identity + GitHub OIDC federation - see task dark-factory-019 diff --git a/infra/kubernetes.tf b/infra/kubernetes.tf new file mode 100644 index 0000000..2b056f8 --- /dev/null +++ b/infra/kubernetes.tf @@ -0,0 +1 @@ +# Kubernetes namespaces + RBAC - see task dark-factory-af1 diff --git a/infra/main.tf b/infra/main.tf new file mode 100644 index 0000000..ed553aa --- /dev/null +++ b/infra/main.tf @@ -0,0 +1,20 @@ +data "azurerm_client_config" "current" {} + +resource "azurerm_resource_group" "main" { + name = var.resource_group_name + location = var.location + tags = var.tags +} + +# 4-char numeric suffix for globally unique names (ACR, etc.) +resource "random_string" "suffix" { + length = 4 + upper = false + lower = false + numeric = true + special = false +} + +locals { + acr_name = var.acr_name != "" ? var.acr_name : "acragentic${random_string.suffix.result}" +} diff --git a/infra/outputs.tf b/infra/outputs.tf new file mode 100644 index 0000000..6884b84 --- /dev/null +++ b/infra/outputs.tf @@ -0,0 +1,29 @@ +output "resource_group_name" { + description = "Name of the Azure Resource Group" + value = azurerm_resource_group.main.name +} + +output "cluster_name" { + description = "Name of the AKS cluster" + value = var.cluster_name +} + +output "get_credentials_command" { + description = "Command to fetch AKS kubeconfig" + value = "az aks get-credentials --resource-group ${azurerm_resource_group.main.name} --name ${var.cluster_name}" +} + +output "acr_login_server" { + description = "ACR login server hostname" + value = try(azurerm_container_registry.main.login_server, "") +} + +output "uami_client_id" { + description = "Client ID of the User-Assigned Managed Identity (for workload identity annotations)" + value = try(azurerm_user_assigned_identity.main.client_id, "") +} + +output "oidc_issuer_url" { + description = "OIDC issuer URL of the AKS cluster (for federated credential configuration)" + value = try(azurerm_kubernetes_cluster.main.oidc_issuer_url, "") +} diff --git a/infra/providers.tf b/infra/providers.tf new file mode 100644 index 0000000..5d9f31f --- /dev/null +++ b/infra/providers.tf @@ -0,0 +1,47 @@ +# --------------------------------------------------------------------------- +# OIDC authentication for GitHub Actions +# Set the following environment variables in your workflow (no secrets needed): +# ARM_USE_OIDC=true +# ARM_TENANT_ID= +# ARM_SUBSCRIPTION_ID= +# ARM_CLIENT_ID= +# --------------------------------------------------------------------------- + +provider "azurerm" { + features {} + # Credentials are sourced from ARM_* environment variables. + # No hardcoded values here — safe for public repos. +} + +provider "azuread" { + # Tenant is sourced from ARM_TENANT_ID / AZURE_TENANT_ID env var. +} + +# --------------------------------------------------------------------------- +# Kubernetes and Helm providers are configured from the AKS cluster outputs +# defined in aks.tf. The try() calls below allow `terraform validate` and +# `terraform plan` to succeed before aks.tf resources exist. +# --------------------------------------------------------------------------- + +locals { + kube_host = try(azurerm_kubernetes_cluster.main.kube_config[0].host, "") + kube_client_certificate = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_certificate), "") + kube_client_key = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_key), "") + kube_cluster_ca_certificate = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].cluster_ca_certificate), "") +} + +provider "kubernetes" { + host = local.kube_host + client_certificate = local.kube_client_certificate + client_key = local.kube_client_key + cluster_ca_certificate = local.kube_cluster_ca_certificate +} + +provider "helm" { + kubernetes { + host = local.kube_host + client_certificate = local.kube_client_certificate + client_key = local.kube_client_key + cluster_ca_certificate = local.kube_cluster_ca_certificate + } +} diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example new file mode 100644 index 0000000..4681704 --- /dev/null +++ b/infra/terraform.tfvars.example @@ -0,0 +1,8 @@ +location = "eastus2" +resource_group_name = "rg-agentic-demo" +cluster_name = "aks-eastus2" +kubernetes_version = "1.30" +node_vm_size = "Standard_D4s_v3" +node_count = 3 +github_org = "MicrosoftGbb" +github_repo = "agentic-platform-engineering" diff --git a/infra/variables.tf b/infra/variables.tf new file mode 100644 index 0000000..4f571c9 --- /dev/null +++ b/infra/variables.tf @@ -0,0 +1,72 @@ +variable "location" { + description = "Azure region for all resources" + type = string + default = "eastus2" +} + +variable "resource_group_name" { + description = "Name of the Azure Resource Group" + type = string + default = "rg-agentic-demo" +} + +variable "cluster_name" { + description = "Name of the AKS cluster" + type = string + default = "aks-eastus2" +} + +variable "kubernetes_version" { + description = "Kubernetes version for the AKS cluster" + type = string + default = "1.30" +} + +variable "node_vm_size" { + description = "VM size for AKS default node pool" + type = string + default = "Standard_D4s_v3" +} + +variable "node_count" { + description = "Number of nodes in the AKS default node pool" + type = number + default = 3 +} + +variable "acr_name" { + description = "Azure Container Registry name. If empty, auto-generated as acragentic" + type = string + default = "" +} + +variable "github_org" { + description = "GitHub org for OIDC federation" + type = string +} + +variable "github_repo" { + description = "GitHub repo name for OIDC federation" + type = string +} + +variable "argocd_chart_version" { + description = "Helm chart version for ArgoCD" + type = string + default = "7.3.4" +} + +variable "aks_mcp_chart_version" { + description = "Helm chart version for the AKS MCP Server" + type = string + default = "0.1.0" +} + +variable "tags" { + description = "Tags to apply to all resources" + type = map(string) + default = { + project = "agentic-platform-engineering" + managed_by = "terraform" + } +} diff --git a/infra/versions.tf b/infra/versions.tf new file mode 100644 index 0000000..9a27724 --- /dev/null +++ b/infra/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.7" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "~> 3.110" + } + azuread = { + source = "hashicorp/azuread" + version = "~> 2.53" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = "~> 2.31" + } + helm = { + source = "hashicorp/helm" + version = "~> 2.14" + } + random = { + source = "hashicorp/random" + version = "~> 3.6" + } + } +} From 210c2b5b1f5a6022780004972da3bcff98f12816 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:02:52 -0400 Subject: [PATCH 2/9] feat(infra): add VNet and AKS subnet (networking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simple flat networking for demo: /8 VNet, /16 AKS subnet. No NSGs or NAT gateway — demo environment only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/main.tf | 15 +++++++++++++++ infra/outputs.tf | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/infra/main.tf b/infra/main.tf index ed553aa..dc4e339 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -18,3 +18,18 @@ resource "random_string" "suffix" { locals { acr_name = var.acr_name != "" ? var.acr_name : "acragentic${random_string.suffix.result}" } + +resource "azurerm_virtual_network" "main" { + name = "vnet-agentic-demo" + address_space = ["10.0.0.0/8"] + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + tags = var.tags +} + +resource "azurerm_subnet" "aks" { + name = "snet-aks" + resource_group_name = azurerm_resource_group.main.name + virtual_network_name = azurerm_virtual_network.main.name + address_prefixes = ["10.240.0.0/16"] +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 6884b84..d0d248d 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -27,3 +27,13 @@ output "oidc_issuer_url" { description = "OIDC issuer URL of the AKS cluster (for federated credential configuration)" value = try(azurerm_kubernetes_cluster.main.oidc_issuer_url, "") } + +output "vnet_id" { + description = "ID of the Virtual Network" + value = azurerm_virtual_network.main.id +} + +output "aks_subnet_id" { + description = "ID of the AKS subnet" + value = azurerm_subnet.aks.id +} From 2cce2bc6dde1689d0185f0c959beb0efd5da4975 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:03:28 -0400 Subject: [PATCH 3/9] feat(infra): add Azure Container Registry (acr.tf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basic SKU for demo; name auto-generated from random suffix if not provided via var.acr_name. Admin disabled — workload identity used for AKS pull access (role assignment added in aks.tf). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/acr.tf | 9 ++++++++- infra/outputs.tf | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/infra/acr.tf b/infra/acr.tf index bd6c64e..9da896a 100644 --- a/infra/acr.tf +++ b/infra/acr.tf @@ -1 +1,8 @@ -# Azure Container Registry - see task dark-factory-9ho +resource "azurerm_container_registry" "main" { + name = local.acr_name + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + sku = "Basic" + admin_enabled = false + tags = var.tags +} diff --git a/infra/outputs.tf b/infra/outputs.tf index d0d248d..0ba8a50 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -15,7 +15,12 @@ output "get_credentials_command" { output "acr_login_server" { description = "ACR login server hostname" - value = try(azurerm_container_registry.main.login_server, "") + value = azurerm_container_registry.main.login_server +} + +output "acr_id" { + description = "Resource ID of the Azure Container Registry (used for AKS role assignment)" + value = azurerm_container_registry.main.id } output "uami_client_id" { From 3e7f706911b19336bd8052f82d2d7a3d601f57f1 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:05:11 -0400 Subject: [PATCH 4/9] feat(infra): add AKS cluster with OIDC + workload identity (aks.tf) Standard_D4s_v3 x3 nodes, Azure CNI, OIDC issuer enabled, workload identity enabled. ACR pull role assigned to kubelet identity. Kubernetes and Helm providers wired to cluster kube_config output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/aks.tf | 43 ++++++++++++++++++++++++++++++++++++++++++- infra/outputs.tf | 12 +++++++++--- infra/providers.tf | 21 +++++++++------------ 3 files changed, 60 insertions(+), 16 deletions(-) diff --git a/infra/aks.tf b/infra/aks.tf index 30b95d3..7046964 100644 --- a/infra/aks.tf +++ b/infra/aks.tf @@ -1 +1,42 @@ -# AKS cluster - see task dark-factory-dhz +resource "azurerm_kubernetes_cluster" "main" { + name = var.cluster_name + location = azurerm_resource_group.main.location + resource_group_name = azurerm_resource_group.main.name + dns_prefix = var.cluster_name + kubernetes_version = var.kubernetes_version + + default_node_pool { + name = "system" + node_count = var.node_count + vm_size = var.node_vm_size + vnet_subnet_id = azurerm_subnet.aks.id + os_disk_size_gb = 128 + upgrade_settings { + max_surge = "10%" + } + } + + identity { + type = "SystemAssigned" + } + + # Enable OIDC issuer for workload identity + oidc_issuer_enabled = true + workload_identity_enabled = true + + network_profile { + network_plugin = "azure" + network_policy = "azure" + load_balancer_sku = "standard" + service_cidr = "10.0.0.0/16" + dns_service_ip = "10.0.0.10" + } + + tags = var.tags +} + +resource "azurerm_role_assignment" "aks_acr_pull" { + scope = azurerm_container_registry.main.id + role_definition_name = "AcrPull" + principal_id = azurerm_kubernetes_cluster.main.kubelet_identity[0].object_id +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 0ba8a50..9c2a495 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -5,12 +5,12 @@ output "resource_group_name" { output "cluster_name" { description = "Name of the AKS cluster" - value = var.cluster_name + value = azurerm_kubernetes_cluster.main.name } output "get_credentials_command" { description = "Command to fetch AKS kubeconfig" - value = "az aks get-credentials --resource-group ${azurerm_resource_group.main.name} --name ${var.cluster_name}" + value = "az aks get-credentials --resource-group ${azurerm_resource_group.main.name} --name ${azurerm_kubernetes_cluster.main.name}" } output "acr_login_server" { @@ -30,7 +30,7 @@ output "uami_client_id" { output "oidc_issuer_url" { description = "OIDC issuer URL of the AKS cluster (for federated credential configuration)" - value = try(azurerm_kubernetes_cluster.main.oidc_issuer_url, "") + value = azurerm_kubernetes_cluster.main.oidc_issuer_url } output "vnet_id" { @@ -42,3 +42,9 @@ output "aks_subnet_id" { description = "ID of the AKS subnet" value = azurerm_subnet.aks.id } + +output "kube_config" { + description = "Raw kubeconfig for the AKS cluster" + value = azurerm_kubernetes_cluster.main.kube_config_raw + sensitive = true +} diff --git a/infra/providers.tf b/infra/providers.tf index 5d9f31f..9be3f23 100644 --- a/infra/providers.tf +++ b/infra/providers.tf @@ -24,24 +24,21 @@ provider "azuread" { # --------------------------------------------------------------------------- locals { - kube_host = try(azurerm_kubernetes_cluster.main.kube_config[0].host, "") - kube_client_certificate = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_certificate), "") - kube_client_key = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].client_key), "") - kube_cluster_ca_certificate = try(base64decode(azurerm_kubernetes_cluster.main.kube_config[0].cluster_ca_certificate), "") + kube_config = azurerm_kubernetes_cluster.main.kube_config[0] } provider "kubernetes" { - host = local.kube_host - client_certificate = local.kube_client_certificate - client_key = local.kube_client_key - cluster_ca_certificate = local.kube_cluster_ca_certificate + host = local.kube_config.host + client_certificate = base64decode(local.kube_config.client_certificate) + client_key = base64decode(local.kube_config.client_key) + cluster_ca_certificate = base64decode(local.kube_config.cluster_ca_certificate) } provider "helm" { kubernetes { - host = local.kube_host - client_certificate = local.kube_client_certificate - client_key = local.kube_client_key - cluster_ca_certificate = local.kube_cluster_ca_certificate + host = local.kube_config.host + client_certificate = base64decode(local.kube_config.client_certificate) + client_key = base64decode(local.kube_config.client_key) + cluster_ca_certificate = base64decode(local.kube_config.cluster_ca_certificate) } } From 653053615c03ca44d21dc3cf1e723a334b91c199 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:06:59 -0400 Subject: [PATCH 5/9] feat(infra): add UAMI and GitHub Actions OIDC federation (identity.tf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 federated credentials: environments copilot+demo, branch main, PRs. UAMI granted Contributor on RG and AKS Cluster Admin for workflow use. github_actions_env_vars output surfaces all ARM_* values needed for GitHub Actions secrets — no static credentials required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/identity.tf | 61 ++++++++++++++++++++++++++++++++++++++++++++++- infra/outputs.tf | 19 +++++++++++++-- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/infra/identity.tf b/infra/identity.tf index d1ec434..b31c71d 100644 --- a/infra/identity.tf +++ b/infra/identity.tf @@ -1 +1,60 @@ -# Managed Identity + GitHub OIDC federation - see task dark-factory-019 +resource "azurerm_user_assigned_identity" "workload" { + name = "uami-agentic-workload" + resource_group_name = azurerm_resource_group.main.name + location = azurerm_resource_group.main.location + tags = var.tags +} + +# Environment: copilot +resource "azurerm_federated_identity_credential" "gh_env_copilot" { + name = "github-env-copilot" + resource_group_name = azurerm_resource_group.main.name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:${var.github_org}/${var.github_repo}:environment:copilot" +} + +# Environment: demo +resource "azurerm_federated_identity_credential" "gh_env_demo" { + name = "github-env-demo" + resource_group_name = azurerm_resource_group.main.name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:${var.github_org}/${var.github_repo}:environment:demo" +} + +# Branch: main +resource "azurerm_federated_identity_credential" "gh_branch_main" { + name = "github-branch-main" + resource_group_name = azurerm_resource_group.main.name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main" +} + +# Pull requests +resource "azurerm_federated_identity_credential" "gh_pr" { + name = "github-pull-request" + resource_group_name = azurerm_resource_group.main.name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = "https://token.actions.githubusercontent.com" + subject = "repo:${var.github_org}/${var.github_repo}:pull_request" +} + +# Contributor on the resource group (deploy AKS, ACR, etc.) +resource "azurerm_role_assignment" "workload_rg_contributor" { + scope = azurerm_resource_group.main.id + role_definition_name = "Contributor" + principal_id = azurerm_user_assigned_identity.workload.principal_id +} + +# AKS cluster admin (for kubectl access in workflows) +resource "azurerm_role_assignment" "workload_aks_admin" { + scope = azurerm_kubernetes_cluster.main.id + role_definition_name = "Azure Kubernetes Service Cluster Admin Role" + principal_id = azurerm_user_assigned_identity.workload.principal_id +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 9c2a495..3b47e72 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -24,8 +24,23 @@ output "acr_id" { } output "uami_client_id" { - description = "Client ID of the User-Assigned Managed Identity (for workload identity annotations)" - value = try(azurerm_user_assigned_identity.main.client_id, "") + description = "Client ID of the User-Assigned Managed Identity (ARM_CLIENT_ID for GitHub Actions)" + value = azurerm_user_assigned_identity.workload.client_id +} + +output "uami_principal_id" { + description = "Principal ID of the User-Assigned Managed Identity" + value = azurerm_user_assigned_identity.workload.principal_id +} + +output "github_actions_env_vars" { + description = "Environment variables / secrets to configure in GitHub Actions" + value = { + ARM_CLIENT_ID = azurerm_user_assigned_identity.workload.client_id + ARM_SUBSCRIPTION_ID = data.azurerm_client_config.current.subscription_id + ARM_TENANT_ID = data.azurerm_client_config.current.tenant_id + ARM_USE_OIDC = "true" + } } output "oidc_issuer_url" { From 2bf4289b658fa73c959daa0452c4be990ae0c2c2 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:08:11 -0400 Subject: [PATCH 6/9] feat(infra): add Kubernetes namespaces and workload identity SA (kubernetes.tf) argocd and aks-mcp namespaces. Workload identity service account for aks-mcp with UAMI client ID annotation. Federated credential wiring the SA to the UAMI via the cluster OIDC issuer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/identity.tf | 9 +++++++++ infra/kubernetes.tf | 32 +++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/infra/identity.tf b/infra/identity.tf index b31c71d..34a7c4a 100644 --- a/infra/identity.tf +++ b/infra/identity.tf @@ -45,6 +45,15 @@ resource "azurerm_federated_identity_credential" "gh_pr" { subject = "repo:${var.github_org}/${var.github_repo}:pull_request" } +resource "azurerm_federated_identity_credential" "aks_mcp_sa" { + name = "aks-mcp-service-account" + resource_group_name = azurerm_resource_group.main.name + parent_id = azurerm_user_assigned_identity.workload.id + audience = ["api://AzureADTokenExchange"] + issuer = azurerm_kubernetes_cluster.main.oidc_issuer_url + subject = "system:serviceaccount:aks-mcp:aks-mcp" +} + # Contributor on the resource group (deploy AKS, ACR, etc.) resource "azurerm_role_assignment" "workload_rg_contributor" { scope = azurerm_resource_group.main.id diff --git a/infra/kubernetes.tf b/infra/kubernetes.tf index 2b056f8..6baa1bd 100644 --- a/infra/kubernetes.tf +++ b/infra/kubernetes.tf @@ -1 +1,31 @@ -# Kubernetes namespaces + RBAC - see task dark-factory-af1 +resource "kubernetes_namespace" "argocd" { + metadata { + name = "argocd" + labels = { + "app.kubernetes.io/managed-by" = "terraform" + } + } +} + +resource "kubernetes_namespace" "aks_mcp" { + metadata { + name = "aks-mcp" + labels = { + "app.kubernetes.io/managed-by" = "terraform" + "azure.workload.identity/use" = "true" + } + } +} + +resource "kubernetes_service_account" "aks_mcp" { + metadata { + name = "aks-mcp" + namespace = kubernetes_namespace.aks_mcp.metadata[0].name + annotations = { + "azure.workload.identity/client-id" = azurerm_user_assigned_identity.workload.client_id + } + labels = { + "azure.workload.identity/use" = "true" + } + } +} From b326102be19a45139a0a5d4b7e304ffc1c6ae369 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:09:19 -0400 Subject: [PATCH 7/9] feat(infra): deploy ArgoCD via Helm (argocd.tf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argo-helm chart, notifications controller enabled (required for ArgoCD → GitHub webhook integration in Act-3). Admin password auto-generated, sensitive output. LoadBalancer service type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/argocd.tf | 34 +++++++++++++++++++++++++++++++++- infra/outputs.tf | 6 ++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/infra/argocd.tf b/infra/argocd.tf index 0295a42..2cb0865 100644 --- a/infra/argocd.tf +++ b/infra/argocd.tf @@ -1 +1,33 @@ -# ArgoCD via Helm - see task dark-factory-y4c +resource "random_password" "argocd_admin" { + length = 16 + special = true +} + +resource "helm_release" "argocd" { + name = "argocd" + repository = "https://argoproj.github.io/argo-helm" + chart = "argo-cd" + version = var.argocd_chart_version + namespace = kubernetes_namespace.argocd.metadata[0].name + create_namespace = false + wait = true + timeout = 600 + + set { + name = "configs.secret.argocdServerAdminPassword" + value = bcrypt(random_password.argocd_admin.result) + } + + set { + name = "server.service.type" + value = "LoadBalancer" + } + + # Enable notifications controller for ArgoCD notifications + set { + name = "notifications.enabled" + value = "true" + } + + depends_on = [kubernetes_namespace.argocd] +} diff --git a/infra/outputs.tf b/infra/outputs.tf index 3b47e72..c3a7977 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -63,3 +63,9 @@ output "kube_config" { value = azurerm_kubernetes_cluster.main.kube_config_raw sensitive = true } + +output "argocd_admin_password" { + description = "ArgoCD admin password — use with username 'admin'" + value = random_password.argocd_admin.result + sensitive = true +} From 19f074efb950f019670ee47ebfa36240476a0b7e Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:09:35 -0400 Subject: [PATCH 8/9] feat(infra): deploy AKS MCP Server via Helm (aks-mcp.tf) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses pre-created workload identity SA (kubernetes.tf). Workload identity pod label set so Azure SDK picks up federated token. Port 8000 — matches .copilot/mcp-config.json localhost reference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/aks-mcp.tf | 46 +++++++++++++++++++++++++++++++++++++++++++++- infra/outputs.tf | 5 +++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/infra/aks-mcp.tf b/infra/aks-mcp.tf index 91bda3b..93d123e 100644 --- a/infra/aks-mcp.tf +++ b/infra/aks-mcp.tf @@ -1 +1,45 @@ -# AKS MCP Server via Helm - see task dark-factory-71d +resource "helm_release" "aks_mcp" { + name = "aks-mcp" + repository = "oci://ghcr.io/azure/aks-mcp/charts" + chart = "aks-mcp" + version = var.aks_mcp_chart_version + namespace = kubernetes_namespace.aks_mcp.metadata[0].name + create_namespace = false + wait = true + timeout = 300 + + set { + name = "serviceAccount.create" + value = "false" + } + + set { + name = "serviceAccount.name" + value = kubernetes_service_account.aks_mcp.metadata[0].name + } + + set { + name = "podLabels.azure\\.workload\\.identity/use" + value = "true" + } + + set { + name = "env.AZURE_CLIENT_ID" + value = azurerm_user_assigned_identity.workload.client_id + } + + set { + name = "env.AZURE_TENANT_ID" + value = data.azurerm_client_config.current.tenant_id + } + + set { + name = "service.port" + value = "8000" + } + + depends_on = [ + kubernetes_namespace.aks_mcp, + kubernetes_service_account.aks_mcp + ] +} diff --git a/infra/outputs.tf b/infra/outputs.tf index c3a7977..fae2e3e 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -69,3 +69,8 @@ output "argocd_admin_password" { value = random_password.argocd_admin.result sensitive = true } + +output "aks_mcp_port_forward_command" { + description = "Command to port-forward AKS MCP server locally" + value = "kubectl port-forward -n aks-mcp svc/aks-mcp 8000:8000" +} From 00ff41e0dea260eb12a821dd90f224a219fb8dd9 Mon Sep 17 00:00:00 2001 From: Ray Kao Date: Thu, 26 Mar 2026 15:11:36 -0400 Subject: [PATCH 9/9] docs(infra): add README with setup guide and remote state migration Covers prerequisites, quick-start, post-apply commands, GitHub Actions secrets setup, and optional Azure Storage backend migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- infra/README.md | 133 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 infra/README.md diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..f479b31 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,133 @@ +# Agentic Platform Engineering — Infrastructure + +This Terraform configuration provisions a complete Azure-hosted platform for the agentic-platform-engineering workshop: an AKS cluster with workload identity, a container registry, ArgoCD for GitOps, and the AKS MCP Server for AI-assisted cluster management — all wired together with GitHub Actions OIDC so no long-lived secrets are required. + +## Prerequisites + +| Tool | Minimum version | +|------|----------------| +| [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) (`az`) | Latest, authenticated (`az login`) | +| [Terraform](https://developer.hashicorp.com/terraform/install) | >= 1.7 | +| [kubectl](https://kubernetes.io/docs/tasks/tools/) | Latest | +| [helm](https://helm.sh/docs/intro/install/) | Latest | + +You also need a fork or clone of [MicrosoftGbb/agentic-platform-engineering](https://github.com/MicrosoftGbb/agentic-platform-engineering) — the `github_org` and `github_repo` variables must match your fork. + +## What Gets Provisioned + +| Resource | Details | +|----------|---------| +| **Resource Group** | `rg-agentic-demo` (configurable) | +| **Virtual Network** | `vnet-agentic-demo`, `10.0.0.0/8` | +| **AKS Subnet** | `snet-aks`, `10.240.0.0/16` | +| **AKS Cluster** | `aks-eastus2`, Kubernetes 1.30, `Standard_D4s_v3` × 3 nodes, OIDC issuer + workload identity enabled, Azure CNI | +| **Azure Container Registry** | Basic SKU, auto-named `acragentic<4-digit-suffix>` (or set `acr_name`). AKS kubelet identity gets `AcrPull`. | +| **User-Assigned Managed Identity** | `uami-agentic-workload` — Contributor on the resource group, AKS Cluster Admin | +| **Federated Identity Credentials** | 5 total: GitHub env `copilot`, GitHub env `demo`, branch `main`, pull requests, and the `aks-mcp` Kubernetes service account | +| **ArgoCD** | Helm chart `7.3.4`, namespace `argocd`, LoadBalancer service, notifications controller enabled, random 16-char admin password | +| **AKS MCP Server** | Helm chart `0.1.0` from `oci://ghcr.io/azure/aks-mcp/charts`, namespace `aks-mcp`, port 8000, workload identity via dedicated service account | + +## Quick Start + +```bash +# 1. Copy and edit variables +cp terraform.tfvars.example terraform.tfvars +# Edit terraform.tfvars — set github_org and github_repo at minimum + +# 2. Initialize +terraform init + +# 3. Plan +terraform plan + +# 4. Apply (~15 min) +terraform apply +``` + +## After Apply + +```bash +# Connect to the cluster +$(terraform output -raw get_credentials_command) + +# Get GitHub Actions secrets to configure +terraform output -json github_actions_env_vars + +# Get ArgoCD admin password (username: admin) +terraform output -raw argocd_admin_password + +# Access ArgoCD UI +kubectl port-forward -n argocd svc/argocd-server 8080:443 +# Open https://localhost:8080 + +# Access AKS MCP server +$(terraform output -raw aks_mcp_port_forward_command) +# Server listening on http://localhost:8000 +``` + +## GitHub Actions Setup + +After `terraform apply`, configure the following **repository secrets** in your GitHub fork (values come from `terraform output -json github_actions_env_vars`): + +| Secret | Value | +|--------|-------| +| `ARM_CLIENT_ID` | Client ID of the managed identity | +| `ARM_SUBSCRIPTION_ID` | Your Azure subscription ID | +| `ARM_TENANT_ID` | Your Azure tenant ID | +| `ARM_USE_OIDC` | `true` | + +Also create two **GitHub Environments** named exactly `copilot` and `demo` (Settings → Environments). The federated credentials are scoped to these environment names — workflows using other environment names will fail to authenticate. + +## Remote State (Optional) + +By default Terraform stores state locally. For team or CI use, migrate to Azure Storage: + +```bash +# 1. Create storage account (one-time) +az group create -n rg-terraform-state -l eastus2 +az storage account create -n -g rg-terraform-state --sku Standard_LRS +az storage container create -n tfstate --account-name + +# 2. Copy backend.tf.example to backend.tf and fill in values +cp backend.tf.example backend.tf +# Edit backend.tf — set storage_account_name + +# 3. Migrate existing state +terraform init -reconfigure +``` + +## Variables Reference + +| Variable | Description | Default | +|----------|-------------|---------| +| `location` | Azure region for all resources | `eastus2` | +| `resource_group_name` | Name of the Azure Resource Group | `rg-agentic-demo` | +| `cluster_name` | Name of the AKS cluster | `aks-eastus2` | +| `kubernetes_version` | Kubernetes version for the AKS cluster | `1.30` | +| `node_vm_size` | VM size for the AKS default node pool | `Standard_D4s_v3` | +| `node_count` | Number of nodes in the default node pool | `3` | +| `acr_name` | ACR name — auto-generated as `acragentic` if empty | `""` | +| `github_org` | GitHub org for OIDC federation (**required**) | — | +| `github_repo` | GitHub repo name for OIDC federation (**required**) | — | +| `argocd_chart_version` | Helm chart version for ArgoCD | `7.3.4` | +| `aks_mcp_chart_version` | Helm chart version for the AKS MCP Server | `0.1.0` | +| `tags` | Tags applied to all resources | `{project, managed_by}` | + +## Outputs Reference + +| Output | Description | Sensitive | +|--------|-------------|-----------| +| `resource_group_name` | Name of the Azure Resource Group | No | +| `cluster_name` | Name of the AKS cluster | No | +| `get_credentials_command` | `az aks get-credentials` command ready to run | No | +| `acr_login_server` | ACR login server hostname | No | +| `acr_id` | Resource ID of the ACR | No | +| `uami_client_id` | Client ID of the managed identity (`ARM_CLIENT_ID`) | No | +| `uami_principal_id` | Principal ID of the managed identity | No | +| `github_actions_env_vars` | Map of all GitHub Actions secrets to configure | No | +| `oidc_issuer_url` | OIDC issuer URL of the AKS cluster | No | +| `vnet_id` | Resource ID of the Virtual Network | No | +| `aks_subnet_id` | Resource ID of the AKS subnet | No | +| `kube_config` | Raw kubeconfig for the AKS cluster | **Yes** | +| `argocd_admin_password` | ArgoCD admin password (username: `admin`) | **Yes** | +| `aks_mcp_port_forward_command` | `kubectl port-forward` command for the AKS MCP server | No |