From 10ceee406e99e3db3d24570d3bcc791a02a5fff0 Mon Sep 17 00:00:00 2001 From: Justin Van Patten Date: Mon, 15 Nov 2021 11:22:44 -0800 Subject: [PATCH] [sdk/nodejs] Unmarshal output values in component provider (#8205) This adds support for unmarshaling output values in the Node.js provider. --- CHANGELOG_PENDING.md | 3 + sdk/nodejs/provider/server.ts | 66 +++- sdk/nodejs/tests/provider.spec.ts | 506 ++++++++++++++++++++++++++++++ 3 files changed, 565 insertions(+), 10 deletions(-) diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 1aa9185c6..22ea407fa 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -14,6 +14,9 @@ - [sdk/python] - Unmarshal output values in component provider. [#8212](https://github.com/pulumi/pulumi/pull/8212) +- [sdk/nodejs] - Unmarshal output values in component provider. + [#8205](https://github.com/pulumi/pulumi/pull/8205) + ### Bug Fixes - [engine] - Compute dependents correctly during targeted deletes. diff --git a/sdk/nodejs/provider/server.ts b/sdk/nodejs/provider/server.ts index 6a2b29007..402d74fba 100644 --- a/sdk/nodejs/provider/server.ts +++ b/sdk/nodejs/provider/server.ts @@ -94,6 +94,7 @@ class Server implements grpc.UntypedServiceImplementation { const resp = new provproto.ConfigureResponse(); resp.setAcceptsecrets(true); resp.setAcceptresources(true); + resp.setAcceptoutputs(true); callback(undefined, resp); } @@ -481,33 +482,78 @@ function configureRuntime(req: any, engineAddr: string) { runtime.setAllConfig(pulumiConfig, req.getConfigsecretkeysList()); } -// deserializeInputs deserializes the inputs struct and applies appropriate dependencies. -async function deserializeInputs(inputsStruct: any, inputDependencies: any): Promise { +/** + * deserializeInputs deserializes the inputs struct and applies appropriate dependencies. + * @internal + */ +export async function deserializeInputs(inputsStruct: any, inputDependencies: any): Promise { const result: Inputs = {}; + const deserializedInputs = runtime.deserializeProperties(inputsStruct); for (const k of Object.keys(deserializedInputs)) { - const inputDeps = inputDependencies.get(k); - const depsUrns: resource.URN[] = inputDeps?.getUrnsList() ?? []; - const deps = depsUrns.map(depUrn => new resource.DependencyResource(depUrn)); const input = deserializedInputs[k]; const isSecret = runtime.isRpcSecret(input); - const isResourceReference = resource.Resource.isInstance(input) - && depsUrns.length === 1 - && depsUrns[0] === await input.urn.promise(); - if (isResourceReference || (!isSecret && deps.length === 0)) { - // If it's a prompt value, return it directly without wrapping it as an output. + const depsUrns: resource.URN[] = inputDependencies.get(k)?.getUrnsList() ?? []; + + if (!isSecret && (depsUrns.length === 0 || containsOutputs(input) || await isResourceReference(input, depsUrns))) { + // If the input isn't a secret and either doesn't have any dependencies, already contains Outputs (from + // deserialized output values), or is a resource reference, then we can return it directly without + // wrapping it as an output. result[k] = input; } else { // Otherwise, wrap it in an output so we can handle secrets and/or track dependencies. // Note: If the value is or contains an unknown value, the Output will mark its value as // unknown automatically, so we just pass true for isKnown here. + const deps = depsUrns.map(depUrn => new resource.DependencyResource(depUrn)); result[k] = new Output(deps, Promise.resolve(runtime.unwrapRpcSecret(input)), Promise.resolve(true), Promise.resolve(isSecret), Promise.resolve([])); } } + return result; } +/** + * Returns true if the input is a resource reference. + */ +async function isResourceReference(input: any, deps: string[]): Promise { + return resource.Resource.isInstance(input) + && deps.length === 1 + && deps[0] === await input.urn.promise(); +} + +/** + * Returns true if the deserialized input contains Outputs (deeply), excluding properties of Resources. + * @internal + */ +export function containsOutputs(input: any): boolean { + if (Array.isArray(input)) { + for (const e of input) { + if (containsOutputs(e)) { + return true; + } + } + } + else if (typeof input === "object") { + if (Output.isInstance(input)) { + return true; + } + else if (resource.Resource.isInstance(input)) { + // Do not drill into instances of Resource because they will have properties that are + // instances of Output (e.g. urn, id, etc.) and we're only looking for instances of + // Output that aren't associated with a Resource. + return false; + } + + for (const k of Object.keys(input)) { + if (containsOutputs(input[k])) { + return true; + } + } + } + return false; +} + // grpcResponseFromError creates a gRPC response representing an error from a dynamic provider's // resource. This is typically either a creation error, in which the API server has (virtually) // rejected the resource, or an initialization error, where the API server has accepted the diff --git a/sdk/nodejs/tests/provider.spec.ts b/sdk/nodejs/tests/provider.spec.ts index 110cd2bc5..03dbec6d5 100644 --- a/sdk/nodejs/tests/provider.spec.ts +++ b/sdk/nodejs/tests/provider.spec.ts @@ -15,8 +15,41 @@ import * as assert from "assert"; import { asyncTest } from "./util"; +import * as pulumi from ".."; import * as internals from "../provider/internals"; +const gstruct = require("google-protobuf/google/protobuf/struct_pb.js"); + +class TestResource extends pulumi.CustomResource { + constructor(name: string, opts?: pulumi.CustomResourceOptions) { + super("test:index:TestResource", name, {}, opts); + } +} + +class TestModule implements pulumi.runtime.ResourceModule { + construct(name: string, type: string, urn: string): pulumi.Resource { + switch (type) { + case "test:index:TestResource": + return new TestResource(name, { urn }); + default: + throw new Error(`unknown resource type ${type}`); + } + } +} + +class TestMocks implements pulumi.runtime.Mocks { + call(args: pulumi.runtime.MockCallArgs): Record { + throw new Error(`unknown function ${args.token}`); + } + + newResource(args: pulumi.runtime.MockResourceArgs): { id: string | undefined; state: Record } { + return { + id: args.name + "_id", + state: args.inputs, + }; + } +} + describe("provider", () => { it("parses arguments generated by --logflow", asyncTest(async () => { const parsedArgs = internals.parseArgs(["--logtostderr", "-v=9", "--tracing", "127.0.0.1:6007", "127.0.0.1:12345"]); @@ -26,4 +59,477 @@ describe("provider", () => { assert.fail("failed to parse"); } })); + + describe("deserializeInputs", () => { + beforeEach(() => { + pulumi.runtime._reset(); + pulumi.runtime._resetResourcePackages(); + pulumi.runtime._resetResourceModules(); + }); + + async function assertOutputEqual( + actual: any, value: ((v: any) => Promise) | any, known: boolean, secret: boolean, deps?: pulumi.URN[]) { + assert.ok(pulumi.Output.isInstance(actual)); + + if (typeof value === "function") { + await value(await actual.promise()); + } else { + assert.deepStrictEqual(await actual.promise(), value); + } + + assert.deepStrictEqual(await actual.isKnown, known); + assert.deepStrictEqual(await actual.isSecret, secret); + + const actualDeps = new Set(); + const resources = await actual.allResources!(); + for (const r of resources) { + const urn = await r.urn.promise(); + actualDeps.add(urn); + } + assert.deepStrictEqual(actualDeps, new Set(deps ?? [])); + } + + function createSecret(value: any) { + return { + [pulumi.runtime.specialSigKey]: pulumi.runtime.specialSecretSig, + value, + }; + } + + function createResourceRef(urn: pulumi.URN, id?: pulumi.ID) { + return { + [pulumi.runtime.specialSigKey]: pulumi.runtime.specialResourceSig, + urn, + ...(id && { id }), + }; + } + + function createOutputValue(value?: any, secret?: boolean, dependencies?: pulumi.URN[]) { + return { + [pulumi.runtime.specialSigKey]: pulumi.runtime.specialOutputValueSig, + ...(value !== undefined && { value }), + ...(secret && { secret }), + ...(dependencies && { dependencies }), + }; + } + + const testURN = "urn:pulumi:stack::project::test:index:TestResource::name"; + const testID = "name_id"; + + const tests: { + name: string; + input: any; + deps?: string[]; + expected?: any; + assert?: (actual: any) => Promise; + }[] = [ + { + name: "unknown", + input: pulumi.runtime.unknownValue, + deps: ["fakeURN"], + assert: async(actual) => { + await assertOutputEqual(actual, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "array nested unknown", + input: [pulumi.runtime.unknownValue], + deps: ["fakeURN"], + assert: async(actual) => { + await assertOutputEqual(actual, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "object nested unknown", + input: { foo: pulumi.runtime.unknownValue }, + deps: ["fakeURN"], + assert: async(actual) => { + await assertOutputEqual(actual, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "unknown output value", + input: createOutputValue(undefined, false, ["fakeURN"]), + deps: ["fakeURN"], + assert: async(actual) => { + await assertOutputEqual(actual, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "unknown output value (no deps)", + input: createOutputValue(), + assert: async(actual) => { + await assertOutputEqual(actual, undefined, false, false); + }, + }, + { + name: "array nested unknown output value", + input: [createOutputValue(undefined, false, ["fakeURN"])], + deps: ["fakeURN"], + assert: async(actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "array nested unknown output value (no deps)", + input: [createOutputValue(undefined, false, ["fakeURN"])], + assert: async(actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "object nested unknown output value", + input: { foo: createOutputValue(undefined, false, ["fakeURN"]) }, + deps: ["fakeURN"], + assert: async(actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "object nested unknown output value (no deps)", + input: { foo: createOutputValue(undefined, false, ["fakeURN"]) }, + assert: async(actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, undefined, false, false, ["fakeURN"]); + }, + }, + { + name: "string value (no deps)", + input: "hi", + expected: "hi", + }, + { + name: "array nested string value (no deps)", + input: ["hi"], + expected: ["hi"], + }, + { + name: "object nested string value (no deps)", + input: { foo: "hi" }, + expected: { foo: "hi" }, + }, + { + name: "string output value", + input: createOutputValue("hi", false, ["fakeURN"]), + deps: ["fakeURN"], + assert: async (actual) => { + await assertOutputEqual(actual, "hi", true, false, ["fakeURN"]); + }, + }, + { + name: "string output value (no deps)", + input: createOutputValue("hi"), + assert: async (actual) => { + await assertOutputEqual(actual, "hi", true, false); + }, + }, + { + name: "array nested string output value", + input: [createOutputValue("hi", false, ["fakeURN"])], + deps: ["fakeURN"], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], "hi", true, false, ["fakeURN"]); + }, + }, + { + name: "array nested string output value (no deps)", + input: [createOutputValue("hi", false, ["fakeURN"])], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], "hi", true, false, ["fakeURN"]); + }, + }, + { + name: "object nested string output value", + input: { foo: createOutputValue("hi", false, ["fakeURN"]) }, + deps: ["fakeURN"], + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, "hi", true, false, ["fakeURN"]); + }, + }, + { + name: "object nested string output value (no deps)", + input: { foo: createOutputValue("hi", false, ["fakeURN"]) }, + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, "hi", true, false, ["fakeURN"]); + }, + }, + { + name: "string secret (no deps)", + input: createSecret("shh"), + assert: async (actual) => { + await assertOutputEqual(actual, "shh", true, true); + }, + }, + { + name: "array nested string secret (no deps)", + input: [createSecret("shh")], + assert: async (actual) => { + await assertOutputEqual(actual, ["shh"], true, true); + }, + }, + { + name: "object nested string secret (no deps)", + input: { foo: createSecret("shh") }, + assert: async (actual) => { + await assertOutputEqual(actual, { foo: "shh" }, true, true); + }, + }, + { + name: "string secret output value (no deps)", + input: createOutputValue("shh", true), + assert: async (actual) => { + await assertOutputEqual(actual, "shh", true, true); + }, + }, + { + name: "array nested string secret output value (no deps)", + input: [createOutputValue("shh", true)], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], "shh", true, true); + }, + }, + { + name: "object nested string secret output value (no deps)", + input: { foo: createOutputValue("shh", true) }, + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, "shh", true, true); + }, + }, + { + name: "string secret output value", + input: createOutputValue("shh", true, ["fakeURN1", "fakeURN2"]), + deps: ["fakeURN1", "fakeURN2"], + assert: async (actual) => { + await assertOutputEqual(actual, "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "string secret output value (no deps)", + input: createOutputValue("shh", true, ["fakeURN1", "fakeURN2"]), + assert: async (actual) => { + await assertOutputEqual(actual, "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "array nested string secret output value", + input: [createOutputValue("shh", true, ["fakeURN1", "fakeURN2"])], + deps: ["fakeURN1", "fakeURN2"], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "array nested string secret output value (no deps)", + input: [createOutputValue("shh", true, ["fakeURN1", "fakeURN2"])], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + await assertOutputEqual(actual[0], "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "object nested string secret output value", + input: { foo: createOutputValue("shh", true, ["fakeURN1", "fakeURN2"]) }, + deps: ["fakeURN1", "fakeURN2"], + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "object nested string secret output value (no deps)", + input: { foo: createOutputValue("shh", true, ["fakeURN1", "fakeURN2"]) }, + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + await assertOutputEqual(actual.foo, "shh", true, true, ["fakeURN1", "fakeURN2"]); + }, + }, + { + name: "resource ref", + input: createResourceRef(testURN, testID), + deps: [testURN], + assert: async (actual) => { + assert.ok(actual instanceof TestResource); + assert.deepStrictEqual(await actual.urn.promise(), testURN); + assert.deepStrictEqual(await actual.id.promise(), testID); + }, + }, + { + name: "resource ref (no deps)", + input: createResourceRef(testURN, testID), + assert: async (actual) => { + assert.ok(actual instanceof TestResource); + assert.deepStrictEqual(await actual.urn.promise(), testURN); + assert.deepStrictEqual(await actual.id.promise(), testID); + }, + }, + { + name: "array nested resource ref", + input: [createResourceRef(testURN, testID)], + deps: [testURN], + assert: async (actual) => { + await assertOutputEqual(actual, async (v: any) => { + assert.ok(Array.isArray(v)); + assert.ok(v[0] instanceof TestResource); + assert.deepStrictEqual(await v[0].urn.promise(), testURN); + assert.deepStrictEqual(await v[0].id.promise(), testID); + }, true, false, [testURN]); + }, + }, + { + name: "array nested resource ref (no deps)", + input: [createResourceRef(testURN, testID)], + assert: async (actual) => { + assert.ok(Array.isArray(actual)); + assert.ok(actual[0] instanceof TestResource); + assert.deepStrictEqual(await actual[0].urn.promise(), testURN); + assert.deepStrictEqual(await actual[0].id.promise(), testID); + }, + }, + { + name: "object nested resource ref", + input: { foo: createResourceRef(testURN, testID) }, + deps: [testURN], + assert: async (actual) => { + await assertOutputEqual(actual, async (v: any) => { + assert.ok(v.foo instanceof TestResource); + assert.deepStrictEqual(await v.foo.urn.promise(), testURN); + assert.deepStrictEqual(await v.foo.id.promise(), testID); + }, true, false, [testURN]); + }, + }, + { + name: "object nested resource ref (no deps)", + input: { foo: createResourceRef(testURN, testID) }, + assert: async (actual) => { + assert.ok(actual.foo instanceof TestResource); + assert.deepStrictEqual(await actual.foo.urn.promise(), testURN); + assert.deepStrictEqual(await actual.foo.id.promise(), testID); + }, + }, + { + name: "object nested resource ref and secret", + input: { + foo: createResourceRef(testURN, testID), + bar: createSecret("shh"), + }, + deps: [testURN], + assert: async (actual) => { + // Because there's a nested secret, the top-level property is an output. + await assertOutputEqual(actual, async (v: any) => { + assert.ok(v.foo instanceof TestResource); + assert.deepStrictEqual(await v.foo.urn.promise(), testURN); + assert.deepStrictEqual(await v.foo.id.promise(), testID); + assert.deepStrictEqual(v.bar, "shh"); + }, true, true, [testURN]); + }, + }, + { + name: "object nested resource ref and secret output value", + input: { + foo: createResourceRef(testURN, testID), + bar: createOutputValue("shh", true), + }, + deps: [testURN], + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + assert.ok(actual.foo instanceof TestResource); + assert.deepStrictEqual(await actual.foo.urn.promise(), testURN); + assert.deepStrictEqual(await actual.foo.id.promise(), testID); + await assertOutputEqual(actual.bar, "shh", true, true); + }, + }, + { + name: "object nested resource ref and secret output value (no deps)", + input: { + foo: createResourceRef(testURN, testID), + bar: createOutputValue("shh", true), + }, + assert: async (actual) => { + assert.ok(!pulumi.Output.isInstance(actual)); + assert.ok(actual.foo instanceof TestResource); + assert.deepStrictEqual(await actual.foo.urn.promise(), testURN); + assert.deepStrictEqual(await actual.foo.id.promise(), testID); + await assertOutputEqual(actual.bar, "shh", true, true); + }, + }, + ]; + for (const test of tests) { + it(`deserializes '${test.name}' correctly`, asyncTest(async () => { + pulumi.runtime.setMocks(new TestMocks(), "project", "stack", true); + pulumi.runtime.registerResourceModule("test", "index", new TestModule()); + new TestResource("name"); // Create an instance so it can be deserialized. + + const inputs = { value: test.input }; + const inputsStruct = gstruct.Struct.fromJavaScript(inputs); + const inputDependencies = { + get: () => ({ + getUrnsList: () => test.deps, + }), + }; + const result = await pulumi.provider.deserializeInputs(inputsStruct, inputDependencies); + const actual = result["value"]; + + if (test.assert) { + await test.assert(actual); + } else { + assert.deepStrictEqual(actual, test.expected); + } + })); + } + }); + + describe("containsOutputs", () => { + const tests: { + name: string; + input: any; + expected: boolean; + }[] = [ + { + name: "Output", + input: pulumi.Output.create("hi"), + expected: true, + }, + { + name: "[Output]", + input: [pulumi.Output.create("hi")], + expected: true, + }, + { + name: "{ foo: Output }", + input: { foo: pulumi.Output.create("hi") }, + expected: true, + }, + { + name: "Resource", + input: new pulumi.DependencyResource("fakeURN"), + expected: false, + }, + { + name: "[Resource]", + input: [new pulumi.DependencyResource("fakeURN")], + expected: false, + }, + { + name: "{ foo: Resource }", + input: { foo: new pulumi.DependencyResource("fakeURN") }, + expected: false, + }, + ]; + for (const test of tests) { + it(`${test.name} should return ${test.expected}`, () => { + const actual = pulumi.provider.containsOutputs(test.input); + assert.strictEqual(actual, test.expected); + }); + } + }); });