2020-08-27 17:43:23 +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.
2020-08-19 18:13:42 +00:00
package auto
import (
"bytes"
2020-08-22 05:20:32 +00:00
"context"
2024-01-26 16:01:17 +00:00
"fmt"
2020-09-15 01:56:04 +00:00
"io"
2024-01-26 16:01:17 +00:00
"net/http"
2020-08-19 18:13:42 +00:00
"os"
"os/exec"
2024-01-27 01:25:23 +00:00
"path/filepath"
2024-01-26 16:01:17 +00:00
"runtime"
"strings"
2023-06-28 16:02:04 +00:00
2024-01-26 16:01:17 +00:00
"github.com/blang/semver"
"github.com/pulumi/pulumi/sdk/v3"
"github.com/pulumi/pulumi/sdk/v3/go/common/env"
2023-06-28 16:02:04 +00:00
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
2024-01-26 16:01:17 +00:00
"github.com/pulumi/pulumi/sdk/v3/go/common/util/httputil"
2020-08-19 18:13:42 +00:00
)
2020-08-29 02:10:39 +00:00
const unknownErrorCode = - 2
2024-01-26 16:01:17 +00:00
// PulumiCommand manages the Pulumi CLI and runs operations.
type PulumiCommand interface {
Run ( ctx context . Context ,
workdir string ,
stdin io . Reader ,
additionalOutput [ ] io . Writer ,
additionalErrorOutput [ ] io . Writer ,
additionalEnv [ ] string ,
args ... string ) ( string , string , int , error )
Version ( ) semver . Version
}
type PulumiCommandOptions struct {
// Version is the version to install or validate.
Version semver . Version
// Root sets the directory where the CLI should be installed to, or from
// where the CLI should be retrieved.
Root string
// SkipVersionCheck is used to disable the validation of the found Pulumi
// binary.
SkipVersionCheck bool
}
// withDefaults returns a new copy of the options with default values set.
// Version defaults to the CLI version matching the current SDK release. Root
// defaults to $HOME/.pulumi/versions/$VERSION.
func ( opts * PulumiCommandOptions ) withDefaults ( ) ( * PulumiCommandOptions , error ) {
newOpts := & PulumiCommandOptions {
Version : opts . Version ,
Root : opts . Root ,
SkipVersionCheck : opts . SkipVersionCheck ,
}
if newOpts . Version . EQ ( semver . Version { } ) {
newOpts . Version = sdk . Version
}
if newOpts . Root == "" {
home , err := os . UserHomeDir ( )
if err != nil {
return nil , err
}
2024-01-27 01:25:23 +00:00
newOpts . Root = filepath . Join ( home , ".pulumi" , "versions" , newOpts . Version . String ( ) )
2024-01-26 16:01:17 +00:00
}
return newOpts , nil
}
type pulumiCommand struct {
version semver . Version
command string
}
// NewPulumiCommand creates a 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`.
func NewPulumiCommand ( opts * PulumiCommandOptions ) ( PulumiCommand , error ) {
if opts == nil {
opts = & PulumiCommandOptions { }
}
command := "pulumi"
if opts . Root != "" {
2024-01-27 01:25:23 +00:00
command = filepath . Join ( opts . Root , "bin" , "pulumi" )
if runtime . GOOS == "windows" {
command += ".exe"
}
2024-01-26 16:01:17 +00:00
}
cmd := exec . Command ( command , "version" )
out , err := cmd . Output ( )
if err != nil {
return pulumiCommand { } , fmt . Errorf ( "failed to run `pulumi version`: %w" , err )
}
currentVersion := strings . TrimSpace ( string ( out ) )
min := minimumVersion
if opts . Version . GT ( min ) {
min = opts . Version
}
skipVersionCheck := opts . SkipVersionCheck || env . SkipVersionCheck . Value ( )
version , err := parseAndValidatePulumiVersion ( min , currentVersion , skipVersionCheck )
if err != nil {
return pulumiCommand { } , err
}
return pulumiCommand {
version : version ,
command : command ,
} , nil
}
// InstallPulumiCommand downloads and installs the Pulumi CLI. By default the
// CLI version matching the current SDK release is installed in
// $HOME/.pulumi/versions/$VERSION. Set `opts.Root` to specify a different
// directory, and `opts.Version` to install a custom version.
func InstallPulumiCommand ( ctx context . Context , opts * PulumiCommandOptions ) (
PulumiCommand ,
error ,
) {
if opts == nil {
opts = & PulumiCommandOptions { }
}
opts , err := opts . withDefaults ( )
if err != nil {
return pulumiCommand { } , err
}
pulumi , err := NewPulumiCommand ( opts )
if err == nil {
// Found an installation and it satisfies the version requirement
return pulumi , nil
}
if runtime . GOOS == "windows" {
if err := installWindows ( ctx , opts . Version , opts . Root ) ; err != nil {
return pulumiCommand { } , err
}
} else {
if err := installPosix ( ctx , opts . Version , opts . Root ) ; err != nil {
return pulumiCommand { } , err
}
}
return NewPulumiCommand ( opts )
}
func downloadToTmpFile ( ctx context . Context , url , filePattern string ) ( _ string , err error ) {
req , err := http . NewRequestWithContext ( ctx , "GET" , url , nil )
if err != nil {
return "" , err
}
resp , err := httputil . DoWithRetry ( req , http . DefaultClient )
if err != nil {
return "" , fmt . Errorf ( "failed to download %s: %w" , url , err )
}
defer resp . Body . Close ( )
scriptData , err := io . ReadAll ( resp . Body )
if err != nil {
return "" , fmt . Errorf ( "failed to read response from %s: %w" , url , err )
}
tmp , err := os . CreateTemp ( "" , filePattern )
if err != nil {
return "" , err
}
scriptPath := tmp . Name ( )
defer func ( ) {
if err != nil {
os . Remove ( scriptPath )
}
} ( )
// The permissions here are ignored because the tmp file already exists.
// We need to explicitly call chmod below to set the desired permissions.
err = os . WriteFile ( scriptPath , scriptData , 0 o600 )
if err != nil {
return "" , err
}
if err = tmp . Close ( ) ; err != nil {
return "" , err
}
err = os . Chmod ( scriptPath , 0 o700 )
if err != nil {
return "" , err
}
return scriptPath , nil
}
func installWindows ( ctx context . Context , version semver . Version , root string ) error {
scriptPath , err := downloadToTmpFile ( ctx , "https://get.pulumi.com/install.ps1" , "install-*.ps1" )
if err != nil {
return err
}
defer os . Remove ( scriptPath )
command := "powershell.exe"
if os . Getenv ( "SystemRoot" ) != "" {
2024-01-27 01:25:23 +00:00
command = filepath . Join ( os . Getenv ( "SystemRoot" ) , "System32" , "WindowsPowerShell" , "v1.0" , "powershell.exe" )
2024-01-26 16:01:17 +00:00
}
args := [ ] string {
"-NoProfile" ,
"-InputFormat" ,
"None" ,
"-ExecutionPolicy" ,
"Bypass" ,
"-File" ,
scriptPath ,
"-NoEditPath" ,
"-InstallRoot" ,
root ,
"-Version" ,
version . String ( ) ,
}
cmd := exec . CommandContext ( ctx , command , args ... )
out , err := cmd . Output ( )
if err != nil {
return fmt . Errorf ( "installation failed with %w: %s" , err , out )
}
return nil
}
func installPosix ( ctx context . Context , version semver . Version , root string ) error {
scriptPath , err := downloadToTmpFile ( ctx , "https://get.pulumi.com/install.sh" , "install-*.sh" )
if err != nil {
return err
}
defer os . Remove ( scriptPath )
args := [ ] string {
"--no-edit-path" ,
"--install-root" , root ,
"--version" , version . String ( ) ,
}
cmd := exec . CommandContext ( ctx , scriptPath , args ... )
out , err := cmd . Output ( )
if err != nil {
return fmt . Errorf ( "installation failed with %w: %s" , err , out )
}
return nil
}
// Run executes a Pulumi CLI command.
func ( p pulumiCommand ) Run ( ctx context . Context ,
2020-08-22 05:20:32 +00:00
workdir string ,
2023-12-12 08:37:33 +00:00
stdin io . Reader ,
2020-09-15 01:56:04 +00:00
additionalOutput [ ] io . Writer ,
2022-07-19 17:10:10 +00:00
additionalErrorOutput [ ] io . Writer ,
2020-08-22 05:20:32 +00:00
additionalEnv [ ] string ,
args ... string ,
) ( string , string , int , error ) {
2020-08-19 18:13:42 +00:00
// all commands should be run in non-interactive mode.
// this causes commands to fail rather than prompting for input (and thus hanging indefinitely)
2021-12-23 18:44:56 +00:00
args = withNonInteractiveArg ( args )
2024-03-27 21:12:00 +00:00
cmd := exec . CommandContext ( ctx , p . command , args ... ) //nolint:gosec
2020-08-19 18:13:42 +00:00
cmd . Dir = workdir
2024-01-26 16:01:17 +00:00
env := append ( os . Environ ( ) , additionalEnv ... )
2024-01-27 01:25:23 +00:00
if filepath . IsAbs ( p . command ) {
pulumiBin := filepath . Dir ( p . command )
2024-01-26 16:01:17 +00:00
env = fixupPath ( env , pulumiBin )
}
cmd . Env = env
2020-09-15 01:56:04 +00:00
2020-08-19 18:13:42 +00:00
var stdout bytes . Buffer
var stderr bytes . Buffer
2020-09-15 01:56:04 +00:00
additionalOutput = append ( additionalOutput , & stdout )
2022-07-19 17:10:10 +00:00
additionalErrorOutput = append ( additionalErrorOutput , & stderr )
2020-09-15 01:56:04 +00:00
cmd . Stdout = io . MultiWriter ( additionalOutput ... )
2022-07-19 17:10:10 +00:00
cmd . Stderr = io . MultiWriter ( additionalErrorOutput ... )
2023-12-12 08:37:33 +00:00
cmd . Stdin = stdin
2020-09-15 01:56:04 +00:00
2020-08-29 02:10:39 +00:00
code := unknownErrorCode
2020-08-19 18:13:42 +00:00
err := cmd . Run ( )
if exitError , ok := err . ( * exec . ExitError ) ; ok {
code = exitError . ExitCode ( )
2022-04-22 12:06:02 +00:00
} else if err == nil {
// If there was no error then the exit code was 0
code = 0
2020-08-19 18:13:42 +00:00
}
return stdout . String ( ) , stderr . String ( ) , code , err
}
2021-12-23 18:44:56 +00:00
2024-01-26 16:01:17 +00:00
// The version of the current Pulumi CLI installation.
func ( p pulumiCommand ) Version ( ) semver . Version {
return p . version
}
2021-12-23 18:44:56 +00:00
func withNonInteractiveArg ( args [ ] string ) [ ] string {
2023-06-28 16:02:04 +00:00
out := slice . Prealloc [ string ] ( len ( args ) )
2021-12-23 18:44:56 +00:00
seen := false
for _ , a := range args {
out = append ( out , a )
if a == "--non-interactive" {
seen = true
}
}
if ! seen {
out = append ( out , "--non-interactive" )
}
return out
}
2024-01-26 16:01:17 +00:00
// fixupPath returns a new copy of `env` where the `PATH` entry contains
// pulumiBin as the first path.
func fixupPath ( env [ ] string , pulumiBin string ) [ ] string {
newEnv := make ( [ ] string , len ( env ) )
copy ( newEnv , env )
pathIndex := - 1
for i , e := range env {
2024-03-05 12:23:25 +00:00
// Case-insensitive compare, as Windows will normally be "Path", not "PATH".
Fix slice bounds out of range panic in fixupPath (#16575)
I noticed that sst currently panics if an environment variable's name is
shorter than 5 characters. This fixes the root cause.
```
V=1 sst deploy --stage=dev
SST ❍ ion 0.0.416 ready!
➜ App: myapp
Stage: dev
~ Deploying
panic: runtime error: slice bounds out of range [:5] with length 3
goroutine 1 [running]:
github.com/pulumi/pulumi/sdk/v3/go/auto.fixupPath({0x140018ec408, 0xb1, 0x1400013dfc0?}, {0x1400013dfc0, 0x32})
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/cmd.go:324 +0x270
github.com/pulumi/pulumi/sdk/v3/go/auto.pulumiCommand.Run({{0x3, 0x73, 0x2, {0x0, 0x0, 0x0}, {0x0, 0x0, 0x0}}, {0x1400013dfc0, ...}}, ...)
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/cmd.go:273 +0x394
github.com/pulumi/pulumi/sdk/v3/go/auto.(*LocalWorkspace).runPulumiInputCmdSync(0x14001494000, {0x106fb2b28, 0x1400061c0f0}, {0x0, 0x0}, {0x14000aeba00, 0x4, 0x4})
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/local_workspace.go:776 +0x1f4
github.com/pulumi/pulumi/sdk/v3/go/auto.(*LocalWorkspace).runPulumiCmdSync(...)
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/local_workspace.go:790
github.com/pulumi/pulumi/sdk/v3/go/auto.(*LocalWorkspace).SelectStack(0x14001494000, {0x106fb2b28, 0x1400061c0f0}, {0x16b392623, 0x3})
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/local_workspace.go:568 +0x160
github.com/pulumi/pulumi/sdk/v3/go/auto.SelectStack({0x106fb2b28?, 0x1400061c0f0?}, {0x16b392623, 0x3}, {0x106fcd6e0, 0x14001494000})
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/stack.go:179 +0x5c
github.com/pulumi/pulumi/sdk/v3/go/auto.UpsertStack({0x106fb2b28, 0x1400061c0f0}, {0x16b392623, 0x3}, {0x106fcd6e0, 0x14001494000})
/home/runner/go/pkg/mod/github.com/pulumi/pulumi/sdk/v3@v3.115.2/go/auto/stack.go:191 +0x34
github.com/sst/ion/pkg/project.(*stack).Run(0x14000a42058, {0x106fb2b28, 0x1400061c0f0}, 0x140000bbcc0)
/home/runner/work/ion/ion/pkg/project/stack.go:283 +0x1cf4
main.init.func3(0x14000064550)
/home/runner/work/ion/ion/cmd/sst/main.go:458 +0x2c8
main.run()
/home/runner/work/ion/ion/cmd/sst/main.go:160 +0x6cc
main.main()
/home/runner/work/ion/ion/cmd/sst/main.go:52 +0x12c
```
2024-07-08 09:10:58 +00:00
if len ( e ) >= 5 && strings . EqualFold ( e [ 0 : 5 ] , "PATH=" ) {
2024-01-26 16:01:17 +00:00
pathIndex = i
break
}
}
if pathIndex >= 0 {
pathEntry := pulumiBin
2024-03-05 12:23:25 +00:00
oldPath := env [ pathIndex ] [ 5 : ]
2024-01-26 16:01:17 +00:00
if oldPath != "" {
pathEntry = pulumiBin + string ( os . PathListSeparator ) + oldPath
}
2024-04-19 06:20:33 +00:00
newEnv [ pathIndex ] = "PATH=" + pathEntry
2024-01-26 16:01:17 +00:00
} else {
2024-04-19 06:20:33 +00:00
newEnv = append ( newEnv , "PATH=" + pulumiBin )
2024-01-26 16:01:17 +00:00
}
return newEnv
}
//nolint:lll
func parseAndValidatePulumiVersion ( minVersion semver . Version , currentVersion string , optOut bool ) ( semver . Version , error ) {
version , err := semver . ParseTolerant ( currentVersion )
if err != nil && ! optOut {
return semver . Version { } , fmt . Errorf ( "Unable to parse Pulumi CLI version (skip with %s=true): %w" , env . SkipVersionCheck . Var ( ) . Name ( ) , err )
}
if optOut {
return version , nil
}
if minVersion . Major < version . Major {
return semver . Version { } , fmt . Errorf ( "Major version mismatch. You are using Pulumi CLI version %s with Automation SDK v%v. Please update the SDK." , currentVersion , minVersion . Major ) //nolint
}
if minVersion . GT ( version ) {
return semver . Version { } , fmt . Errorf ( "Minimum version requirement failed. The minimum CLI version requirement is %s, your current CLI version is %s. Please update the Pulumi CLI." , minimumVersion , currentVersion ) //nolint
}
return version , nil
}