3.5. Container Registry

In this lab you will add an Azure Container Registry (ACR) to the Kubernetes component and grant the cluster’s kubelet identity permission to pull images from it.

Step 1 — Install the Random Provider

ACR names must be globally unique and contain only alphanumeric characters. To guarantee uniqueness without manual coordination, add the pulumi-random provider to requirements.txt:

pulumi-random==4.21.0

Then install it:

pip install -r requirements.txt

Explanation

pulumi-random wraps HashiCorp’s random provider and exposes resources such as RandomInteger and RandomString. Unlike Python’s random module, values are generated once and stored in the Pulumi state file — they remain stable across subsequent pulumi up runs. Every participant in the workshop generates a different random suffix, avoiding global naming conflicts in Azure.


Step 2 — Add add_registry to the Component

Add import pulumi_random at the top of kubernetes.py, then add the add_registry method after grant_role:

import pulumi_random
def add_registry(self, name: str) -> azure_native.containerregistry.Registry:
    rand = pulumi_random.RandomInteger(
        f"{name}-suffix",
        min=10000000,
        max=99999999,
        opts=self.child_opts
    )
    registry = azure_native.containerregistry.Registry(
        name,
        registry_name=rand.result.apply(lambda n: f"cr{n}"),
        resource_group_name=self.rg.name,
        sku=azure_native.containerregistry.SkuArgs(name="Basic"),
        admin_user_enabled=True,
        opts=self.child_opts
    )
    client = azure_native.authorization.get_client_config_output()
    kubelet_object_id = self.cluster.identity_profile.apply(
        lambda profile: profile["kubeletidentity"].object_id
    )
    azure_native.authorization.RoleAssignment(
        f"{name}-acrpull",
        scope=registry.id,
        principal_id=kubelet_object_id,
        principal_type="ServicePrincipal",
        role_definition_id=client.subscription_id.apply(
            lambda sub: (
                f"/subscriptions/{sub}/providers/Microsoft.Authorization"
                f"/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d"  # AcrPull
            )
        ),
        opts=self.child_opts
    )
    return registry

Explanation

RandomInteger with min=10000000, max=99999999 produces exactly 8 digits. Combined with the cr prefix the registry name is 10 characters — well within the 5–50 alphanumeric-only constraint ACR enforces.

PropertyValue
registry_nameGlobally unique, 5–50 alphanumeric characters, no hyphens
skuBasic for development; Standard/Premium for geo-replication
admin_user_enabledEnables password-based login for local docker push

The role assignment grants AcrPull to the AKS kubelet identity — the identity used by each node agent when pulling images at pod startup.

Kubelet identity vs cluster identity

AKS creates two managed identities:

IdentityUsed byRequired for
Cluster identityAKS control planeManaging Azure resources (LBs, disks)
Kubelet identityEach node (kubelet)Pulling images from ACR

identity_profile["kubeletidentity"].object_id is the kubelet identity. The AcrPull role assignment must target this identity — assigning it to the cluster identity alone will not allow nodes to pull images.

The role assignment is scoped to the ACR resource ID, not the subscription, following the same least-privilege principle used for the cluster admin role in the previous lab.


Step 3 — Wire up __main__.py

Call add_registry after the node pool loop and add an export:

registry = aks.add_registry("acr")

pulumi.export("registry_name", registry.name)

Explanation

"acr" is the logical Pulumi resource name used to track the registry in state. The physical ACR name (cr{random}) is generated internally by the component. Keeping the logical name short and stable means the resource identity in state does not change if the naming scheme is ever adjusted.


Step 4 — Deploy

The preview will show three new resources: a RandomInteger, the Registry, and a RoleAssignment. Confirm with yes to deploy.

pulumi up

Explanation

RandomInteger appears as a first-class resource in the Pulumi graph. Pulumi creates it on the first pulumi up, stores the result in state, and reuses the same value on every subsequent run — the registry name never changes after initial creation.


Step 5 — Verify

Confirm the registry was created and the kubelet identity holds the AcrPull role:

az acr show \
  --name $(pulumi stack output registry_name) \
  --query "{name:name, loginServer:loginServer, sku:sku.name}" \
  -o table

Confirm that the kubelet identity has the AcrPull role:

az role assignment list \
  --scope $(az acr show --name $(pulumi stack output registry_name) --query id -o tsv) \
  --query "[].{principal:principalName, role:roleDefinitionName}" \
  -o table

Explanation

The first command confirms the registry was created and shows its login server address (cr{random}.azurecr.io). The second lists role assignments on the ACR resource and should show the AKS kubelet managed identity holding AcrPull.


Complete files

requirements.txt

pulumi==3.244.0
pulumi-azure-native==3.19.0
pulumi-random==4.21.0

kubernetes.py

import pulumi
import pulumi_azure_native as azure_native
import pulumi_random


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_registry(self, name: str) -> azure_native.containerregistry.Registry:
        rand = pulumi_random.RandomInteger(
            f"{name}-suffix",
            min=10000000,
            max=99999999,
            opts=self.child_opts
        )
        registry = azure_native.containerregistry.Registry(
            name,
            registry_name=rand.result.apply(lambda n: f"cr{n}"),
            resource_group_name=self.rg.name,
            sku=azure_native.containerregistry.SkuArgs(name="Basic"),
            admin_user_enabled=True,
            opts=self.child_opts
        )
        client = azure_native.authorization.get_client_config_output()
        kubelet_object_id = self.cluster.identity_profile.apply(
            lambda profile: profile["kubeletidentity"].object_id
        )
        azure_native.authorization.RoleAssignment(
            f"{name}-acrpull",
            scope=registry.id,
            principal_id=kubelet_object_id,
            principal_type="ServicePrincipal",
            role_definition_id=client.subscription_id.apply(
                lambda sub: (
                    f"/subscriptions/{sub}/providers/Microsoft.Authorization"
                    f"/roleDefinitions/7f951dda-4ed3-4680-a7ca-43fe172d538d"  # AcrPull
                )
            ),
            opts=self.child_opts
        )
        return registry

    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)

    registry = aks.add_registry("acr")

    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)
    pulumi.export("registry_name", registry.name)


main()