diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ba49d40..6b2b2b834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ - `refresh` will now warn instead of returning an error when it notices a resource is in an unhealthy state. This is in service of https://github.com/pulumi/pulumi/issues/2633. +- A new "test mode" can be enabled by setting the `PULUMI_TEST_MODE` environment variable to + `true` in either the Node.js or Python SDK. This new mode allows you to unit test your Pulumi programs + using standard test harnesses, without needing to run the program using the Pulumi CLI. In this mode, limited + functionality is available, however basic resource object allocation with input properties will work. + Note that no actual engine operations will occur in this mode, and that you'll need to use the + `PULUMI_CONFIG`, `PULUMI_NODEJS_PROJECT`, and `PULUMI_NODEJS_STACK` environment variables to control settings + the CLI would have otherwise managed for you. ## 0.17.5 (Released April 8, 2019) diff --git a/sdk/nodejs/metadata.ts b/sdk/nodejs/metadata.ts index 8a8ab03c5..94e73eee8 100644 --- a/sdk/nodejs/metadata.ts +++ b/sdk/nodejs/metadata.ts @@ -20,19 +20,11 @@ import * as runtime from "./runtime"; * getProject returns the current project name. It throws an exception if none is registered. */ export function getProject(): string { - const project = runtime.getProject(); - if (project) { - return project; - } - throw new Error("Project unknown; are you using the Pulumi CLI?"); + return runtime.getProject(); } /** * getStack returns the current stack name. It throws an exception if none is registered. */ export function getStack(): string { - const stack = runtime.getStack(); - if (stack) { - return stack; - } - throw new Error("Stack unknown; are you using the Pulumi CLI?"); + return runtime.getStack(); } diff --git a/sdk/nodejs/runtime/resource.ts b/sdk/nodejs/runtime/resource.ts index 98313f62e..4133beb1a 100644 --- a/sdk/nodejs/runtime/resource.ts +++ b/sdk/nodejs/runtime/resource.ts @@ -15,7 +15,15 @@ import * as grpc from "grpc"; import * as log from "../log"; import { Input, Inputs, Output } from "../output"; -import { ComponentResource, CustomResource, CustomResourceOptions, ID, Resource, ResourceOptions, URN } from "../resource"; +import { + ComponentResource, + CustomResource, + CustomResourceOptions, + ID, + Resource, + ResourceOptions, + URN, +} from "../resource"; import { debuggablePromise } from "./debuggable"; import { @@ -29,7 +37,15 @@ import { transferProperties, unknownValue, } from "./rpc"; -import { excessiveDebugOutput, getMonitor, getRootResource, rpcKeepAlive, serialize } from "./settings"; +import { + excessiveDebugOutput, + getMonitor, + getProject, + getRootResource, + getStack, + rpcKeepAlive, + serialize, +} from "./settings"; const gstruct = require("google-protobuf/google/protobuf/struct_pb.js"); const resproto = require("../proto/resource_pb.js"); @@ -56,6 +72,13 @@ interface ResourceResolverOperation { propertyToDirectDependencyURNs: Map>; } +/** + * Creates a test URN in the case where the engine isn't available to give us one. + */ +function createTestUrn(t: string, name: string): string { + return `urn:pulumi:${getStack()}::${getProject()}::${t}::${name}`; +} + /** * Reads an existing custom resource's state from the resource monitor. Note that resources read in this way * will not be part of the resulting stack's state, as they are presumed to belong to another. @@ -69,7 +92,7 @@ export function readResource(res: Resource, t: string, name: string, props: Inpu const label = `resource:${name}[${t}]#...`; log.debug(`Reading resource: id=${Output.isInstance(id) ? "Output" : id}, t=${t}, name=${name}`); - const monitor: any = getMonitor(); + const monitor = getMonitor(); const resopAsync = prepareResource(label, res, true, props, opts); const preallocError = new Error(); @@ -91,18 +114,28 @@ export function readResource(res: Resource, t: string, name: string, props: Inpu // Now run the operation, serializing the invocation if necessary. const opLabel = `monitor.readResource(${label})`; runAsyncResourceOp(opLabel, async () => { - const resp: any = await debuggablePromise(new Promise((resolve, reject) => - monitor.readResource(req, (err: Error, innerResponse: any) => { - log.debug(`ReadResource RPC finished: ${label}; err: ${err}, resp: ${innerResponse}`); - if (err) { - preallocError.message = - `failed to read resource #${resolvedID} '${name}' [${t}]: ${err.message}`; - reject(preallocError); - } - else { - resolve(innerResponse); - } - })), opLabel); + let resp: any; + if (monitor) { + // If we're attached to the engine, make an RPC call and wait for it to resolve. + resp = await debuggablePromise(new Promise((resolve, reject) => + (monitor as any).readResource(req, (err: Error, innerResponse: any) => { + log.debug(`ReadResource RPC finished: ${label}; err: ${err}, resp: ${innerResponse}`); + if (err) { + preallocError.message = + `failed to read resource #${resolvedID} '${name}' [${t}]: ${err.message}`; + reject(preallocError); + } + else { + resolve(innerResponse); + } + })), opLabel); + } else { + // If we aren't attached to the engine, in test mode, mock up a fake response for testing purposes. + resp = { + getUrn: () => createTestUrn(t, name), + getProperties: () => req.getProperties(), + }; + } // Now resolve everything: the URN, the ID (supplied as input), and the output properties. resop.resolveURN(resp.getUrn()); @@ -122,7 +155,7 @@ export function registerResource(res: Resource, t: string, name: string, custom: const label = `resource:${name}[${t}]`; log.debug(`Registering resource: t=${t}, name=${name}, custom=${custom}`); - const monitor: any = getMonitor(); + const monitor = getMonitor(); const resopAsync = prepareResource(label, res, custom, props, opts); // In order to present a useful stack trace if an error does occur, we preallocate potential @@ -155,25 +188,36 @@ export function registerResource(res: Resource, t: string, name: string, custom: // Now run the operation, serializing the invocation if necessary. const opLabel = `monitor.registerResource(${label})`; runAsyncResourceOp(opLabel, async () => { - const resp: any = await debuggablePromise(new Promise((resolve, reject) => - monitor.registerResource(req, (err: grpc.ServiceError, innerResponse: any) => { - log.debug(`RegisterResource RPC finished: ${label}; err: ${err}, resp: ${innerResponse}`); - if (err) { - // If the monitor is unavailable, it is in the process of shutting down or has already - // shut down. Don't emit an error and don't do any more RPCs, just exit. - if (err.code === grpc.status.UNAVAILABLE) { - log.debug("Resource monitor is terminating"); - process.exit(0); - } + let resp: any; + if (monitor) { + // If we're running with an attachment to the engine, perform the operation. + resp = await debuggablePromise(new Promise((resolve, reject) => + (monitor as any).registerResource(req, (err: grpc.ServiceError, innerResponse: any) => { + log.debug(`RegisterResource RPC finished: ${label}; err: ${err}, resp: ${innerResponse}`); + if (err) { + // If the monitor is unavailable, it is in the process of shutting down or has already + // shut down. Don't emit an error and don't do any more RPCs, just exit. + if (err.code === grpc.status.UNAVAILABLE) { + log.debug("Resource monitor is terminating"); + process.exit(0); + } - // Node lets us hack the message as long as we do it before accessing the `stack` property. - preallocError.message = `failed to register new resource ${name} [${t}]: ${err.message}`; - reject(preallocError); - } - else { - resolve(innerResponse); - } - })), opLabel); + // Node lets us hack the message as long as we do it before accessing the `stack` property. + preallocError.message = `failed to register new resource ${name} [${t}]: ${err.message}`; + reject(preallocError); + } + else { + resolve(innerResponse); + } + })), opLabel); + } else { + // If we aren't attached to the engine, in test mode, mock up a fake response for testing purposes. + resp = { + getUrn: () => createTestUrn(t, name), + getId: () => undefined, + getObject: () => req.getObject(), + }; + } resop.resolveURN(resp.getUrn()); @@ -424,32 +468,33 @@ export function registerResourceOutputs(res: Resource, outputs: Inputs | Promise (excessiveDebugOutput ? `, outputs=${JSON.stringify(outputsObj)}` : ``)); // Fetch the monitor and make an RPC request. - const monitor: any = getMonitor(); + const monitor = getMonitor(); + if (monitor) { + const req = new resproto.RegisterResourceOutputsRequest(); + req.setUrn(urn); + req.setOutputs(outputsObj); - const req = new resproto.RegisterResourceOutputsRequest(); - req.setUrn(urn); - req.setOutputs(outputsObj); + const label = `monitor.registerResourceOutputs(${urn}, ...)`; + await debuggablePromise(new Promise((resolve, reject) => + (monitor as any).registerResourceOutputs(req, (err: grpc.ServiceError, innerResponse: any) => { + log.debug(`RegisterResourceOutputs RPC finished: urn=${urn}; `+ + `err: ${err}, resp: ${innerResponse}`); + if (err) { + // If the monitor is unavailable, it is in the process of shutting down or has already + // shut down. Don't emit an error and don't do any more RPCs, just exit. + if (err.code === grpc.status.UNAVAILABLE) { + log.debug("Resource monitor is terminating"); + process.exit(0); + } - const label = `monitor.registerResourceOutputs(${urn}, ...)`; - await debuggablePromise(new Promise((resolve, reject) => - monitor.registerResourceOutputs(req, (err: grpc.ServiceError, innerResponse: any) => { - log.debug(`RegisterResourceOutputs RPC finished: urn=${urn}; `+ - `err: ${err}, resp: ${innerResponse}`); - if (err) { - // If the monitor is unavailable, it is in the process of shutting down or has already - // shut down. Don't emit an error and don't do any more RPCs, just exit. - if (err.code === grpc.status.UNAVAILABLE) { - log.debug("Resource monitor is terminating"); - process.exit(0); + log.error(`Failed to end new resource registration '${urn}': ${err.stack}`); + reject(err); } - - log.error(`Failed to end new resource registration '${urn}': ${err.stack}`); - reject(err); - } - else { - resolve(); - } - })), label); + else { + resolve(); + } + })), label); + } }, false); } diff --git a/sdk/nodejs/runtime/settings.ts b/sdk/nodejs/runtime/settings.ts index 942a4ca81..2ae8ad7aa 100644 --- a/sdk/nodejs/runtime/settings.ts +++ b/sdk/nodejs/runtime/settings.ts @@ -14,6 +14,7 @@ import * as grpc from "grpc"; import { RunError } from "../errors"; +import * as log from "../log"; import { ComponentResource, URN } from "../resource"; import { debuggablePromise } from "./debuggable"; @@ -35,39 +36,89 @@ export interface Options { readonly parallel?: number; // the degree of parallelism for resource operations (default is serial). readonly engineAddr?: string; // a connection string to the engine's RPC, in case we need to reestablish. readonly monitorAddr?: string; // a connection string to the monitor's RPC, in case we need to reestablish. - - dryRun?: boolean; // whether we are performing a preview (true) or a real deployment (false). + readonly dryRun?: boolean; // whether we are performing a preview (true) or a real deployment (false). + readonly testModeEnabled?: boolean; // true if we're in testing mode (allows execution without the CLI). } /** - * _options are the current deployment options being used for this entire session. + * options are the current deployment options being used for this entire session. */ const options = loadOptions(); /* @internal Used only for testing purposes */ -export function setIsDryRun(val: boolean) { - options.dryRun = val; +export function _setIsDryRun(val: boolean) { + (options as any).dryRun = val; } /** - * Returns true if we're currently performing a dry-run, or false if this is a true update. + * Returns true if we're currently performing a dry-run, or false if this is a true update. Note that we + * always consider executions in test mode to be "dry-runs", since we will never actually carry out an update, + * and therefore certain output properties will never be resolved. */ export function isDryRun(): boolean { - return options.dryRun === true; + return options.dryRun === true || isTestModeEnabled(); +} + +/* @internal Used only for testing purposes */ +export function _setTestModeEnabled(val: boolean) { + (options as any).testModeEnabled = val; +} + +/** + * Returns true if test mode is enabled (PULUMI_TEST_MODE). + */ +export function isTestModeEnabled(): boolean { + return options.testModeEnabled === true; +} + +/** + * Checks that test mode is enabled and, if not, throws an error. + */ +function requireTestModeEnabled(): void { + if (!isTestModeEnabled()) { + throw new Error("Program run without the `pulumi` CLI; this may not be what you want " + + "(enable PULUMI_TEST_MODE to disable this error)"); + } } /** * Get the project being run by the current update. */ -export function getProject(): string | undefined { - return options.project; +export function getProject(): string { + if (options.project) { + return options.project; + } + + // If the project is missing, specialize the error. First, if test mode is disabled: + requireTestModeEnabled(); + + // And now an error if test mode is enabled, instructing how to manually configure the project: + throw new Error("Missing project name; for test mode, please set PULUMI_NODEJS_PROJECT"); +} + +/* @internal Used only for testing purposes. */ +export function _setProject(val: string) { + (options as any).project = val; } /** * Get the stack being targeted by the current update. */ -export function getStack(): string | undefined { - return options.stack; +export function getStack(): string { + if (options.stack) { + return options.stack; + } + + // If the stack is missing, specialize the error. First, if test mode is disabled: + requireTestModeEnabled(); + + // And now an error if test mode is enabled, instructing how to manually configure the stack: + throw new Error("Missing stack name; for test mode, please set PULUMI_NODEJS_STACK"); +} + +/* @internal Used only for testing purposes. */ +export function _setStack(val: string) { + (options as any).stack = val; } /** @@ -85,19 +136,18 @@ export function hasMonitor(): boolean { /** * getMonitor returns the current resource monitoring service client for RPC communications. */ -export function getMonitor(): Object { +export function getMonitor(): Object | undefined { if (!monitor) { const addr = options.monitorAddr; if (addr) { // Lazily initialize the RPC connection to the monitor. monitor = new resrpc.ResourceMonitorClient(addr, grpc.credentials.createInsecure()); } else { - // Otherwise, this is an error. - throw new RunError( - "Pulumi program not connected to the engine -- are you running with the `pulumi` CLI?"); + // If test mode isn't enabled, we can't run the program without an engine. + requireTestModeEnabled(); } } - return monitor!; + return monitor; } /** @@ -152,6 +202,7 @@ function loadOptions(): Options { parallel: parallel, monitorAddr: process.env["PULUMI_NODEJS_MONITOR"], engineAddr: process.env["PULUMI_NODEJS_ENGINE"], + testModeEnabled: (process.env["PULUMI_TEST_MODE"] === "true"), }; } diff --git a/sdk/nodejs/tests/output.spec.ts b/sdk/nodejs/tests/output.spec.ts index 36d9baad0..99712cb7a 100644 --- a/sdk/nodejs/tests/output.spec.ts +++ b/sdk/nodejs/tests/output.spec.ts @@ -40,7 +40,7 @@ function mustCompile(): Output { describe("output", () => { it("propagates true isKnown bit from inner Output", asyncTest(async () => { - runtime.setIsDryRun(true); + runtime._setIsDryRun(true); const output1 = new Output(new Set(), Promise.resolve("outer"), Promise.resolve(true)); const output2 = output1.apply(v => new Output(new Set(), Promise.resolve("inner"), Promise.resolve(true))); @@ -53,7 +53,7 @@ describe("output", () => { })); it("propagates false isKnown bit from inner Output", asyncTest(async () => { - runtime.setIsDryRun(true); + runtime._setIsDryRun(true); const output1 = new Output(new Set(), Promise.resolve("outer"), Promise.resolve(true)); const output2 = output1.apply(v => new Output(new Set(), Promise.resolve("inner"), Promise.resolve(false))); @@ -66,7 +66,7 @@ describe("output", () => { })); it("can await even when isKnown is a rejected promise.", asyncTest(async () => { - runtime.setIsDryRun(true); + runtime._setIsDryRun(true); const output1 = new Output(new Set(), Promise.resolve("outer"), Promise.resolve(true)); const output2 = output1.apply(v => new Output(new Set(), Promise.resolve("inner"), Promise.reject(new Error()))); diff --git a/sdk/nodejs/tests/testmode.spec.ts b/sdk/nodejs/tests/testmode.spec.ts new file mode 100644 index 000000000..27d21f1f6 --- /dev/null +++ b/sdk/nodejs/tests/testmode.spec.ts @@ -0,0 +1,70 @@ +// Copyright 2016-2018, 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. + +import * as assert from "assert"; +import { Output } from "../output"; +import { CustomResource } from "../resource"; +import * as runtime from "../runtime"; + +class FakeResource extends CustomResource { + public x?: Output; + constructor(name: string, props?: { x: number }) { + super("nodejs:test:FakeResource", name, props); + } +} + +const testModeDisabledError = (err: Error) => { + return err.message === "Program run without the `pulumi` CLI; this may not be what you want " + + "(enable PULUMI_TEST_MODE to disable this error)"; +}; + +describe("testMode", () => { + it("rejects non-test mode", () => { + // Allocating a resource directly while not in test mode errors out. + assert.throws(() => { const _ = new FakeResource("fake"); }, testModeDisabledError); + // Fetching the project name while not in test mode errors out. + assert.throws(() => { const _ = runtime.getProject(); }, testModeDisabledError); + // Fetching the stack name while not in test mode errors out. + assert.throws(() => { const _ = runtime.getStack(); }, testModeDisabledError); + }); + it("accepts test mode", () => { + (async () => { + // Set up all the test mode envvars, so that the test will pass. + runtime._setTestModeEnabled(true); + const testProject = "TestProject"; + runtime._setProject(testProject); + const testStack = "TestStack"; + runtime._setStack(testStack); + try { + // Allocating a resource directly while in test mode succeeds. + let res: FakeResource | undefined; + assert.doesNotThrow(() => { res = new FakeResource("fake", { x: 42 }); }); + const x = await new Promise((resolve) => res!.x!.apply(resolve)); + assert.equal(x, 42); + // Fetching the project name while in test mode succeeds. + let project: string | undefined; + assert.doesNotThrow(() => { project = runtime.getProject(); }); + assert.equal(project, testProject); + // Fetching the stack name while in test mode succeeds. + let stack: string | undefined; + assert.doesNotThrow(() => { stack = runtime.getStack(); }); + assert.equal(stack, testStack); + } finally { + runtime._setTestModeEnabled(false); + runtime._setProject(""); + runtime._setStack(""); + } + })(); + }); +}); diff --git a/sdk/nodejs/tsconfig.json b/sdk/nodejs/tsconfig.json index 12216f44a..ee48a0a4a 100644 --- a/sdk/nodejs/tsconfig.json +++ b/sdk/nodejs/tsconfig.json @@ -58,6 +58,7 @@ "tests/init.spec.ts", "tests/iterable.spec.ts", "tests/output.spec.ts", + "tests/testmode.spec.ts", "tests/unwrap.spec.ts", "tests/util.ts", "tests/runtime/closureLoader.spec.ts", diff --git a/sdk/python/lib/pulumi/runtime/resource.py b/sdk/python/lib/pulumi/runtime/resource.py index 7873b3ef1..4f44ec790 100644 --- a/sdk/python/lib/pulumi/runtime/resource.py +++ b/sdk/python/lib/pulumi/runtime/resource.py @@ -15,6 +15,7 @@ import asyncio import sys import traceback from typing import Optional, Any, Callable, List, NamedTuple, Dict, Set, TYPE_CHECKING +from google.protobuf import struct_pb2 import grpc from . import rpc, settings, known_types @@ -187,17 +188,23 @@ def register_resource(res: 'Resource', ty: str, name: str, custom: bool, props: ) def do_rpc_call(): - try: - return monitor.RegisterResource(req) - except grpc.RpcError as exn: - # See the comment on invoke for the justification for disabling - # this warning - # pylint: disable=no-member - if exn.code() == grpc.StatusCode.UNAVAILABLE: - sys.exit(0) + if monitor: + # If there is a monitor available, make the true RPC request to the engine. + try: + return monitor.RegisterResource(req) + except grpc.RpcError as exn: + # See the comment on invoke for the justification for disabling + # this warning + # pylint: disable=no-member + if exn.code() == grpc.StatusCode.UNAVAILABLE: + sys.exit(0) + + details = exn.details() + raise Exception(details) + else: + # If no monitor is available, we'll need to fake up a response, for testing. + return RegisterResponse(create_test_urn(ty, name), None, resolver.serialized_props) - details = exn.details() - raise Exception(details) resp = await asyncio.get_event_loop().run_in_executor(None, do_rpc_call) except Exception as exn: log.debug(f"exception when preparing or executing rpc: {traceback.format_exc()}") @@ -220,6 +227,7 @@ def register_resource(res: 'Resource', ty: str, name: str, custom: bool, props: asyncio.ensure_future(RPC_MANAGER.do_rpc("register resource", do_register)()) + def register_resource_outputs(res: 'Resource', outputs: 'Union[Inputs, Awaitable[Inputs], Output[Inputs]]'): async def do_register_resource_outputs(): urn = await res.urn.future() @@ -229,19 +237,42 @@ def register_resource_outputs(res: 'Resource', outputs: 'Union[Inputs, Awaitable req = resource_pb2.RegisterResourceOutputsRequest(urn=urn, outputs=serialized_props) def do_rpc_call(): - try: - return monitor.RegisterResourceOutputs(req) - except grpc.RpcError as exn: - # See the comment on invoke for the justification for disabling - # this warning - # pylint: disable=no-member - if exn.code() == grpc.StatusCode.UNAVAILABLE: - sys.exit(0) + if monitor: + # If there's an engine attached, perform the RPC. Otherwise, simply ignore it. + try: + return monitor.RegisterResourceOutputs(req) + except grpc.RpcError as exn: + # See the comment on invoke for the justification for disabling + # this warning + # pylint: disable=no-member + if exn.code() == grpc.StatusCode.UNAVAILABLE: + sys.exit(0) - details = exn.details() - raise Exception(details) + details = exn.details() + raise Exception(details) + else: + return None await asyncio.get_event_loop().run_in_executor(None, do_rpc_call) log.debug(f"resource registration successful: urn={urn}, props={serialized_props}") asyncio.ensure_future(RPC_MANAGER.do_rpc("register resource outputs", do_register_resource_outputs)()) + + +class RegisterResponse: + urn: str + id: str + object: struct_pb2.Struct + + # pylint: disable=redefined-builtin + def __init__(self, urn: str, id: str, object: struct_pb2.Struct): + self.urn = urn + self.id = id + self.object = object + + +def create_test_urn(ty: str, name: str) -> str: + """ + Creates a test URN for cases where the engine isn't available to give us one (i.e., test mode). + """ + return 'urn:pulumi:{0}::{1}::{2}::{3}'.format(settings.get_stack(), settings.get_project(), ty, name) diff --git a/sdk/python/lib/pulumi/runtime/settings.py b/sdk/python/lib/pulumi/runtime/settings.py index 2afa0181a..291680ed5 100644 --- a/sdk/python/lib/pulumi/runtime/settings.py +++ b/sdk/python/lib/pulumi/runtime/settings.py @@ -32,6 +32,7 @@ class Settings: stack: Optional[str] parallel: Optional[str] dry_run: Optional[bool] + test_mode_enabled: Optional[bool] """ A bag of properties for configuring the Pulumi Python language runtime. @@ -42,12 +43,14 @@ class Settings: project: Optional[str] = None, stack: Optional[str] = None, parallel: Optional[str] = None, - dry_run: Optional[bool] = None): + dry_run: Optional[bool] = None, + test_mode_enabled: Optional[bool] = None): # Save the metadata information. self.project = project self.stack = stack self.parallel = parallel self.dry_run = dry_run + self.test_mode_enabled = test_mode_enabled # Actually connect to the monitor/engine over gRPC. if monitor: @@ -81,18 +84,60 @@ def is_dry_run() -> bool: return True if SETTINGS.dry_run else False +def is_test_mode_enabled() -> bool: + """ + Returns true if test mode is enabled (PULUMI_TEST_MODE). + """ + return True if SETTINGS.test_mode_enabled else False + + +def _set_test_mode_enabled(v: Optional[bool]): + """ + Enable or disable testing mode programmatically -- meant for testing only. + """ + SETTINGS.test_mode_enabled = v + + +def require_test_mode_enabled(): + if not is_test_mode_enabled(): + raise RunError('Program run without the `pulumi` CLI; this may not be what you want '+ + '(enable PULUMI_TEST_MODE to disable this error)') + + def get_project() -> Optional[str]: """ Returns the current project name. """ - return SETTINGS.project + project = SETTINGS.project + if not project: + require_test_mode_enabled() + raise RunError('Missing project name; for test mode, please set PULUMI_NODEJS_PROJECT') + return project + + +def _set_project(v: Optional[str]): + """ + Set the project name programmatically -- meant for testing only. + """ + SETTINGS.project = v def get_stack() -> Optional[str]: """ Returns the current stack name. """ - return SETTINGS.stack + stack = SETTINGS.stack + if not stack: + require_test_mode_enabled() + raise RunError('Missing stack name; for test mode, please set PULUMI_NODEJS_STACK') + return stack + + +def _set_stack(v: Optional[str]): + """ + Set the stack name programmatically -- meant for testing only. + """ + SETTINGS.stack = v def get_monitor() -> Optional[resource_pb2_grpc.ResourceMonitorStub]: @@ -101,7 +146,7 @@ def get_monitor() -> Optional[resource_pb2_grpc.ResourceMonitorStub]: """ monitor = SETTINGS.monitor if not monitor: - raise RunError('Pulumi program not connected to the engine -- are you running with the `pulumi` CLI?') + require_test_mode_enabled() return monitor diff --git a/sdk/python/lib/test/test_test_mode.py b/sdk/python/lib/test/test_test_mode.py new file mode 100644 index 000000000..1701abef2 --- /dev/null +++ b/sdk/python/lib/test/test_test_mode.py @@ -0,0 +1,76 @@ +# Copyright 2016-2018, 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. + +import asyncio +import unittest + +from pulumi import Output +from pulumi.errors import RunError +from pulumi.resource import CustomResource +from pulumi.runtime.settings import _set_project, _set_stack, _set_test_mode_enabled, get_project, get_stack + + +class FakeResource(CustomResource): + x: Output[float] + + def __init__(__self__, name, x=None): + __props__ = dict() + __props__['x'] = x + super(FakeResource, __self__).__init__('python:test:FakeResource', name, __props__, None) + + +def async_test(coro): + def wrapper(*args, **kwargs): + loop = asyncio.new_event_loop() + loop.run_until_complete(coro(*args, **kwargs)) + loop.close() + return wrapper + + +class TestModeTests(unittest.TestCase): + def test_reject_non_test_resource(self): + self.assertRaises(RunError, lambda: FakeResource("fake")) + + def test_reject_non_test_project(self): + self.assertRaises(RunError, lambda: get_project()) + + def test_reject_non_test_stack(self): + self.assertRaises(RunError, lambda: get_stack()) + + @async_test + async def test_test_mode_values(self): + # Swap in temporary values. + _set_test_mode_enabled(True) + test_project = "TestProject" + _set_project(test_project) + test_stack = "TestStack" + _set_stack(test_stack) + try: + # Now access the settings -- in test mode, this will work. + p = get_project() + self.assertEqual(test_project, p) + s = get_stack() + self.assertEqual(test_stack, s) + + # Allocate a resource and make sure its output property is set as expected. + x_fut = asyncio.Future() + res = FakeResource("fake", x=42) + res.x.apply(lambda x: x_fut.set_result(x)) + x_val = await x_fut + self.assertEqual(42, x_val) + finally: + # Reset global state back to its previous settings. + _set_test_mode_enabled(False) + _set_project(None) + _set_stack(None)