3.6. Static IP and DNS

In this lab you will provision a static public IP for the ingress controller, create a personal DNS zone under labs.netrics.dev, and delegate it from the shared parent zone using a secondary Pulumi provider targeting a different Azure subscription.

Step 1 — Create the DnsZone Component

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

import pulumi
import pulumi_azure_native as azure_native


class DnsZone(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        zone_name: str,
        opts: pulumi.ResourceOptions = None,
    ):
        super().__init__("workshop:azure:DnsZone", name, {}, opts)
        self.child_opts = pulumi.ResourceOptions(parent=self)

        self._zone_name = zone_name
        self._subdomain = zone_name.split(".")[0]

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

        self.zone = azure_native.dns.Zone(
            self._zone_name,
            resource_group_name=self._rg.name,
            zone_name=self._zone_name,
            zone_type=azure_native.dns.ZoneType.PUBLIC,
            opts=self.child_opts
        )

    def delegate_from_zone(
        self,
        parent_zone: pulumi.Output[azure_native.dns.GetZoneResult],
        dns_provider: pulumi.ProviderResource,
    ) -> azure_native.dns.RecordSet:
        parent_rg = parent_zone.id.apply(lambda resource_id: resource_id.split("/")[4])
        ns_records = self.zone.name_servers.apply(
            lambda servers: [
                azure_native.dns.NsRecordArgs(nsdname=ns) for ns in servers
            ]
        )
        return azure_native.dns.RecordSet(
            self._zone_name,
            resource_group_name=parent_rg,
            zone_name=parent_zone.name,
            relative_record_set_name=self._subdomain,
            record_type="NS",
            ttl=3600,
            ns_records=ns_records,
            opts=pulumi.ResourceOptions(
                parent=self,
                provider=dns_provider
            )
        )

    def create_a_record(
        self,
        name: str,
        ip_address: pulumi.Input,
    ) -> azure_native.dns.RecordSet:
        return azure_native.dns.RecordSet(
            f"{name}.{self._zone_name}",
            resource_group_name=self._rg.name,
            zone_name=self.zone.name,
            relative_record_set_name=name,
            record_type="A",
            ttl=300,
            a_records=[azure_native.dns.ARecordArgs(ipv4_address=ip_address)],
            opts=self.child_opts
        )

Explanation

DnsZone owns a resource group and an Azure DNS zone. The zone_name is passed in by the caller — the component is agnostic about the zone hierarchy it belongs to. self._subdomain is derived by splitting on . to get the first label (e.g. angehrig), which is used as the relative record name when delegating from the parent zone.

The component exposes two methods:

MethodWhat it does
delegate_from_zone(…)Adds an NS record in the parent zone pointing to this zone’s servers
create_a_record(…)Adds an A record inside this zone

delegate_from_zone reads self.zone.name_servers — an Output[List[str]] — and wraps each nameserver string into NsRecordArgs(nsdname=…) via .apply(). The transform is required because ns_records expects List[NsRecordArgs]; plain strings are not accepted.

create_a_record passes zone_name=self.zone.name — the name Output of the zone resource — rather than the equivalent plain string. Pulumi uses the Output dependency graph, not string equality, to sequence operations. Passing a plain string would give Pulumi no signal to wait for the zone to exist first, causing the record set creation to fail with a ParentResourceNotFound error.

The parent zone’s resource group is not returned by Azure’s DNS API. It is derived from the ARM resource ID using resource_id.split("/")[4]. ARM IDs always follow the pattern /subscriptions/{sub}/resourceGroups/{rg}/providers/…, so index 4 is always the resource group name.


Step 2 — Add the DNS Subscription Config Key

Add dnsSubscriptionId to config/Pulumi.dev.yaml:

cloud-native-lab:dnsSubscriptionId: 3162c0c7-8439-4461-9552-66ffff2bb299

Explanation

dnsSubscriptionId identifies the Azure subscription where the shared labs.netrics.dev zone lives. Keeping it in config rather than hardcoding it lets different workshop environments point at different parent zones without touching program code.


Step 3 — Add add_public_ip to the Kubernetes Component

Open kubernetes.py and add the following method after add_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
    )

Then call it from __main__.py after the registry and role assignment blocks:

public_ip = aks.add_public_ip(f"ingress-{suffix}")

Explanation

A pre-provisioned static IP lets you set the DNS A record before the ingress controller is deployed. When Traefik is installed in a later lab, you point it at this IP and the hostname is already resolving.

PropertyValueReason
sku.nameStandardRequired for AKS load balancers and availability zone support
public_ip_allocation_methodStaticIP is assigned immediately at creation and never changes

The IP is placed in self.cluster.node_resource_group — the resource group AKS creates and manages for its data-plane resources (nodes, disks, load balancers). This is the Azure-recommended location for static public IPs used by AKS load balancers: the cloud-controller-manager already has full permissions there, so no extra role assignment is needed and there is no IAM propagation delay. Using self.cluster.node_resource_group as the resource_group_name input also makes the PIP implicitly depend on the cluster, so Pulumi waits for the node resource group to exist before creating the IP.


Step 4 — Initialize the DNS Provider

Add the following to __main__.py after the static IP:

dns_subscription_id = config.require("dnsSubscriptionId")
dns_provider = azure_native.Provider(
    "dns-provider",
    subscription_id=dns_subscription_id
)

parent_zone = azure_native.dns.get_zone_output(
    resource_group_name="rg-dns",
    zone_name="labs.netrics.dev",
    opts=pulumi.InvokeOptions(provider=dns_provider)
)

Explanation

By default every azure_native resource uses the provider configured via azure-native:subscriptionId in the stack config — your own lab subscription. The shared labs.netrics.dev zone lives in a different subscription, so resources that touch it need a second provider instance explicitly bound to that subscription ID.

azure_native.Provider(…, subscription_id=…) creates a named provider. Resources and lookups opt into it individually via opts=ResourceOptions(provider=dns_provider) or opts=InvokeOptions(provider=dns_provider). Resources that do not set provider= continue to use your lab subscription.

get_zone_output is a read-only data source — it looks up an existing Azure resource without creating or owning it. Passing provider=dns_provider ensures the API call is authenticated against the DNS subscription. The returned Output[GetZoneResult] is passed directly into delegate_from_zone, which reads .name for the zone name and parses the resource group from .id.

One deployment, two subscriptions

A single pulumi up can manage resources across multiple Azure subscriptions simultaneously. Each provider instance holds its own credentials and subscription context. Pulumi resolves which provider to use for each resource at graph execution time based on the provider= option — no separate stacks or deployments are needed.


Step 5 — Wire the DNS Zone

Add the import at the top of __main__.py, then append the DnsZone component, delegation, A record, and exports:

from dns_zone import DnsZone
dns = DnsZone(
    f"dns-{suffix}",
    zone_name=f"{username}.labs.netrics.dev"
)

dns.delegate_from_zone(parent_zone, dns_provider)
dns.create_a_record("*", public_ip.ip_address)

pulumi.export("public_ip", public_ip.ip_address)
pulumi.export("dns_zone", dns.zone.name)

Explanation

zone_name=f"{username}.labs.netrics.dev" is the only caller-specific detail — the component is agnostic about the zone hierarchy.

delegate_from_zone creates an NS record for {username} in labs.netrics.dev pointing to the nameservers of the newly created child zone. After this record propagates, queries for anything under {username}.labs.netrics.dev are forwarded by Azure DNS to your zone.

delegate_from_zone receives dns_provider explicitly so Pulumi authenticates against the shared subscription when writing the NS record — without it the call would fail because the parent zone is not in your lab subscription.

create_a_record("*", …) creates a wildcard A record. Any hostname under the zone — app.{username}.labs.netrics.dev, grafana.{username}.labs.netrics.dev — resolves to the ingress IP without a separate DNS record per application.


Step 6 — Deploy

pulumi up

The preview will show: a PublicIPAddress, a dns-provider provider resource, the DnsZone component containing a resource group and a DNS zone, an NS RecordSet in the parent zone (created via dns_provider), and a wildcard A RecordSet in the child zone. Confirm with yes.

Explanation

The NS delegation record is written to the shared subscription using dns_provider, while the DNS zone, A record, and public IP are created in your lab subscription using the default provider. Pulumi executes both sets of API calls in the same graph traversal.


Step 7 — Verify

Confirm the public IP was allocated:

pulumi stack output public_ip

Check that the NS delegation was written to the parent zone:

az network dns record-set ns show \
  --resource-group rg-dns \
  --zone-name labs.netrics.dev \
  --name $(pulumi config get username) \
  --subscription $(pulumi config get dnsSubscriptionId) \
  --query "nsRecords[].nsdname" \
  -o table

Verify the wildcard A record resolves:

dig "test.$(pulumi stack output dns_zone)" @8.8.8.8 +short

Explanation

The az command queries the parent zone in the shared subscription directly, bypassing any local DNS caching. The dig command queries Google’s public resolver; a response matching the public IP confirms that both the NS delegation and the wildcard A record are in place and globally visible.


Complete files

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_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
        )

dns_zone.py

import pulumi
import pulumi_azure_native as azure_native


class DnsZone(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        zone_name: str,
        opts: pulumi.ResourceOptions = None,
    ):
        super().__init__("workshop:azure:DnsZone", name, {}, opts)
        self.child_opts = pulumi.ResourceOptions(parent=self)

        self._zone_name = zone_name
        self._subdomain = zone_name.split(".")[0]

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

        self.zone = azure_native.dns.Zone(
            self._zone_name,
            resource_group_name=self._rg.name,
            zone_name=self._zone_name,
            zone_type=azure_native.dns.ZoneType.PUBLIC,
            opts=self.child_opts
        )

    def delegate_from_zone(
        self,
        parent_zone: pulumi.Output[azure_native.dns.GetZoneResult],
        dns_provider: pulumi.ProviderResource,
    ) -> azure_native.dns.RecordSet:
        parent_rg = parent_zone.id.apply(lambda resource_id: resource_id.split("/")[4])
        ns_records = self.zone.name_servers.apply(
            lambda servers: [
                azure_native.dns.NsRecordArgs(nsdname=ns) for ns in servers
            ]
        )
        return azure_native.dns.RecordSet(
            self._zone_name,
            resource_group_name=parent_rg,
            zone_name=parent_zone.name,
            relative_record_set_name=self._subdomain,
            record_type="NS",
            ttl=3600,
            ns_records=ns_records,
            opts=pulumi.ResourceOptions(
                parent=self,
                provider=dns_provider
            )
        )

    def create_a_record(
        self,
        name: str,
        ip_address: pulumi.Input,
    ) -> azure_native.dns.RecordSet:
        return azure_native.dns.RecordSet(
            f"{name}.{self._zone_name}",
            resource_group_name=self._rg.name,
            zone_name=self.zone.name,
            relative_record_set_name=name,
            record_type="A",
            ttl=300,
            a_records=[azure_native.dns.ARecordArgs(ipv4_address=ip_address)],
            opts=self.child_opts
        )

__main__.py

import pulumi
import pulumi_azure_native as azure_native

from dns_zone import DnsZone
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
    )

    public_ip = aks.add_public_ip(f"ingress-{suffix}")

    dns_subscription_id = config.require("dnsSubscriptionId")
    dns_provider = azure_native.Provider(
        "dns-provider",
        subscription_id=dns_subscription_id
    )

    parent_zone = azure_native.dns.get_zone_output(
        resource_group_name="rg-dns",
        zone_name="labs.netrics.dev",
        opts=pulumi.InvokeOptions(provider=dns_provider)
    )

    dns = DnsZone(
        f"dns-{suffix}",
        zone_name=f"{username}.labs.netrics.dev"
    )

    dns.delegate_from_zone(parent_zone, dns_provider)
    dns.create_a_record("*", public_ip.ip_address)

    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)


main()

config/Pulumi.dev.yaml (new key only)

cloud-native-lab:dnsSubscriptionId: 3162c0c7-8439-4461-9552-66ffff2bb299