2020-09-18 00:17:34 +00:00
// 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.
2022-09-03 18:24:52 +00:00
import execa from "execa" ;
2024-01-19 10:43:31 +00:00
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" ;
2020-10-08 18:07:35 +00:00
import { createCommandError } from "./errors" ;
2020-10-07 01:51:30 +00:00
2024-01-19 10:43:31 +00:00
const SKIP_VERSION_CHECK_VAR = "PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK" ;
2024-07-10 17:22:24 +00:00
/ * *
* @internal
* /
2020-09-18 02:01:17 +00:00
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 ;
}
2020-10-07 01:51:30 +00:00
toString ( ) : string {
2020-09-18 02:01:17 +00:00
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 ` ;
}
}
2024-01-19 10:43:31 +00:00
export interface PulumiCommandOptions {
2024-03-25 14:33:15 +00:00
/ * *
* The version of the CLI to use . Defaults to the CLI version matching the SDK version .
* /
2024-01-19 10:43:31 +00:00
version? : semver.SemVer ;
2024-03-25 14:33:15 +00:00
/ * *
* The directory to install the CLI in or where to look for an existing
* installation . Defaults to $HOME / . pulumi / versions / $VERSION .
* /
2024-01-19 10:43:31 +00:00
root? : string ;
/ * *
* Skips the minimum CLI version check , see ` PULUMI_AUTOMATION_API_SKIP_VERSION_CHECK ` .
* /
skipVersionCheck? : boolean ;
}
export class PulumiCommand {
2024-06-24 11:14:56 +00:00
private constructor (
readonly command : string ,
readonly version : semver.SemVer | null ,
) { }
2020-09-18 00:17:34 +00:00
2024-01-19 10:43:31 +00:00
/ * *
* 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
2024-01-26 13:41:10 +00:00
* 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 ` .
2024-01-19 10:43:31 +00:00
* /
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" ] ) ;
2024-01-26 13:41:10 +00:00
const skipVersionCheck = ! ! opts ? . skipVersionCheck || ! ! process . env [ SKIP_VERSION_CHECK_VAR ] ;
2024-01-19 10:43:31 +00:00
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 ,
2024-09-17 13:23:58 +00:00
signal? : AbortSignal ,
2024-01-19 10:43:31 +00:00
) : 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" ? ";" : ":" ;
2024-03-05 12:23:25 +00:00
const envPath = pulumiBin + sep + ( additionalEnv [ "PATH" ] || process . env . PATH ) ;
2024-01-19 10:43:31 +00:00
additionalEnv [ "PATH" ] = envPath ;
}
2024-09-17 13:23:58 +00:00
return exec ( this . command , args , cwd , additionalEnv , onOutput , signal ) ;
2024-01-19 10:43:31 +00:00
}
}
async function exec (
command : string ,
2020-09-18 00:17:34 +00:00
args : string [ ] ,
2024-01-19 10:43:31 +00:00
cwd? : string ,
additionalEnv ? : { [ key : string ] : string } ,
2020-09-18 00:17:34 +00:00
onOutput ? : ( data : string ) = > void ,
2024-09-17 13:23:58 +00:00
signal? : AbortSignal ,
2020-09-18 00:17:34 +00:00
) : Promise < CommandResult > {
2024-01-19 10:43:31 +00:00
const unknownErrCode = - 2 ;
2021-12-23 18:44:56 +00:00
2024-01-19 10:43:31 +00:00
const env = additionalEnv ? { . . . additionalEnv } : undefined ;
2020-09-18 00:17:34 +00:00
2022-08-29 20:44:03 +00:00
try {
2024-01-19 10:43:31 +00:00
const proc = execa ( command , args , { env , cwd } ) ;
2022-09-12 16:41:08 +00:00
if ( onOutput && proc . stdout ) {
proc . stdout ! . on ( "data" , ( data : any ) = > {
2023-04-29 02:16:01 +00:00
if ( data ? . toString ) {
2022-09-12 16:41:08 +00:00
data = data . toString ( ) ;
}
onOutput ( data ) ;
} ) ;
}
2024-09-17 13:23:58 +00:00
if ( signal ) {
signal . addEventListener ( "abort" , ( ) = > {
proc . kill ( "SIGINT" , { forceKillAfterTimeout : false } ) ;
} ) ;
}
2022-09-12 16:41:08 +00:00
const { stdout , stderr , exitCode } = await proc ;
2022-08-29 20:44:03 +00:00
const commandResult = new CommandResult ( stdout , stderr , exitCode ) ;
if ( exitCode !== 0 ) {
throw createCommandError ( commandResult ) ;
}
2022-09-12 16:41:08 +00:00
2022-08-29 20:44:03 +00:00
return commandResult ;
} catch ( err ) {
const error = err as Error ;
throw createCommandError ( new CommandResult ( "" , error . message , unknownErrCode , error ) ) ;
}
2020-09-18 00:17:34 +00:00
}
2024-01-19 10:43:31 +00:00
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 ;
}