pulumi/sdk/nodejs/automation/cmd.ts

306 lines
10 KiB
TypeScript

// Copyright 2016-2020, 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 execa from "execa";
import * as fs from "fs";
import got from "got";
import * as os from "os";
import * as path from "path";
import * as semver from "semver";
import * as tmp from "tmp";
import { version as DEFAULT_VERSION } from "../version";
import { minimumVersion } from "./minimumVersion";
import { createCommandError } from "./errors";
const SKIP_VERSION_CHECK_VAR = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK";
/**
* @internal
*/
export class CommandResult {
stdout: string;
stderr: string;
code: number;
err?: Error;
constructor(stdout: string, stderr: string, code: number, err?: Error) {
this.stdout = stdout;
this.stderr = stderr;
this.code = code;
this.err = err;
}
toString(): string {
let errStr = "";
if (this.err) {
errStr = this.err.toString();
}
return `code: ${this.code}\n stdout: ${this.stdout}\n stderr: ${this.stderr}\n err?: ${errStr}\n`;
}
}
export interface PulumiCommandOptions {
/**
* The version of the CLI to use. Defaults to the CLI version matching the SDK version.
*/
version?: semver.SemVer;
/**
* The directory to install the CLI in or where to look for an existing
* installation. Defaults to $HOME/.pulumi/versions/$VERSION.
*/
root?: string;
/**
* Skips the minimum CLI version check, see `PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK`.
*/
skipVersionCheck?: boolean;
}
export class PulumiCommand {
private constructor(
readonly command: string,
readonly version: semver.SemVer | null,
) {}
/**
* Get a new Pulumi instance that uses the installation in `opts.root`.
* Defaults to using the pulumi binary found in $PATH if no installation
* root is specified. If `opts.version` is specified, it validates that
* the CLI is compatible with the requested version and throws an error if
* not. This validation can be skipped by setting the environment variable
* `PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK` or setting
* `opts.skipVersionCheck` to `true`. Note that the environment variable
* always takes precedence. If it is set it is not possible to re-enable
* the validation with `opts.skipVersionCheck`.
*/
static async get(opts?: PulumiCommandOptions): Promise<PulumiCommand> {
const command = opts?.root ? path.resolve(path.join(opts.root, "bin/pulumi")) : "pulumi";
const { stdout } = await exec(command, ["version"]);
const skipVersionCheck = !!opts?.skipVersionCheck || !!process.env[SKIP_VERSION_CHECK_VAR];
let min = minimumVersion;
if (opts?.version && semver.gt(opts.version, minimumVersion)) {
min = opts.version;
}
const version = parseAndValidatePulumiVersion(min, stdout.trim(), skipVersionCheck);
return new PulumiCommand(command, version);
}
/**
* Installs the Pulumi CLI.
*/
static async install(opts?: PulumiCommandOptions): Promise<PulumiCommand> {
const optsWithDefaults = withDefaults(opts);
try {
return await PulumiCommand.get(optsWithDefaults);
} catch (err) {
// ignore
}
if (process.platform === "win32") {
await PulumiCommand.installWindows(optsWithDefaults);
} else {
await PulumiCommand.installPosix(optsWithDefaults);
}
return await PulumiCommand.get(optsWithDefaults);
}
private static async installWindows(opts: Required<PulumiCommandOptions>): Promise<void> {
const response = await got("https://get.pulumi.com/install.ps1");
const script = await writeTempFile(response.body, { extension: ".ps1" });
try {
const command = process.env.SystemRoot
? path.join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
: "powershell.exe";
const args = [
"-NoProfile",
"-InputFormat",
"None",
"-ExecutionPolicy",
"Bypass",
"-File",
script.path,
"-NoEditPath",
"-InstallRoot",
opts.root,
"-Version",
`${opts.version}`,
];
await exec(command, args);
} finally {
script.cleanup();
}
}
private static async installPosix(opts: Required<PulumiCommandOptions>): Promise<void> {
const response = await got("https://get.pulumi.com/install.sh");
const script = await writeTempFile(response.body);
try {
const args = [script.path, "--no-edit-path", "--install-root", opts.root, "--version", `${opts.version}`];
await exec("/bin/sh", args);
} finally {
script.cleanup();
}
}
/** @internal */
public run(
args: string[],
cwd: string,
additionalEnv: { [key: string]: string },
onOutput?: (data: string) => void,
signal?: AbortSignal,
): Promise<CommandResult> {
// all commands should be run in non-interactive mode.
// this causes commands to fail rather than prompting for input (and thus hanging indefinitely)
if (!args.includes("--non-interactive")) {
args.push("--non-interactive");
}
// Prepend the folder where the CLI is installed to the path to ensure
// we pickup the matching bundled plugins.
if (path.isAbsolute(this.command)) {
const pulumiBin = path.dirname(this.command);
const sep = os.platform() === "win32" ? ";" : ":";
const envPath = pulumiBin + sep + (additionalEnv["PATH"] || process.env.PATH);
additionalEnv["PATH"] = envPath;
}
return exec(this.command, args, cwd, additionalEnv, onOutput, signal);
}
}
async function exec(
command: string,
args: string[],
cwd?: string,
additionalEnv?: { [key: string]: string },
onOutput?: (data: string) => void,
signal?: AbortSignal,
): Promise<CommandResult> {
const unknownErrCode = -2;
const env = additionalEnv ? { ...additionalEnv } : undefined;
try {
const proc = execa(command, args, { env, cwd });
if (onOutput && proc.stdout) {
proc.stdout!.on("data", (data: any) => {
if (data?.toString) {
data = data.toString();
}
onOutput(data);
});
}
if (signal) {
signal.addEventListener("abort", () => {
proc.kill("SIGINT", { forceKillAfterTimeout: false });
});
}
const { stdout, stderr, exitCode } = await proc;
const commandResult = new CommandResult(stdout, stderr, exitCode);
if (exitCode !== 0) {
throw createCommandError(commandResult);
}
return commandResult;
} catch (err) {
const error = err as Error;
throw createCommandError(new CommandResult("", error.message, unknownErrCode, error));
}
}
function withDefaults(opts?: PulumiCommandOptions): Required<PulumiCommandOptions> {
let version = opts?.version;
if (!version) {
version = new semver.SemVer(DEFAULT_VERSION);
}
let root = opts?.root;
if (!root) {
root = path.join(os.homedir(), ".pulumi", "versions", `${version}`);
}
const skipVersionCheck = opts?.skipVersionCheck !== undefined ? opts.skipVersionCheck : false;
return { version, root, skipVersionCheck };
}
function writeTempFile(
contents: string,
options?: { extension?: string },
): Promise<{ path: string; cleanup: () => void }> {
return new Promise<{ path: string; cleanup: () => void }>((resolve, reject) => {
tmp.file(
{
// Powershell requires a `.ps1` extension.
postfix: options?.extension,
// Powershell won't execute the script if the file descriptor is open.
discardDescriptor: true,
},
(tmpErr, tmpPath, _fd, cleanup) => {
if (tmpErr) {
reject(tmpErr);
} else {
fs.writeFile(tmpPath, contents, (writeErr) => {
if (writeErr) {
cleanup();
reject(writeErr);
} else {
resolve({ path: tmpPath, cleanup });
}
});
}
},
);
});
}
/**
* @internal
* Throws an error if the Pulumi CLI version is not valid.
*
* @param minVersion The minimum acceptable version of the Pulumi CLI.
* @param currentVersion The currently known version. `null` indicates that the current version is unknown.
* @param optOut If the user has opted out of the version check.
*/
export function parseAndValidatePulumiVersion(
minVersion: semver.SemVer,
currentVersion: string,
optOut: boolean,
): semver.SemVer | null {
const version = semver.parse(currentVersion);
if (optOut) {
return version;
}
if (version == null) {
throw new Error(
`Failed to parse Pulumi CLI version. This is probably an internal error. You can override this by setting "${SKIP_VERSION_CHECK_VAR}" to "true".`,
);
}
if (minVersion.major < version.major) {
throw new Error(
`Major version mismatch. You are using Pulumi CLI version ${currentVersion} with Automation SDK v${minVersion.major}. Please update the SDK.`,
);
}
if (minVersion.compare(version) === 1) {
throw new Error(
`Minimum version requirement failed. The minimum CLI version requirement is ${minVersion.toString()}, your current CLI version is ${currentVersion}. Please update the Pulumi CLI.`,
);
}
return version;
}