In Pulumi resources have inputs and outputs. Inputs are fairly obvious but Outputs have some interesting characteristics that are worth taking a closer look at. Outputs are effectively promises to resolve to a value once a resource is created. Outputs can in turn be used as inputs to a different resource which has the nice side-effect of establishing ordering/dependencies.
When using Pulumi with Go you’ll see that resource structs have a $TypeOutput
for most of their fields, like StringOutput
. These will eventually resolve to
a value, after the resource has been created.A $TypeOutput
type satisfies the
requirements of the equivalent $TypeInput
. This means you can chuck an output
into an equivalent input and not worry about it. This is really lovely and is
also more or less what you expect. It makes the input and outputs feel like
normal types.
An ID is usually assigned by your cloud provider’s backend and is returned
upon resource creation. It’s typically an opaque thing you should not attach
any meaning to. For example, if you want to attach an IP to a server you’ll
need to provide the IP resource’s ID to your server and not the IP address.
Resources returned by a provider embed pulumi.CustomResourceState
which gives
us access to the ID()
method. This returns a pulumi.IDOutput
which you can
use anywhere a pulumi.ID(Ptr)Input
is expected or by virtue of it having
ToString(Ptr)Output
methods anywhere a pulumi.String(Ptr)Input
is desired.
Unfortunately, things get complicated when the ID is actually an integer. The
cloud provider API expects the ID to be passed in as such and won’t accept its
stringified counterpart. Easy, just call ToInt(Ptr)Output
on IDOutput
. You
could, except it doesn’t exist. Ohno.
A concrete example is when creating servers on Hetzner Cloud. Though you
don’t have to do this, you can first create the IPv4 and IPv6 addresses and then
pass their IDs into the hcloud.ServerArgs
when calling hcloud.NewServer()
.
Though the Hetzner Cloud API states that it only accepts the ID of an already
created PrimaryIp
resource, Pulumi wants a pulumi.IntPtrInput
there and
not a pulumi.IDInput
. This makes sense in that IDOutput
can’t be converted
to an IntPtrOutput
currently, but it means we need to work around that type
dissonance.
The way to do this is using Apply. The result of a call to ApplyT()
in
Go is a new output of some new type. This means we end up writing code like
this:
ip, err := hcloud.NewPrimaryIp(...)
server, err := hcloud.NewServer(ctx, name, &hcloud.ServerArgs{
PublicNets: hcloud.ServerPublicNetArray{
&hcloud.ServerPublicNetArgs{
Ipv4Enabled: pulumi.Bool(true),
Ipv4: ip.ID().ToStringOutput().ApplyT(func(val string) (*int, error) {
i, err := strconv.Atoi(val)
return &i, err
}).(pulumi.IntPtrOutput),
},
},
})
This code is a lot and hard to understand. Lets first factor out the callback
in ApplyT()
to its own function to make this a bit more manageable:
func IDToIntPtr(val string) (*int, error) {
i, err := strconv.Atoi(val)
return &i, err
}
Then pass it into ApplyT
by name, but don’t call it (don’t add the ()
):
ip.ID().ToStringOutput().ApplyT(IDToIntPtr).(pulumi.IntPtrOutput),
The benefit of this is that you can reuse the function. You’ll need it a lot when working with providers that need the ID as an integer. You can now also write tests for this thing which isn’t possible for inline callbacks.
So what wizardry is it that we’re performing here? We leverage the fact that
IDOutput
has a ToStringOutput
. This gets us close to a native Go string
which we can then convert to its non-stringified integer using the standard
library. The second trick is going through ApplyT()
. Its signature accepts an
interface{}
callback but its documented to be one of func (v U) T
or
func (v U) (T, error)
. The duality and opaqueness is a bit annoying here but
remember that this existed well before generics were a thing in Go. The type of
v
is the ElementType()
of the Output we call ApplyT()
on. Unsurprisingly
for a StringOutput
that’s a string.
At this point we now have a native Go string inside our callback which we can
transform to an integer. Since we know we’ll need to get to an IntPtrOutput
we have our callback return a *int
. However, the return type of ApplyT()
is
Output
. For native types that have an equivalent Pulumi type we can use a type
assertion to get to our final destination, the .(pulumi.IntPtrOutput)
. In
cases where this isn’t available you’ll need to coerce to AnyOutput
.
We’ve now come full circle and finally have an Output type that satisfies the
IntPtrInput
requirement of hcloud.ServerPublicNetArgs.Ipv4
. If you think
this is a bit too much effort to have to go through you’re not alone. The ID
being an integer and needing to be treated as such is an issue affecting a few
providers like Hetzner and Digital Ocean. I’ve filed issue #11750 to see
how to resolve that. Though this post has looked at the issue from a Go
perspective this problem applies to other languages you can use with Pulumi too.