diff --git a/examples/examples_test.go b/examples/examples_test.go index cebe64bdd..d57d2b4dd 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -12,11 +12,12 @@ import ( "github.com/blang/semver" "github.com/pkg/errors" - "github.com/stretchr/testify/assert" + "github.com/pulumi/pulumi/pkg/resource" "github.com/pulumi/pulumi/pkg/resource/deploy/providers" "github.com/pulumi/pulumi/pkg/testing/integration" + "github.com/pulumi/pulumi/pkg/util/contract" ) func TestExamples(t *testing.T) { @@ -102,6 +103,72 @@ func TestExamples(t *testing.T) { })) } + // Add a secrets example: This deploys a program that spins up a bunch of custom resources with different sets + // of secret inputs. + examples = append(examples, integration.ProgramTestOptions{ + Dir: path.Join(cwd, "secrets"), + Dependencies: []string{"@pulumi/pulumi"}, + Config: map[string]string{ + "message": "plaintext message", + }, + Secrets: map[string]string{ + "apiKey": "FAKE_API_KEY_FOR_TESTING", + }, + ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) { + assert.NotNil(t, stackInfo.Deployment.SecretsProviders, "Deployment should have a secrets provider") + + isEncrypted := func(v interface{}) bool { + if m, ok := v.(map[string]interface{}); ok { + sigKey := m[resource.SigKey] + if sigKey == nil { + return false + } + + v, vOk := sigKey.(string) + if !vOk { + return false + } + + if v != resource.SecretSig { + return false + } + + ciphertext := m["ciphertext"] + if ciphertext == nil { + return false + } + + _, cOk := ciphertext.(string) + return cOk + } + + return false + } + + for _, res := range stackInfo.Deployment.Resources { + if res.Type == "pulumi-nodejs:dynamic:Resource" { + switch res.URN.Name() { + case "sValue", "sApply", "cValue", "cApply": + assert.Truef(t, isEncrypted(res.Inputs["value"]), "input value should be encrypted") + assert.Truef(t, isEncrypted(res.Outputs["value"]), "output value should be encrypted") + case "pValue", "pApply": + assert.Falsef(t, isEncrypted(res.Inputs["value"]), "input value should not be encrypted") + assert.Falsef(t, isEncrypted(res.Outputs["value"]), "output value should not be encrypted") + case "pDummy": + assert.Falsef(t, isEncrypted(res.Outputs["value"]), "output value should not be encrypted") + case "sDummy": + // Creation of this resource passes in a custom resource options to ensure that "value" is + // treated as secret. In the state file, we'll see this as an uncrypted input with an + // encrypted output. + assert.Truef(t, isEncrypted(res.Outputs["value"]), "output value should be encrypted") + default: + contract.Assertf(false, "unknown name type: %s", res.URN.Name()) + } + } + } + }, + }) + // The compat test only works on Node 6.10.X because its uses the old 0.10.0 pulumi package, which only supported // a single node version, since it had the native runtime component. if nodeVer, err := getNodeVersion(); err != nil && nodeVer.Major == 6 && nodeVer.Minor == 10 { diff --git a/examples/secrets/Pulumi.yaml b/examples/secrets/Pulumi.yaml new file mode 100644 index 000000000..0d8476a59 --- /dev/null +++ b/examples/secrets/Pulumi.yaml @@ -0,0 +1,3 @@ +name: secrets-dynamic +runtime: nodejs +description: A small example that uses our secrets support. diff --git a/examples/secrets/index.ts b/examples/secrets/index.ts new file mode 100644 index 000000000..0bb37e779 --- /dev/null +++ b/examples/secrets/index.ts @@ -0,0 +1,40 @@ +import * as pulumi from "@pulumi/pulumi"; + +import { ReflectResource, DummyResource } from "./provider"; + +const c = new pulumi.Config(); + +// ApiKey is an Output and marked as a secret. If it is used as an input for any resources, the value will +// be encrypted. +const apiKey = c.requireSecret("apiKey"); + +// A plaintext message. We could turn this into a secret after the fact by passing it to `pulumi.secret` if we wished. +const message = c.require("message"); + +// Secrets are viral. When you combine secrets with `pulumi.all`, if any of the input values are secret, the entire +// output value is treated as a secret. Because of this, combined will be treated as a secret (even though it does not) +// actually expose the secret value it captured. +const combined = pulumi.all([apiKey, message]).apply(([s, p]) => { + return p; +}) + +// Since these inputs are either directly secrets, or become secrets via an `apply` of a secret, we expect that in +// the state file, they will be encrypted. +export const secretMessage = new ReflectResource("sValue", apiKey).value; +export const secretApply = new ReflectResource("sApply", apiKey.apply(x => x.length)).value; + +// These are paintext values, so they will be stored as is in the state file. +export const plaintextMessage = new ReflectResource("pValue", message).value; +export const plaintextApply = new ReflectResource("pApply", message.length).value; + +// These are secrets, as well, based on the composition above. We expect that these will also be stored as secrets +// in the state file. +export const combinedMessage = new ReflectResource("cValue", combined).value; +export const combinedApply = new ReflectResource("cApply", combined.apply(x => x.length)).value; + +// The dummy resource just provides a single output named "value" with a simple message. But we can use +// `additionalSecretOutputs` as a way to enforce that it is treated as a secret. +export const dummyValue = new DummyResource("pDummy").value; +export const dummyValueAdditionalSecrets = new DummyResource("sDummy", { + additionalSecretOutputs: ["value"], +}).value; \ No newline at end of file diff --git a/examples/secrets/package.json b/examples/secrets/package.json new file mode 100644 index 000000000..913dd156a --- /dev/null +++ b/examples/secrets/package.json @@ -0,0 +1,9 @@ +{ + "name": "secrets", + "devDependencies": { + "@types/node": "latest" + }, + "peerDependencies": { + "@pulumi/pulumi": "latest" + } +} diff --git a/examples/secrets/provider.ts b/examples/secrets/provider.ts new file mode 100644 index 000000000..a25894108 --- /dev/null +++ b/examples/secrets/provider.ts @@ -0,0 +1,34 @@ +import * as pulumi from "@pulumi/pulumi"; +import * as dynamic from "@pulumi/pulumi/dynamic"; + +class ReflectProvider implements dynamic.ResourceProvider { + public check(olds: any, news: any) { return Promise.resolve({ inputs: news }); } + public diff(id: pulumi.ID, olds: any, news: any) { return Promise.resolve({}); } + public delete(id: pulumi.ID, props: any) { return Promise.resolve(); } + public create(inputs: any) { return Promise.resolve({ id: "0", outs: inputs }); } + public update(id: string, olds: any, news: any) { return Promise.resolve({ outs: news }); } +} + +export class ReflectResource extends dynamic.Resource { + public readonly value: pulumi.Output; + + constructor(name: string, value: pulumi.Input, opts?: pulumi.CustomResourceOptions) { + super(new ReflectProvider(), name, {value: value }, opts); + } +} + +class DummyProvider implements dynamic.ResourceProvider { + public check(olds: any, news: any) { return Promise.resolve({ inputs: news }); } + public diff(id: pulumi.ID, olds: any, news: any) { return Promise.resolve({}); } + public delete(id: pulumi.ID, props: any) { return Promise.resolve(); } + public create(inputs: any) { return Promise.resolve({ id: "0", outs: {"value": "hello"} }); } + public update(id: string, olds: any, news: any) { return Promise.resolve({ outs: news }); } +} + +export class DummyResource extends dynamic.Resource { + public readonly value: pulumi.Output; + + constructor(name: string, opts?: pulumi.CustomResourceOptions) { + super(new DummyProvider(), name, {}, opts); + } +} diff --git a/examples/secrets/tsconfig.json b/examples/secrets/tsconfig.json new file mode 100644 index 000000000..16caa48c2 --- /dev/null +++ b/examples/secrets/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "outDir": "bin", + "target": "es6", + "lib": [ + "es6" + ], + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "strictNullChecks": true + }, + "files": [ + "index.ts" + ] +}