From d7d526a6568b4e30ac3b7a139cfecded402e51f8 Mon Sep 17 00:00:00 2001 From: Fraser Waters Date: Thu, 29 Aug 2024 23:27:32 +0100 Subject: [PATCH] Test and fix support for local plugins This updates the pulumi dependency to [d9df4771815894ab6ddaf17b78f6f023c91b7148](https://github.com/pulumi/pulumi/commit/d9df4771815894ab6ddaf17b78f6f023c91b7148) which has a number of fixes for plugin and schema loading. This allows YAML to correctly load schemas for plugins defined in the Pulumi.yaml file itself. We often use this for integegration tests, but we expect users to increasinly make use of this feature in the future as an alternative to dynamic providers. --- CHANGELOG_PENDING.md | 2 + go.mod | 6 +- go.sum | 8 +- pkg/pulumiyaml/packages.go | 9 +- pkg/server/server.go | 14 +- pkg/tests/integration_test.go | 12 + pkg/tests/testdata/local/Pulumi.yaml | 10 + pkg/tests/testprovider/.gitignore | 2 + pkg/tests/testprovider/PulumiPlugin.yaml | 1 + pkg/tests/testprovider/component.go | 101 ++++++ pkg/tests/testprovider/echo.go | 222 +++++++++++++ pkg/tests/testprovider/fails_on_create.go | 83 +++++ pkg/tests/testprovider/fails_on_delete.go | 89 ++++++ pkg/tests/testprovider/main.go | 370 ++++++++++++++++++++++ pkg/tests/testprovider/random.go | 192 +++++++++++ 15 files changed, 1108 insertions(+), 13 deletions(-) create mode 100644 pkg/tests/testdata/local/Pulumi.yaml create mode 100644 pkg/tests/testprovider/.gitignore create mode 100644 pkg/tests/testprovider/PulumiPlugin.yaml create mode 100644 pkg/tests/testprovider/component.go create mode 100644 pkg/tests/testprovider/echo.go create mode 100644 pkg/tests/testprovider/fails_on_create.go create mode 100644 pkg/tests/testprovider/fails_on_delete.go create mode 100644 pkg/tests/testprovider/main.go create mode 100644 pkg/tests/testprovider/random.go diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index db5b3070..4b4e6704 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -6,3 +6,5 @@ - Parse the items property on config type declarations to prevent diagnostic messages about unknown fields [#615](https://github.com/pulumi/pulumi-yaml/pull/615) + +- Fix usage of local plugins (those defined in the Pulumi.yaml file plugins section) [#619](https://github.com/pulumi/pulumi-yaml/pull/619) diff --git a/go.mod b/go.mod index 9a9fbcfd..59d99c47 100644 --- a/go.mod +++ b/go.mod @@ -12,12 +12,13 @@ require ( github.com/hexops/autogold v1.3.0 github.com/iancoleman/strcase v0.2.0 github.com/pkg/errors v0.9.1 - github.com/pulumi/pulumi/pkg/v3 v3.129.1-0.20240819035819-d75251c49f8a - github.com/pulumi/pulumi/sdk/v3 v3.129.1-0.20240819035819-d75251c49f8a + github.com/pulumi/pulumi/pkg/v3 v3.130.1-0.20240831132520-d9df47718158 + github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240831132520-d9df47718158 github.com/spf13/afero v1.9.5 github.com/stretchr/testify v1.9.0 github.com/zclconf/go-cty v1.13.2 google.golang.org/grpc v1.63.2 + google.golang.org/protobuf v1.33.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -186,7 +187,6 @@ require ( google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240311173647-c811ad7063a7 // indirect - google.golang.org/protobuf v1.33.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect lukechampine.com/frand v1.4.2 // indirect mvdan.cc/gofumpt v0.5.0 // indirect diff --git a/go.sum b/go.sum index bc57eadb..03fe3873 100644 --- a/go.sum +++ b/go.sum @@ -430,10 +430,10 @@ github.com/pulumi/esc v0.9.1 h1:HH5eEv8sgyxSpY5a8yePyqFXzA8cvBvapfH8457+mIs= github.com/pulumi/esc v0.9.1/go.mod h1:oEJ6bOsjYlQUpjf70GiX+CXn3VBmpwFDxUTlmtUN84c= github.com/pulumi/inflector v0.1.1 h1:dvlxlWtXwOJTUUtcYDvwnl6Mpg33prhK+7mzeF+SobA= github.com/pulumi/inflector v0.1.1/go.mod h1:HUFCjcPTz96YtTuUlwG3i3EZG4WlniBvR9bd+iJxCUY= -github.com/pulumi/pulumi/pkg/v3 v3.129.1-0.20240819035819-d75251c49f8a h1:0+5MrobQVNIJRXxeeVEgNsnS8jVYxwm390K5wuboS+Y= -github.com/pulumi/pulumi/pkg/v3 v3.129.1-0.20240819035819-d75251c49f8a/go.mod h1:W3c7JgO064kUH5IfyQMCgmsAr5iwr3PO6KEeIQiO0dY= -github.com/pulumi/pulumi/sdk/v3 v3.129.1-0.20240819035819-d75251c49f8a h1:1k4pn6Ef3Ts5C6UMKpQGcVzs6GmfHOJWW76wVaAUiKE= -github.com/pulumi/pulumi/sdk/v3 v3.129.1-0.20240819035819-d75251c49f8a/go.mod h1:p1U24en3zt51agx+WlNboSOV8eLlPWYAkxMzVEXKbnY= +github.com/pulumi/pulumi/pkg/v3 v3.130.1-0.20240831132520-d9df47718158 h1:X7NKRRMROkOjou/WYdhH5JhwqFm0QUI98eVBfO5y0zs= +github.com/pulumi/pulumi/pkg/v3 v3.130.1-0.20240831132520-d9df47718158/go.mod h1:8Wt6MyH3CO7ssKjYOuhmZHjV/H8oCeSCKe8jjVskuDo= +github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240831132520-d9df47718158 h1:FF8TMAxHXhLPL+ug2M3gulInEULONmg9UL9ajRbdVIc= +github.com/pulumi/pulumi/sdk/v3 v3.130.1-0.20240831132520-d9df47718158/go.mod h1:p1U24en3zt51agx+WlNboSOV8eLlPWYAkxMzVEXKbnY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= diff --git a/pkg/pulumiyaml/packages.go b/pkg/pulumiyaml/packages.go index 6071ccce..b49f04df 100644 --- a/pkg/pulumiyaml/packages.go +++ b/pkg/pulumiyaml/packages.go @@ -18,6 +18,7 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" "github.com/pulumi/pulumi/sdk/v3/go/common/util/contract" + "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" ) type ResourceTypeToken string @@ -86,8 +87,8 @@ func (l packageLoader) Close() { } } -func NewPackageLoader() (PackageLoader, error) { - host, err := newResourcePackageHost() +func NewPackageLoader(plugins *workspace.Plugins) (PackageLoader, error) { + host, err := newResourcePackageHost(plugins) if err != nil { return nil, err } @@ -466,7 +467,7 @@ func getResourceConstants(props []*schema.Property) map[string]interface{} { return constantProps } -func newResourcePackageHost() (plugin.Host, error) { +func newResourcePackageHost(plugins *workspace.Plugins) (plugin.Host, error) { cwd, err := os.Getwd() if err != nil { return nil, err @@ -474,7 +475,7 @@ func newResourcePackageHost() (plugin.Host, error) { sink := diag.DefaultSink(os.Stderr, os.Stderr, diag.FormatOptions{ Color: cmdutil.GetGlobalColorization(), }) - pluginCtx, err := plugin.NewContext(sink, sink, nil, nil, cwd, nil, true, nil) + pluginCtx, err := plugin.NewContextWithRoot(sink, sink, nil, cwd, cwd, nil, true, nil, plugins, nil, nil) if err != nil { return nil, err } diff --git a/pkg/server/server.go b/pkg/server/server.go index 554447cb..69243b05 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -26,6 +26,7 @@ import ( "github.com/pulumi/pulumi/sdk/v3/go/common/apitype" "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" "github.com/pulumi/pulumi/sdk/v3/go/common/version" + "github.com/pulumi/pulumi/sdk/v3/go/common/workspace" "github.com/pulumi/pulumi/sdk/v3/go/pulumi" pulumirpc "github.com/pulumi/pulumi/sdk/v3/proto/go" @@ -135,6 +136,15 @@ func (host *yamlLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest fmt.Sprintf(`PULUMI_CONFIG=%s`, jsonConfigValue), } + projPath, err := workspace.DetectProjectPathFrom(req.Info.RootDirectory) + if err != nil { + return nil, err + } + proj, err := workspace.LoadProject(projPath) + if err != nil { + return nil, err + } + template, diags, err := host.loadTemplate(compilerEnv) if err != nil { return &pulumirpc.RunResponse{Error: err.Error()}, nil @@ -166,7 +176,7 @@ func (host *yamlLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest ConfigSecretKeys: req.GetConfigSecretKeys(), ConfigPropertyMap: confPropMap, Organization: req.Organization, - Parallel: int(req.GetParallel()), + Parallel: req.GetParallel(), DryRun: req.GetDryRun(), MonitorAddr: req.GetMonitorAddress(), EngineAddr: host.engineAddress, @@ -177,7 +187,7 @@ func (host *yamlLanguageHost) Run(ctx context.Context, req *pulumirpc.RunRequest defer pctx.Close() // Now instruct the Pulumi Go SDK to run the pulumi YAML interpreter. if err := pulumi.RunWithContext(pctx, func(ctx *pulumi.Context) error { - loader, err := pulumiyaml.NewPackageLoader() + loader, err := pulumiyaml.NewPackageLoader(proj.Plugins) if err != nil { return err } diff --git a/pkg/tests/integration_test.go b/pkg/tests/integration_test.go index 2c5b57fe..6831d8ae 100644 --- a/pkg/tests/integration_test.go +++ b/pkg/tests/integration_test.go @@ -121,3 +121,15 @@ func TestEnvVarsKeepConflictingValues(t *testing.T) { } integration.ProgramTest(t, &testOptions) } + +// Test a local provider plugin. +// +//nolint:paralleltest // ProgramTest calls t.Parallel() +func TestLocalPlugin(t *testing.T) { + integration.ProgramTest(t, &integration.ProgramTestOptions{ + Dir: filepath.Join("testdata", "local"), + LocalProviders: []integration.LocalDependency{ + {Package: "testprovider", Path: "testprovider"}, + }, + }) +} diff --git a/pkg/tests/testdata/local/Pulumi.yaml b/pkg/tests/testdata/local/Pulumi.yaml new file mode 100644 index 00000000..5230f66a --- /dev/null +++ b/pkg/tests/testdata/local/Pulumi.yaml @@ -0,0 +1,10 @@ +name: local +scription: An integration test showing the use of a a local plugin +runtime: yaml +resources: + res1: + type: testprovider:index:Random + properties: + length: 10 + res2: + type: testprovider:index:Echo \ No newline at end of file diff --git a/pkg/tests/testprovider/.gitignore b/pkg/tests/testprovider/.gitignore new file mode 100644 index 00000000..f4015fe7 --- /dev/null +++ b/pkg/tests/testprovider/.gitignore @@ -0,0 +1,2 @@ +pulumi-resource-testprovider +schema-testprovider.json \ No newline at end of file diff --git a/pkg/tests/testprovider/PulumiPlugin.yaml b/pkg/tests/testprovider/PulumiPlugin.yaml new file mode 100644 index 00000000..735ef965 --- /dev/null +++ b/pkg/tests/testprovider/PulumiPlugin.yaml @@ -0,0 +1 @@ +runtime: go \ No newline at end of file diff --git a/pkg/tests/testprovider/component.go b/pkg/tests/testprovider/component.go new file mode 100644 index 00000000..3881a88e --- /dev/null +++ b/pkg/tests/testprovider/component.go @@ -0,0 +1,101 @@ +// Copyright 2016-2021, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +package main + +import ( + "errors" + "fmt" + "reflect" + + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +type Random struct { + pulumi.CustomResourceState + + Length pulumi.IntOutput `pulumi:"length"` + Result pulumi.StringOutput `pulumi:"result"` +} + +func NewRandom(ctx *pulumi.Context, + name string, args *RandomArgs, opts ...pulumi.ResourceOption, +) (*Random, error) { + if args == nil || args.Length == nil { + return nil, errors.New("missing required argument 'Length'") + } + var resource Random + err := ctx.RegisterResource("testprovider:index:Random", name, args, &resource, opts...) + if err != nil { + return nil, err + } + return &resource, nil +} + +type randomArgs struct { + Length int `pulumi:"length"` + Prefix string `pulumi:"prefix"` +} + +type RandomArgs struct { + Length pulumi.IntInput + Prefix pulumi.StringInput +} + +func (RandomArgs) ElementType() reflect.Type { + return reflect.TypeOf((*randomArgs)(nil)).Elem() +} + +type Component struct { + pulumi.ResourceState + + ChildID pulumi.IDOutput `pulumi:"childId"` +} + +type ComponentArgs struct { + Length int `pulumi:"length"` +} + +func NewComponent(ctx *pulumi.Context, name string, args *ComponentArgs, + opts ...pulumi.ResourceOption, +) (*Component, error) { + if args == nil { + return nil, errors.New("args is required") + } + + component := &Component{} + err := ctx.RegisterComponentResource("testprovider:index:Component", name, component, opts...) + if err != nil { + return nil, err + } + + res, err := NewRandom(ctx, fmt.Sprintf("child-%s", name), &RandomArgs{ + Length: pulumi.Int(args.Length), + }, pulumi.Parent(component)) + if err != nil { + return nil, err + } + + component.ChildID = res.ID() + + if err := ctx.RegisterResourceOutputs(component, pulumi.Map{ + "childId": component.ChildID, + }); err != nil { + return nil, err + } + + return component, nil +} diff --git a/pkg/tests/testprovider/echo.go b/pkg/tests/testprovider/echo.go new file mode 100644 index 00000000..c9d5b05f --- /dev/null +++ b/pkg/tests/testprovider/echo.go @@ -0,0 +1,222 @@ +// Copyright 2016-2021, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +package main + +import ( + "context" + "os" + "strconv" + + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" + rpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + providerSchema.Resources["testprovider:index:Echo"] = pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "A test resource that echoes its input.", + Properties: map[string]pschema.PropertySpec{ + "echo": { + TypeSpec: pschema.TypeSpec{ + Ref: "pulumi.json#/Any", + }, + Description: "Input to echo.", + }, + }, + Type: "object", + }, + InputProperties: map[string]pschema.PropertySpec{ + "echo": { + TypeSpec: pschema.TypeSpec{ + Ref: "pulumi.json#/Any", + }, + Description: "An echoed input.", + }, + }, + Methods: map[string]string{ + "doEchoMethod": "testprovider:index:Echo/doEchoMethod", + }, + } + providerSchema.Functions["testprovider:index:doEcho"] = pschema.FunctionSpec{ + Description: "A test invoke that echoes its input.", + Inputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "echo": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + Outputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "echo": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + } + if os.Getenv("PULUMI_TEST_MULTI_ARGUMENT_INPUTS") != "" { + // Conditionally add this if an env flag is set, since it does not work with all langs + providerSchema.Functions["testprovider:index:doMultiEcho"] = pschema.FunctionSpec{ + Description: "A test invoke that echoes its input, using multiple inputs.", + MultiArgumentInputs: []string{ + "echoA", + "echoB", + }, + Inputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "echoA": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + "echoB": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + Outputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "echoA": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + "echoB": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + } + } + providerSchema.Functions["testprovider:index:Echo/doEchoMethod"] = pschema.FunctionSpec{ + Description: "A test call that echoes its input.", + Inputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "__self__": { + TypeSpec: pschema.TypeSpec{ + Ref: "#/types/testprovider:index:Echo", + }, + }, + "echo": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + Outputs: &pschema.ObjectTypeSpec{ + Properties: map[string]pschema.PropertySpec{ + "echo": { + TypeSpec: pschema.TypeSpec{ + Type: "string", + }, + }, + }, + }, + } +} + +type echoProvider struct { + id int +} + +func (p *echoProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + return &rpc.CheckResponse{Inputs: req.News, Failures: nil}, nil +} + +func (p *echoProvider) Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + olds, err := plugin.UnmarshalProperties(req.GetOlds(), plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}) + if err != nil { + return nil, err + } + + news, err := plugin.UnmarshalProperties(req.GetNews(), plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}) + if err != nil { + return nil, err + } + + d := olds.Diff(news) + changes := rpc.DiffResponse_DIFF_NONE + var replaces []string + if d != nil && d.Changed("echo") { + changes = rpc.DiffResponse_DIFF_SOME + replaces = append(replaces, "echo") + } + + return &rpc.DiffResponse{ + Changes: changes, + Replaces: replaces, + }, nil +} + +func (p *echoProvider) Create(ctx context.Context, req *rpc.CreateRequest) (*rpc.CreateResponse, error) { + inputs, err := plugin.UnmarshalProperties(req.GetProperties(), plugin.MarshalOptions{ + KeepUnknowns: true, + SkipNulls: true, + }) + if err != nil { + return nil, err + } + + outputProperties, err := plugin.MarshalProperties( + inputs, + plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}, + ) + if err != nil { + return nil, err + } + + p.id++ + return &rpc.CreateResponse{ + Id: strconv.Itoa(p.id), + Properties: outputProperties, + }, nil +} + +func (p *echoProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) { + return &rpc.ReadResponse{ + Id: req.Id, + Properties: req.Properties, + }, nil +} + +func (p *echoProvider) Update(ctx context.Context, req *rpc.UpdateRequest) (*rpc.UpdateResponse, error) { + panic("Update not implemented") +} + +func (p *echoProvider) Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (p *echoProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { + return &rpc.InvokeResponse{Return: req.Args}, nil +} + +func (p *echoProvider) Call(ctx context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) { + return &rpc.CallResponse{Return: req.Args}, nil +} diff --git a/pkg/tests/testprovider/fails_on_create.go b/pkg/tests/testprovider/fails_on_create.go new file mode 100644 index 00000000..6221c2b9 --- /dev/null +++ b/pkg/tests/testprovider/fails_on_create.go @@ -0,0 +1,83 @@ +// Copyright 2016-2023, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +package main + +import ( + "context" + "errors" + + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + rpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + providerSchema.Resources["testprovider:index:FailsOnCreate"] = pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "A test resource fails on create.", + Properties: map[string]pschema.PropertySpec{}, + Type: "object", + }, + InputProperties: map[string]pschema.PropertySpec{}, + } +} + +type failsOnCreateProvider struct{} + +func (p *failsOnCreateProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + return &rpc.CheckResponse{Inputs: req.News, Failures: nil}, nil +} + +func (p *failsOnCreateProvider) Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + return &rpc.DiffResponse{ + Changes: rpc.DiffResponse_DIFF_NONE, + }, nil +} + +func (p *failsOnCreateProvider) Create( + ctx context.Context, req *rpc.CreateRequest, +) (*rpc.CreateResponse, error) { + return nil, errors.New("Create always fails for the FailsOnCreate resource") +} + +func (p *failsOnCreateProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) { + return &rpc.ReadResponse{ + Id: req.Id, + Properties: req.Properties, + }, nil +} + +func (p *failsOnCreateProvider) Update( + ctx context.Context, req *rpc.UpdateRequest, +) (*rpc.UpdateResponse, error) { + panic("Update not implemented") +} + +func (p *failsOnCreateProvider) Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (p *failsOnCreateProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { + // The fails-on-create provider doesn't support any invokes currently. + panic("Invoke not implemented") +} + +func (p *failsOnCreateProvider) Call(ctx context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) { + // The random provider doesn't support any call currently. + panic("Call not implemented") +} diff --git a/pkg/tests/testprovider/fails_on_delete.go b/pkg/tests/testprovider/fails_on_delete.go new file mode 100644 index 00000000..632fa412 --- /dev/null +++ b/pkg/tests/testprovider/fails_on_delete.go @@ -0,0 +1,89 @@ +// Copyright 2016-2023, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +package main + +import ( + "context" + "errors" + "strconv" + + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + rpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + providerSchema.Resources["testprovider:index:FailsOnDelete"] = pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "A test resource fails on delete.", + Properties: map[string]pschema.PropertySpec{}, + Type: "object", + }, + InputProperties: map[string]pschema.PropertySpec{}, + } +} + +type failsOnDeleteProvider struct { + id int +} + +func (p *failsOnDeleteProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + return &rpc.CheckResponse{Inputs: req.News, Failures: nil}, nil +} + +func (p *failsOnDeleteProvider) Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + return &rpc.DiffResponse{ + Changes: rpc.DiffResponse_DIFF_NONE, + }, nil +} + +func (p *failsOnDeleteProvider) Create( + ctx context.Context, req *rpc.CreateRequest, +) (*rpc.CreateResponse, error) { + p.id++ + return &rpc.CreateResponse{ + Id: strconv.Itoa(p.id), + }, nil +} + +func (p *failsOnDeleteProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) { + return &rpc.ReadResponse{ + Id: req.Id, + Properties: req.Properties, + }, nil +} + +func (p *failsOnDeleteProvider) Update( + ctx context.Context, req *rpc.UpdateRequest, +) (*rpc.UpdateResponse, error) { + panic("Update not implemented") +} + +func (p *failsOnDeleteProvider) Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) { + return nil, errors.New("Delete always fails for the FailsOnDelete resource") +} + +func (p *failsOnDeleteProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { + // The fails-on-delete provider doesn't support any invokes currently. + panic("Invoke not implemented") +} + +func (p *failsOnDeleteProvider) Call(ctx context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) { + // The random provider doesn't support any call currently. + panic("Call not implemented") +} diff --git a/pkg/tests/testprovider/main.go b/pkg/tests/testprovider/main.go new file mode 100644 index 00000000..a2f737b4 --- /dev/null +++ b/pkg/tests/testprovider/main.go @@ -0,0 +1,370 @@ +// Copyright 2016-2023, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +// A provider with resources for use in tests. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/pkg/v3/resource/provider" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "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" + rpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + + "google.golang.org/protobuf/types/known/emptypb" +) + +const ( + providerName = "testprovider" + version = "0.0.1" +) + +var providerSchema = pschema.PackageSpec{ + Name: "testprovider", + Description: "A test provider.", + DisplayName: "testprovider", + + Config: pschema.ConfigSpec{}, + + Provider: pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "The provider type for the testprovider package.", + Type: "object", + }, + InputProperties: map[string]pschema.PropertySpec{}, + }, + + Types: map[string]pschema.ComplexTypeSpec{}, + Resources: map[string]pschema.ResourceSpec{}, + Functions: map[string]pschema.FunctionSpec{}, + Language: map[string]pschema.RawMessage{}, +} + +// Minimal set of methods to implement a basic provider. +type testProvider interface { + Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) + Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) + Create(ctx context.Context, req *rpc.CreateRequest) (*rpc.CreateResponse, error) + Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) + Update(ctx context.Context, req *rpc.UpdateRequest) (*rpc.UpdateResponse, error) + Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) + Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) + Call(ctx context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) +} + +var testProviders = func() map[string]testProvider { + ep := &echoProvider{} + + testProviders := map[string]testProvider{ + "testprovider:index:Random": &randomProvider{}, + "testprovider:index:Echo": ep, + "testprovider:index:Echo/doEchoMethod": ep, + "testprovider:index:doEcho": ep, + "testprovider:index:doMultiEcho": ep, + "testprovider:index:FailsOnDelete": &failsOnDeleteProvider{}, + "testprovider:index:FailsOnCreate": &failsOnCreateProvider{}, + } + + return testProviders +}() + +func providerForURN(urn string) (testProvider, string, bool) { + ty := string(resource.URN(urn).Type()) + provider, ok := testProviders[ty] + return provider, ty, ok +} + +//nolint:unused,deadcode +func main() { + if err := provider.Main(providerName, func(host *provider.HostClient) (rpc.ResourceProviderServer, error) { + return makeProvider(host, providerName, version) + }); err != nil { + cmdutil.ExitError(err.Error()) + } +} + +type testproviderProvider struct { + rpc.UnimplementedResourceProviderServer + + parameter string + + host *provider.HostClient + name string + version string +} + +func makeProvider(host *provider.HostClient, name, version string) (rpc.ResourceProviderServer, error) { + // Return the new provider + return &testproviderProvider{ + host: host, + name: name, + version: version, + }, nil +} + +// CheckConfig validates the configuration for this provider. +func (p *testproviderProvider) CheckConfig(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + return &rpc.CheckResponse{Inputs: req.GetNews()}, nil +} + +// DiffConfig diffs the configuration for this provider. +func (p *testproviderProvider) DiffConfig(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + return &rpc.DiffResponse{}, nil +} + +// Configure configures the resource provider with "globals" that control its behavior. +func (p *testproviderProvider) Configure(_ context.Context, req *rpc.ConfigureRequest) (*rpc.ConfigureResponse, error) { + return &rpc.ConfigureResponse{ + AcceptSecrets: true, + }, nil +} + +func (p *testproviderProvider) Parameterize(_ context.Context, req *rpc.ParameterizeRequest) (*rpc.ParameterizeResponse, error) { + switch params := req.GetParameters().(type) { + case *rpc.ParameterizeRequest_Args: + args := params.Args.Args + if len(args) != 1 { + return nil, fmt.Errorf("expected exactly one argument") + } + p.parameter = args[0] + case *rpc.ParameterizeRequest_Value: + val := string(params.Value.Value) + if val == "" { + return nil, fmt.Errorf("expected a non-empty string value") + } + p.parameter = val + default: + return nil, fmt.Errorf("unexpected parameter type") + } + + for k, prov := range testProviders { + testProviders[strings.Replace(k, "testprovider", p.parameter, 1)] = prov + } + + return &rpc.ParameterizeResponse{ + Name: p.parameter, + Version: version, + }, nil +} + +// Invoke dynamically executes a built-in function in the provider. +func (p *testproviderProvider) Invoke(_ context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { + if p, ok := testProviders[req.GetTok()]; ok { + return p.Invoke(context.Background(), req) + } + + tok := req.GetTok() + if tok == "testprovider:index:returnArgs" { + return &rpc.InvokeResponse{ + Return: req.Args, + }, nil + } + return nil, fmt.Errorf("Unknown Invoke token '%s'", tok) +} + +// StreamInvoke dynamically executes a built-in function in the provider. The result is streamed +// back as a series of messages. +func (p *testproviderProvider) StreamInvoke(req *rpc.InvokeRequest, + server rpc.ResourceProvider_StreamInvokeServer, +) error { + tok := req.GetTok() + return fmt.Errorf("Unknown StreamInvoke token '%s'", tok) +} + +func (p *testproviderProvider) Call(_ context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) { + tok := req.GetTok() + + if p, ok := testProviders[tok]; ok { + return p.Call(context.Background(), req) + } + + return nil, fmt.Errorf("Unknown Call token '%s'", tok) +} + +func (p *testproviderProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Check(ctx, req) +} + +// Diff checks what impacts a hypothetical update will have on the resource's properties. +func (p *testproviderProvider) Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Diff(ctx, req) +} + +// Create allocates a new instance of the provided resource and returns its unique ID afterwards. +func (p *testproviderProvider) Create(ctx context.Context, req *rpc.CreateRequest) (*rpc.CreateResponse, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Create(ctx, req) +} + +// Read the current live state associated with a resource. +func (p *testproviderProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Read(ctx, req) +} + +// Update updates an existing resource with new values. +func (p *testproviderProvider) Update(ctx context.Context, req *rpc.UpdateRequest) (*rpc.UpdateResponse, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Update(ctx, req) +} + +// Delete tears down an existing resource with the given ID. If it fails, the resource is assumed +// to still exist. +func (p *testproviderProvider) Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) { + provider, ty, ok := providerForURN(req.GetUrn()) + if !ok { + return nil, fmt.Errorf("Unknown resource type '%s'", ty) + } + return provider.Delete(ctx, req) +} + +// Construct creates a new component resource. +func (p *testproviderProvider) Construct(ctx context.Context, req *rpc.ConstructRequest) (*rpc.ConstructResponse, error) { + if req.Type != "testprovider:index:Component" { + return nil, fmt.Errorf("unknown resource type %s", req.Type) + } + + return pulumiprovider.Construct( + ctx, req, p.host.EngineConn(), + func(ctx *pulumi.Context, typ, name string, inputs pulumiprovider.ConstructInputs, + options pulumi.ResourceOption, + ) (*pulumiprovider.ConstructResult, error) { + args := &ComponentArgs{} + if err := inputs.CopyTo(args); err != nil { + return nil, fmt.Errorf("setting args: %w", err) + } + + component, err := NewComponent(ctx, name, args, options) + if err != nil { + return nil, err + } + + return pulumiprovider.NewConstructResult(component) + }) +} + +// GetPluginInfo returns generic information about this plugin, like its version. +func (p *testproviderProvider) GetPluginInfo(context.Context, *emptypb.Empty) (*rpc.PluginInfo, error) { + return &rpc.PluginInfo{ + Version: p.version, + }, nil +} + +func (p *testproviderProvider) Attach(ctx context.Context, req *rpc.PluginAttach) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +// GetSchema returns the JSON-serialized schema for the provider. +func (p *testproviderProvider) GetSchema(ctx context.Context, + req *rpc.GetSchemaRequest, +) (*rpc.GetSchemaResponse, error) { + makeJSONString := func(v any) ([]byte, error) { + var out bytes.Buffer + encoder := json.NewEncoder(&out) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(v); err != nil { + return nil, err + } + return out.Bytes(), nil + } + + sch := providerSchema + // If we have a parameter, we'll return a copy of the provider's resources and + // functions -- this is just enough to test that the engine is calling + // Parameterize and GetSchema correctly. + if req.SubpackageName != "" { + if req.SubpackageName == p.parameter { + sch = pschema.PackageSpec{ + Name: p.parameter, + Version: "1.0.0", + Parameterization: &pschema.ParameterizationSpec{ + BaseProvider: pschema.BaseProviderSpec{ + Name: "testprovider", + Version: version, + }, + Parameter: []byte(p.parameter), + }, + Resources: map[string]pschema.ResourceSpec{}, + Functions: map[string]pschema.FunctionSpec{}, + } + + for k, r := range providerSchema.Resources { + sch.Resources[strings.Replace(k, "testprovider", p.parameter, 1)] = r + for k, m := range r.Methods { + r.Methods[k] = strings.Replace(m, "testprovider", p.parameter, 1) + } + } + for k, f := range providerSchema.Functions { + sch.Functions[strings.Replace(k, "testprovider", p.parameter, 1)] = f + for k, prop := range f.Inputs.Properties { + if prop.TypeSpec.Ref != "" { + prop.TypeSpec.Ref = strings.Replace(prop.TypeSpec.Ref, "testprovider", p.parameter, 1) + f.Inputs.Properties[k] = prop + } + } + } + } else { + return nil, fmt.Errorf("expected subpackage %s", req.SubpackageName) + } + } + + schemaJSON, err := makeJSONString(sch) + if err != nil { + return nil, err + } + return &rpc.GetSchemaResponse{ + Schema: string(schemaJSON), + }, nil +} + +// Cancel signals the provider to gracefully shut down and abort any ongoing resource operations. +// Operations aborted in this way will return an error (e.g., `Update` and `Create` will either a +// creation error or an initialization error). Since Cancel is advisory and non-blocking, it is up +// to the host to decide how long to wait after Cancel is called before (e.g.) +// hard-closing any gRPC connection. +func (p *testproviderProvider) Cancel(context.Context, *emptypb.Empty) (*emptypb.Empty, error) { + return &emptypb.Empty{}, nil +} + +func (p *testproviderProvider) GetMapping(context.Context, *rpc.GetMappingRequest) (*rpc.GetMappingResponse, error) { + return &rpc.GetMappingResponse{}, nil +} diff --git a/pkg/tests/testprovider/random.go b/pkg/tests/testprovider/random.go new file mode 100644 index 00000000..c0b4132b --- /dev/null +++ b/pkg/tests/testprovider/random.go @@ -0,0 +1,192 @@ +// Copyright 2016-2021, Pulumi Corporation. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//go:build !all +// +build !all + +package main + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + + pschema "github.com/pulumi/pulumi/pkg/v3/codegen/schema" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin" + rpc "github.com/pulumi/pulumi/sdk/v3/proto/go" + + "google.golang.org/protobuf/types/known/emptypb" +) + +func init() { + providerSchema.Resources["testprovider:index:Random"] = pschema.ResourceSpec{ + ObjectTypeSpec: pschema.ObjectTypeSpec{ + Description: "A test resource that generates a random string of a given length and with an optional prefix.", + Properties: map[string]pschema.PropertySpec{ + "length": { + TypeSpec: pschema.TypeSpec{Type: "integer"}, + Description: "The length of the random string (not including the prefix, if any).", + }, + "prefix": { + TypeSpec: pschema.TypeSpec{Type: "string"}, + Description: "An optional prefix.", + }, + "result": { + TypeSpec: pschema.TypeSpec{Type: "string"}, + Description: "A random string.", + }, + }, + Type: "object", + }, + InputProperties: map[string]pschema.PropertySpec{ + "length": { + TypeSpec: pschema.TypeSpec{Type: "integer"}, + Description: "The length of the random string (not including the prefix, if any).", + }, + "prefix": { + TypeSpec: pschema.TypeSpec{Type: "string"}, + Description: "An optional prefix.", + }, + }, + } +} + +type randomProvider struct{} + +func (p *randomProvider) Check(ctx context.Context, req *rpc.CheckRequest) (*rpc.CheckResponse, error) { + return &rpc.CheckResponse{Inputs: req.News, Failures: nil}, nil +} + +func (p *randomProvider) Diff(ctx context.Context, req *rpc.DiffRequest) (*rpc.DiffResponse, error) { + olds, err := plugin.UnmarshalProperties(req.GetOlds(), plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}) + if err != nil { + return nil, err + } + + news, err := plugin.UnmarshalProperties(req.GetNews(), plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}) + if err != nil { + return nil, err + } + + d := olds.Diff(news) + var replaces []string + changes := rpc.DiffResponse_DIFF_NONE + if d.Changed("length") { + changes = rpc.DiffResponse_DIFF_SOME + replaces = append(replaces, "length") + } + + if d.Changed("prefix") { + changes = rpc.DiffResponse_DIFF_SOME + replaces = append(replaces, "prefix") + } + + return &rpc.DiffResponse{ + Changes: changes, + Replaces: replaces, + }, nil +} + +func (p *randomProvider) Create(ctx context.Context, req *rpc.CreateRequest) (*rpc.CreateResponse, error) { + inputs, err := plugin.UnmarshalProperties(req.GetProperties(), plugin.MarshalOptions{ + KeepUnknowns: true, + SkipNulls: true, + }) + if err != nil { + return nil, err + } + + if !inputs["length"].IsNumber() { + return nil, fmt.Errorf("expected input property 'length' of type 'number' but got '%s", inputs["length"].TypeString()) + } + + n := int(inputs["length"].NumberValue()) + + var prefix string + if p, has := inputs["prefix"]; has { + if !p.IsString() { + return nil, fmt.Errorf("expected input property 'prefix' of type 'string' but got '%s", p.TypeString()) + } + prefix = p.StringValue() + } + + // Actually "create" the random number + result, err := makeRandom(n) + if err != nil { + return nil, err + } + + outputs := resource.NewPropertyMapFromMap(map[string]interface{}{ + "length": n, + "result": prefix + result, + }) + if prefix != "" { + outputs["prefix"] = resource.NewStringProperty(prefix) + } + outputs["result"] = resource.MakeSecret(outputs["result"]) + + outputProperties, err := plugin.MarshalProperties( + outputs, + plugin.MarshalOptions{KeepUnknowns: true, SkipNulls: true}, + ) + if err != nil { + return nil, err + } + return &rpc.CreateResponse{ + Id: result, + Properties: outputProperties, + }, nil +} + +func (p *randomProvider) Read(ctx context.Context, req *rpc.ReadRequest) (*rpc.ReadResponse, error) { + // Just return back the input state. + return &rpc.ReadResponse{ + Id: req.Id, + Properties: req.Properties, + }, nil +} + +func (p *randomProvider) Update(ctx context.Context, req *rpc.UpdateRequest) (*rpc.UpdateResponse, error) { + // Our Random resource will never be updated - if there is a diff, it will be a replacement. + panic("Update not implemented") +} + +func (p *randomProvider) Delete(ctx context.Context, req *rpc.DeleteRequest) (*emptypb.Empty, error) { + // Note that for our Random resource, we don't have to do anything on Delete. + return &emptypb.Empty{}, nil +} + +func (p *randomProvider) Invoke(ctx context.Context, req *rpc.InvokeRequest) (*rpc.InvokeResponse, error) { + // The random provider doesn't support any invokes currently. + panic("Invoke not implemented") +} + +func (p *randomProvider) Call(ctx context.Context, req *rpc.CallRequest) (*rpc.CallResponse, error) { + // The random provider doesn't support any call currently. + panic("Call not implemented") +} + +func makeRandom(length int) (string, error) { + charset := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + result := make([]rune, length) + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[num.Int64()] + } + return string(result), nil +}