A component resource is essentially a bundling of a number of resources. For example, when I want to provision a server I also need to assign it IPv4 and IPv6 addresses. I can do this independently, or I can create a custom Server component that does these things for me. This lets me encapsulate the way I work with infrastructure in my code and I can test the behaviour of a component.
Despite the fact that Component Resources have been around for at least 2-3 years (best I can tell) and that there’s even support for multi-language component resources (maybe?), the documentation itself is rather underwhelming and the examples provided very bare bones. There’s some real world examples out there but most of them are either for Typescript or Python which doesn’t help me much. Thankfully the workshop repository gave me a good and practical example that I could build on.
This blog post distills what I’ve discovered building my own component resource. It’s important to note that things in this blog post may be wrong as I might have misunderstood the code, examples and intent. Pulumi is also evolving at a fairly rapid pace so even if my understanding is correct at the time of writing, it might not be tomorrow or six months from now.
All examples here assume usage of the Pulumi Go SDK version 3. Where necessary, the examples use the Hetzner Cloud provider. There is nothing specific about this to Hetzner Cloud and everything in this post applies to wrapping resources from any or multiple providers.
Basics
At its core you need a few things:
- A struct encapsulating your component and storing the resources we create through it
- An args struct representing inputs to your component, if any
Your component also needs a constructor, something like NewXXX
. Within the
constructor we’ll create all our child resources. We also need to call two
special functions within our constructor:
ctx.RegisterComponentResource()
to register our resource with Pulumictx.RegisterResourceOutputs()
to register any outputs. You should always call this method at the end of your constructor, even if you don’t want to register any outputs. It signals to Pulumi that we’re done with this component
A basic skeleton would thus be:
type Server struct {
pulumi.ResourceState
... more things here later ...
}
type ServerArgs struct {
... more things here later ...
}
func NewServer(ctx *pulumi.Context, name string, args *ServerArgs, opts ...pulumi.ResourceOption) (*Server, error) {
server := &Server{}
if err := ctx.RegisterComponentResource("infra:internal:Server", name, server, opts...); err != nil {
return nil, err
}
... more things here later ...
ctx.RegisterResourceOutputs(server, pulumi.Map{})
return server, nil
}
The infra:internal:Server
we pass into RegisterComponentResource
is the
convetion explained in the Component Resource documentation. For Go it’s
roughly module:import:path:StructName
. Since I have my Server
component in
the internal package and my project is called infra
I went with
infa:internal:Server
. The module:
might seem redundant but remember that
component resources may be shared so they might be called from a different
module entirely.
ctx.RegisterResourceOutputs()
adds any outputs to your stack. You could
return the IP address and FQDN for a machine as outputs or anything else a
different resource could use as an input.
Taking inputs
The ServerArgs
is how we take inputs. You can add fields to it with regular
Go types and access them as args.XXX
in your constructor. There’s no need
for any special Pulumi types or the ServerArgs
and serverArgs
mirroring
you see in provider code.
Fields can be the type or the pointer to a type, depending on whether you want
to be able to distinguish between a value having been explicitly set or it
being the zero value of a type. Since getting a pointer to a primitive type
like int
can be annoying, here’s a generics snippet to help with that:
func Ptr[T any](p T) *T {
return &p
}
You can now easily do Ptr(1)
to get a pointer to an int
with value 1
. The
Go compiler is able to infer the type, so you don’t have to Ptr[int](1)
. It’s
a small but useful thing and ironically my number 1 use case of generics in Go.
If we now wanted to pass a hostname into our NewServer()
we could go about it
as follows:
- Update the
ServerArgs
struct with aHostname
of typestring
- Access
args.Hostname
in our constructor and do something with it
type ServerArgs struct {
Hostname string
}
func NewServer(ctx *pulumi.Context, name string, args *ServerArgs, opts ...pulumi.ResourceOption) (*Server, error) {
server := &Server{}
if err := ctx.RegisterComponentResource("infra:internal:Server", name, server, opts...); err != nil {
return nil, err
}
fmt.Println(args.Hostname)
ctx.RegisterResourceOutputs(server, pulumi.Map{})
return server, nil
}
This is also the point where you can do any defaulting and validation of the
values set on args
. It might be useful to have a helper like
func validateServerArgs(args *ServerArgs)
to do these kinds of things for
you.
Creating a resource and storing it
Lets say that for our server we always attach an IPv4 and IPv6 address. We don’t care about explicitly controlling the IP itself, just that each box has one making it reachable over both protocols. We need to create those resources and store them on our component.
First, lets add a spot on our Server
struct to store this object:
type Server struct {
pulumi.ResourceState
ipv4 *hcloud.PrimaryIp
ipv6 *hcloud.PrimaryIp
}
Now lets update the constructor to create this child resource of our component and store it:
func NewServer(ctx *pulumi.Context, name string, args *ServerArgs, opts ...pulumi.ResourceOption) (*Server, error) {
server := &Server{}
if err := ctx.RegisterComponentResource("infra:internal:Server", name, server, opts...); err != nil {
return nil, err
}
server.ipv4, err = hcloud.NewPrimaryIp(ctx, "ipv4", &hcloud.PrimaryIpArgs{
Datacenter: pulumi.String("a-datacenter"),
Type: pulumi.String("ipv4"),
AssigneeType: pulumi.String("server"),
},
pulumi.ResourceOption(pulumi.Parent(server)),
)
if err != nil {
return nil, err
}
server.ipv6, err = hcloud.NewPrimaryIp(ctx, "ipv6", &hcloud.PrimaryIpArgs{
Datacenter: pulumi.String("a-datacenter"),
Type: pulumi.String("ipv6"),
AssigneeType: pulumi.String("server"),
},
pulumi.ResourceOption(pulumi.Parent(server)),
)
if err != nil {
return nil, err
}
ctx.RegisterResourceOutputs(server, pulumi.Map{})
return server, nil
}
Most of this code should be straightforward except for the
pulumi.ResourceOption()
call. This is necessary in order to attach the child
resource to its parent, our component. There’s nothing much to it, but you have
to remember to do it.
Other than that, the result of hcloud.NewPrimaryIp()
is now stored on our
Server instance and we can access it later. For example we can use it to access
the IpAddress
property to find out what the assigned IP is.
Providing outputs
Outputs are attributes that a user may wish to access as a result of a resource being created. For example, the IPv4 and IPv6 addresses we may create as part of our Server, the hostname etc. They might also be things you may want to pass as an input into another resource.
You use ctx.RegisterResourceOutputs()
for this which takes a pulumi.Map{}
.
That type is a map[string]pulumi.Input
so the value can be a complex type
too like an array or another pulumi.Map
.
In the case of our server we could do something like:
func NewServer(ctx *pulumi.Context, name string, args *ServerArgs, opts ...pulumi.ResourceOption) (*Server, error) {
... more things here ...
ctx.RegisterResourceOutputs(server, pulumi.Map{
"ipv4address": server.ipv4.IpAddress,
"ipv6address": server.ipv6.IpAddress,
})
}
Taking in providers
It’s advisable to globally disable default providers and explicitly pass in a provider. Changing the provider also causes the resource to change so it’s helpful to set this up from the start.
The provider is passed in through a map of providers that map names to an
instance of a provider. It is one of the opts ... pulumi.ResourceOption
you
can pass in to our constructor.
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
prov, err := hcloud.NewProvider(ctx, "provider", &hcloud.ProviderArgs{
Token: config.New(ctx, "hcloud").RequireSecret("token"),
})
if err != nil {
return err
}
server, err := NewServer(ctx, "server", &ServerArgs{}, pulumi.ProviderMap(
map[string]pulumi.ProviderResource{
"hcloud": prov,
},
))
})
}
If your component resource is the child of another component resource that
already gets called with an explicit provider, the child resource will inherit
the providers from the parent. But for explicitness and to aid in potential
refactoring it’s probably nicer to always pass in the pulumi.ProviderMap
. to
any custom resource component constructor.
Moon Prism Power, Make Up!
One very neat aspect of component resources is that you can apply a transformation to it. This means you can change inputs passed to any/all child resources of a component without having to refactor the component to let you pass in such an input. This should be used with care as it’s a very powerful feature that can absolutely break things, but it can come in very handy especially when you’re not the one building the component resource.
The official documentation provides two examples that show how to build and use
transformations at either the resource or the stack level. The one thing it
doesn’t touch on is how to know what the value of args.Type
is. Thankfully,
we can make a transformation to tell us that!
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
prov, err := hcloud.NewProvider(ctx, "provider", &hcloud.ProviderArgs{
Token: config.New(ctx, "hcloud").RequireSecret("token"),
})
if err != nil {
return err
}
transformation := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
fmt.Printf("%+v\n", args)
return nil
}
server, err := NewServer(ctx, "server", &ServerArgs{}, pulumi.ProviderMap(
map[string]pulumi.ProviderResource{
"hcloud": prov,
},
),
pulumi.Transformations([]pulumi.ResourceTransformation{transformation}),
)
})
}
pulumi.Transformations()
returns a pulumi.ResourceOption
so we can pass
that straight into our constructor, no changes required. You can now run
pulumi preview
and will be greeted with something like:
Diagnostics:
pulumi:pulumi:Stack (...):
&{Resource:0xc00044d520 Type:infra:internal:Server Name:server Props:<nil> Opts:[0xba8ac0 0xba8980]}
&{Resource:0xc0001d9320 Type:hcloud:index/primaryIp:PrimaryIp Name:ipv4 Props:0xc0002e4400 Opts:[0xba7f40]}
&{Resource:0xc0001d97a0 Type:hcloud:index/primaryIp:PrimaryIp Name:ipv6 Props:0xc0002e4480 Opts:[0xba7ea0]}
Alternatively, you can attach it at the stack level like so:
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
prov, err := hcloud.NewProvider(ctx, "provider", &hcloud.ProviderArgs{
Token: config.New(ctx, "hcloud").RequireSecret("token"),
})
if err != nil {
return err
}
transformation := func(args *pulumi.ResourceTransformationArgs) *pulumi.ResourceTransformationResult {
fmt.Printf("%+v\n", args)
return nil
}
ctx.RegisterStackTransformation(transformation)
server, err := NewServer(ctx, "server", &ServerArgs{}, pulumi.ProviderMap(
map[string]pulumi.ProviderResource{
"hcloud": prov,
},
))
})
}
You’ll get the same output since we don’t have other things in our stack yet. But lets say you had a few more resources, some created before our component resource you’d see something like this instead:
Diagnostics:
pulumi:pulumi:Stack (...):
&{Resource:0xc000190000 Type:hcloud:index/placementGroup:PlacementGroup Name:spread Props:0xc0002495f0 Opts:[0xacce60]}
&{Resource:0xc0001900e0 Type:hcloud:index/sshKey:SshKey Name:laptop Props:0xc000249800 Opts:[0xacce60]}
&{Resource:0xc0001901c0 Type:hcloud:index/sshKey:SshKey Name:desktop Props:0xc000249920 Opts:[0xacce60]}
&{Resource:0xc0001902a0 Type:hcloud:index/firewall:Firewall Name:firewall Props:0xc000020a80 Opts:[0xacce60]}
&{Resource:0xc000282820 Type:infra:internal:Server Name:server Props:<nil> Opts:[0xba8900]}
&{Resource:0xc000208480 Type:hcloud:index/primaryIp:PrimaryIp Name:ipv4 Props:0xc000192100 Opts:[0xba7f40]}
&{Resource:0xc000208900 Type:hcloud:index/primaryIp:PrimaryIp Name:ipv6 Props:0xc000192180 Opts:[0xba7ea0]}
When you attach to a stack, it will only apply to all resources in the stack
after you called ctx.RegisterStackTransformation()
. So make sure to declare
and attach any stack transformations before you create your resources.
Conclusion
In this post we walked through how to create a component resource, take inputs, create and attach child resources, provide outputs and how to use advanced features like transformations in order to bust open the abstraction using the Go programming language. I hope this provides you with a good starting point. We didn’t dive into how to test the component since it’s just Go code. The same things that apply to how to do testing with Go in general apply here too.
As mentioned at the start, please be aware that Pulumi is still evolving at a rapid pace and that these examples may be outdated by the time you see them.