pulumi/sdk/nodejs/runtime/property.ts
joeduffy f9995159c6 Fix a handful of things, mostly logging
* Initialize the diganostics logger with opts.Debug when doing
  a Deploy, like we do Plan.

* Don't spew leaked promises if there were Log.errors.

* Serialize logging RPC calls so that they can't appear out of order.

* Print stack traces in more places and, in particular, remember
  the original context for any errors that may occur asynchronously,
  like resource registration and calls to mapValue.

* Include origin stack traces generally in more error messages.

* Add some more mapValue test cases.

* Only undefined-propagate mapValue values during dry-runs.
2017-09-09 13:43:51 -07:00

177 lines
8.2 KiB
TypeScript

// Copyright 2016-2017, Pulumi Corporation. All rights reserved.
import * as asset from "../asset";
import { Computed, MaybeComputed } from "../computed";
import { Resource, URN } from "../resource";
import { debuggablePromise } from "./debuggable";
import { Log } from "./log";
import { errorString, getMonitor, isDryRun } from "./settings";
// mapValueCallbackRecursionCount tracks the recursion depth inside of mapValue callbacks. This is used to
// prevent resource creation inside of such callbacks, as doing so leads to conditional resource creation.
let mapValueCallbackRecursionCount = 0;
// isInsideMapValueCallback is used by the runtime to ensure resources aren't conditionally created.
export function isInsideMapValueCallback(): boolean {
return (mapValueCallbackRecursionCount > 0);
}
// Property is the internal representation of a resource's property state. It is used by the runtime
// to resolve to final values and hook into important lifecycle states.
export class Property<T> implements Computed<T> {
public readonly inputPromise: Promise<T | undefined>; // a promise for this property's final input.
public readonly outputPromise: Promise<T | undefined>; // a promise for this property's final output.
private input: T | undefined; // the property's value, evolving until it becomes final.
private output: T | undefined; // the property's output value, or undefined if it is unknown.
private resolveInput: ((v: T | undefined) => void) | undefined; // the resolver used to resolve input values.
private resolveOutput: ((v: T | undefined) => void) | undefined; // the resolver used to resolve output values.
// constructs a new property. If immediate is true, the input state is used to resolve the final value
// immediately. Otherwise, input values will not lead to resolution of the final value, and some internal
// logic will instead need to manually invoke setOutput in order to resolve it to a final value.
constructor(value: MaybeComputed<T> | undefined, setInput: boolean, setOutput: boolean) {
// We use different input and output promises, because we depend on the different values in different cases.
this.inputPromise = debuggablePromise(new Promise<T | undefined>(
(resolve: (v: T | undefined) => void) => { this.resolveInput = resolve; },
), "inputPromise");
this.outputPromise = debuggablePromise(new Promise<T | undefined>(
(resolve: (v: T | undefined) => void) => { this.resolveOutput = resolve; },
), "outputPromise");
Property.resolveTo(this, value, setInput, setOutput);
}
private static resolveTo<T>(p: Property<T>, value: MaybeComputed<T> | undefined,
setInput: boolean, setOutput: boolean): void {
// If this is another computed, we need to wire up to its resolution; else just store the value.
if (value && value instanceof Promise) {
value.then(
(v: T | Promise<T>) => { Property.resolveTo(p, v, setInput, setOutput); },
(err: Error) => { Log.error(`Unexpected error in dependent mapValue promise: ${err}`); },
);
}
else if (value && value instanceof Property) {
value.outputPromise.then(
(v: T | undefined) => { Property.resolveTo(p, v, setInput, setOutput); },
(err: Error) => { Log.error(`Unexpected error in dependent mapValue property: ${err}`); },
);
}
else if (value && (value as Computed<T>).mapValue) {
(value as Computed<T>).mapValue(
(v: MaybeComputed<T>) => { Property.resolveTo(p, v, setInput, setOutput); });
}
else {
if (setInput) {
p.setInput(<T>value);
}
if (setOutput) {
p.setOutput(<T>value, true, false);
}
}
}
// mapValue attaches a callback for the resolution of a computed value, and returns a newly computed value.
public mapValue<U>(callback: (v: T) => MaybeComputed<U>): Computed<U> {
let result = new Property<U>(undefined, false, false);
// Fire off a promise hinging on the target's output that will resolve the resulting property.
let outcome: Promise<any> = this.outputPromise.then((value: T | undefined) => {
// If the value is unknown, and this is a dry-run, propagate an unknown. Otherwise, use the callback
// to compute something new.
if (value === undefined && isDryRun()) {
Property.resolveTo(result, undefined, true, true);
}
else {
// There's a callback; invoke it and resolve the value.
try {
mapValueCallbackRecursionCount++;
let transformed: MaybeComputed<U> = callback(<T>value);
Property.resolveTo(result, transformed, true, true);
}
finally {
mapValueCallbackRecursionCount--;
}
}
});
// Ensure we log any errors and keep the original stack trace for debuggability.
let origin: Error = new Error("MapValue had an unhandled error");
outcome.catch((err: Error) => {
Log.error(errorString(origin));
Log.error(errorString(err));
});
return result;
}
// done marks the resolver as done, and prevents subsequent changes. If it was initialized with a value, and no
// new value has subsequently arrived, then that value is propagated as the final value.
public done(dryRun: boolean): void {
// If the value hasn't reached a final state yet, conditionally propagate its provisional value. Note
// that if we're still planning, we can't know that this state is final, and so we'll propagate undefined.
let value: T | undefined;
if (!dryRun) {
value = this.output;
if (value === undefined) {
value = this.input;
}
}
this.setOutput(value, true, true);
}
// awaitingInput returns true if the input has yet to arrive.
public awaitingInput(): boolean {
return (this.resolveInput !== undefined);
}
// awaitingOutput returns true if the output has yet to arrive.
public awaitingOutput(): boolean {
return (this.resolveOutput !== undefined);
}
// setInput resolves the initial input value of a property.
public setInput(value: T | undefined): void {
if (!this.awaitingInput()) {
throw new Error(`Illegal attempt to set a property input multiple times (${value})`);
}
else if (value instanceof Promise) {
throw new Error(`Unexpected dependent promise value for property input`);
}
else if (value instanceof Property) {
throw new Error(`Unexpected dependent property value for property input`);
}
this.input = value;
this.resolveInput!(value);
this.resolveInput = undefined;
}
// setOutput resolves the final output value of a property.
public setOutput(value: T | undefined, isFinal: boolean, skipIfAlready: boolean): void {
if (this.awaitingOutput()) {
if (value instanceof Promise) {
throw new Error(`Unexpected dependent promise value for property input`);
}
else if (value instanceof Property) {
throw new Error(`Unexpected dependent property value for property input`);
}
this.output = value;
if (isFinal) {
this.resolveOutput!(value);
this.resolveOutput = undefined;
}
}
else if (!skipIfAlready) {
throw new Error(`Illegal attempt to set a property output multiple times (${value})`);
}
}
// toString overrides the standard toString to provide a "helpful" message. Most likely this was a mistake,
// and perhaps the message will help to indicate this, although sometimes it is helpful.
public toString(): string {
return `[pulumi-fabric Property: ` +
`input=${this.input} output=${this.output} ` +
`resolved=${this.resolveOutput === undefined}]`;
}
}