2.7. Component Resources

Component resources let you group related Pulumi resources into a reusable Python class with the same interface as built-in providers.

Step 1 — Clean up previous stacks

Destroy any running resources and make sure dev is the active stack:

pulumi stack select dev
pulumi destroy

If a prod stack still exists, remove it too:

pulumi stack select prod
pulumi destroy
pulumi stack rm prod
pulumi stack select dev

Confirm the state is empty:

pulumi stack ls
pulumi stack

Explanation

This lab replaces __main__.py entirely. Starting with an empty dev state means pulumi up creates the new component tree from scratch, making the preview output easier to read and avoiding any confusion from resources left over from the previous labs.


Step 2 — Extract a WebServer component

Create a new file web_server.py in the project directory:

import pulumi
import pulumi_docker as docker


class WebServer(pulumi.ComponentResource):
    def __init__(self, name: str, image_tag: str, port: int, opts=None):
        super().__init__("workshop:index:WebServer", name, {}, opts)

        self.child_opts = pulumi.ResourceOptions(parent=self)

        image = docker.RemoteImage(
            f"{name}-image",
            name=f"nginxdemos/hello:{image_tag}",
            keep_locally=True,
            opts=self.child_opts
        )

        self.container = docker.Container(
            f"{name}-container",
            image=image.image_id,
            ports=[docker.ContainerPortArgs(internal=80, external=port)],
            opts=self.child_opts
        )

        self.url = self.container.name.apply(
            lambda n: f"http://localhost:{port}"
        )

        self.register_outputs({
            "url": self.url
        })

Explanation

pulumi.ComponentResource is the base class for all component resources. The constructor signature follows the same three-part pattern as built-in resources: logical name, inputs, and options.

The super().__init__() call registers the component in the Pulumi graph under the type workshop:index:WebServer — a namespaced type string in the form <package>:<module>:<class>.

pulumi.ResourceOptions(parent=self) wires every child resource to the component as its logical owner. This has two effects:

  • The children appear nested under the component in pulumi preview and pulumi up output
  • Destroying the component destroys all its children

self.register_outputs() declares the component’s public outputs. These appear on the component node in the preview tree and can be accessed by callers.


Step 3 — Deploy two instances

Replace __main__.py with the following:

import pulumi
from web_server import WebServer


def main():
    config = pulumi.Config()
    image_tag = config.require("image-tag")

    blue = WebServer("blue", image_tag=image_tag, port=8080)
    green = WebServer("green", image_tag=image_tag, port=8081)

    pulumi.export("blue_url", blue.url)
    pulumi.export("green_url", green.url)


main()

Run a preview to see the component tree, then deploy:

pulumi preview
pulumi up

Verify both servers are running:

curl http://localhost:8080
curl http://localhost:8081

Explanation

The calling code in __main__.py is now concise — two lines declare two fully independent nginx servers. The preview output shows the component hierarchy:

 +   pulumi:pulumi:Stack             pulumi-basics-dev  create
 +   ├─ workshop:index:WebServer     blue               create
 +   │  ├─ docker:index:RemoteImage  blue-image         create
 +   │  └─ docker:index:Container    blue-container     create
 +   └─ workshop:index:WebServer     green              create
 +      ├─ docker:index:RemoteImage  green-image        create
 +      └─ docker:index:Container    green-container    create

Each WebServer instance has its own isolated state. Changing port on green triggers a replace only for green-containerblue is not touched.

The same pattern scales to Azure

In the Azure labs, component resources are the natural abstraction for grouping related cloud resources — a KubernetesCluster component might bundle the ManagedCluster, the node pool, and the RoleAssignment into a single reusable unit. Same pattern, different provider.


Step 4 — Add an input with a default

Extend WebServer to accept an optional labels map and pass it to the container:

class WebServer(pulumi.ComponentResource):
    def __init__(
        self,
        name: str,
        image_tag: str,
        port: int,
        labels: dict | None = None,
        opts=None,
    ):
        super().__init__("workshop:index:WebServer", name, {}, opts)

        self.child_opts = pulumi.ResourceOptions(parent=self)
        effective_labels = labels or {}

        image = docker.RemoteImage(
            f"{name}-image",
            name=f"nginxdemos/hello:{image_tag}",
            keep_locally=True,
            opts=self.child_opts
        )

        self.container = docker.Container(
            f"{name}-container",
            image=image.image_id,
            ports=[docker.ContainerPortArgs(internal=80, external=port)],
            labels=[
                docker.ContainerLabelArgs(label=k, value=v)
                for k, v in effective_labels.items()
            ],
            opts=self.child_opts
        )

        self.url = self.container.name.apply(
            lambda n: f"http://localhost:{port}"
        )

        self.register_outputs({"url": self.url})

Pass labels from __main__.py:

    blue = WebServer("blue", image_tag=image_tag, port=8080, labels={"slot": "blue"})
    green = WebServer("green", image_tag=image_tag, port=8081, labels={"slot": "green"})

Apply:

pulumi up
docker inspect $(docker ps -qf "name=blue") --format '{{json .Config.Labels}}'

Explanation

Optional inputs follow the standard Python pattern — None default, guard with or {} before use. The component interface stays clean: callers that do not need labels simply omit the argument.

This is the core value proposition of component resources: a stable, typed interface with sensible defaults that hides provider-specific boilerplate. Teams can publish components as Python packages and consume them the same way as any Pulumi provider.