Introducing Resource Methods for Pulumi Packages
Posted on
It’s now possible to provide resource methods from Pulumi Packages. Resource methods are similar to functions, but instead of being exposed as top-level functions in a module, methods are exposed as methods on a resource class. This allows for a more object-oriented approach to exposing functionality—operations performed by a resource (that potentially use the resource’s state) can now be exposed as methods on the resource. Resource methods can be implemented once, in your language of choice, and made available to users in all Pulumi languages.
When authoring component resources, it’s often useful to provide additional functionality through methods on the component. For example, the Cluster
component in the eks
package has a getKubeconfig
method that can be used to generate a kubeconfig for authentication with the cluster that does not use the default AWS credential provider chain, but is instead scoped based on the passed-in arguments. Until now, that method has only been available from JavaScript/TypeScript (the language the Cluster
component was written in). With the new support for resource methods for Pulumi Packages, we can make this method available to all the Pulumi languages, which is exactly what we’ve done in pulumi-eks v0.34.0.
const cluster = new eks.Cluster("mycluster");
const kubeconfig = cluster.getKubeconfig(...);
cluster = eks.Cluster("mycluster")
kubeconfig = cluster.get_kubeconfig(...)
var cluster = new Cluster("mycluster");
var kubeconfig = cluster.GetKubeconfig(...);
cluster, err := eks.NewCluster(ctx, "mycluster", nil)
if err != nil {
return err
}
kubeconfig, err := cluster.GetKubeconfig(ctx, &eks.ClusterGetKubeconfigArgs{...})
if err != nil {
return err
}
This post will show how you can provide resource methods from your component packages.
Authoring Methods
It’s always been possible to provide module-level functions from Pulumi Packages. For example, the aws
package provides many module-level functions, such as the getAmi
function, which can be used to get the ID of an existing Amazon Machine Image (AMI). Functions are declared in the package’s schema, and their functionality is implemented in the provider through the Invoke
remote procedure call (RPC).
Methods are authored in a similar manner to functions. Methods are declared in the schema and implemented in the provider’s Call
RPC (similar to Invoke
).
Let’s walk through an example. We’ll author a Message
component that accepts a message as input. The component then provides a getMessage
method that accepts a recipient name and returns the message customized for the recipient. To get started authoring a component package, refer to the package documentation.
Schema
We’ll start with declaring the method and component in the Pulumi schema. First, define the function representing the method:
"functions": {
"example:index:Message/getMessage": {
"inputs": {
"properties": {
"__self__": {
"$ref": "#/resources/example:index:Message"
},
"recipient": {
"type": "string"
}
},
"required": ["__self__", "recipient"]
},
"outputs": {
"properties": {
"result": {
"type": "string"
}
},
"required": ["result"]
}
}
},
Our method has two required arguments: __self__
and recipient
. __self__
is a special input required by all methods that represents the component resource and is typed as such. The method has one result named result
.
Next, define our Message
component:
"resources": {
"example:index:Message": {
"isComponent": true,
"inputProperties": {
"message": {
"type": "string"
},
},
"requiredInputs": ["message"],
"properties": {
"message": {
"type": "string"
},
},
"required": ["message"],
"methods": {
"getMessage": "example:index:Message/getMessage"
}
}
},
Our component has a single required input/output property: message
. The method is specified in the methods
property, which references the method’s function definition: "getMessage": "example:index:Message/getMessage"
.
Here’s our schema all together:
{
"version": "0.0.1",
"name": "example",
"functions": {
"example:index:Message/getMessage": {
"inputs": {
"properties": {
"__self__": {
"$ref": "#/resources/example:index:Message"
},
"recipient": {
"type": "string"
}
},
"required": ["__self__", "recipient"]
},
"outputs": {
"properties": {
"result": {
"type": "string"
}
},
"required": ["result"]
}
}
},
"resources": {
"example:index:Message": {
"isComponent": true,
"inputProperties": {
"message": {
"type": "string"
},
},
"requiredInputs": ["message"],
"properties": {
"message": {
"type": "string"
},
},
"required": ["message"],
"methods": {
"getMessage": "example:index:Message/getMessage"
}
}
},
"language": {
"csharp": {
"packageReferences": {
"Pulumi": "3.12"
},
"liftSingleValueMethodReturns": true
},
"go": {
"liftSingleValueMethodReturns": true
},
"nodejs": {
"devDependencies": {
"@types/node": "latest"
},
"liftSingleValueMethodReturns": true
},
"python": {
"liftSingleValueMethodReturns": true
}
}
}
To make it easier for users of our method, we also set "liftSingleValueMethodReturns": true
for each language, which makes single-value methods return the single value directly, rather than wrapping the results in a Result
type.
Component Implementation
We can implement the component and make it available to any Pulumi language. We’re going to show some implementation examples in TypeScript/JavaScript, Python, and Go.
Here’s the implementation of the Message
component:
import * as pulumi from "@pulumi/pulumi";
interface MessageArgs {
message: pulumi.Input<string>;
}
class Message extends pulumi.ComponentResource {
public readonly message!: pulumi.Output<string>;
constructor(name: string, args: MessageArgs, opts?: pulumi.ComponentResourceOptions) {
const props = { message: args?.message }
super("example:index:Message", name, props, opts);
if (opts?.urn) {
// Skip further initialization when being constructed from a resource reference.
return;
}
this.registerOutputs(props);
}
getMessage(recipient: pulumi.Input<string>): pulumi.Output<string> {
return pulumi.iterpolate `${recipient}, ${this.message}!`;
}
}
from typing import Optional
import pulumi
class Message(pulumi.ComponentResource):
@property
@pulumi.getter
def message(self) -> pulumi.Output[str]:
return pulumi.get(self, "message")
def __init__(self,
resource_name: str,
opts: Optional[pulumi.ResourceOptions] = None,
message: Optional[pulumi.Input[str]] = None) -> None:
args = {"message": message}
super().__init__("example:index:Message", resource_name, args, opts)
if opts and opts.urn:
# Skip further initialization when being constructed from a resource reference.
return
self.register_outputs(args)
def get_message(self, recipient: pulumi.Input[str]) -> pulumi.Output[str]:
return pulumi.Output.concat(recipient, ", ", self.message, "!")
Authoring multi-language components in .NET is not yet supported.
import (
"errors"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
type Message struct {
pulumi.ResourceState
Message pulumi.StringOutput `pulumi:"message"`
}
type MessageArgs struct {
Message pulumi.StringInput `pulumi:"message"`
}
func NewMessage(ctx *pulumi.Context, name string, args *MessageArgs, opts ...pulumi.ResourceOption) (*Message, error) {
if args == nil {
return nil, errors.New("args is required")
}
message := &Message{}
err := ctx.RegisterComponentResource("example:index:Message", name, message, opts...)
if err != nil {
return nil, err
}
message.Message = args.Message.ToStringOutput()
if err := ctx.RegisterResourceOutputs(message, pulumi.Map{
"message": args.Message,
}); err != nil {
return nil, err
}
return message, nil
}
type GetMessageArgs struct {
Recipient pulumi.StringInput `pulumi:"recipient"`
}
func (c *Message) GetMessage(args *GetMessageArgs) StringOutput {
return pulumi.Sprintf("%s, %s!", args.Recipient, c.Message)
}
Provider RPCs
Next, wire up the provider RPCs with the component implementation:
import * as pulumi from "@pulumi/pulumi";
import * as provider from "@pulumi/pulumi/provider";
class Provider implements provider.Provider {
public readonly version = "0.0.1";
constructor() {
// Register any resources that can come back as resource references that need to be rehydrated.
pulumi.runtime.registerResourceModule("example", "index", {
version: this.version,
construct: (name, type, urn) => {
switch (type) {
case "example:index:Message":
return new Component(name, <any>undefined, { urn });
default:
throw new Error(`unknown resource type ${type}`);
}
},
});
}
async construct(name: string, type: string, inputs: pulumi.Inputs,
options: pulumi.ComponentResourceOptions): Promise<provider.ConstructResult> {
if (type != "example:index:Message") {
throw new Error(`unknown resource type ${type}`);
}
const message = new Message(name, <MessageArgs>inputs, options);
return {
urn: message.urn,
state: inputs,
};
}
async call(token: string, inputs: pulumi.Inputs): Promise<provider.InvokeResult> {
switch (token) {
case "example:index:Message/getMessage":
const self: Component = inputs.__self__;
return {
outputs: {
result: self.getMessage(inputs.recipient),
},
};
default:
throw new Error(`unknown method ${token}`);
}
}
}
export function main(args: string[]) {
return provider.main(new Provider(), args);
}
main(process.argv.slice(2));
from typing import Optional
import sys
import pulumi
import pulumi.provider as provider
class Provider(provider.Provider):
VERSION = "0.0.1"
class Module(pulumi.runtime.ResourceModule):
def version(self):
return Provider.VERSION
def construct(self, name: str, typ: str, urn: str) -> pulumi.Resource:
if typ == "example:index:Message":
return Component(name, pulumi.ResourceOptions(urn=urn))
else:
raise Exception(f"unknown resource type {typ}")
def __init__(self):
super().__init__(Provider.VERSION)
pulumi.runtime.register_resource_module("example", "index", Provider.Module())
def construct(self, name: str, resource_type: str, inputs: pulumi.Inputs,
options: Optional[pulumi.ResourceOptions] = None) -> provider.ConstructResult:
if resource_type != "example:index:Message":
raise Exception(f"unknown resource type {resource_type}")
message = Message(name, opts=options, message=inputs["message"])
return provider.ConstructResult(
urn=component.urn,
state=inputs)
def call(self, token: str, args: pulumi.Inputs) -> provider.CallResult:
if token != "example:index:Message/getMessage":
raise Exception(f'unknown method {token}')
message: Message = args["__self__"]
outputs = {
"result": message.get_message(args["recipient"])
}
return provider.CallResult(outputs=outputs)
if __name__ == "__main__":
provider.main(Provider(), sys.argv[1:])
Authoring multi-language components in .NET is not yet supported.
import (
"errors"
"fmt"
"github.com/blang/semver"
"github.com/pulumi/pulumi/pkg/v3/resource/provider"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
pulumiprovider "github.com/pulumi/pulumi/sdk/v3/go/pulumi/provider"
)
const providerName = "example"
const version = "0.0.1"
type module struct {
version semver.Version
}
func (m *module) Version() semver.Version {
return m.version
}
func (m *module) Construct(ctx *pulumi.Context, name, typ, urn string) (r pulumi.Resource, err error) {
if typ != "example:index:Message" {
return nil, fmt.Errorf("unknown resource type: %s", typ)
}
r = &Component{}
err = ctx.RegisterResource(typ, name, nil, r, pulumi.URN_(urn))
return
}
type GetMessageResult struct {
Result pulumi.StringOutput `pulumi:"result"`
}
func main() {
pulumi.RegisterResourceModule("example", "index", &module{semver.MustParse(version)})
if err := provider.MainWithOptions(provider.Options{
Name: providerName,
Version: version,
Construct: func(ctx *pulumi.Context, typ, name string, inputs pulumiprovider.ConstructInputs,
options pulumi.ResourceOption) (*pulumiprovider.ConstructResult, error) {
if typ != "example:index:Message" {
return nil, fmt.Errorf("unknown resource type %s", typ)
}
args := &MessageArgs{}
if err := inputs.CopyTo(args); err != nil {
return nil, fmt.Errorf("setting args: %w", err)
}
message, err := NewMessage(ctx, name, args, options)
if err != nil {
return nil, fmt.Errorf("creating component: %w", err)
}
return pulumiprovider.NewConstructResult(message)
},
Call: func(ctx *pulumi.Context, tok string, args pulumiprovider.CallArgs) (*pulumiprovider.CallResult, error) {
if tok != "example:index:Message/getMessage" {
return nil, fmt.Errorf("unknown method %s", tok)
}
methodArgs := &GetMessageArgs{}
res, err := args.CopyTo(methodArgs)
if err != nil {
return nil, fmt.Errorf("setting args: %w", err)
}
message := res.(*Message)
result, message.GetMessage(methodArgs)
return pulumiprovider.NewCallResult(&GetMessageResult{
Result: result
})
},
}); err != nil {
cmdutil.ExitError(err.Error())
}
}
The Construct
RPC is called when creating an instance of the component. The Call
RPC is called when a method is called.
Using the Component
Now we can build and try out using the component and its method from any Pulumi language:
const component = new example.Message("mycomponent", {
message: "hello world",
});
export const message = component.getMessage({ recipient: "Alice" }); // Exports "Alice, hello world!"
component = example.Message("mycomponent", message="hello world")
message = component.get_message(recipient="Alice")
pulumi.export("message", message) # Exports "Alice, hello world!"
var component = new Message("mycomponent", new MessageArgs
{
Message = "hello world",
});
// Exports "Alice, hello world!"
this.Message = component.GetMessage(new MessageGetMessageArgs
{
Recipient = "Alice",
});
component, err := example.NewMessage(ctx, "mycomponent", &example.MessageArgs{
Message: "hello world",
})
if err != nil {
return err
}
message, err := component.GetMessage(ctx, &example.MessageGetMessageArgs{Recipient: "Alice"})
if err != nil {
return err
}
pulumi.Export("message", message) // Exports "Alice, hello world!"
Wrapping Up
With support for resource methods for Pulumi Packages, you can now create component resources with methods and make them available to use from any Pulumi language. We look forward to seeing the components you create!