3.4. AKS Cluster

In this lab you will introduce a Pulumi ComponentResource that owns its own resource group and models the full AKS cluster lifecycle, add node pool configuration to the stack config, deploy a running Kubernetes cluster, and grant yourself admin access via a role assignment.

Step 1 — Create the Kubernetes Component

Create a new file kubernetes.py next to __main__.py:

import pulumi
import pulumi_azure_native as azure_native


class Kubernetes(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        subnet_id: pulumi.Input[str],
        system_pool: dict,
        kubernetes_version: str,
        availability_zones: list[str] = None,
        opts: pulumi.ResourceOptions = None
    ):
        super().__init__("workshop:azure:Kubernetes", name, {}, opts)
        self._subnet_id = subnet_id
        self._availability_zones = availability_zones
        self.child_opts = pulumi.ResourceOptions(parent=self)

        self.rg = azure_native.resources.ResourceGroup(
            name,
            resource_group_name=f"rg-{name}",
            opts=self.child_opts
        )

        self.cluster = azure_native.containerservice.ManagedCluster(
            name,
            resource_name_=name,
            resource_group_name=self.rg.name,
            dns_prefix=name,
            node_resource_group=f"rg-nodes-{name}",
            kubernetes_version=kubernetes_version,
            identity=azure_native.containerservice.ManagedClusterIdentityArgs(
                type=azure_native.containerservice.ResourceIdentityType.SYSTEM_ASSIGNED
            ),
            aad_profile=azure_native.containerservice.ManagedClusterAADProfileArgs(
                managed=True,
                enable_azure_rbac=True
            ),
            network_profile=azure_native.containerservice.ContainerServiceNetworkProfileArgs(
                network_plugin="azure"
            ),
            agent_pool_profiles=[
                azure_native.containerservice.ManagedClusterAgentPoolProfileArgs(
                    **system_pool,
                    vnet_subnet_id=subnet_id,
                    availability_zones=availability_zones
                )
            ],
            opts=self.child_opts
        )

    def add_node_pool(
        self,
        name: str,
        **kwargs
    ) -> azure_native.containerservice.AgentPool:
        return azure_native.containerservice.AgentPool(
            name,
            agent_pool_name=name,
            resource_group_name=self.rg.name,
            resource_name_=self.cluster.name,
            vnet_subnet_id=self._subnet_id,
            availability_zones=self._availability_zones,
            **kwargs,
            opts=self.child_opts
        )

Explanation

A ComponentResource is a Pulumi construct that groups related resources under a single logical node. It has two responsibilities:

ResponsibilityCode
Register the component typesuper().__init__("workshop:azure:Kubernetes", name, {}, opts)
Scope child resourcespulumi.ResourceOptions(parent=self) on every child resource

parent=self tells Pulumi that the ResourceGroup, ManagedCluster, and every AgentPool belong to this component. They appear indented underneath Kubernetes in pulumi up output, and are destroyed together when the component is destroyed.

The resource group is created inside the component — it is an implementation detail, not a caller concern. __main__.py just instantiates Kubernetes(f"aks-{suffix}") and the component owns the rest. Node pools are added via add_node_pool, keeping the cluster definition stable while pools can be added or changed independently.

node_resource_group explicitly names the resource group where AKS places node infrastructure (VMs, disks, NICs). Without it Azure generates a name like MC_rg-lab_aks-lab_westeurope, which is hard to predict. Pass it as a plain string, not a ResourceGroup Output — it is an instruction to AKS to use that name when it creates the group on your behalf. Declaring it as a Pulumi resource would cause a conflict because Azure would try to create the same group twice.

Three resource groups, three lifecycles

AKS uses two resource groups by design:

Resource groupContentsManaged by
rg-{suffix}VNet, subnet — shared networkingYou
rg-aks-{suffix}ManagedCluster resourceYou
rg-nodes-aks-{suffix}VMs, disks, NICs — node infrastructureAKS

The node resource group is owned and managed by AKS. Avoid modifying resources inside it directly — changes will be reconciled away by the control plane.

SystemAssigned identity

ResourceIdentityType.SYSTEM_ASSIGNED tells Azure to create and manage a service principal for the cluster automatically. AKS uses it to pull images from ACR, write to disk, and communicate with the Azure control plane — no manual credential rotation required.

Azure RBAC for Kubernetes

aad_profile with managed=True, enable_azure_rbac=True enables Azure RBAC on the cluster’s Kubernetes API. Role assignments on the cluster resource then control who can kubectl — no separate RoleBinding YAML required. Without this flag, role assignments are silently ignored by the Kubernetes API server.


Step 2 — Add a Static Egress IP

Extend the Kubernetes constructor in kubernetes.py to allocate a static public IP for AKS outbound traffic and wire it into the load balancer profile.

Add the IP resource after the resource group declaration:

self.egress_ip = azure_native.network.PublicIPAddress(
    f"egress-{name}",
    resource_group_name=self.rg.name,
    public_ip_address_name=f"pip-egress-{name}",
    sku=azure_native.network.PublicIPAddressSkuArgs(name="Standard"),
    public_ip_allocation_method="Static",
    opts=self.child_opts
)

Then update the network_profile inside ManagedCluster:

network_profile=azure_native.containerservice.ContainerServiceNetworkProfileArgs(
    network_plugin="azure",
    load_balancer_sku="standard",
    load_balancer_profile=azure_native.containerservice.ManagedClusterLoadBalancerProfileArgs(
        outbound_ips=azure_native.containerservice.ManagedClusterLoadBalancerProfileOutboundIPsArgs(
            public_ips=[azure_native.containerservice.ResourceReferenceArgs(
                id=self.egress_ip.id
            )]
        )
    )
),

Finally, after the cluster declaration, grant the cluster’s managed identity Network Contributor on the egress IP:

client = azure_native.authorization.get_client_config_output()
azure_native.authorization.RoleAssignment(
    f"{name}-egress-network-contributor",
    scope=self.egress_ip.id,
    principal_id=self.cluster.identity.principal_id,
    principal_type="ServicePrincipal",
    role_definition_id=client.subscription_id.apply(
        lambda sub: (
            f"/subscriptions/{sub}/providers/Microsoft.Authorization"
            f"/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7"  # Network Contributor
        )
    ),
    opts=self.child_opts
)

Explanation

By default AKS routes outbound traffic through a randomly-assigned Azure-managed public IP. That address can change if the cluster is recreated, making it impossible to reliably whitelist in database firewall rules, NSGs, or third-party allowlists. A static IP resolves this: the egress address is known in advance and stable across redeployments.

The IP is created in the cluster’s own resource group (self.rg). It cannot go in the node resource group because AKS creates that group during cluster provisioning — it does not exist yet when Pulumi evaluates the constructor.

load_balancer_sku="standard" is required. The Basic SKU does not support custom outbound IP assignment; Standard is the default for new clusters but must be stated explicitly when load_balancer_profile is used. outbound_ips tells AKS to route all egress through your IP instead of its automatically provisioned one.

The role assignment is necessary because the egress IP lives in rg-{name} (the cluster’s resource group), not in the node resource group where AKS’s managed identity already holds Network Contributor. Without it the cloud-controller-manager is denied Microsoft.Network/publicIPAddresses/join/action when it tries to attach the IP to the load balancer. Microsoft documents this requirement explicitly: “if you customized your outbound IP, make sure your cluster identity has permissions to both the outbound public IP and the inbound public IP.”

Assigning to self.egress_ip exposes the resource as a component property. Callers can read aks.egress_ip.ip_address to discover the address without reaching into Azure directly.


Step 3 — Add Node Pool Config

config/Pulumi.dev.yaml already exists with the provider keys from the previous lab. Append the aks block under the existing config: key:

config:
  # … existing provider keys …
  cloud-native-lab:aks:
    kubernetes_version: "1.34.7"
    availability_zones: ["1", "2", "3"]
    nodes:
      - name: system
        count: 1
        vm_size: Standard_B2s_v2
        os_type: Linux
        mode: System
      - name: applications
        count: 1
        vm_size: Standard_B2s_v2
        os_type: Linux
        mode: User

Explanation

Pulumi namespaces config keys under the project name (cloud-native-lab:aks) to avoid collisions when multiple projects share a stack. config.require_object("aks") strips the prefix and returns the value.

kubernetes_version pins the control-plane version explicitly. Without it, Azure selects a default version that can change between regions and over time, making cluster behaviour non-reproducible. Pinning ensures every pulumi up — regardless of who runs it or when — produces a cluster at the same version, and forces version upgrades to be a deliberate config change rather than a side-effect of redeployment.

availability_zones is a list of zone numbers ("1", "2", "3") applied to every node pool. When a pool has more nodes than zones, AKS distributes them evenly; with a single node AKS picks one zone from the list. Without this field, Azure chooses a zone implicitly — which zone is unspecified and may differ between deployments. Pinning zones makes placement reproducible and enables multi-zone clusters where workloads survive a zone failure.

The field sits at the aks level rather than inside each node entry because all pools in the same cluster must use the same zone set — splitting pools across different zone sets would prevent zone-aware scheduling from working correctly. Switzerland North supports zones 1, 2, and 3.

Moving pool parameters into config keeps the program code environment-agnostic. A staging stack can run a single-node pool; production can define multiple pools — without touching __main__.py.


Step 4 — Wire the Component into __main__.py

Add the import and the cluster block after the subnet declaration:

from kubernetes import Kubernetes

# after the subnet block …

aks_config = config.require_object("aks")
nodes = aks_config["nodes"]
kubernetes_version = aks_config["kubernetes_version"]
availability_zones = aks_config["availability_zones"]

aks = Kubernetes(
    f"aks-{suffix}",
    subnet_id=subnet.id,
    system_pool=nodes[0],
    kubernetes_version=kubernetes_version,
    availability_zones=availability_zones
)

for pool in nodes[1:]:
    aks.add_node_pool(**pool)

pulumi.export("cluster_name", aks.cluster.name)

Explanation

AKS requires at least one node pool at cluster creation time — it cannot be added after the fact via AgentPool. The first entry in nodes is therefore passed as agent_pool_profiles directly to ManagedCluster through the component’s **kwargs. Any additional entries (nodes[1:]) are attached afterwards via add_node_pool.

nodes[0] is passed as a whole dict to system_pool. Inside the component, **system_pool unpacks it so every key (name, count, vm_size, os_type, mode) is forwarded to ManagedClusterAgentPoolProfileArgs. vnet_subnet_id is injected separately inside the component because it is a cross-resource Output, not a static config value.

mode="System" marks this pool as the system pool. AKS requires exactly one system pool; it runs core cluster components such as CoreDNS and the metrics server. User workloads should run on separate user pools to keep system services isolated.


Step 5 — Deploy

The preview will show four new resources: the AKS resource group, the Kubernetes component, the ManagedCluster, and the AgentPool. Confirm with yes to deploy.

pulumi up

Provisioning an AKS cluster takes several minutes. Once complete, verify with the Azure CLI:

CLUSTER=$(pulumi stack output cluster_name)
az aks show \
  --name $CLUSTER \
  --resource-group rg-$CLUSTER \
  --query "{name:name, provisioningState:provisioningState, kubernetesVersion:kubernetesVersion}" \
  -o table

Explanation

CLUSTER=$(pulumi stack output cluster_name) captures the deployed cluster name into a shell variable. The component names the resource group rg-{cluster_name}, so the same variable covers both arguments without any manual lookup.


Step 6 — Grant Cluster Admin Access

Add grant_role to the Kubernetes component in kubernetes.py:

def grant_role(
    self,
    role_definition_id: str,
    principal_id: str | pulumi.Output[str]
) -> azure_native.authorization.RoleAssignment:
    client = azure_native.authorization.get_client_config_output()
    return azure_native.authorization.RoleAssignment(
        f"role-{role_definition_id}",
        scope=self.cluster.id,
        principal_id=principal_id,
        role_definition_id=client.subscription_id.apply(
            lambda sub: (
                f"/subscriptions/{sub}/providers/Microsoft.Authorization"
                f"/roleDefinitions/{role_definition_id}"
            )
        ),
        opts=self.child_opts
    )

Then call it from __main__.py:

client = azure_native.authorization.get_client_config_output()
aks.grant_role(
    role_definition_id="b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b",  # Azure Kubernetes Service RBAC Cluster Admin
    principal_id=client.object_id
)

Then run:

pulumi up

Explanation

grant_role takes a role definition UUID and a principal ID, then creates a RoleAssignment scoped to the cluster. get_client_config_output() is called internally to retrieve the subscription ID needed to build the full role definition resource path the Azure API requires — the caller only needs to know the UUID.

get_client_config_output() in __main__.py returns the object ID of the currently authenticated principal — the user who ran az login. Passing it to grant_role grants you Azure Kubernetes Service RBAC Cluster Admin access on this cluster only.

Azure built-in role GUIDs are globally stable

Built-in role IDs are defined by Microsoft and are identical in every Azure tenant and subscription worldwide — they never change between environments. The UUID passed to grant_role is always the same regardless of who deploys or where. It is safe and intentional to hardcode them. The authoritative list is at Built-in roles for Azure RBAC.

Role scope: cluster, not subscription

The role assignment is scoped to the cluster resource ID, not the subscription. This follows the principle of least privilege: your identity gets kubectl admin access on this cluster only, not on every cluster in the subscription.


Step 7 — Verify Cluster Access

Fetch credentials using the Azure CLI:

CLUSTER=$(pulumi stack output cluster_name)
az aks get-credentials \
  --name $CLUSTER \
  --resource-group rg-$CLUSTER \
  --overwrite-existing

Then verify that the role assignment grants access to the Kubernetes API:

kubectl get pods -A

Explanation

az aks get-credentials writes a kubeconfig context to ~/.kube/config. Because Azure RBAC is enabled on the cluster, the Kubernetes API authenticates your request using your Entra ID identity — the same one the role assignment was granted to. kubectl get pods -A lists all pods across all namespaces; a successful response confirms both connectivity and authorization.

kubectl fails with an authentication error?

Some environments require kubelogin to perform the Entra ID token exchange. If kubectl get pods returns an authentication error, install it and convert the kubeconfig:

az aks install-cli
kubelogin convert-kubeconfig -l azurecli
–overwrite-existing

If a context for this cluster already exists in ~/.kube/config from a previous run, --overwrite-existing replaces it silently instead of prompting.


Complete files

kubernetes.py

import pulumi
import pulumi_azure_native as azure_native


class Kubernetes(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        subnet_id: pulumi.Input[str],
        system_pool: dict,
        kubernetes_version: str,
        availability_zones: list[str] = None,
        opts: pulumi.ResourceOptions = None
    ):
        super().__init__("workshop:azure:Kubernetes", name, {}, opts)
        self._subnet_id = subnet_id
        self._availability_zones = availability_zones
        self.child_opts = pulumi.ResourceOptions(parent=self)

        self.rg = azure_native.resources.ResourceGroup(
            name,
            resource_group_name=f"rg-{name}",
            opts=self.child_opts
        )

        self.egress_ip = azure_native.network.PublicIPAddress(
            f"egress-{name}",
            resource_group_name=self.rg.name,
            public_ip_address_name=f"pip-egress-{name}",
            sku=azure_native.network.PublicIPAddressSkuArgs(name="Standard"),
            public_ip_allocation_method="Static",
            opts=self.child_opts
        )

        self.cluster = azure_native.containerservice.ManagedCluster(
            name,
            resource_name_=name,
            resource_group_name=self.rg.name,
            dns_prefix=name,
            node_resource_group=f"rg-nodes-{name}",
            kubernetes_version=kubernetes_version,
            identity=azure_native.containerservice.ManagedClusterIdentityArgs(
                type=azure_native.containerservice.ResourceIdentityType.SYSTEM_ASSIGNED
            ),
            aad_profile=azure_native.containerservice.ManagedClusterAADProfileArgs(
                managed=True,
                enable_azure_rbac=True
            ),
            network_profile=azure_native.containerservice.ContainerServiceNetworkProfileArgs(
                network_plugin="azure",
                load_balancer_sku="standard",
                load_balancer_profile=azure_native.containerservice.ManagedClusterLoadBalancerProfileArgs(
                    outbound_ips=azure_native.containerservice.ManagedClusterLoadBalancerProfileOutboundIPsArgs(
                        public_ips=[azure_native.containerservice.ResourceReferenceArgs(
                            id=self.egress_ip.id
                        )]
                    )
                )
            ),
            agent_pool_profiles=[
                azure_native.containerservice.ManagedClusterAgentPoolProfileArgs(
                    **system_pool,
                    vnet_subnet_id=subnet_id,
                    availability_zones=availability_zones
                )
            ],
            opts=self.child_opts
        )

        client = azure_native.authorization.get_client_config_output()
        azure_native.authorization.RoleAssignment(
            f"{name}-egress-network-contributor",
            scope=self.egress_ip.id,
            principal_id=self.cluster.identity.principal_id,
            principal_type="ServicePrincipal",
            role_definition_id=client.subscription_id.apply(
                lambda sub: (
                    f"/subscriptions/{sub}/providers/Microsoft.Authorization"
                    f"/roleDefinitions/4d97b98b-1d4f-4787-a291-c67834d212e7"  # Network Contributor
                )
            ),
            opts=self.child_opts
        )

    def grant_role(
        self,
        role_definition_id: str,
        principal_id: str | pulumi.Output[str]
    ) -> azure_native.authorization.RoleAssignment:
        client = azure_native.authorization.get_client_config_output()
        return azure_native.authorization.RoleAssignment(
            f"role-{role_definition_id}",
            scope=self.cluster.id,
            principal_id=principal_id,
            role_definition_id=client.subscription_id.apply(
                lambda sub: (
                    f"/subscriptions/{sub}/providers/Microsoft.Authorization"
                    f"/roleDefinitions/{role_definition_id}"
                )
            ),
            opts=self.child_opts
        )

    def add_node_pool(
        self,
        name: str,
        **kwargs
    ) -> azure_native.containerservice.AgentPool:
        return azure_native.containerservice.AgentPool(
            name,
            agent_pool_name=name,
            resource_group_name=self.rg.name,
            resource_name_=self.cluster.name,
            vnet_subnet_id=self._subnet_id,
            availability_zones=self._availability_zones,
            **kwargs,
            opts=self.child_opts
        )

__main__.py

import pulumi
import pulumi_azure_native as azure_native

from kubernetes import Kubernetes


def main():
    config = pulumi.Config()
    username = config.require("username")
    suffix = f"lab-{username}-{pulumi.get_stack()}"

    rg = azure_native.resources.ResourceGroup(
        f"default-{suffix}",
        resource_group_name=f"rg-{suffix}"
    )

    vnet = azure_native.network.VirtualNetwork(
        f"default-{suffix}",
        virtual_network_name=f"vnet-{suffix}",
        resource_group_name=rg.name,
        address_space=azure_native.network.AddressSpaceArgs(
            address_prefixes=["10.0.0.0/8"]
        )
    )

    subnet = azure_native.network.Subnet(
        f"aks-nodes-{suffix}",
        subnet_name=f"aks-nodes-{suffix}",
        resource_group_name=rg.name,
        virtual_network_name=vnet.name,
        address_prefix="10.240.0.0/16"
    )

    aks_config = config.require_object("aks")
    nodes = aks_config["nodes"]
    kubernetes_version = aks_config["kubernetes_version"]
    availability_zones = aks_config["availability_zones"]

    aks = Kubernetes(
        f"aks-{suffix}",
        subnet_id=subnet.id,
        system_pool=nodes[0],
        kubernetes_version=kubernetes_version,
        availability_zones=availability_zones
    )

    for pool in nodes[1:]:
        aks.add_node_pool(**pool)

    client = azure_native.authorization.get_client_config_output()
    aks.grant_role(
        role_definition_id="b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b",  # Azure Kubernetes Service RBAC Cluster Admin
        principal_id=client.object_id
    )

    pulumi.export("cluster_name", aks.cluster.name)


main()

config/Pulumi.dev.yaml (aks block, nested under config:)

config:
  # … existing provider keys …
  cloud-native-lab:aks:
    kubernetes_version: "1.34.7"
    availability_zones: ["1", "2", "3"]
    nodes:
      - name: system
        count: 1
        vm_size: Standard_B2s_v2
        os_type: Linux
        mode: System
      - name: applications
        count: 1
        vm_size: Standard_B2s_v2
        os_type: Linux
        mode: User