diff --git a/modules/aks/github-connector/backplane/main.tf b/modules/aks/github-connector/backplane/main.tf index f0f7b9e27..051374635 100644 --- a/modules/aks/github-connector/backplane/main.tf +++ b/modules/aks/github-connector/backplane/main.tf @@ -58,7 +58,7 @@ resource "azurerm_role_assignment" "bb_github_connector_acr" { # The ClusterIssuer is needed so that SSL certificates can be issued for projects using the GitHub Actions Connector. resource "kubernetes_cluster_role" "clusterissuer_reader" { metadata { - name = "clusterissuer-reader" + name = "aks-github-connector-clusterissuer-reader" } rule { diff --git a/modules/aks/github-connector/backplane/provider.tf b/modules/aks/github-connector/backplane/provider.tf new file mode 100644 index 000000000..58daf53ce --- /dev/null +++ b/modules/aks/github-connector/backplane/provider.tf @@ -0,0 +1,8 @@ +# Configured from the cluster this backplane already reads, so a caller needs no kubernetes provider +# of their own. Uses the admin credentials, as the connector's config.tf output does. +provider "kubernetes" { + host = data.azurerm_kubernetes_cluster.aks.kube_admin_config[0].host + cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.aks.kube_admin_config[0].cluster_ca_certificate) + client_certificate = base64decode(data.azurerm_kubernetes_cluster.aks.kube_admin_config[0].client_certificate) + client_key = base64decode(data.azurerm_kubernetes_cluster.aks.kube_admin_config[0].client_key) +} diff --git a/modules/aks/github-connector/buildingblock/kubernetes.tf b/modules/aks/github-connector/buildingblock/kubernetes.tf index 0c016dd69..884172c9d 100644 --- a/modules/aks/github-connector/buildingblock/kubernetes.tf +++ b/modules/aks/github-connector/buildingblock/kubernetes.tf @@ -44,7 +44,7 @@ resource "kubernetes_cluster_role_binding" "github_actions_clusterissuer_access" role_ref { api_group = "rbac.authorization.k8s.io" kind = "ClusterRole" - name = "clusterissuer-reader" # This role is created in the backplane module + name = "aks-github-connector-clusterissuer-reader" # This role is created in the backplane module } subject { diff --git a/modules/aks/github-connector/buildingblock/main.tf b/modules/aks/github-connector/buildingblock/main.tf deleted file mode 100644 index 7faa034cb..000000000 --- a/modules/aks/github-connector/buildingblock/main.tf +++ /dev/null @@ -1 +0,0 @@ -# TODO: Implement main.tf diff --git a/modules/aks/github-connector/buildingblock/outputs.tf b/modules/aks/github-connector/buildingblock/outputs.tf deleted file mode 100644 index d3c766438..000000000 --- a/modules/aks/github-connector/buildingblock/outputs.tf +++ /dev/null @@ -1 +0,0 @@ -# TODO: Implement outputs.tf diff --git a/modules/aks/github-connector/meshstack_integration.tf b/modules/aks/github-connector/meshstack_integration.tf index 8822ff56c..0c23cd346 100644 --- a/modules/aks/github-connector/meshstack_integration.tf +++ b/modules/aks/github-connector/meshstack_integration.tf @@ -11,9 +11,10 @@ variable "github" { variable "aks" { type = object({ + namespace_definition_uuid = string connector_config_tf_base64 = string }) - description = "AKS integration configuration. `connector_config_tf_base64` is the base64-encoded `config.tf` payload passed into the run (for example: `data:application/octet-stream;base64,ZXhhbXBsZQ==`)." + description = "AKS integration configuration. `namespace_definition_uuid` is the UUID of the deployed Azure AKS Namespace building block definition; the connector wires itself to the namespace of its parent block of that definition. `connector_config_tf_base64` is the base64-encoded `config.tf` payload passed into the run (for example: `data:application/octet-stream;base64,ZXhhbXBsZQ==`)." } variable "notification_subscribers" { @@ -85,8 +86,7 @@ resource "meshstack_building_block_definition" "aks_github_connector" { display_name = coalesce(var.bbd_display_name, "GitHub Actions AKS Connector") notification_subscribers = var.notification_subscribers symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/${var.hub.git_ref}/modules/aks/github-connector/buildingblock/logo.png" - target_type = "TENANT_LEVEL" - supported_platforms = [{ name = "AZURE_KUBERNETES_SERVICE" }] + target_type = "WORKSPACE_LEVEL" readme = coalesce(var.bbd_readme, chomp(<-deploy`) | Lets the identity read the AKS cluster and fetch the user credentials. | +| `Azure Kubernetes Service RBAC Cluster Admin` | Lets the identity create namespaces, quotas and role bindings. Scoped to the one cluster. | +| Microsoft Graph `User.Read.All` and `Group.ReadWrite.All` | Lets the identity create the namespace's Entra groups and look up the workspace members it adds to them. Needs admin consent. | +| `AcrPull` for the kubelet identity (optional) | Set `acr_id` and every namespace's workloads can pull from that registry without an image pull secret. | + +## Prerequisites + +- The AKS cluster is Entra-integrated and has **Azure RBAC for Kubernetes authorization** enabled + (`azure_rbac_enabled = true`). `modules/azure/aks` does not set this yet; enable it on the cluster + before deploying this backplane. +- The cluster's API server is reachable from the building block runner. diff --git a/modules/aks/namespace/backplane/main.tf b/modules/aks/namespace/backplane/main.tf new file mode 100644 index 000000000..5c28371a2 --- /dev/null +++ b/modules/aks/namespace/backplane/main.tf @@ -0,0 +1,85 @@ +data "azurerm_kubernetes_cluster" "this" { + name = var.aks.cluster_name + resource_group_name = var.aks.resource_group_name +} + +resource "azurerm_resource_group" "backplane" { + name = var.name + location = var.location +} + +resource "azurerm_user_assigned_identity" "backplane" { + name = var.name + location = var.location + resource_group_name = azurerm_resource_group.backplane.name +} + +resource "azurerm_federated_identity_credential" "backplane" { + for_each = { for i, s in var.workload_identity_federation.subjects : tostring(i) => s } + + name = "subject-${each.key}" + user_assigned_identity_id = azurerm_user_assigned_identity.backplane.id + audience = ["api://AzureADTokenExchange"] + issuer = var.workload_identity_federation.issuer + subject = each.value +} + +# Reading the cluster through the ARM provider is all the building block needs besides the +# in-cluster rights below. +resource "azurerm_role_definition" "backplane" { + name = "${var.name}-deploy" + description = "Enables the ${var.name} building block to look up the AKS cluster it creates namespaces in" + scope = var.scope + + permissions { + actions = [ + "Microsoft.ContainerService/managedClusters/read", + "Microsoft.ContainerService/managedClusters/listClusterUserCredential/action", + ] + } + + assignable_scopes = [var.scope] +} + +resource "azurerm_role_assignment" "backplane" { + scope = data.azurerm_kubernetes_cluster.this.id + role_definition_id = azurerm_role_definition.backplane.role_definition_resource_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id +} + +# Creating a namespace is a cluster-scoped write, so the identity needs cluster admin. The built-in +# role is the narrowest one Azure offers for that; it is scoped to this one cluster. +resource "azurerm_role_assignment" "cluster_admin" { + scope = data.azurerm_kubernetes_cluster.this.id + role_definition_name = "Azure Kubernetes Service RBAC Cluster Admin" + principal_id = azurerm_user_assigned_identity.backplane.principal_id +} + +resource "azurerm_role_assignment" "kubelet_acr_pull" { + lifecycle { + enabled = var.acr_id != null + } + + scope = var.acr_id + role_definition_name = "AcrPull" + principal_id = data.azurerm_kubernetes_cluster.this.kubelet_identity[0].object_id +} + +# The building block creates one Entra group per namespace role and fills it with workspace members, +# so the identity reads users and writes groups. Granting these app roles needs admin consent: the +# principal deploying this backplane must be allowed to assign Graph app roles. +data "azuread_service_principal" "msgraph" { + client_id = "00000003-0000-0000-c000-000000000000" # Microsoft Graph +} + +resource "azuread_app_role_assignment" "user_read_all" { + app_role_id = data.azuread_service_principal.msgraph.app_role_ids["User.Read.All"] + principal_object_id = azurerm_user_assigned_identity.backplane.principal_id + resource_object_id = data.azuread_service_principal.msgraph.object_id +} + +resource "azuread_app_role_assignment" "group_readwrite_all" { + app_role_id = data.azuread_service_principal.msgraph.app_role_ids["Group.ReadWrite.All"] + principal_object_id = azurerm_user_assigned_identity.backplane.principal_id + resource_object_id = data.azuread_service_principal.msgraph.object_id +} diff --git a/modules/aks/namespace/backplane/outputs.tf b/modules/aks/namespace/backplane/outputs.tf new file mode 100644 index 000000000..2b6b805dc --- /dev/null +++ b/modules/aks/namespace/backplane/outputs.tf @@ -0,0 +1,7 @@ +output "identity" { + value = { + client_id = azurerm_user_assigned_identity.backplane.client_id + principal_id = azurerm_user_assigned_identity.backplane.principal_id + tenant_id = azurerm_user_assigned_identity.backplane.tenant_id + } +} diff --git a/modules/aks/namespace/backplane/provider.tf b/modules/aks/namespace/backplane/provider.tf new file mode 100644 index 000000000..baca72e44 --- /dev/null +++ b/modules/aks/namespace/backplane/provider.tf @@ -0,0 +1,5 @@ +provider "azurerm" { + features {} +} + +provider "azuread" {} diff --git a/modules/aks/namespace/backplane/variables.tf b/modules/aks/namespace/backplane/variables.tf new file mode 100644 index 000000000..180d885e9 --- /dev/null +++ b/modules/aks/namespace/backplane/variables.tf @@ -0,0 +1,45 @@ +variable "name" { + type = string + nullable = false + description = "Name for the building block identity and role definition." + validation { + condition = can(regex("^[-a-z0-9]+$", var.name)) + error_message = "Only alphanumeric lowercase characters and dashes are allowed." + } +} + +variable "scope" { + type = string + nullable = false + description = "Scope the custom role definition is assignable at (management group or subscription ID). Must contain the AKS cluster." +} + +variable "location" { + type = string + nullable = false + description = "Azure region for the UAMI resource." +} + +variable "workload_identity_federation" { + type = object({ + issuer = string + subjects = list(string) + }) + nullable = false + description = "WIF issuer and subjects for federated authentication." +} + +variable "aks" { + type = object({ + cluster_name = string + resource_group_name = string + }) + nullable = false + description = "The existing AKS cluster namespaces are created in. It must have Azure RBAC for Kubernetes authorization enabled." +} + +variable "acr_id" { + type = string + default = null + description = "Resource ID of the Azure Container Registry the cluster's workloads pull images from. When set, the cluster's kubelet identity gets AcrPull on it, so no namespace needs an image pull secret." +} diff --git a/modules/aks/namespace/backplane/versions.tf b/modules/aks/namespace/backplane/versions.tf new file mode 100644 index 000000000..d581b5eed --- /dev/null +++ b/modules/aks/namespace/backplane/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.11.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.65.0, < 5.0.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.8.0, < 4.0.0" + } + } +} diff --git a/modules/aks/namespace/buildingblock/README.md b/modules/aks/namespace/buildingblock/README.md new file mode 100644 index 000000000..03c93ff0e --- /dev/null +++ b/modules/aks/namespace/buildingblock/README.md @@ -0,0 +1,77 @@ +--- +name: Azure AKS Namespace +supportedPlatforms: + - azure +description: Creates a Kubernetes namespace on an existing AKS cluster with a resource quota, default container limits and Entra group access, ordered at workspace level. +--- + +# Azure AKS Namespace + +Creates one namespace on an existing AKS cluster. It is a **workspace** building block: no meshStack +project, tenant or platform is involved. The block authenticates to the cluster as the backplane's +managed identity through workload identity federation and holds no kubeconfig. + +## What it creates + +- the namespace, labelled with the owning workspace +- a `ResourceQuota` and a `LimitRange` with default container requests and limits +- an Entra security group per namespace role (`admin`, `edit`, `view`), filled with the workspace + members whose workspace role maps to it (see `role_mapping`) +- a `RoleBinding` per group to the `admin`, `edit` or `view` cluster role + + +## Requirements + +| Name | Version | +|------|---------| +| [azuread](#requirement\_azuread) | >= 3.8.0, < 4.0.0 | +| [azurerm](#requirement\_azurerm) | >= 4.64.0, < 5.0.0 | +| [http](#requirement\_http) | >= 3.4.0, < 4.0.0 | +| [kubernetes](#requirement\_kubernetes) | >= 2.35.1, < 3.0.0 | + +## Modules + +No modules. + +## Resources + +| Name | Type | +|------|------| +| [azuread_group.namespace](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/group) | resource | +| [azuread_group_member.namespace](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/resources/group_member) | resource | +| [kubernetes_limit_range_v1.this](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/limit_range_v1) | resource | +| [kubernetes_namespace_v1.this](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/namespace_v1) | resource | +| [kubernetes_resource_quota_v1.this](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/resource_quota_v1) | resource | +| [kubernetes_role_binding_v1.group](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/resources/role_binding_v1) | resource | +| [azuread_users.members](https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/data-sources/users) | data source | +| [azurerm_client_config.current](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/client_config) | data source | +| [azurerm_kubernetes_cluster.this](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/data-sources/kubernetes_cluster) | data source | +| [http_http.aks_token](https://registry.terraform.io/providers/hashicorp/http/latest/docs/data-sources/http) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [aks\_cluster\_name](#input\_aks\_cluster\_name) | Name of the AKS cluster the namespace is created in. | `string` | n/a | yes | +| [aks\_resource\_group\_name](#input\_aks\_resource\_group\_name) | Resource group of the AKS cluster. | `string` | n/a | yes | +| [default\_container\_limits](#input\_default\_container\_limits) | LimitRange defaults for containers that declare no requests or limits of their own. |
object({
cpu_request = optional(string, "100m")
memory_request = optional(string, "128Mi")
cpu_limit = optional(string, "500m")
memory_limit = optional(string, "512Mi")
})
| `{}` | no | +| [group\_name\_prefix](#input\_group\_name\_prefix) | Prepended to the display names of the Entra groups this block creates. Leave empty to omit. | `string` | `""` | no | +| [namespace\_name](#input\_namespace\_name) | Name of the Kubernetes namespace to create. | `string` | n/a | yes | +| [oidc\_token\_file\_path](#input\_oidc\_token\_file\_path) | File the building block runner writes its federated OIDC token to. | `string` | `"/var/run/secrets/workload-identity/azure/token"` | no | +| [resource\_quota](#input\_resource\_quota) | ResourceQuota applied to the namespace. |
object({
requests_cpu = optional(string, "2")
requests_memory = optional(string, "4Gi")
limits_cpu = optional(string, "4")
limits_memory = optional(string, "8Gi")
pods = optional(string, "50")
})
| `{}` | no | +| [role\_mapping](#input\_role\_mapping) | Maps a meshStack workspace role name to the namespace role (`admin`, `edit` or `view`) its members get. A workspace role missing here gets no access. | `map(string)` | n/a | yes | +| [user\_lookup\_attribute](#input\_user\_lookup\_attribute) | Directory attribute used to match workspace members to Entra users. 'upn' matches the User Principal Name; 'email' matches the primary mail address. | `string` | `"email"` | no | +| [users](#input\_users) | Workspace members with their meshStack roles, injected by meshStack. Each is added to the group of the namespace role their workspace role maps to. |
list(object({
meshIdentifier = string
username = string
firstName = string
lastName = string
email = string
euid = string
roles = list(string)
}))
| `[]` | no | +| [workspace\_identifier](#input\_workspace\_identifier) | Identifier of the meshStack workspace that owns the namespace. Recorded as a label. | `string` | n/a | yes | + +## Outputs + +| Name | Description | +|------|-------------| +| [cluster\_name](#output\_cluster\_name) | Name of the AKS cluster hosting the namespace. | +| [cluster\_resource\_group\_name](#output\_cluster\_resource\_group\_name) | Resource group of the AKS cluster hosting the namespace. | +| [group\_object\_ids](#output\_group\_object\_ids) | Map of namespace role (admin, edit, view) to the object ID of the Entra group this block created. | +| [namespace\_name](#output\_namespace\_name) | Name of the created Kubernetes namespace. | +| [summary](#output\_summary) | Markdown summary shown in meshPanel after the run. | +| [unresolved\_users](#output\_unresolved\_users) | Workspace members that have no matching object in the directory and were therefore not added to any group. | + diff --git a/modules/aks/namespace/buildingblock/logo.png b/modules/aks/namespace/buildingblock/logo.png new file mode 100644 index 000000000..971d7de63 Binary files /dev/null and b/modules/aks/namespace/buildingblock/logo.png differ diff --git a/modules/aks/namespace/buildingblock/main.tf b/modules/aks/namespace/buildingblock/main.tf new file mode 100644 index 000000000..dc4ef23cd --- /dev/null +++ b/modules/aks/namespace/buildingblock/main.tf @@ -0,0 +1,145 @@ +locals { + namespace_roles = toset(["admin", "edit", "view"]) + + unique_user_euids = toset([for user in var.users : user.euid]) + + user_group_assignments = { + for pair in flatten([ + for user in var.users : [ + for role in user.roles : { + key = "${user.euid}-${var.role_mapping[role]}" + euid = user.euid + role = var.role_mapping[role] + } + if contains(keys(var.role_mapping), role) + ] + ]) : pair.key => pair + } +} + +# One bulk lookup with ignore_missing, rather than a data source per member: a workspace member +# with no object in the directory (external collaborator, service account, a guest that was never +# invited) must not fail the whole run. Unresolved members are reported through the +# unresolved_users output instead. +data "azuread_users" "members" { + lifecycle { + enabled = length(local.unique_user_euids) > 0 + } + + ignore_missing = true + + mails = var.user_lookup_attribute == "email" ? sort(tolist(local.unique_user_euids)) : null + user_principal_names = var.user_lookup_attribute == "upn" ? sort(tolist(local.unique_user_euids)) : null +} + +locals { + # Directory lookups are case-insensitive, so match on a lowercased key to avoid treating a member + # whose meshStack euid differs only in case from the directory value as unresolved. + # + # `try` because the lookup is disabled when there is no member to resolve, and reading an + # attribute of a disabled data source is an error rather than an empty list. + lookup_values = { + for u in try(data.azuread_users.members.users, []) : + lower(coalesce(var.user_lookup_attribute == "email" ? u.mail : u.user_principal_name, "")) => u.object_id + if coalesce(var.user_lookup_attribute == "email" ? u.mail : u.user_principal_name, "") != "" + } + + unresolved_user_euids = sort([ + for euid in local.unique_user_euids : euid + if !contains(keys(local.lookup_values), lower(euid)) + ]) + + created_group_object_ids = { for role, g in azuread_group.namespace : role => g.object_id } +} + +resource "azuread_group" "namespace" { + for_each = local.namespace_roles + + display_name = join(".", compact([var.group_name_prefix, var.workspace_identifier, var.namespace_name, each.value])) + security_enabled = true + mail_enabled = false +} + +resource "azuread_group_member" "namespace" { + for_each = { + for key, assignment in local.user_group_assignments : key => assignment + if contains(keys(local.lookup_values), lower(assignment.euid)) + } + + group_object_id = azuread_group.namespace[each.value.role].object_id + member_object_id = local.lookup_values[lower(each.value.euid)] +} + +resource "kubernetes_namespace_v1" "this" { + metadata { + name = var.namespace_name + + labels = { + "app.kubernetes.io/managed-by" = "meshstack" + "meshcloud.io/workspace" = var.workspace_identifier + } + } +} + +resource "kubernetes_resource_quota_v1" "this" { + metadata { + name = "meshstack-quota" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + + spec { + hard = { + "requests.cpu" = var.resource_quota.requests_cpu + "requests.memory" = var.resource_quota.requests_memory + "limits.cpu" = var.resource_quota.limits_cpu + "limits.memory" = var.resource_quota.limits_memory + "pods" = var.resource_quota.pods + } + } +} + +# Without defaults, a pod that declares no requests or limits is rejected once a quota on those +# resources exists. +resource "kubernetes_limit_range_v1" "this" { + metadata { + name = "meshstack-defaults" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + + spec { + limit { + type = "Container" + + default_request = { + cpu = var.default_container_limits.cpu_request + memory = var.default_container_limits.memory_request + } + default = { + cpu = var.default_container_limits.cpu_limit + memory = var.default_container_limits.memory_limit + } + } + } +} + +# Each group's name is its namespace role, which is also the cluster role it is bound to. +resource "kubernetes_role_binding_v1" "group" { + for_each = azuread_group.namespace + + metadata { + name = "meshstack-${each.key}" + namespace = kubernetes_namespace_v1.this.metadata[0].name + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = each.key + } + + subject { + api_group = "rbac.authorization.k8s.io" + kind = "Group" + name = each.value.object_id + } +} diff --git a/modules/aks/namespace/buildingblock/outputs.tf b/modules/aks/namespace/buildingblock/outputs.tf new file mode 100644 index 000000000..c72dff57b --- /dev/null +++ b/modules/aks/namespace/buildingblock/outputs.tf @@ -0,0 +1,53 @@ +output "namespace_name" { + description = "Name of the created Kubernetes namespace." + value = kubernetes_namespace_v1.this.metadata[0].name +} + +output "cluster_name" { + description = "Name of the AKS cluster hosting the namespace." + value = var.aks_cluster_name +} + +output "cluster_resource_group_name" { + description = "Resource group of the AKS cluster hosting the namespace." + value = var.aks_resource_group_name +} + +output "group_object_ids" { + description = "Map of namespace role (admin, edit, view) to the object ID of the Entra group this block created." + value = local.created_group_object_ids +} + +output "unresolved_users" { + description = "Workspace members that have no matching object in the directory and were therefore not added to any group." + value = local.unresolved_user_euids +} + +output "summary" { + description = "Markdown summary shown in meshPanel after the run." + value = <<-EOT + # AKS namespace `${kubernetes_namespace_v1.this.metadata[0].name}` + + Created on cluster `${var.aks_cluster_name}` (resource group `${var.aks_resource_group_name}`). + + Fetch credentials with: + + ``` + az aks get-credentials --name ${var.aks_cluster_name} --resource-group ${var.aks_resource_group_name} + kubectl config set-context --current --namespace ${kubernetes_namespace_v1.this.metadata[0].name} + ``` + + ## Access + + | Namespace role | Entra group | + |---|---| + | `admin` | `${azuread_group.namespace["admin"].display_name}` | + | `edit` | `${azuread_group.namespace["edit"].display_name}` | + | `view` | `${azuread_group.namespace["view"].display_name}` | + + ${length(azuread_group_member.namespace)} membership(s) assigned from ${length(local.unique_user_euids)} workspace member(s), matched on `${var.user_lookup_attribute}`.${length(local.unresolved_user_euids) == 0 ? "" : " Not found in the directory and skipped: ${join(", ", local.unresolved_user_euids)}."} + + Quota: ${var.resource_quota.requests_cpu} CPU / ${var.resource_quota.requests_memory} requested, + ${var.resource_quota.limits_cpu} CPU / ${var.resource_quota.limits_memory} limit, ${var.resource_quota.pods} pods. + EOT +} diff --git a/modules/aks/namespace/buildingblock/provider.tf b/modules/aks/namespace/buildingblock/provider.tf new file mode 100644 index 000000000..3d5f71802 --- /dev/null +++ b/modules/aks/namespace/buildingblock/provider.tf @@ -0,0 +1,47 @@ +provider "azurerm" { + features {} +} + +data "azurerm_client_config" "current" {} + +data "azurerm_kubernetes_cluster" "this" { + name = var.aks_cluster_name + resource_group_name = var.aks_resource_group_name +} + +# Exchanges the runner's federated token for an Entra token for the AKS API server. This is what +# `kubelogin get-token --login workloadidentity` does; doing it here keeps the run free of any +# tool on the runner host (see module-layout.md, Pre-run Scripts). The token is valid for about an +# hour and lands in the run's state, which is why it is the only credential this block handles. +data "http" "aks_token" { + url = "https://login.microsoftonline.com/${data.azurerm_client_config.current.tenant_id}/oauth2/v2.0/token" + method = "POST" + + request_headers = { + Content-Type = "application/x-www-form-urlencoded" + } + + request_body = join("&", [ + "grant_type=client_credentials", + "client_id=${data.azurerm_client_config.current.client_id}", + # Well-known application ID of AKS's Entra server app, the audience every AKS cluster accepts. + "scope=6dae42f8-4368-4678-94ff-3960e28e3630/.default", + "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion=${urlencode(trimspace(file(var.oidc_token_file_path)))}", + ]) + + lifecycle { + postcondition { + condition = self.status_code == 200 + error_message = "Entra rejected the token exchange with HTTP ${self.status_code}. Check the backplane's federated credential." + } + } +} + +provider "kubernetes" { + host = data.azurerm_kubernetes_cluster.this.kube_config[0].host + cluster_ca_certificate = base64decode(data.azurerm_kubernetes_cluster.this.kube_config[0].cluster_ca_certificate) + token = sensitive(jsondecode(data.http.aks_token.response_body).access_token) +} + +provider "azuread" {} diff --git a/modules/aks/namespace/buildingblock/variables.tf b/modules/aks/namespace/buildingblock/variables.tf new file mode 100644 index 000000000..0c704fcac --- /dev/null +++ b/modules/aks/namespace/buildingblock/variables.tf @@ -0,0 +1,99 @@ +variable "aks_cluster_name" { + type = string + nullable = false + description = "Name of the AKS cluster the namespace is created in." +} + +variable "aks_resource_group_name" { + type = string + nullable = false + description = "Resource group of the AKS cluster." +} + +variable "workspace_identifier" { + type = string + nullable = false + description = "Identifier of the meshStack workspace that owns the namespace. Recorded as a label." +} + +variable "namespace_name" { + type = string + nullable = false + description = "Name of the Kubernetes namespace to create." + + validation { + condition = can(regex("^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$", var.namespace_name)) + error_message = "Must be a valid DNS label: lowercase alphanumerics and dashes, at most 63 characters, starting and ending alphanumeric." + } +} + +variable "resource_quota" { + type = object({ + requests_cpu = optional(string, "2") + requests_memory = optional(string, "4Gi") + limits_cpu = optional(string, "4") + limits_memory = optional(string, "8Gi") + pods = optional(string, "50") + }) + default = {} + description = "ResourceQuota applied to the namespace." +} + +variable "default_container_limits" { + type = object({ + cpu_request = optional(string, "100m") + memory_request = optional(string, "128Mi") + cpu_limit = optional(string, "500m") + memory_limit = optional(string, "512Mi") + }) + default = {} + description = "LimitRange defaults for containers that declare no requests or limits of their own." +} + +variable "oidc_token_file_path" { + type = string + default = "/var/run/secrets/workload-identity/azure/token" + description = "File the building block runner writes its federated OIDC token to." +} + +variable "group_name_prefix" { + type = string + default = "" + description = "Prepended to the display names of the Entra groups this block creates. Leave empty to omit." +} + +variable "user_lookup_attribute" { + type = string + default = "email" + description = "Directory attribute used to match workspace members to Entra users. 'upn' matches the User Principal Name; 'email' matches the primary mail address." + + validation { + condition = contains(["upn", "email"], var.user_lookup_attribute) + error_message = "Must be 'upn' or 'email'." + } +} + +variable "role_mapping" { + type = map(string) + nullable = false + description = "Maps a meshStack workspace role name to the namespace role (`admin`, `edit` or `view`) its members get. A workspace role missing here gets no access." + + validation { + condition = alltrue([for r in values(var.role_mapping) : contains(["admin", "edit", "view"], r)]) + error_message = "Values must be 'admin', 'edit' or 'view'." + } +} + +variable "users" { + type = list(object({ + meshIdentifier = string + username = string + firstName = string + lastName = string + email = string + euid = string + roles = list(string) + })) + default = [] + description = "Workspace members with their meshStack roles, injected by meshStack. Each is added to the group of the namespace role their workspace role maps to." +} diff --git a/modules/aks/namespace/buildingblock/versions.tf b/modules/aks/namespace/buildingblock/versions.tf new file mode 100644 index 000000000..6aea61510 --- /dev/null +++ b/modules/aks/namespace/buildingblock/versions.tf @@ -0,0 +1,20 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.64.0, < 5.0.0" + } + azuread = { + source = "hashicorp/azuread" + version = ">= 3.8.0, < 4.0.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.35.1, < 3.0.0" + } + http = { + source = "hashicorp/http" + version = ">= 3.4.0, < 4.0.0" + } + } +} diff --git a/modules/aks/namespace/e2e/main.tf b/modules/aks/namespace/e2e/main.tf new file mode 100644 index 000000000..6d727414c --- /dev/null +++ b/modules/aks/namespace/e2e/main.tf @@ -0,0 +1,67 @@ +variable "test_context" { + type = object({ + hub_git_ref = string + workspace = string + name_suffix = string + + fixtures = object({ + azure = object({ + subscription_uuid = string + entra_tenant_id = string + + # An AKS cluster with Azure RBAC for Kubernetes enabled, which the smoke-test harness has to + # provide: no other hub module needs a cluster. + aks = object({ + cluster_name = string + resource_group_name = string + }) + }) + }) + }) + + nullable = false +} + +module "aks_namespace" { + source = "../" + + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + + hub = { + git_ref = var.test_context.hub_git_ref + bbd_draft = true + } + + azure_tenant_id = var.test_context.fixtures.azure.entra_tenant_id + azure_subscription_id = var.test_context.fixtures.azure.subscription_uuid + azure_scope = "/subscriptions/${var.test_context.fixtures.azure.subscription_uuid}" + azure_location = "westeurope" + aks_cluster_name = var.test_context.fixtures.azure.aks.cluster_name + aks_resource_group_name = var.test_context.fixtures.azure.aks.resource_group_name + + # Unique backplane name per test run so role definitions don't clash across concurrent/retried runs. + backplane_name = "hub-e2e-aksns-${var.test_context.name_suffix}" +} + +resource "meshstack_building_block" "this" { + depends_on = [module.aks_namespace] + + wait_for_completion = true + + spec = { + building_block_definition_version_ref = module.aks_namespace.building_block_definition.version_ref + + display_name = "smoke-test-aks-namespace-${var.test_context.name_suffix}" + target_ref = { + kind = "meshWorkspace" + name = var.test_context.workspace + } + + inputs = { + namespace_name = { value = jsonencode("e2e-${substr(var.test_context.name_suffix, 0, 10)}") } + } + } +} diff --git a/modules/aks/namespace/e2e/provider.tf b/modules/aks/namespace/e2e/provider.tf new file mode 100644 index 000000000..693259040 --- /dev/null +++ b/modules/aks/namespace/e2e/provider.tf @@ -0,0 +1,6 @@ +provider "azurerm" { + subscription_id = var.test_context.fixtures.azure.subscription_uuid + tenant_id = var.test_context.fixtures.azure.entra_tenant_id + + features {} +} diff --git a/modules/aks/namespace/e2e/terraform.tf b/modules/aks/namespace/e2e/terraform.tf new file mode 100644 index 000000000..39e32bf61 --- /dev/null +++ b/modules/aks/namespace/e2e/terraform.tf @@ -0,0 +1,12 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + } + azurerm = { + source = "hashicorp/azurerm" + } + } +} diff --git a/modules/aks/namespace/e2e/tests/azure_aks_namespace_hub.tftest.hcl b/modules/aks/namespace/e2e/tests/azure_aks_namespace_hub.tftest.hcl new file mode 100644 index 000000000..d29ec522a --- /dev/null +++ b/modules/aks/namespace/e2e/tests/azure_aks_namespace_hub.tftest.hcl @@ -0,0 +1,11 @@ +run "azure_aks_namespace_hub" { + assert { + condition = meshstack_building_block.this.status.status == "SUCCEEDED" + error_message = "aks/namespace hub building block expected SUCCEEDED, got ${meshstack_building_block.this.status.status}" + } + + assert { + condition = startswith(jsondecode(meshstack_building_block.this.status.outputs["namespace_name"].value), "e2e-") + error_message = "expected namespace_name to start with 'e2e-', got ${jsondecode(meshstack_building_block.this.status.outputs["namespace_name"].value)}" + } +} diff --git a/modules/aks/namespace/meshstack_integration.tf b/modules/aks/namespace/meshstack_integration.tf new file mode 100644 index 000000000..9c546305a --- /dev/null +++ b/modules/aks/namespace/meshstack_integration.tf @@ -0,0 +1,401 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID used for provider authentication." +} + +variable "azure_subscription_id" { + type = string + description = "Azure subscription that hosts the AKS cluster." +} + +variable "azure_scope" { + type = string + description = "Azure management group or subscription scope the custom role definition is assignable at. Must contain the AKS cluster." +} + +variable "azure_location" { + type = string + description = "Azure region for the backplane's managed identity." +} + +variable "aks_cluster_name" { + type = string + description = "Name of the existing AKS cluster namespaces are created in." +} + +variable "aks_resource_group_name" { + type = string + description = "Resource group of the AKS cluster." +} + +variable "acr_id" { + type = string + default = null + description = "Resource ID of the Azure Container Registry workloads pull from. When set, the cluster's kubelet identity gets AcrPull on it." +} + +variable "azure_user_lookup_attribute" { + type = string + default = "email" + description = "Directory attribute used to match meshStack workspace members to Entra users. 'email' matches the user's SMTP address and is the right choice when members are B2B guests, whose user principal name is rewritten on invitation. 'upn' only works for members native to this tenant." + + validation { + condition = contains(["upn", "email"], var.azure_user_lookup_attribute) + error_message = "Must be 'upn' or 'email'." + } +} + +variable "azure_group_name_prefix" { + type = string + default = "" + description = "Prepended to the display names of the Entra groups created per namespace. Leave empty to omit." +} + +variable "workspace_role_mapping" { + type = map(string) + default = { + "Workspace Owner" = "admin" + "Workspace Manager" = "admin" + "Workspace Member" = "edit" + } + description = "Maps a meshStack workspace role name to the namespace role (admin, edit or view) its members get in every namespace. A workspace role missing here gets no access." +} + +variable "resource_quota" { + type = object({ + requests_cpu = optional(string, "2") + requests_memory = optional(string, "4Gi") + limits_cpu = optional(string, "4") + limits_memory = optional(string, "8Gi") + pods = optional(string, "50") + }) + default = { + requests_cpu = "2" + requests_memory = "4Gi" + limits_cpu = "4" + limits_memory = "8Gi" + pods = "50" + } + description = "ResourceQuota applied to every namespace the building block creates. Application teams cannot change it." +} + +variable "backplane_name" { + type = string + default = "azure-aks-namespace" + description = "Name for the backplane resources (UAMI, role definition). Must match pattern ^[-a-z0-9]+$." +} + +variable "notification_subscribers" { + type = list(string) + default = [] + description = "List of email addresses to notify on building block lifecycle events." +} + +variable "bbd_display_name" { + type = string + default = null + description = "Overrides the name of the marketplace entry application teams see in the catalog." +} + +variable "bbd_description" { + type = string + default = null + description = "Overrides the one-line description shown next to the marketplace entry." +} + +variable "bbd_readme" { + type = string + default = null + description = "Overrides the markdown readme shown in the marketplace before ordering." +} + +variable "building_block_runner_uuid" { + type = string + default = null + description = "Runs this building block on the given meshStack building block runner instead of the shared one meshStack hosts." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to building block definition metadata." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = { + git_ref = "main" + bbd_draft = true + } + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, the building block definition version is kept in draft mode. + EOT +} + +output "building_block_definition" { + description = "BBD is consumed in building block compositions." + value = { + uuid = meshstack_building_block_definition.this.metadata.uuid + version_ref = var.hub.bbd_draft ? meshstack_building_block_definition.this.version_latest : meshstack_building_block_definition.this.version_latest_release + } +} + +module "backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/aks/namespace/backplane?ref=${var.hub.git_ref}" + + name = var.backplane_name + scope = var.azure_scope + location = var.azure_location + + aks = { + cluster_name = var.aks_cluster_name + resource_group_name = var.aks_resource_group_name + } + acr_id = var.acr_id + + workload_identity_federation = { + issuer = meshstack_building_block_definition.this.version_latest.workload_identity_federation.issuer + subjects = [meshstack_building_block_definition.this.version_latest.workload_identity_federation.subject] + } +} + +resource "meshstack_building_block_definition" "this" { + metadata = { + owned_by_workspace = var.meshstack.owning_workspace_identifier + tags = var.meshstack.tags + } + + spec = { + display_name = coalesce(var.bbd_display_name, "Azure AKS Namespace") + description = coalesce(var.bbd_description, "A Kubernetes namespace on the shared AKS cluster, with a resource quota and access for your workspace members.") + support_url = "mailto:support@meshcloud.io" + documentation_url = "https://hub.meshcloud.io/platforms/aks/definitions/azure-aks-namespace" + notification_subscribers = var.notification_subscribers + symbol = "https://raw.githubusercontent.com/meshcloud/meshstack-hub/${var.hub.git_ref}/modules/aks/namespace/buildingblock/logo.png" + target_type = "WORKSPACE_LEVEL" + + readme = coalesce(var.bbd_readme, chomp(<<-EOT + Creates a **Kubernetes namespace** on the platform team's AKS cluster for your workspace. You + get an isolated place to run workloads, with a resource quota and default container limits + already in place. + + ## 🎯 When to use it + + Use this building block when you: + - need a namespace to deploy a containerised application to + - want your workspace members to manage it without cluster-wide rights + + Ordering it creates an Entra group per namespace role (`admin`, `edit`, `view`) and adds your + workspace members to them according to their workspace role. + + ## 💡 Usage examples + + **Example 1: A dev and a prod namespace** + Order the block twice, once as `myapp-dev` and once as `myapp-prod`. + + **Example 2: Read-only access for auditors** + Give your auditors the workspace Reader role and they land in the namespace's `view` group, which can + inspect workloads without changing them. + + ## 📊 Shared Responsibility + + | Responsibility | Platform Team | Application Team | + |---|:---:|:---:| + | Operate the AKS cluster and its identity | ✅ | ❌ | + | Set up the backplane identity and permissions | ✅ | ❌ | + | Set the namespace quota | ✅ | ❌ | + | Choose the namespace name | ❌ | ✅ | + | Decide who is a workspace member and with which role | ❌ | ✅ | + | Deploy and operate workloads in the namespace | ❌ | ✅ | + EOT + )) + } + + version_spec = { + draft = var.hub.bbd_draft + + deletion_mode = "DELETE" + runner_ref = var.building_block_runner_uuid == null ? null : { + kind = "meshBuildingBlockRunner" + uuid = var.building_block_runner_uuid + } + + implementation = { + terraform = { + terraform_version = "1.12.5" + repository_url = "https://github.com/meshcloud/meshstack-hub.git" + repository_path = "modules/aks/namespace/buildingblock" + ref_name = var.hub.git_ref + use_mesh_http_backend_fallback = true + } + } + + inputs = { + ARM_CLIENT_ID = { + type = "STRING" + display_name = "ARM Client ID" + description = "Client ID of the UAMI used to authenticate with Azure." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(module.backplane.identity.client_id) + } + ARM_TENANT_ID = { + type = "STRING" + display_name = "ARM Tenant ID" + description = "Azure Entra tenant ID for authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_tenant_id) + } + ARM_SUBSCRIPTION_ID = { + type = "STRING" + display_name = "ARM Subscription ID" + description = "The Azure subscription that hosts the AKS cluster." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode(var.azure_subscription_id) + } + ARM_USE_OIDC = { + type = "STRING" + display_name = "ARM Use OIDC" + description = "Enables OIDC-based workload identity federation for the Azure provider." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("true") + } + ARM_OIDC_TOKEN_FILE_PATH = { + type = "STRING" + display_name = "ARM OIDC Token File Path" + description = "Path to the OIDC token file used for workload identity federation authentication." + assignment_type = "STATIC" + is_environment = true + argument = jsonencode("/var/run/secrets/workload-identity/azure/token") + } + aks_cluster_name = { + type = "STRING" + display_name = "AKS Cluster Name" + description = "Name of the AKS cluster the namespace is created in." + assignment_type = "STATIC" + argument = jsonencode(var.aks_cluster_name) + } + aks_resource_group_name = { + type = "STRING" + display_name = "AKS Resource Group" + description = "Resource group of the AKS cluster." + assignment_type = "STATIC" + argument = jsonencode(var.aks_resource_group_name) + } + workspace_identifier = { + type = "STRING" + display_name = "Workspace Identifier" + description = "The meshStack workspace that owns the namespace." + assignment_type = "WORKSPACE_IDENTIFIER" + } + namespace_name = { + type = "STRING" + display_name = "Namespace Name" + description = "Name of the Kubernetes namespace, e.g. 'myapp-dev'." + assignment_type = "USER_INPUT" + value_validation_regex = "^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$" + validation_regex_error_message = "Lowercase letters, digits and dashes only, at most 63 characters, starting and ending with a letter or digit." + } + group_name_prefix = { + type = "STRING" + display_name = "Group Name Prefix" + description = "Prepended to the display names of the Entra groups created for the namespace." + assignment_type = "STATIC" + argument = jsonencode(var.azure_group_name_prefix) + } + user_lookup_attribute = { + type = "STRING" + display_name = "User Lookup Attribute" + description = "Directory attribute used to look up users. 'email' matches the SMTP address; 'upn' matches the User Principal Name." + assignment_type = "STATIC" + argument = jsonencode(var.azure_user_lookup_attribute) + } + role_mapping = { + type = "CODE" + display_name = "Role Mapping" + description = "Maps meshStack workspace roles to the namespace roles admin, edit and view." + assignment_type = "STATIC" + # jsonencode twice is correct for structured inputs, see https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block_definition#argument-1 + argument = jsonencode(jsonencode(var.workspace_role_mapping)) + } + resource_quota = { + type = "CODE" + display_name = "Resource Quota" + description = "ResourceQuota applied to the namespace: requests_cpu, requests_memory, limits_cpu, limits_memory and pods." + assignment_type = "STATIC" + # jsonencode twice is correct for structured inputs, see https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block_definition#argument-1 + argument = jsonencode(jsonencode(var.resource_quota)) + } + users = { + type = "CODE" + display_name = "Users" + description = "Workspace members with their roles. Injected automatically by meshStack." + assignment_type = "USER_PERMISSIONS" + } + } + + outputs = { + namespace_name = { + type = "STRING" + display_name = "Namespace Name" + description = "Name of the created Kubernetes namespace." + assignment_type = "NONE" + } + cluster_name = { + type = "STRING" + display_name = "AKS Cluster Name" + description = "Name of the AKS cluster hosting the namespace." + assignment_type = "NONE" + } + cluster_resource_group_name = { + type = "STRING" + display_name = "AKS Resource Group" + description = "Resource group of the AKS cluster hosting the namespace." + assignment_type = "NONE" + } + group_object_ids = { + type = "STRING" + display_name = "Group Object IDs" + description = "JSON map of namespace role to the Entra group created for it." + assignment_type = "NONE" + } + unresolved_users = { + type = "STRING" + display_name = "Unresolved Users" + description = "JSON list of workspace members not found in the directory, and so not added to any group." + assignment_type = "NONE" + } + summary = { + type = "STRING" + display_name = "Summary" + description = "Markdown summary shown in meshPanel after the run." + assignment_type = "SUMMARY" + } + } + } +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.26.2" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.64.0, < 5.0.0" + } + } +} diff --git a/modules/aks/starterkit/buildingblock/README.md b/modules/aks/starterkit/buildingblock/README.md index 6d9727c62..8c7ca3287 100644 --- a/modules/aks/starterkit/buildingblock/README.md +++ b/modules/aks/starterkit/buildingblock/README.md @@ -26,10 +26,8 @@ No modules. | Name | Type | |------|------| | [meshstack_building_block.github_actions](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block) | resource | +| [meshstack_building_block.namespace](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block) | resource | | [meshstack_building_block.repo](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block) | resource | -| [meshstack_project.this](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/project) | resource | -| [meshstack_project_user_binding.creator_admin](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/project_user_binding) | resource | -| [meshstack_tenant.this](https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/tenant) | resource | | [random_id.repo_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/id) | resource | ## Inputs @@ -38,15 +36,11 @@ No modules. |------|-------------|------|---------|:--------:| | [apps\_base\_domain](#input\_apps\_base\_domain) | Base domain used for application URLs (e.g. 'likvid-k8s.msh.host'). The app subdomain will be prefixed to this value. | `string` | `"likvid-k8s.msh.host"` | no | | [archive\_repo\_on\_destroy](#input\_archive\_repo\_on\_destroy) | Whether to archive github repository when destroying the terraform resource, or delete it. Defaults to true (archive). | `bool` | `true` | no | -| [building\_block\_definition\_version\_refs](#input\_building\_block\_definition\_version\_refs) | Building block definition versions this starter kit creates its child building blocks from, keyed by definition name (`git-repository` and `github-actions-connector`). |
map(object({
uuid = string
}))
| n/a | yes | -| [creator](#input\_creator) | Information about the creator of the resources who will be assigned Project Admin role |
object({
type = string
identifier = string
displayName = string
username = optional(string)
email = optional(string)
euid = optional(string)
})
| n/a | yes | +| [building\_block\_definition\_version\_refs](#input\_building\_block\_definition\_version\_refs) | Building block definition versions this starter kit creates its child building blocks from, keyed by definition name (`git-repository`, `aks-namespace` and `github-actions-connector`). |
map(object({
uuid = string
}))
| n/a | yes | | [github\_org](#input\_github\_org) | GitHub organization name. Used only for display purposes. | `string` | n/a | yes | | [github\_repo\_input\_repo\_visibility](#input\_github\_repo\_input\_repo\_visibility) | Visibility of the GitHub repository (e.g., public, private). | `string` | `"private"` | no | | [github\_template\_repo\_path](#input\_github\_template\_repo\_path) | GitHub repository template to use when creating the application repository, in the format 'owner/repo'. | `string` | `"likvid-bank/aks-starterkit-template"` | no | -| [landing\_zone\_refs](#input\_landing\_zone\_refs) | Landing zone references keyed by stage (usually dev and prod). Wired in as a static building block input from the platform/backplane that owns the meshLandingZones (their `.ref` outputs). | `map(object({ name = string, kind = optional(string, "meshLandingZone") }))` | n/a | yes | -| [name](#input\_name) | This name will be used for the created projects, app subdomain and GitHub repository. | `string` | n/a | yes | -| [platform\_ref](#input\_platform\_ref) | Reference (by uuid) to the meshPlatform the tenants are created on. Wired in as a static building block input from the platform/backplane that owns the meshPlatform (its `.ref` output). Required because the meshTenant v4 API references platforms by ref. |
object({
uuid = string
kind = optional(string, "meshPlatform")
})
| n/a | yes | -| [project\_tags](#input\_project\_tags) | Tags for the created Dev/Prod projects. |
object({
dev = map(list(string))
prod = map(list(string))

owner_tag_key = optional(string, null)
})
| n/a | yes | +| [name](#input\_name) | This name will be used for the created namespaces, app subdomain and GitHub repository. | `string` | n/a | yes | | [repo\_admin](#input\_repo\_admin) | GitHub handle of the user who will be assigned as the repository admin. Delete building block definition input if not needed. | `string` | `null` | no | | [workspace\_identifier](#input\_workspace\_identifier) | n/a | `string` | n/a | yes | diff --git a/modules/aks/starterkit/buildingblock/main.tf b/modules/aks/starterkit/buildingblock/main.tf index 284789196..c948f99f1 100644 --- a/modules/aks/starterkit/buildingblock/main.tf +++ b/modules/aks/starterkit/buildingblock/main.tf @@ -5,11 +5,14 @@ locals { repo_name = "${local.identifier}-${random_id.repo_suffix.hex}" - # GitHub Actions environment name per stage. Indexed by stage key (fails on an unknown stage). + # GitHub Actions environment name per stage. Its keys are the stages the starter kit creates a + # namespace for. github_environment_names = { dev = "development" prod = "production" } + + stages = toset(keys(local.github_environment_names)) } # Generate a random suffix for the repository name to ensure uniqueness @@ -17,69 +20,6 @@ resource "random_id" "repo_suffix" { byte_length = 4 // 8 hex characters } -resource "meshstack_project" "this" { - for_each = var.landing_zone_refs - - metadata = { - name = "${local.identifier}-${each.key}" - owned_by_workspace = var.workspace_identifier - } - spec = { - display_name = "${var.name} ${title(each.key)}" - tags = merge( - var.project_tags[each.key], - var.project_tags.owner_tag_key == null ? {} : { - (var.project_tags.owner_tag_key) : [var.creator.displayName] - } - ) - } -} - -resource "meshstack_project_user_binding" "creator_admin" { - for_each = var.creator.type == "User" && var.creator.username != null ? var.landing_zone_refs : {} - - metadata = { - name = uuid() - } - - role_ref = { - name = "Project Admin" - } - - target_ref = { - owned_by_workspace = var.workspace_identifier - name = meshstack_project.this[each.key].metadata.name - } - - subject = { - name = var.creator.username - } -} - -resource "meshstack_tenant" "this" { - for_each = var.landing_zone_refs - - metadata = { - owned_by_workspace = var.workspace_identifier - owned_by_project = meshstack_project.this[each.key].metadata.name - } - - spec = { - platform_ref = var.platform_ref - landing_zone_ref = var.landing_zone_refs[each.key] - } - - # This orders the destroy, not the create. Deleted in parallel, the tenant and the binding are both - # the run's first write, so both run the same `INSERT ... ON DUPLICATE KEY UPDATE` on meshStack's - # Author table for the run's fresh ephemeral API key. The two deadlock, the loser's transaction is - # marked rollback-only, and its delete returns a 500 that fails the whole destroy run. Ordering them - # means the first delete creates the Author row and the second one finds it. - # - # A band-aid: meshStack does not retry on a deadlock. Remove it once meshStack does. This is not the - # BD-2288 create-ordering workaround that ddd5dba removed from here — that one is still fixed. - depends_on = [meshstack_project_user_binding.creator_admin] -} - resource "meshstack_building_block" "repo" { spec = { building_block_definition_version_ref = var.building_block_definition_version_refs["git-repository"] @@ -123,14 +63,44 @@ resource "meshstack_building_block" "repo" { } } +resource "meshstack_building_block" "namespace" { + for_each = local.stages + + spec = { + building_block_definition_version_ref = var.building_block_definition_version_refs["aks-namespace"] + + display_name = "${var.name} ${title(each.key)} namespace" + target_ref = { + kind = "meshWorkspace" + name = var.workspace_identifier + } + + inputs = { + namespace_name = { + value = jsonencode("${local.identifier}-${each.key}") + } + } + } + + lifecycle { + postcondition { + condition = self.status.status == "SUCCEEDED" + error_message = "Building block ${self.metadata.uuid} is ${self.status.status}, not SUCCEEDED. See its run in meshPanel." + } + } +} + resource "meshstack_building_block" "github_actions" { - for_each = var.landing_zone_refs + for_each = local.stages spec = { building_block_definition_version_ref = var.building_block_definition_version_refs["github-actions-connector"] - target_ref = meshstack_tenant.this[each.key].ref - display_name = "GHA Connector ${title(each.key)}" - parent_building_block_refs = [meshstack_building_block.repo.ref] + target_ref = { + kind = "meshWorkspace" + name = var.workspace_identifier + } + display_name = "GHA Connector ${title(each.key)}" + parent_building_block_refs = [meshstack_building_block.repo.ref, meshstack_building_block.namespace[each.key].ref] inputs = { github_environment_name = { value = jsonencode(local.github_environment_names[each.key]) @@ -138,7 +108,7 @@ resource "meshstack_building_block" "github_actions" { additional_environment_variables = { value = jsonencode(jsonencode({ "DOMAIN_NAME" = each.key == "prod" ? local.identifier : "${local.identifier}-${each.key}" - "AKS_NAMESPACE_NAME" = meshstack_tenant.this[each.key].spec.platform_tenant_id + "AKS_NAMESPACE_NAME" = jsondecode(meshstack_building_block.namespace[each.key].status.outputs.namespace_name.value) })) } } @@ -151,58 +121,3 @@ resource "meshstack_building_block" "github_actions" { } } } - -# --- State address migrations (no resource recreation) --- -# The building block moves are chained: hop 1 is the resource-type migration -# (meshstack_building_block_v2 -> meshstack_building_block), hop 2 is the dev/prod -> for_each address -# change. Terraform follows the chain, so a deployed meshstack_building_block_v2.github_actions_dev -# migrates v2 -> GA -> for_each in sequence without recreation. - -# Hop 1 — resource-type migrations. -moved { - from = meshstack_building_block_v2.repo - to = meshstack_building_block.repo -} -moved { - from = meshstack_building_block_v2.github_actions_dev - to = meshstack_building_block.github_actions_dev -} -moved { - from = meshstack_building_block_v2.github_actions_prod - to = meshstack_building_block.github_actions_prod -} - -# Hop 2 — spelled-out dev/prod -> for_each keys (this refactor). Projects and bindings have no -# hop-1 migration, so their move is the only hop. -moved { - from = meshstack_tenant.dev - to = meshstack_tenant.this["dev"] -} -moved { - from = meshstack_tenant.prod - to = meshstack_tenant.this["prod"] -} -moved { - from = meshstack_project.dev - to = meshstack_project.this["dev"] -} -moved { - from = meshstack_project.prod - to = meshstack_project.this["prod"] -} -moved { - from = meshstack_project_user_binding.creator_dev_admin[0] - to = meshstack_project_user_binding.creator_admin["dev"] -} -moved { - from = meshstack_project_user_binding.creator_prod_admin[0] - to = meshstack_project_user_binding.creator_admin["prod"] -} -moved { - from = meshstack_building_block.github_actions_dev - to = meshstack_building_block.github_actions["dev"] -} -moved { - from = meshstack_building_block.github_actions_prod - to = meshstack_building_block.github_actions["prod"] -} diff --git a/modules/aks/starterkit/buildingblock/outputs.tf b/modules/aks/starterkit/buildingblock/outputs.tf index 27aec462d..69a7757a2 100644 --- a/modules/aks/starterkit/buildingblock/outputs.tf +++ b/modules/aks/starterkit/buildingblock/outputs.tf @@ -25,13 +25,11 @@ This starter kit has set up the following resources in workspace `${var.workspac @buildingblock[${meshstack_building_block.repo.metadata.uuid}] -@project[${meshstack_project.this["dev"].metadata.owned_by_workspace}.${meshstack_project.this["dev"].metadata.name}]\ -    @tenant[${meshstack_tenant.this["dev"].metadata.uuid}]\ -        @buildingblock[${meshstack_building_block.github_actions["dev"].metadata.uuid}] +@buildingblock[${meshstack_building_block.namespace["dev"].metadata.uuid}]\ +    @buildingblock[${meshstack_building_block.github_actions["dev"].metadata.uuid}] -@project[${meshstack_project.this["prod"].metadata.owned_by_workspace}.${meshstack_project.this["prod"].metadata.name}]\ -    @tenant[${meshstack_tenant.this["prod"].metadata.uuid}]\ -        @buildingblock[${meshstack_building_block.github_actions["prod"].metadata.uuid}] +@buildingblock[${meshstack_building_block.namespace["prod"].metadata.uuid}]\ +    @buildingblock[${meshstack_building_block.github_actions["prod"].metadata.uuid}] --- @@ -69,9 +67,8 @@ View deployment status: [GitHub Actions](${jsondecode(meshstack_building_block.r - Check workflow status in the [Actions tab](<${jsondecode(meshstack_building_block.repo.status.outputs.repo_html_url.value)}/actions>) ### 3. Manage Access -- Invite team members via meshStack: - - [Dev Access](#/w/${var.workspace_identifier}/p/${meshstack_project.this["dev"].metadata.name}/access-management/role-mapping) - - [Prod Access](#/w/${var.workspace_identifier}/p/${meshstack_project.this["prod"].metadata.name}/access-management/role-mapping) +- Namespace access follows your workspace roles: owners and managers get `admin`, members `edit`, + readers `view`. Change a member's workspace role in meshStack and re-run the namespace block. --- diff --git a/modules/aks/starterkit/buildingblock/variables.tf b/modules/aks/starterkit/buildingblock/variables.tf index 8aa355a0e..eccea6ab9 100644 --- a/modules/aks/starterkit/buildingblock/variables.tf +++ b/modules/aks/starterkit/buildingblock/variables.tf @@ -4,20 +4,7 @@ variable "workspace_identifier" { variable "name" { type = string - description = "This name will be used for the created projects, app subdomain and GitHub repository." -} - -variable "platform_ref" { - type = object({ - uuid = string - kind = optional(string, "meshPlatform") - }) - description = "Reference (by uuid) to the meshPlatform the tenants are created on. Wired in as a static building block input from the platform/backplane that owns the meshPlatform (its `.ref` output). Required because the meshTenant v4 API references platforms by ref." -} - -variable "landing_zone_refs" { - type = map(object({ name = string, kind = optional(string, "meshLandingZone") })) - description = "Landing zone references keyed by stage (usually dev and prod). Wired in as a static building block input from the platform/backplane that owns the meshLandingZones (their `.ref` outputs)." + description = "This name will be used for the created namespaces, app subdomain and GitHub repository." } variable "building_block_definition_version_refs" { @@ -25,7 +12,7 @@ variable "building_block_definition_version_refs" { type = map(object({ uuid = string })) - description = "Building block definition versions this starter kit creates its child building blocks from, keyed by definition name (`git-repository` and `github-actions-connector`)." + description = "Building block definition versions this starter kit creates its child building blocks from, keyed by definition name (`git-repository`, `aks-namespace` and `github-actions-connector`)." } variable "github_repo_input_repo_visibility" { @@ -45,33 +32,12 @@ variable "github_org" { description = "GitHub organization name. Used only for display purposes." } -variable "creator" { - type = object({ - type = string - identifier = string - displayName = string - username = optional(string) - email = optional(string) - euid = optional(string) - }) - description = "Information about the creator of the resources who will be assigned Project Admin role" -} - variable "repo_admin" { type = string description = "GitHub handle of the user who will be assigned as the repository admin. Delete building block definition input if not needed." default = null } -variable "project_tags" { - type = object({ - dev = map(list(string)) - prod = map(list(string)) - - owner_tag_key = optional(string, null) - }) - description = "Tags for the created Dev/Prod projects." -} variable "github_template_repo_path" { type = string description = "GitHub repository template to use when creating the application repository, in the format 'owner/repo'." @@ -82,4 +48,4 @@ variable "apps_base_domain" { type = string description = "Base domain used for application URLs (e.g. 'likvid-k8s.msh.host'). The app subdomain will be prefixed to this value." default = "likvid-k8s.msh.host" -} \ No newline at end of file +} diff --git a/modules/aks/starterkit/meshstack_integration.tf b/modules/aks/starterkit/meshstack_integration.tf index a19bc06e4..0020718b0 100644 --- a/modules/aks/starterkit/meshstack_integration.tf +++ b/modules/aks/starterkit/meshstack_integration.tf @@ -1,16 +1,8 @@ -variable "platform_ref" { - type = object({ - uuid = string - kind = optional(string, "meshPlatform") - }) - description = "Reference (by uuid) to the meshPlatform tenants are created on — e.g. the `.ref` output of the meshstack_platform resource/backplane that owns it. Wired to the building block as a static input; required since the meshTenant v4 API references platforms by ref." -} - variable "building_block_definition_version_refs" { type = map(object({ uuid = string })) - description = "Building block definition versions the starter kit creates its child building blocks from, keyed by definition name (`git-repository` and `github-actions-connector`)." + description = "Building block definition versions the starter kit creates its child building blocks from, keyed by definition name (`git-repository`, `aks-namespace` and `github-actions-connector`)." } variable "github_org" { @@ -28,28 +20,11 @@ variable "apps_base_domain" { description = "Base domain used for app URLs (example: `apps.prod.example.com`)." } -variable "landing_zone_refs" { - type = map(object({ name = string, kind = optional(string, "meshLandingZone") })) - description = "Landing zone references keyed by stage (usually dev and prod), e.g. the `.ref` outputs of the meshstack_landingzone resources/backplane that owns them." -} - variable "notification_subscribers" { type = list(string) default = [] } -variable "project_tags" { - type = object({ - dev = map(list(string)) - prod = map(list(string)) - }) - default = { - dev = { environment = ["dev"] } - prod = { environment = ["prod"] } - } - description = "Configure project tags of starter kit, for dev and prod." -} - variable "bbd_display_name" { type = string default = null @@ -101,7 +76,7 @@ output "building_block_definition" { } locals { - name_regex = "^[a-zA-Z0-9-]{0,24}$" # underscore and dots not allowed because of K8s namespace, max length of 25 because of project character limit and suffixes added by the building block + name_regex = "^[a-zA-Z0-9-]{0,24}$" # underscore and dots not allowed because of K8s namespace, max length of 25 because of the suffixes the building block adds to the name } resource "meshstack_building_block_definition" "aks_starterkit" { @@ -137,12 +112,10 @@ This building block is ideal for teams that: This building block automates the creation of the following resources: - **GitHub Repository**: A new repository to store your application code and Dockerfile. -- **Development Project**: You, as the creator, will have access to this project and AKS namespace. - - **AKS Namespace**: A dedicated Kubernetes namespace for development. - - **GitHub Actions Connector**: Connects the GitHub repository to the development AKS namespace via GitHub Actions and deploys after every commit to the main branch. -- **Production Project**: You, as the creator, will have access to this project and AKS namespace. - - **AKS Namespace**: A dedicated Kubernetes namespace for production. - - **GitHub Actions Connector**: Connects the GitHub repository to the production AKS namespace via GitHub Actions and deploys after every commit to the release branch. +- **Development namespace**: A dedicated Kubernetes namespace for development, with a resource quota. + - **GitHub Actions Connector**: Connects the GitHub repository to the development namespace via GitHub Actions and deploys after every commit to the main branch. +- **Production namespace**: A dedicated Kubernetes namespace for production, with a resource quota. + - **GitHub Actions Connector**: Connects the GitHub repository to the production namespace via GitHub Actions and deploys after every commit to the release branch. ## Shared Responsibilities @@ -152,7 +125,7 @@ This building block automates the creation of the following resources: | Create and manage Git repository | ✅ | ❌ | | Set up GitHub Actions CI/CD pipeline | ✅ | ❌ | | Build and scan Docker images | ✅ | ❌ | -| Manage Kubernetes namespaces (dev/prod) | ✅ | ❌ | +| Create Kubernetes namespaces (dev/prod) and grant access | ✅ | ❌ | | Manage resources inside namespaces | ❌ | ✅ | | Develop and maintain application source code | ❌ | ✅ | | Maintain application configurations | ❌ | ✅ | @@ -177,34 +150,8 @@ EOT } } inputs = { - "creator" = { - assignment_type = "AUTHOR" - description = "Information about the creator of the resources who will be assigned Project Admin role" - display_name = "Creator" - is_environment = false - type = "CODE" - updateable_by_consumer = false - } - "platform_ref" = { - # jsonencode twice is correct for structured inputs, see the project_tags input below. - argument = jsonencode(jsonencode(var.platform_ref)) - assignment_type = "STATIC" - display_name = "Platform Reference" - is_environment = false - type = "CODE" - updateable_by_consumer = false - } - "landing_zone_refs" = { - # jsonencode twice is correct for structured inputs, see the project_tags input below. - argument = jsonencode(jsonencode(var.landing_zone_refs)) - assignment_type = "STATIC" - display_name = "Landing Zone References for Dev/Prod." - is_environment = false - type = "CODE" - updateable_by_consumer = false - } "building_block_definition_version_refs" = { - # jsonencode twice is correct for structured inputs, see the project_tags input below. + # jsonencode twice is correct for structured inputs, see https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block_definition#argument-1 argument = jsonencode(jsonencode(var.building_block_definition_version_refs)) assignment_type = "STATIC" description = "Definition versions the starter kit creates its child building blocks from." @@ -248,22 +195,14 @@ EOT } "name" = { assignment_type = "USER_INPUT" - description = "This name will be used for the created projects, AKS namespaces and GitHub repository." - display_name = "Name of the Project" + description = "This name will be used for the created AKS namespaces and GitHub repository." + display_name = "Name" is_environment = false type = "STRING" updateable_by_consumer = false value_validation_regex = local.name_regex validation_regex_error_message = "No underscore/dots/spaces are allowed. A maximum length of 25 characters is allowed." } - "project_tags" = { - # jsonencode twice is correct, see https://registry.terraform.io/providers/meshcloud/meshstack/latest/docs/resources/building_block_definition#argument-1 - argument = jsonencode(jsonencode(var.project_tags)) - assignment_type = "STATIC" - description = "Tags for the created Dev/Prod projects." - display_name = "Project Tags" - type = "CODE" - } "workspace_identifier" = { assignment_type = "WORKSPACE_IDENTIFIER" description = "" @@ -308,15 +247,6 @@ EOT "BUILDINGBLOCK_DELETE", "BUILDINGBLOCK_LIST", "BUILDINGBLOCK_SAVE", - "PROJECTPRINCIPALROLE_DELETE", - "PROJECTPRINCIPALROLE_LIST", - "PROJECTPRINCIPALROLE_SAVE", - "PROJECT_DELETE", - "PROJECT_LIST", - "PROJECT_SAVE", - "TENANT_DELETE", - "TENANT_LIST", - "TENANT_SAVE", ] } } diff --git a/reference-architectures/azure-kubernetes/README.md b/reference-architectures/azure-kubernetes/README.md index 8e739be8b..687e58445 100644 --- a/reference-architectures/azure-kubernetes/README.md +++ b/reference-architectures/azure-kubernetes/README.md @@ -1,15 +1,16 @@ --- name: Azure Kubernetes Platform description: > - A self-service Azure Kubernetes platform that gives application teams their own - AKS namespaces with GitHub repositories, GitHub Actions CI/CD pipelines, - and a container registry backed by Azure Container Registry — all orchestrated - through a single starterkit building block. + A self-service Azure Kubernetes platform built from workspace building blocks only. Application + teams order a starterkit that creates their AKS namespaces, a GitHub repository and GitHub Actions + CI/CD pipelines, without any meshStack project, tenant, platform or landing zone. cloudProviders: - azure buildingBlocks: - path: aks/starterkit role: Orchestrates the full developer onboarding experience by composing all building blocks into one self-service offering. + - path: aks/namespace + role: Creates a Kubernetes namespace with a resource quota, default limits and Entra group access on an existing AKS cluster. - path: github/repository role: Creates GitHub repositories with predefined configurations and access control for application teams. - path: aks/github-connector @@ -20,18 +21,17 @@ buildingBlocks: ## Overview -The **Azure Kubernetes Platform** (AKS) reference architecture delivers a complete, self-service -Kubernetes experience on Azure. It combines three Hub building blocks into a cohesive -platform that allows application teams to go from "I need a place to run my app" to -"my code is deployed" in minutes — without filing tickets or waiting for manual -provisioning. +The **Azure Kubernetes Platform** reference architecture delivers a self-service Kubernetes +experience on Azure from four Hub building blocks. Application teams go from "I need a place to run +my app" to "my code is deployed" in minutes, without filing tickets. -It assumes the platform team already operates one or more AKS clusters. This architecture -layers developer self-service on top of those clusters. +Every building block runs at **workspace level**. There is no meshStack project, tenant, platform +or landing zone in this architecture: a namespace is a building block, not a tenant. The platform +team still operates the AKS cluster; this architecture layers developer self-service on top of it. **Target audience:** -- **Platform engineers** who already have AKS clusters and want to add self-service onboarding. +- **Platform engineers** who already have an AKS cluster and want to add self-service onboarding. - **Application teams** who need a fast, secure path to Kubernetes with built-in CI/CD. ## Architecture Diagram @@ -40,60 +40,130 @@ layers developer self-service on top of those clusters. ## How It Works -### 1. Developer Starterkit — `aks/starterkit` +### 1. Developer Starterkit: `aks/starterkit` -The starterkit is a **meshStack building block composition** that application teams -request from the self-service catalog. When a developer clicks "Order", the starterkit -automatically: +The starterkit is a **building block composition** that application teams order from the +self-service catalog. When a developer clicks "Order", it: -1. Creates a **GitHub repository** from a configurable template - (`github/repository`). -2. Creates a **dev project** with a dedicated AKS namespace and wires it to the repo - via a **GitHub Actions connector** (`aks/github-connector`). -3. Creates a **prod project** with a separate AKS namespace and connector that - triggers on the `release` branch. -4. Grants the requesting developer **Project Admin** on both projects. +1. Creates a **GitHub repository** from a configurable template (`github/repository`). +2. Creates a **dev** and a **prod** namespace (`aks/namespace`). +3. Wires each namespace to the repository with a **GitHub Actions connector** + (`aks/github-connector`). Dev deploys from `main`, prod from `release`. -### 2. GitHub Repository — `github/repository` +Each connector is ordered with the repository and its namespace as parent building blocks, and +reads the namespace name from the namespace block's output. -Each application team gets its own repository with: +### 2. Namespace: `aks/namespace` -- Predefined structure (Dockerfile, Kubernetes manifests, GitHub Actions workflow). -- Branch protection rules and team-based access control. -- Template-based initialization for consistent project scaffolding. +Creates the namespace on the platform team's cluster, with: -### 3. CI/CD Pipeline — `aks/github-connector` +- a `ResourceQuota` and a `LimitRange` with default container requests and limits +- an Entra group per namespace role (`admin`, `edit`, `view`), filled from the workspace's members + by their workspace role (owner and manager, member, reader), and bound to the matching cluster role +- the owning workspace recorded as a label -The connector building block creates: +The block runs as a user-assigned managed identity that meshStack's building block runner federates +into. It holds no kubeconfig. The identity has cluster admin on the one cluster through Azure RBAC +for Kubernetes, and Microsoft Graph `User.Read.All` and `Group.ReadWrite.All` to manage the groups. If you set `acr_id` on its backplane, the cluster's kubelet identity also gets +`AcrPull` on your Azure Container Registry, so no namespace needs an image pull secret. -- A **Kubernetes service account** scoped to the target namespace. -- **GitHub Actions environment secrets** for cluster authentication and container - registry access. -- A **GitHub Actions workflow** that builds, scans, and deploys on every push. +### 3. GitHub Repository: `github/repository` -Separate connector instances for dev and prod ensure workload isolation while sharing -the same repository. +Each application team gets its own repository with a predefined structure (Dockerfile, Kubernetes +manifests, GitHub Actions workflow), branch protection and team-based access control. + +### 4. CI/CD Pipeline: `aks/github-connector` + +The connector creates a **Kubernetes service account** scoped to the target namespace, **GitHub +Actions environment secrets** for cluster and container registry access, and a workflow that +builds, scans and deploys on every push. ## Getting Started ### Prerequisites -| Requirement | Description | -|---------------------|------------------------------------------------------------------------| -| meshStack instance | With Terraform/OpenTofu IaC runtime configured. | -| AKS cluster | An existing AKS cluster with a kubeconfig available for the connector. | -| GitHub organization | With a GitHub App installed for the Terraform GitHub provider. | +| Requirement | Description | +|---|---| +| meshStack instance | With the OpenTofu building block runner configured. | +| AKS cluster | Entra-integrated, with Azure RBAC for Kubernetes authorization enabled, and reachable from the runner. | +| Azure Container Registry | Optional. Pass its resource ID to the `aks/namespace` backplane. | +| GitHub organization | With a GitHub App installed for the Terraform GitHub provider. | + +### Deploy it as code + +`meshstack_integration.tf` in this folder is a ready-to-use root module that registers all four +building block definitions in the right order and wires them together: the namespace, the +repository and the connector definitions first, then the starterkit that composes them. A +foundation repository references it like any hub module: + +```hcl +module "azure_kubernetes" { + source = "github.com/meshcloud/meshstack-hub//reference-architectures/azure-kubernetes?ref=" + + meshstack = { owning_workspace_identifier = "platform-team" } + hub = { git_ref = "", bbd_draft = false } + + azure_tenant_id = "..." + azure_subscription_id = "..." + azure_scope = "/subscriptions/..." + azure_location = "westeurope" + aks_cluster_name = "aks-prod" + aks_resource_group_name = "rg-aks-prod" + + github = { + org = "acme-platform" + app_id = "..." + app_installation_id = "..." + app_pem_file = file("github-app.pem") + } + github_template_repo_path = "acme-platform/aks-starterkit-template" + apps_base_domain = "apps.example.com" +} +``` + +`hub.git_ref` must be a literal: the nested modules are sourced from it. + +The root module declares no providers, so the caller configures them: + +| Provider | Used for | +|---|---| +| `meshstack` | The building block definitions. | +| `azurerm`, `azuread` | The namespace backplane (managed identity, role assignments, Graph permissions) and the connector backplane (container registry, registry service principal). | + +The connector backplane configures its own `kubernetes` provider from the cluster's admin +credentials, so the cluster needs local accounts enabled. + +Backplane resource names are unique per subscription or tenant (resource groups, the custom role, +the container registry). Set `namespace_backplane_name` and `connector_backplane_name` when another +deployment already uses the defaults. + +The identity running the deployment needs to create managed identities and role assignments on the +cluster, and to grant Microsoft Graph app roles (admin consent) for the namespace backplane. + +#### What it registers + +| Module | Definition | Wired to | +|---|---|---| +| `aks/namespace` | Namespace with quota and Entra groups | Its backplane identity trusts the definition's runner identity. | +| `github/repository` | GitHub repository from a template | | +| `aks/github-connector` | GitHub Actions to namespace wiring | The repository and namespace definitions. | +| `aks/starterkit` | The single catalog entry for application teams | All three definitions above, by version reference. | + +Set `hub.bbd_draft = false` once the definitions are ready to be used; the starterkit then orders +released versions. ## Shared Responsibilities | Responsibility | Platform Team | Application Team | | --- | --- | --- | -| Provision and manage AKS cluster | ✅ | ❌ | +| Provision and manage the AKS cluster | ✅ | ❌ | | Register and maintain building block definitions | ✅ | ❌ | +| Set up the namespace backplane identity and permissions | ✅ | ❌ | | Configure GitHub App & template repositories | ✅ | ❌ | +| Grant the namespace backplane its Microsoft Graph permissions (admin consent) | ✅ | ❌ | +| Manage workspace members and their roles | ❌ | ✅ | | Order starterkit from the self-service catalog | ❌ | ✅ | | Develop and maintain application source code | ❌ | ✅ | | Manage Kubernetes resources inside namespaces | ❌ | ✅ | | Merge to release branch for production deployments | ❌ | ✅ | | Monitor application health and logs | ❌ | ✅ | - diff --git a/reference-architectures/azure-kubernetes/azure-kubernetes.dot b/reference-architectures/azure-kubernetes/azure-kubernetes.dot index 4d5fda290..fec35d89e 100644 --- a/reference-architectures/azure-kubernetes/azure-kubernetes.dot +++ b/reference-architectures/azure-kubernetes/azure-kubernetes.dot @@ -58,24 +58,19 @@ digraph azure_kubernetes { style="rounded" color="#d5dae0" margin=18 - PLAT [label="🛰️ AKS Namespace Platform" fillcolor="#ecedfb" color="#9aa2e6"] - LZ [label="🛬 Landing Zone ×2\ndev + prod" fillcolor="#ecedfb" color="#9aa2e6"] - PROJ [label="🗂️ Project ×2\ndev + prod" fillcolor="#e5f2ea" color="#85bfa0"] BBD_S [label="📦 aks/starterkit BBD\nself-service catalog entry" fillcolor="#ecedfb" color="#9aa2e6"] BBD_R [label="📦 github/repository BBD" fillcolor="#ecedfb" color="#9aa2e6"] + BBD_N [label="📦 aks/namespace BBD" fillcolor="#ecedfb" color="#9aa2e6"] BBD_C [label="📦 aks/github-connector BBD" fillcolor="#ecedfb" color="#9aa2e6"] - { rank=same; BBD_R -> BBD_C [style=invis] } + { rank=same; BBD_R -> BBD_N -> BBD_C [style=invis] } - PLAT -> LZ - LZ -> PROJ [xlabel="applies to"] BBD_S -> BBD_R [xlabel="composes"] + BBD_S -> BBD_N BBD_S -> BBD_C - BBD_S -> PROJ [xlabel="creates" style=dotted constraint=false] } - PLAT -> AKS [xlabel="replicates into" style=dotted constraint=false] - PROJ -> NS [xlabel="tenant namespace" style=dotted constraint=false] + BBD_N -> NS [xlabel="creates ×2" style=dotted constraint=false] BBD_R -> REPO [xlabel="provisions" style=dotted constraint=false] BBD_C -> GHA [xlabel="provisions" style=dotted constraint=false] GHA -> ACR [xlabel="pushes images" style=dotted constraint=false] diff --git a/reference-architectures/azure-kubernetes/azure-kubernetes.svg b/reference-architectures/azure-kubernetes/azure-kubernetes.svg index 58b713f0f..79cbcc140 100644 --- a/reference-architectures/azure-kubernetes/azure-kubernetes.svg +++ b/reference-architectures/azure-kubernetes/azure-kubernetes.svg @@ -1,11 +1,11 @@ - + azure_kubernetes - + cluster_azure @@ -18,7 +18,7 @@ cluster_mesh - +  meshStack @@ -37,8 +37,8 @@ SUB->AKS - - + + @@ -103,9 +103,9 @@ TPL->REPO - - -cloned into + + +cloned into @@ -122,119 +122,85 @@ runs workflow - + GHA->ACR pushes images - + GHA->NS deploys to - - -PLAT - -🛰️ AKS Namespace Platform - - - -PLAT->AKS - - -replicates into - - - -LZ - -🛬 Landing Zone ×2 -dev + prod - - - -PLAT->LZ - - - - - -PROJ - -🗂️ Project ×2 -dev + prod - - - -LZ->PROJ - - -applies to - - - -PROJ->NS - - -tenant namespace - - + BBD_S - -📦 aks/starterkit BBD -self-service catalog entry - - - -BBD_S->PROJ - - -creates + +📦 aks/starterkit BBD +self-service catalog entry - + BBD_R - -📦 github/repository BBD + +📦 github/repository BBD - + BBD_S->BBD_R - - -composes + + +composes + + + +BBD_N + +📦 aks/namespace BBD + + + +BBD_S->BBD_N + + - + BBD_C - -📦 aks/github-connector BBD + +📦 aks/github-connector BBD BBD_S->BBD_C - - + + - + BBD_R->REPO - - -provisions + + +provisions - + + + +BBD_N->NS + + +creates ×2 + + - + BBD_C->GHA - - -provisions + + +provisions diff --git a/reference-architectures/azure-kubernetes/meshstack_integration.tf b/reference-architectures/azure-kubernetes/meshstack_integration.tf new file mode 100644 index 000000000..d091309b4 --- /dev/null +++ b/reference-architectures/azure-kubernetes/meshstack_integration.tf @@ -0,0 +1,254 @@ +variable "azure_tenant_id" { + type = string + description = "Azure Entra tenant ID the namespace building block authenticates against." +} + +variable "azure_subscription_id" { + type = string + description = "Azure subscription that hosts the AKS cluster." +} + +variable "azure_scope" { + type = string + description = "Azure management group or subscription scope the namespace backplane's custom role is assignable at. Must contain the AKS cluster." +} + +variable "azure_location" { + type = string + description = "Azure region for the backplane resources (managed identity, container registry)." +} + +variable "azure_user_lookup_attribute" { + type = string + default = "email" + description = "Directory attribute used to match meshStack workspace members to Entra users. 'email' suits B2B guests, 'upn' only works for members native to the tenant." +} + +variable "azure_group_name_prefix" { + type = string + default = "" + description = "Prepended to the display names of the Entra groups created per namespace. Leave empty to omit." +} + +variable "aks_cluster_name" { + type = string + description = "Name of the existing AKS cluster. It must be Entra-integrated with Azure RBAC for Kubernetes authorization enabled." +} + +variable "aks_resource_group_name" { + type = string + description = "Resource group of the AKS cluster." +} + +variable "acr_id" { + type = string + default = null + description = "Resource ID of an existing Azure Container Registry. When set, the cluster's kubelet identity gets AcrPull on it." +} + +variable "workspace_role_mapping" { + type = map(string) + default = { + "Workspace Owner" = "admin" + "Workspace Manager" = "edit" + "Workspace Member" = "view" + } + description = "Maps a meshStack workspace role name to the namespace role (admin, edit or view) its members get in every namespace." +} + +variable "resource_quota" { + type = object({ + requests_cpu = optional(string, "2") + requests_memory = optional(string, "4Gi") + limits_cpu = optional(string, "4") + limits_memory = optional(string, "8Gi") + pods = optional(string, "50") + }) + default = { + requests_cpu = "2" + requests_memory = "4Gi" + limits_cpu = "4" + limits_memory = "8Gi" + pods = "50" + } + description = "ResourceQuota applied to every namespace the platform creates." +} + +variable "github" { + type = object({ + org = string + app_id = string + app_installation_id = string + app_pem_file = string + }) + sensitive = true + description = "GitHub organization and the GitHub App credentials the GitHub Terraform provider authenticates with. app_pem_file is the raw PEM private key content." +} + +variable "github_template_repo_path" { + type = string + description = "Template repository (owner/repo) every application repository is created from." + default = "likvid-bank/aks-starterkit-template" +} + +variable "apps_base_domain" { + type = string + description = "Base domain application URLs are built under (example: `apps.example.com`)." + default = "likvid-k8s.msh.host" +} + +variable "namespace_backplane_name" { + type = string + default = "azure-kubernetes-namespace" + description = "Name of the namespace backplane's resource group, managed identity and custom role. The role definition name is unique per Entra tenant, so set a different value when another deployment already uses this one. Must match ^[-a-z0-9]+$." +} + +variable "connector_backplane_name" { + type = string + default = "azure-kubernetes-connector" + description = "Prefix for the resources the GitHub Actions connector's backplane creates (resource group, app registration, container registry). The registry name is this value without dashes, so it must be globally unique across Azure, 5 to 50 lowercase alphanumerics and dashes." +} + +variable "notification_subscribers" { + type = list(string) + default = [] + description = "List of email addresses to notify on building block lifecycle events." +} + +variable "meshstack" { + type = object({ + owning_workspace_identifier = string + tags = optional(map(list(string)), {}) + }) + description = "Shared meshStack context. Tags are optional and propagated to every building block definition." +} + +variable "hub" { + type = object({ + git_ref = optional(string, "main") + bbd_draft = optional(bool, true) + }) + const = true + default = { + git_ref = "main" + bbd_draft = true + } + description = <<-EOT + `git_ref`: Hub release reference. Set to a tag (e.g. 'v1.2.3') or branch or commit sha of the meshstack-hub repo. + `bbd_draft`: If true, the building block definition versions are kept in draft mode. + EOT +} + +output "building_block_definitions" { + description = "The building block definitions this architecture registers, by role. The starterkit is the one application teams order." + value = { + starterkit = module.starterkit.building_block_definition + namespace = module.aks_namespace.building_block_definition + repository = module.github_repository.building_block_definition + github_connector = module.github_connector.building_block_definition + } +} + +module "aks_namespace" { + source = "github.com/meshcloud/meshstack-hub//modules/aks/namespace?ref=${var.hub.git_ref}" + + azure_tenant_id = var.azure_tenant_id + azure_subscription_id = var.azure_subscription_id + azure_scope = var.azure_scope + azure_location = var.azure_location + azure_user_lookup_attribute = var.azure_user_lookup_attribute + azure_group_name_prefix = var.azure_group_name_prefix + aks_cluster_name = var.aks_cluster_name + aks_resource_group_name = var.aks_resource_group_name + acr_id = var.acr_id + backplane_name = var.namespace_backplane_name + workspace_role_mapping = var.workspace_role_mapping + resource_quota = var.resource_quota + notification_subscribers = var.notification_subscribers + + meshstack = var.meshstack + hub = var.hub +} + +module "github_repository" { + source = "github.com/meshcloud/meshstack-hub//modules/github/repository?ref=${var.hub.git_ref}" + + github = var.github + notification_subscribers = var.notification_subscribers + + meshstack = var.meshstack + hub = var.hub +} + +# Mints the cluster-facing credentials the connector hands to GitHub Actions: a kubeconfig stub and +# the container registry the pipelines push to. Its providers (azurerm, azuread, kubernetes) are the +# caller's to configure, see the readme. +module "github_connector_backplane" { + source = "github.com/meshcloud/meshstack-hub//modules/aks/github-connector/backplane?ref=${var.hub.git_ref}" + + resource_prefix = var.connector_backplane_name + + aks = { + cluster_name = var.aks_cluster_name + resource_group_name = var.aks_resource_group_name + } + acr = { + location = var.azure_location + } +} + +module "github_connector" { + source = "github.com/meshcloud/meshstack-hub//modules/aks/github-connector?ref=${var.hub.git_ref}" + + github = { + repo_definition_uuid = module.github_repository.building_block_definition.uuid + org = var.github.org + app_id = var.github.app_id + app_installation_id = var.github.app_installation_id + app_pem_file = var.github.app_pem_file + } + aks = { + namespace_definition_uuid = module.aks_namespace.building_block_definition.uuid + connector_config_tf_base64 = base64encode(module.github_connector_backplane.config_tf) + } + notification_subscribers = var.notification_subscribers + + meshstack = var.meshstack + hub = var.hub +} + +module "starterkit" { + source = "github.com/meshcloud/meshstack-hub//modules/aks/starterkit?ref=${var.hub.git_ref}" + + building_block_definition_version_refs = { + "git-repository" = module.github_repository.building_block_definition.version_ref + "aks-namespace" = module.aks_namespace.building_block_definition.version_ref + "github-actions-connector" = module.github_connector.building_block_definition.version_ref + } + github_org = var.github.org + github_template_repo_path = var.github_template_repo_path + apps_base_domain = var.apps_base_domain + notification_subscribers = var.notification_subscribers + + meshstack = var.meshstack + hub = var.hub +} + +terraform { + required_version = ">= 1.12.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.26.2" + } + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.64.0" + } + } +} + +provider "azurerm" { + features {} +}