4.1. Kubernetes Provider Setup

In this lab you extend the Kubernetes component with a Pulumi Kubernetes provider and an install_helm_chart method, then verify connectivity to the cluster.

Step 1 — Add the Kubernetes Provider Package

Add pulumi-kubernetes to requirements.txt:

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

Install it:

pip install -r requirements.txt

Explanation

pulumi-kubernetes is the Pulumi provider for Kubernetes. It can manage any Kubernetes resource (Deployments, Services, ConfigMaps, …) and includes a helm.v3.Release resource that wraps helm install / helm upgrade. Like all Pulumi providers it tracks deployed state and reconciles on every pulumi up.


Step 2 — Wire Up the Provider in the Kubernetes Component

Open kubernetes.py. Add import base64 and import pulumi_kubernetes as k8s at the top, then add the following block at the end of __init__, after the cluster is created:

creds = azure_native.containerservice.list_managed_cluster_admin_credentials_output(
    resource_group_name=self.rg.name,
    resource_name=self.cluster.name,
    opts=pulumi.InvokeOptions(parent=self)
)
self.kubeconfig = creds.kubeconfigs[0].value.apply(
    lambda v: base64.b64decode(v).decode()
)
self._k8s_provider = k8s.Provider(
    f"{name}-k8s",
    kubeconfig=self.kubeconfig,
    opts=self.child_opts
)

Then add the install_helm_chart method to the class:

def install_helm_chart(
    self,
    name: str,
    chart: str,
    repo: str,
    namespace: str,
    values: dict = None,
    create_namespace: bool = True,
    version: str = None,
    timeout: int = None
) -> k8s.helm.v3.Release:
    args = k8s.helm.v3.ReleaseArgs(
        name=name,
        chart=chart,
        repository_opts=k8s.helm.v3.RepositoryOptsArgs(repo=repo),
        version=version,
        namespace=namespace,
        create_namespace=create_namespace,
        values=values or {},
        timeout=timeout
    )
    return k8s.helm.v3.Release(
        name,
        args,
        opts=pulumi.ResourceOptions.merge(
            self.child_opts,
            pulumi.ResourceOptions(provider=self._k8s_provider)
        )
    )

Explanation

list_managed_cluster_admin_credentials_output calls the Azure API to fetch the AKS admin kubeconfig. The credential value is base64-encoded, so it needs to be decoded before passing it to the Kubernetes provider.

self.kubeconfig is a Pulumi Output[str] — its value is not available in Python at program construction time; Pulumi resolves it during the deploy phase when the cluster is ready.

self._k8s_provider is stored on the component so every call to install_helm_chart uses the same provider instance, pointing at the same cluster. Passing it explicitly via ResourceOptions(provider=...) is important — without it Pulumi falls back to a default provider that reads ~/.kube/config, which may point at a different cluster.

Admin vs user credentials

Admin credentials bypass Azure AD RBAC and work immediately after the cluster is provisioned — no role propagation delay. For this workshop that is the right trade-off. In production you would use user credentials combined with a managed identity and workload identity federation.


Step 3 — Export the Kubeconfig

Add the kubeconfig export to __main__.py alongside the other exports:

pulumi.export("kubeconfig", pulumi.Output.secret(aks.kubeconfig))

Explanation

pulumi.Output.secret(...) marks the value as sensitive — Pulumi redacts it in preview and pulumi up output and encrypts it in state. You can still retrieve it explicitly:

pulumi stack output kubeconfig --show-secrets

Step 4 — Deploy and Verify

Apply the changes:

pulumi up

The new k8s.Provider resource will appear in the diff. After the deploy completes, save the kubeconfig and test cluster access:

pulumi stack output kubeconfig --show-secrets > kubeconfig.yaml
export KUBECONFIG=$PWD/kubeconfig.yaml
kubectl get nodes

You should see the AKS node(s) in Ready state.

Explanation

The kubeconfig.yaml file grants cluster-admin access — treat it like a password. Do not commit it to version control. Add it to .gitignore if needed.


Complete files

requirements.txt

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

kubernetes.py

import base64

import pulumi
import pulumi_azure_native as azure_native
import pulumi_kubernetes as k8s
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
        )

        creds = azure_native.containerservice.list_managed_cluster_admin_credentials_output(
            resource_group_name=self.rg.name,
            resource_name=self.cluster.name,
            opts=pulumi.InvokeOptions(parent=self)
        )
        self.kubeconfig = creds.kubeconfigs[0].value.apply(
            lambda v: base64.b64decode(v).decode()
        )
        self._k8s_provider = k8s.Provider(
            f"{name}-k8s",
            kubeconfig=self.kubeconfig,
            opts=self.child_opts
        )

    def install_helm_chart(
        self,
        name: str,
        chart: str,
        repo: str,
        namespace: str,
        values: dict = None,
        create_namespace: bool = True,
        version: str = None,
        timeout: int = None
    ) -> k8s.helm.v3.Release:
        args = k8s.helm.v3.ReleaseArgs(
            name=name,
            chart=chart,
            repository_opts=k8s.helm.v3.RepositoryOptsArgs(repo=repo),
            version=version,
            namespace=namespace,
            create_namespace=create_namespace,
            values=values or {},
            timeout=timeout
        )
        return k8s.helm.v3.Release(
            name,
            args,
            opts=pulumi.ResourceOptions.merge(
                self.child_opts,
                pulumi.ResourceOptions(provider=self._k8s_provider)
            )
        )

    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_public_ip(self, name: str) -> azure_native.network.PublicIPAddress:
        return azure_native.network.PublicIPAddress(
            name,
            resource_group_name=self.cluster.node_resource_group,
            public_ip_address_name=f"pip-{name}",
            sku=azure_native.network.PublicIPAddressSkuArgs(name="Standard"),
            public_ip_allocation_method="Static",
            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 (exports section)

pulumi.export("cluster_name", aks.cluster.name)
pulumi.export("registry_name", registry.name)
pulumi.export("public_ip", public_ip.ip_address)
pulumi.export("dns_zone", dns.zone.name)
pulumi.export("kubeconfig", pulumi.Output.secret(aks.kubeconfig))