Allow multiple Pulumi SDKs side-by-side (#1132)

Prior to this change, if you ended up with multiple Pulumi SDK
packages loaded side-by-side, we would fail in obscure ways.  The
reason for this was that we initialize and store important state
in static variables.  In the case that you load the same library
twice, however, you end up with separate copies of said statics,
which means we would be missing engine RPC addresses and so on.

This change adds the ability to recover from this situation by
mirroring the initialized state in process-wide environment
variables.  By doing this, we can safely recover simply by reading
them back when we detect that they are missing.  I think we can
eventually go even further here, and eliminate the entry point
launcher shim altogether by simply having the engine launch the
Node program with the right environment variables.  This would
be a nice simplification to the system (fewer moving pieces).

There is still a risk that the separate copy is incompatible.
Presumably the reason for loading multiple copies is that the
NPM/Yarn version solver couldn't resolve to a shared version.
This may yield obscure failure modes should RPC interfaces change.
Figuring out what to do here is part of pulumi/pulumi#957.

This fixes pulumi/pulumi#777 and pulumi/pulumi#1017.
This commit is contained in:
Joe Duffy 2018-04-07 08:02:59 -07:00 committed by GitHub
parent b33d4d762c
commit 28033c22bc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 239 additions and 79 deletions

View file

@ -10,10 +10,6 @@ import { RunError } from "../../errors";
import * as log from "../../log";
import * as runtime from "../../runtime";
const grpc = require("grpc");
const engrpc = require("../../proto/engine_grpc_pb.js");
const resrpc = require("../../proto/resource_grpc_pb.js");
function usage(): void {
console.error(`usage: RUN <flags> [program] <[arg]...>`);
console.error(``);
@ -180,23 +176,17 @@ export function main(args: string[]): void {
return printErrorUsageAndExit(`error: --monitor=addr must be provided.`);
}
const monitor = new resrpc.ResourceMonitorClient(monitorAddr, grpc.credentials.createInsecure());
// If there is an engine argument, connect to it too.
let engine: Object | undefined;
const engineAddr: string | undefined = argv["engine"];
if (engineAddr) {
engine = new engrpc.EngineClient(engineAddr, grpc.credentials.createInsecure());
}
// Now configure the runtime and get it ready to run the program.
runtime.configure({
runtime.setOptions({
project: project,
stack: stack,
dryRun: dryRun,
parallel: parallel,
monitor: monitor,
engine: engine,
monitorAddr: monitorAddr,
engineAddr: engineAddr,
});
// Pluck out the program and arguments.

View file

@ -79,14 +79,19 @@ export function log(engine: any, sev: any, format: any, ...args: any[]): void {
const msg: string = util.format(format, ...args);
const keepAlive: () => void = rpcKeepAlive();
lastLog = lastLog.then(() => {
return new Promise((resolve) => {
const req = new engproto.LogRequest();
req.setSeverity(sev);
req.setMessage(msg);
engine.log(req, () => {
resolve(); // let the next log through
keepAlive(); // permit RPC channel tear-downs
});
return new Promise((resolve, reject) => {
try {
const req = new engproto.LogRequest();
req.setSeverity(sev);
req.setMessage(msg);
engine.log(req, () => {
resolve(); // let the next log through
keepAlive(); // permit RPC channel tear-downs
});
}
catch (err) {
reject(err);
}
});
});
}

View file

@ -5,14 +5,22 @@
import * as runtime from "./runtime";
/**
* getProject returns the current project name, or the empty string if there is none.
* getProject returns the current project name. It throws an exception if none is registered.
*/
export function getProject(): string {
return runtime.options.project || "project";
const project = runtime.getProject();
if (project) {
return project;
}
throw new Error("Project unknown; are you using the Pulumi CLI?");
}
/**
* getStack returns the current stack name, or the empty string if there is none.
* getStack returns the current stack name. It throws an exception if none is registered.
*/
export function getStack(): string {
return runtime.options.stack || "stack";
const stack = runtime.getStack();
if (stack) {
return stack;
}
throw new Error("Stack unknown; are you using the Pulumi CLI?");
}

View file

@ -238,7 +238,7 @@ export class Output<T> {
// During previews do not perform the apply if the engine was not able to
// give us an actual value for this Output.
const perform = await performApply;
if (runtime.options.dryRun && !perform) {
if (runtime.isDryRun() && !perform) {
return <U><any>undefined;
}

View file

@ -7,6 +7,13 @@ const configEnvKey = "PULUMI_CONFIG";
const config: {[key: string]: string} = {};
/**
* allConfig returns a copy of the full config map.
*/
export function allConfig(): {[key: string]: string} {
return Object.assign({}, config);
}
/**
* setConfig sets a configuration variable.
*/

View file

@ -1,7 +1,6 @@
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
import * as log from "../log";
import { options } from "./settings";
/**
* debugPromiseTimeout can be set to enable promises debugging. If it is -1, it has no effect. Be careful setting
@ -88,7 +87,7 @@ export function debuggablePromise<T>(p: Promise<T>, ctx?: any): Promise<T> {
* errorString produces a string from an error, conditionally including additional diagnostics.
*/
export function errorString(err: Error): string {
if (options.includeStackTraces && err.stack) {
if (err.stack) {
return err.stack;
}
return err.toString();

View file

@ -4,7 +4,7 @@ import * as log from "../log";
import { Inputs } from "../resource";
import { debuggablePromise } from "./debuggable";
import { deserializeProperties, serializeProperties } from "./rpc";
import { excessiveDebugOutput, getMonitor, options, rpcKeepAlive, serialize } from "./settings";
import { excessiveDebugOutput, getMonitor, rpcKeepAlive, serialize } from "./settings";
const gstruct = require("google-protobuf/google/protobuf/struct_pb.js");
const resproto = require("../proto/resource_pb.js");

View file

@ -11,7 +11,7 @@ import {
serializeProperty,
transferProperties,
} from "./rpc";
import { excessiveDebugOutput, getMonitor, options, rpcKeepAlive, serialize } from "./settings";
import { excessiveDebugOutput, getMonitor, rpcKeepAlive, serialize } from "./settings";
const gstruct = require("google-protobuf/google/protobuf/struct_pb.js");
const resproto = require("../proto/resource_pb.js");

View file

@ -5,7 +5,7 @@ import * as asset from "../asset";
import * as log from "../log";
import { CustomResource, Input, Inputs, Output, Resource } from "../resource";
import { debuggablePromise, errorString } from "./debuggable";
import { excessiveDebugOutput, options } from "./settings";
import { excessiveDebugOutput, isDryRun } from "./settings";
const gstruct = require("google-protobuf/google/protobuf/struct_pb.js");
@ -126,7 +126,7 @@ export function resolveProperties(
// If either we are performing a real deployment, or this is a stable property value, we
// can propagate its final value. Otherwise, it must be undefined, since we don't know
// if it's final.
if (!options.dryRun) {
if (!isDryRun()) {
// normal 'pulumi update'. resolve the output with the value we got back
// from the engine. That output can always run its .apply calls.
resolve(allProps[k], true);
@ -151,7 +151,7 @@ export function resolveProperties(
// actually propagate the provisional state, because we cannot know for sure that it is final yet.
for (const k of Object.keys(resolvers)) {
if (!allProps.hasOwnProperty(k)) {
if (!options.dryRun) {
if (!isDryRun()) {
throw new Error(
`Unexpected missing property '${k}' on resource '${name}' [${t}] during final deployment`);
}

View file

@ -1,9 +1,15 @@
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
import * as minimist from "minimist";
import { RunError } from "../errors";
import { Resource } from "../resource";
import { loadConfig } from "./config";
import { debuggablePromise } from "./debuggable";
const grpc = require("grpc");
const engrpc = require("../proto/engine_grpc_pb.js");
const resrpc = require("../proto/resource_grpc_pb.js");
/**
* excessiveDebugOutput enables, well, pretty excessive debug output pertaining to resources and properties.
*/
@ -15,69 +21,171 @@ export let excessiveDebugOutput: boolean = false;
export interface Options {
readonly project?: string; // the name of the current project.
readonly stack?: string; // the name of the current stack being deployed into.
readonly engine?: Object; // a live connection to the engine, used for logging, etc.
readonly monitor: Object; // a live connection to the resource monitor that tracks deployments.
readonly parallel?: number; // the degree of parallelism for resource operations (default is serial).
readonly dryRun?: boolean; // whether we are performing a preview (true) or a real deployment (false).
readonly includeStackTraces?: boolean; // whether we include full stack traces in resource errors or not.
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.
}
/**
* options are the current deployment options being used for this entire session.
* _options are the current deployment options being used for this entire session.
*/
export let options: Options = <any>{
dryRun: false,
includeStackTraces: true,
};
let _options: Options | undefined;
/**
* options fetches the current configured options and, if required, lazily initializes them.
*/
function options(): Options {
if (!_options) {
// See if the options are available in memory. This would happen if we load the pulumi SDK multiple
// times into the same heap. In this case, the entry point would have configured one copy of the library,
// which has an independent set of statics. But it left behind the configured state in environment variables.
_options = loadOptions();
}
return _options;
}
/**
* Returns true if we're currently performing a dry-run, or false if this is a true update.
*/
export function isDryRun(): boolean {
return options().dryRun === true;
}
/**
* Get the project being run by the current update.
*/
export function getProject(): string | undefined {
return options().project;
}
/**
* Get the stack being targeted by the current update.
*/
export function getStack(): string | undefined {
return options().stack;
}
/**
* monitor is a live connection to the resource monitor that tracks deployments (lazily initialized).
*/
let monitor: any | undefined;
/**
* hasMonitor returns true if we are currently connected to a resource monitoring service.
*/
export function hasMonitor(): boolean {
return !!options.monitor;
return !!monitor && !!options().monitorAddr;
}
/**
* getMonitor returns the current resource monitoring service client for RPC communications.
*/
export function getMonitor(): Object {
if (!options.monitor) {
throw new RunError(
"Pulumi program not connected to the engine -- are you running with the `pulumi` CLI?\n" +
"This can also happen if you've loaded the Pulumi SDK module multiple times into the same proces");
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?");
}
}
return options.monitor;
return monitor!;
}
/**
* engine is a live connection to the engine, used for logging, etc. (lazily initialized).
*/
let engine: any | undefined;
/**
* getEngine returns the current engine, if any, for RPC communications back to the resource engine.
*/
export function getEngine(): Object | undefined {
return options.engine;
if (!engine) {
const addr = options().engineAddr;
if (addr) {
// Lazily initialize the RPC connection to the engine.
engine = new engrpc.EngineClient(addr, grpc.credentials.createInsecure());
}
}
return engine;
}
/**
* serialize returns true if resource operations should be serialized.
*/
export function serialize(): boolean {
return !options.parallel || options.parallel <= 1;
const p = options().parallel;
return !p || p <= 1;
}
/**
* configured is set to true once configuration has been set.
* setOptions initializes the current runtime with information about whether we are performing a "dry
* run" (preview), versus a real deployment, RPC addresses, and so on. It may only be called once.
*/
let configured: boolean;
/**
* configure initializes the current resource monitor and engine RPC connections, and whether we are performing a "dry
* run" (preview), versus a real deployment, and so on. It may only be called once.
*/
export function configure(opts: Options): void {
if (configured) {
export function setOptions(opts: Options): void {
if (_options) {
throw new Error("Cannot configure runtime settings more than once");
}
Object.assign(options, opts);
configured = true;
// Set environment variables so other copies of the library can do the right thing.
if (opts.project !== undefined) {
process.env["PULUMI_NODEJS_PROJECT"] = opts.project;
}
if (opts.stack !== undefined) {
process.env["PULUMI_NODEJS_STACK"] = opts.stack;
}
if (opts.dryRun !== undefined) {
process.env["PULUMI_NODEJS_DRY_RUN"] = opts.dryRun.toString();
}
if (opts.parallel !== undefined) {
process.env["PULUMI_NODEJS_PARALLEL"] = opts.parallel.toString();
}
if (opts.monitorAddr !== undefined) {
process.env["PULUMI_NODEJS_MONITOR"] = opts.monitorAddr;
}
if (opts.engineAddr !== undefined) {
process.env["PULUMI_NODEJS_ENGINE"] = opts.engineAddr;
}
// Now, save the in-memory static state. All RPC connections will be created lazily as required.
_options = opts;
}
/**
* loadOptions recovers previously configured options in the case that a copy of the runtime SDK library
* is loaded without going through the entry point shim, as happens when multiple copies are loaded.
*/
function loadOptions(): Options {
// Load the config from the environment.
loadConfig();
// The only option that needs parsing is the parallelism flag. Ignore any failures.
let parallel: number | undefined;
const parallelOpt = process.env["PULUMI_NODEJS_PARALLEL"];
if (parallelOpt) {
try {
parallel = parseInt(parallelOpt, 10);
}
catch (err) {
// ignore.
}
}
// Now just hydrate the rest from environment variables. These might be missing, in which case
// we will fail later on when we actually need to create an RPC connection back to the engine.
return {
project: process.env["PULUMI_NODEJS_PROJECT"],
stack: process.env["PULUMI_NODEJS_STACK"],
dryRun: (process.env["PULUMI_NODEJS_DRY_RUN"] === "true"),
parallel: parallel,
monitorAddr: process.env["PULUMI_NODEJS_MONITOR"],
engineAddr: process.env["PULUMI_NODEJS_ENGINE"],
};
}
/**
@ -103,19 +211,24 @@ export function disconnect(): void {
* wait for the existing RPC queue to drain. Any RPCs that come in after this call will crash the process.
*/
export function disconnectSync(): void {
// Otherwise, actually perform the close activities.
try {
if (options.monitor) {
(<any>options.monitor).close();
(<any>options).monitor = null;
// Otherwise, actually perform the close activities (ignoring errors and crashes).
if (monitor) {
try {
monitor.close();
}
if (options.engine) {
(<any>options.engine).close();
(<any>options).engine = null;
catch (err) {
// ignore.
}
monitor = null;
}
catch (err) {
// ignore all failures to avoid crashes during exit.
if (engine) {
try {
engine.close();
}
catch (err) {
// ignore.
}
engine = null;
}
}

View file

@ -20,4 +20,3 @@ assert.equal(configOld.requireNumber("A"), 42);
assert.equal(configOld.get("bbbb"), "a string o' b's");
assert.equal(configOld.require("bbbb"), "a string o' b's");
assert.equal(configOld.get("missingC"), undefined);

View file

@ -0,0 +1,35 @@
// This tests the runtime's ability to be loaded side-by-side with another copy of the same runtime library.
// This is a hard and subtle problem because the runtime is configured with a bunch of state, like whether
// we are doing a dry-run and, more importantly, RPC addresses to communicate with the engine. Normally we
// go through the startup shim to configure all of these things, but when the second copy gets loaded we don't.
// Subsequent copies of the runtime are able to configure themselves by using environment variables.
let assert = require("assert");
const sdkPath = "../../../../../";
// Load the first copy:
let pulumi1 = require(sdkPath);
// Now delete the entry in the require cache, and load up the second copy:
delete require.cache[require.resolve(sdkPath)];
delete require.cache[require.resolve(sdkPath + "/runtime")];
let pulumi2 = require(sdkPath);
// Make sure they are different:
assert(pulumi1 !== pulumi2);
assert(pulumi1.runtime !== pulumi2.runtime);
// Check that various settings are equal:
assert.strictEqual(pulumi1.runtime.isDryRun(), pulumi2.runtime.isDryRun());
assert.strictEqual(pulumi1.runtime.getProject(), pulumi2.runtime.getProject());
assert.strictEqual(pulumi1.runtime.getStack(), pulumi2.runtime.getStack());
assert.deepEqual(pulumi1.runtime.allConfig(), pulumi2.runtime.allConfig());
// Now do some useful things that require RPC connections:
pulumi1.log.info("logging via Pulumi1 works!");
pulumi2.log.info("logging via Pulumi2 works too!");
let res1 = new pulumi1.CustomResource("test:x:resource", "p1p1p1");
res1.urn.apply(urn => assert.strictEqual(urn, "test:x:resource::p1p1p1"));
let res2 = new pulumi2.CustomResource("test:y:resource", "p2p2p2");
res2.urn.apply(urn => assert.strictEqual(urn, "test:y:resource::p2p2p2"));

View file

@ -347,6 +347,14 @@ describe("rpc", () => {
};
},
},
// Test that the runtime can be loaded twice.
"runtime_sxs": {
program: path.join(base, "015.runtime_sxs"),
expectResourceCount: 2,
registerResource: (ctx: any, dryrun: boolean, t: string, name: string, res: any) => {
return { urn: makeUrn(t, name), id: name, props: undefined };
},
},
};
for (const casename of Object.keys(cases)) {
@ -480,12 +488,8 @@ function mockRun(langHostClient: any, monitor: string, opts: RunCase, dryrun: bo
(resolve, reject) => {
const runReq = new langproto.RunRequest();
runReq.setMonitorAddress(monitor);
if (opts.project) {
runReq.setProject(opts.project);
}
if (opts.stack) {
runReq.setStack(opts.stack);
}
runReq.setProject(opts.project || "project");
runReq.setStack(opts.stack || "stack");
if (opts.pwd) {
runReq.setPwd(opts.pwd);
}

View file

@ -1,3 +1,3 @@
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
export let version = "${VERSION}";
export const version = "${VERSION}";