pulumi / pulumi

Pulumi - Infrastructure as Code in any programming language 🚀
https://www.pulumi.com
Apache License 2.0
21.65k stars 1.11k forks source link

[Go SDK] Transform API should accept an Output for the returned property map #16756

Open EronWright opened 2 months ago

EronWright commented 2 months ago

Consider changing the transform API to accept an Output for the Props (as a whole). The current approach accepts pulumi.Map (a map[string]Input), which is difficult to work with, e.g. when transforming inputs asynchronously. One turns to using internals.UnsafeAwaitOutput as a workaround (see the inner transform function in the examples), but it would be preferable to simply do:

return &pulumi.ResourceTransformResult{
    Props: rta.Props.ToMapOutputWithContext(ctx).ApplyT(applier),
    Opts:  rta.Opts,
}

Here's a couple of examples, showing complex transformations of a Kubernetes object (metadata, spec as input). Observe how the metadata property is used to adjust the spec property (deeply).

applyPatchForceAnnotation := func(ctx context.Context, rta *pulumi.ResourceTransformArgs) *pulumi.ResourceTransformResult {
    transform := func(applier interface{}) {
        o := rta.Props.ToMapOutputWithContext(ctx).ApplyT(applier)
        r, err := internals.UnsafeAwaitOutput(ctx, o)
        if err != nil {
            panic(err)
        }
        rta.Props = r.Value.(pulumi.Map)
    }

    switch rta.Type {
    case "kubernetes:helm.sh/v4:Chart":
        // Do nothing for Helm charts
    default:
        transform(func(obj map[string]any) pulumi.Map {
            // note: obj is an ordinary Unstructured object at this point.
            unstructured.SetNestedField(obj, "true", "metadata", "annotations", "pulumi.com/patchForce")
            return pulumi.ToMap(obj)
        })
    }
    return &pulumi.ResourceTransformResult{
        Props: rta.Props,
        Opts:  rta.Opts,
    }
}

transformImage := func(ctx context.Context, rta *pulumi.ResourceTransformArgs) *pulumi.ResourceTransformResult {
    transform := func(applier interface{}) {
        o := rta.Props.ToMapOutputWithContext(ctx).ApplyT(applier)
        r, err := internals.UnsafeAwaitOutput(ctx, o)
        if err != nil {
            panic(err)
        }
        rta.Props = r.Value.(pulumi.Map)
    }

    switch rta.Type {
    case "kubernetes:apps/v1:Deployment":
        transform(func(obj map[string]any) pulumi.Map {
            // note: obj is an ordinary Unstructured object at this point.

            // adjust the container image based on the labels of the deployment (for demonstration purposes)
            labels, _, _ := unstructured.NestedStringMap(obj, "metadata", "labels")
            if app, ok := labels["app.kubernetes.io/instance"]; ok && app == "ingresscontroller" {
                // change the image field for demonstration purposes
                containers, _, _ := unstructured.NestedFieldNoCopy(obj, "spec", "template", "spec", "containers")
                container := containers.([]any)[0].(map[string]any)
                unstructured.SetNestedField(container, "nginx/nginx-ingress:2.3.1-patched", "image")
            }

            return pulumi.ToMap(obj)
        })
    }

    return &pulumi.ResourceTransformResult{
        Props: rta.Props,
        Opts:  rta.Opts,
    }
}
Frassle commented 2 months ago

While I agree this is a good example scenario showing that the current Map output makes for a difficult function to write, I don't think it's safe to change the return value to an MapOutput.

The issue with an MapOutput is the whole value may resolve to unknown and there's no way on the engine/provider protocol to indicate that the entire state of a resource is unknown. We always expect to know what the top-level shape of a resource is, i.e. what top level keys are set. In the case where the props resolve to unknown the only value the SDK could send back to the engine is an empty map, and the behaviour of that is definitely not the same as being able to say it's an unknown map.

In this case, I think the best thing to do is to rewrite the apply to use all on the 'metadata' and 'spec' keys but to return just the 'spec' map to set the 'spec' property with.

There's probably output combinators we could write to make this easier, especially with all the nesting, and the Go SDK is definitely the most tricky language to work with outputs in due to it's non-generic nature.