pulumi-kubernetes icon indicating copy to clipboard operation
pulumi-kubernetes copied to clipboard

Document mocking capabilities for v4 Helm Chart

Open blampe opened this issue 1 year ago • 1 comments

Users were previously able to inject resources into a v3 Helm Chart via the Call method, but the v4 Chart no longer invokes this client-side.

Let's put together an example of how to mock the v4 Chart resource.

blampe avatar Aug 21 '24 18:08 blampe

If this works for the customer we can update docs to reflect it.

❯ cat main.go
package main

import (
	helmv4 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v4"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

func createChart(ctx *pulumi.Context) (*helmv4.Chart, error) {
	return helmv4.NewChart(ctx, "nginx", &helmv4.ChartArgs{
		Chart:   pulumi.String("oci://registry-1.docker.io/bitnamicharts/nginx"),
		Version: pulumi.String("16.0.7"),
	})
}

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		chart, err := createChart(ctx)
		if err != nil {
			return err
		}
		ctx.Export("resources", chart.Resources)
		return nil
	})
}

❯ cat main_test.go
package main

import (
	"fmt"
	"testing"

	"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/stretchr/testify/assert"
)

type HelmMocks []map[string]interface{}

func (m HelmMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
	// Copy our inputs.
	outputs := args.Inputs

	// Convert our map into a PropertyValue Pulumi understands.
	values := []resource.PropertyValue{}
	for _, v := range m {
		values = append(values, resource.NewObjectProperty(resource.NewPropertyMapFromMap(v)))
	}

	// Add our resources to the chart's output.
	outputs["resources"] = resource.NewArrayProperty(values)

	return args.Name + "_id", outputs, nil
}

func (HelmMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
	return nil, fmt.Errorf("not implemented")
}

func TestHelm(t *testing.T) {

	mockResources := []map[string]any{
		{"foo": "bar"},
	}

	pulumi.Run(
		func(ctx *pulumi.Context) error {
			chart, err := createChart(ctx)
			if err != nil {
				return err
			}

			pulumi.All(chart.Resources).ApplyT(func(all []any) error {
				resources := all[0].([]any)

				assert.Len(t, resources, 1)
				return nil
			})

			return nil

		},
		pulumi.WithMocks("project", "stack", HelmMocks(mockResources)),
	)
}

blampe avatar Oct 01 '24 19:10 blampe

Here's a revised example that shows how to provide fake children, to be able to exercise more of a complex resource graph. In this example, the code to be tested is encapsulated as a component resource.

// main.go
package main

import (
	"context"
	"errors"

	corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
	helmv4 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v4"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)

type NginxComponentArgs struct {
	ServiceType pulumi.StringInput
}

// NginxComponent is a component that encapsulates an Nginx chart.
type nginxComponent struct {
	pulumi.ResourceState

	//Output properties of the component
	Chart     *helmv4.Chart          `pulumi:"chart"`
	IngressIp pulumi.StringPtrOutput `pulumi:"ingressIp"`
}

func NewNginxComponent(ctx *pulumi.Context, name string, args *NginxComponentArgs, opts ...pulumi.ResourceOption) (*nginxComponent, error) {
	component := &nginxComponent{}
	err := ctx.RegisterComponentResource("example:NginxComponent", name, component, opts...)
	if err != nil {
		return nil, err
	}

	chart, err := helmv4.NewChart(ctx, name, &helmv4.ChartArgs{
		Chart:   pulumi.String("oci://registry-1.docker.io/bitnamicharts/nginx"),
		Version: pulumi.String("16.0.7"),
		Values: pulumi.Map{
			"serviceType": args.ServiceType,
		},
	}, pulumi.Parent(component))
	if err != nil {
		return nil, err
	}
	component.Chart = chart

	ingressIp := chart.Resources.ApplyTWithContext(ctx.Context(), func(ctx context.Context, resources []any) (pulumi.StringPtrOutput, error) {
		for _, r := range resources {
			switch r := r.(type) {
			case *corev1.Service:
				return r.Status.LoadBalancer().Ingress().Index(pulumi.Int(0)).Ip(), nil
			}
		}
		return pulumi.StringPtrOutput{}, errors.New("service not found")
	}).(pulumi.StringPtrOutput)
	component.IngressIp = ingressIp

	return component, err
}

func main() {
	pulumi.Run(func(ctx *pulumi.Context) error {
		nginx, err := NewNginxComponent(ctx, "nginx", &NginxComponentArgs{
			ServiceType: pulumi.String("LoadBalancer"),
		})
		if err != nil {
			return err
		}
		ctx.Export("ingressIp", nginx.IngressIp)
		return nil
	})
}
// main_test.go
package main

import (
	"context"
	"fmt"
	"testing"

	corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
	metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
	"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
	"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
	"github.com/pulumi/pulumi/sdk/v3/go/pulumi/internals"
	"github.com/stretchr/testify/assert"
)

type HelmMocks struct {
	Context *pulumi.Context
}

func (m HelmMocks) NewResource(args pulumi.MockResourceArgs) (string, resource.PropertyMap, error) {
	outputs := args.Inputs

	switch {
	case args.TypeToken == "kubernetes:helm.sh/v4:Chart" && args.RegisterRPC.Remote:
		// mock the Chart component resource by registering some child resources
		chart := &pulumi.ResourceState{}
		err := m.Context.RegisterComponentResource("kubernetes:helm.sh/v4:Chart", "nginx", chart)
		if err != nil {
			return "", nil, err
		}
		values := args.Inputs["values"].ObjectValue()

		svc, err := corev1.NewService(m.Context, "foo:default/nginx", &corev1.ServiceArgs{
			Metadata: &metav1.ObjectMetaArgs{
				Name:      pulumi.String("nginx"),
				Namespace: pulumi.String("default"),
			},
			Spec: &corev1.ServiceSpecArgs{
				Type: pulumi.StringPtr(values["serviceType"].StringValue()),
			},
		}, pulumi.Parent(chart))
		if err != nil {
			return "", nil, err
		}

		outputs["resources"] = resource.NewArrayProperty([]resource.PropertyValue{
			makeResourceReference(m.Context.Context(), svc),
		})
		return "", outputs, nil

	case args.TypeToken == "kubernetes:core/v1:Service":
		// mock the Service resource by returning a fake ingress IP address
		outputs["status"] = resource.NewObjectProperty(resource.NewPropertyMapFromMap(map[string]interface{}{
			"loadBalancer": map[string]interface{}{
				"ingress": []map[string]interface{}{
					{"ip": "127.0.0.1"},
				},
			},
		}))
		return "default/nginx", outputs, nil

	default:
		return args.ID, args.Inputs, nil
	}
}

func (HelmMocks) Call(args pulumi.MockCallArgs) (resource.PropertyMap, error) {
	return nil, fmt.Errorf("not implemented")
}

func makeResourceReference(ctx context.Context, v pulumi.Resource) resource.PropertyValue {
	urn, err := internals.UnsafeAwaitOutput(ctx, v.URN())
	contract.AssertNoErrorf(err, "Failed to await URN: %v", err)
	contract.Assertf(urn.Known, "URN must be known")
	contract.Assertf(!urn.Secret, "URN must not be secret")

	if custom, ok := v.(pulumi.CustomResource); ok {
		id, err := internals.UnsafeAwaitOutput(ctx, custom.ID())
		contract.AssertNoErrorf(err, "Failed to await ID: %v", err)
		contract.Assertf(!id.Secret, "CustomResource must not have a secret ID")

		return resource.MakeCustomResourceReference(resource.URN(urn.Value.(pulumi.URN)), resource.ID(id.Value.(pulumi.ID)), "")
	}

	return resource.MakeComponentResourceReference(resource.URN(urn.Value.(pulumi.URN)), "")
}

func TestHelm(t *testing.T) {

	mocks := &HelmMocks{}

	err := pulumi.RunErr(
		func(ctx *pulumi.Context) error {
			mocks.Context = ctx
			await := func(v pulumi.Output) any {
				res, err := internals.UnsafeAwaitOutput(ctx.Context(), v)
				contract.AssertNoErrorf(err, "failed to await value: %v", err)
				return res.Value
			}

			// execute the code that is to be tested
			nginx, err := NewNginxComponent(ctx, "foo", &NginxComponentArgs{
				ServiceType: pulumi.String("LoadBalancer"),
			})
			if err != nil {
				return err
			}

			// validate the chart and resources
			assert.NotNil(t, nginx.Chart, "chart is nil")
			resources := await(nginx.Chart.Resources).([]any)
			assert.Len(t, resources, 1, "chart has wrong number of children")
			svc := resources[0].(*corev1.Service)
			assert.NotNil(t, svc, "service resource is nil")
			svcType := await(svc.Spec.Type()).(*string)
			assert.NotNil(t, svc, "service type is nil")
			assert.Equal(t, "LoadBalancer", *svcType, "service type has unexpected value")

			// validate the ingressIp
			ingressIp := await(nginx.IngressIp).(*string)
			assert.NotNil(t, ingressIp, "ingressIp is nil")
			assert.Equal(t, "127.0.0.1", *ingressIp, "ingressIp has unexpected value")

			return nil
		},
		pulumi.WithMocks("project", "stack", mocks),
	)
	assert.NoError(t, err, "expected run to succeed")
}

EronWright avatar Nov 05 '24 08:11 EronWright

I posted a PR to add an example to the examples repository.

EronWright avatar Nov 05 '24 18:11 EronWright