pulumi/pkg/cmd/pulumi/util.go

1353 lines
43 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2016-2023, 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.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
multierror "github.com/hashicorp/go-multierror"
"github.com/opentracing/opentracing-go"
"github.com/spf13/pflag"
survey "github.com/AlecAivazis/survey/v2"
surveycore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
git "github.com/go-git/go-git/v5"
"github.com/pulumi/pulumi/pkg/v3/backend"
"github.com/pulumi/pulumi/pkg/v3/backend/display"
"github.com/pulumi/pulumi/pkg/v3/backend/diy"
"github.com/pulumi/pulumi/pkg/v3/backend/httpstate"
"github.com/pulumi/pulumi/pkg/v3/backend/state"
"github.com/pulumi/pulumi/pkg/v3/engine"
"github.com/pulumi/pulumi/pkg/v3/resource/deploy"
"github.com/pulumi/pulumi/pkg/v3/resource/stack"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/pkg/v3/secrets/cloud"
"github.com/pulumi/pulumi/pkg/v3/secrets/passphrase"
"github.com/pulumi/pulumi/pkg/v3/version"
pkgWorkspace "github.com/pulumi/pulumi/pkg/v3/workspace"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/constant"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag"
"github.com/pulumi/pulumi/sdk/v3/go/common/diag/colors"
"github.com/pulumi/pulumi/sdk/v3/go/common/env"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/plugin"
"github.com/pulumi/pulumi/sdk/v3/go/common/slice"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/ciutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/deepcopy"
declared "github.com/pulumi/pulumi/sdk/v3/go/common/util/env"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/gitutil"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
)
func hasDebugCommands() bool {
return env.DebugCommands.Value()
}
func hasExperimentalCommands() bool {
return env.Experimental.Value()
}
func useLegacyDiff() bool {
return env.EnableLegacyDiff.Value()
}
func useLegacyRefreshDiff() bool {
return env.EnableLegacyRefreshDiff.Value()
}
func disableProviderPreview() bool {
return env.DisableProviderPreview.Value()
}
func disableResourceReferences() bool {
return env.DisableResourceReferences.Value()
}
func disableOutputValues() bool {
return env.DisableOutputValues.Value()
}
// skipConfirmations returns whether or not confirmation prompts should
// be skipped. This should be used by pass any requirement that a --yes
// parameter has been set for non-interactive scenarios.
//
// This should NOT be used to bypass protections for destructive
// operations, such as those that will fail without a --force parameter.
func skipConfirmations() bool {
return env.SkipConfirmations.Value()
}
// backendInstance is used to inject a backend mock from tests.
var backendInstance backend.Backend
func isDIYBackend(ws pkgWorkspace.Context, opts display.Options) (bool, error) {
if backendInstance != nil {
return false, nil
}
// Try to read the current project
project, _, err := ws.ReadProject()
if err != nil && !errors.Is(err, workspace.ErrProjectNotFound) {
return false, err
}
url, err := pkgWorkspace.GetCurrentCloudURL(ws, env.Global(), project)
if err != nil {
return false, fmt.Errorf("could not get cloud url: %w", err)
}
return diy.IsDIYBackendURL(url), nil
}
var DefaultLoginManager backend.LoginManager = &lm{}
type lm struct{}
func (f *lm) Current(
ctx context.Context, ws pkgWorkspace.Context, sink diag.Sink, url string, project *workspace.Project, setCurrent bool,
) (backend.Backend, error) {
if diy.IsDIYBackendURL(url) {
return diy.New(ctx, sink, url, project)
}
insecure := pkgWorkspace.GetCloudInsecure(ws, url)
lm := httpstate.NewLoginManager()
_, err := lm.Current(ctx, url, insecure, setCurrent)
if err != nil {
return nil, err
}
return httpstate.New(sink, url, project, insecure)
}
func (f *lm) Login(
ctx context.Context, ws pkgWorkspace.Context, sink diag.Sink, url string, project *workspace.Project, setCurrent bool,
color colors.Colorization,
) (backend.Backend, error) {
if diy.IsDIYBackendURL(url) {
if setCurrent {
return diy.Login(ctx, sink, url, project)
}
return diy.New(ctx, sink, url, project)
}
insecure := pkgWorkspace.GetCloudInsecure(ws, url)
lm := httpstate.NewLoginManager()
// Color is the only thing used by lm.Login, so we can just request a colors.Colorization and only fill that part of
// the display options in. It's hard to change Login itself because it's circularly depended on by esc.
opts := display.Options{
Color: color,
}
_, err := lm.Login(ctx, url, insecure, "pulumi", "Pulumi stacks", httpstate.WelcomeUser, setCurrent, opts)
if err != nil {
return nil, err
}
return httpstate.New(sink, url, project, insecure)
}
func nonInteractiveCurrentBackend(
ctx context.Context, ws pkgWorkspace.Context, lm backend.LoginManager, project *workspace.Project,
) (backend.Backend, error) {
if backendInstance != nil {
return backendInstance, nil
}
url, err := pkgWorkspace.GetCurrentCloudURL(ws, env.Global(), project)
if err != nil {
return nil, fmt.Errorf("could not get cloud url: %w", err)
}
// Only set current if we don't currently have a cloud URL set.
return lm.Current(ctx, ws, cmdutil.Diag(), url, project, url == "")
}
func currentBackend(
ctx context.Context, ws pkgWorkspace.Context, lm backend.LoginManager, project *workspace.Project,
opts display.Options,
) (backend.Backend, error) {
if backendInstance != nil {
return backendInstance, nil
}
url, err := pkgWorkspace.GetCurrentCloudURL(ws, env.Global(), project)
if err != nil {
return nil, fmt.Errorf("could not get cloud url: %w", err)
}
// Only set current if we don't currently have a cloud URL set.
return lm.Login(ctx, ws, cmdutil.Diag(), url, project, url == "", opts.Color)
}
// Creates a secrets manager for an existing stack, using the stack to pick defaults if necessary and writing any
// changes back to the stack's configuration where applicable.
func createSecretsManagerForExistingStack(
_ context.Context, ws pkgWorkspace.Context, stack backend.Stack, secretsProvider string,
rotateSecretsProvider, creatingStack bool,
) error {
// As part of creating the stack, we also need to configure the secrets provider for the stack.
// We need to do this configuration step for cases where we will be using with the passphrase
// secrets provider or one of the cloud-backed secrets providers. We do not need to do this
// for the Pulumi Cloud backend secrets provider.
// we have an explicit flag to rotate the secrets manager ONLY when it's a passphrase!
isDefaultSecretsProvider := secretsProvider == "" || secretsProvider == "default"
// If we're creating the stack, it's the default secrets provider, and it's the cloud backend
// return early to avoid probing for the project and stack config files, which otherwise
// would fail when creating a stack from a directory that does not have a project file.
if isDefaultSecretsProvider && creatingStack {
if _, isCloud := stack.Backend().(httpstate.Backend); isCloud {
return nil
}
}
project, _, err := ws.ReadProject()
if err != nil {
return err
}
ps, err := loadProjectStack(project, stack)
if err != nil {
return err
}
oldConfig := deepcopy.Copy(ps).(*workspace.ProjectStack)
if isDefaultSecretsProvider {
_, err = stack.DefaultSecretManager(ps)
} else if secretsProvider == passphrase.Type {
_, err = passphrase.NewPromptingPassphraseSecretsManager(ps, rotateSecretsProvider)
} else {
// All other non-default secrets providers are handled by the cloud secrets provider which
// uses a URL schema to identify the provider
_, err = cloud.NewCloudSecretsManager(ps, secretsProvider, rotateSecretsProvider)
}
if err != nil {
return err
}
// Handle if the configuration changed any of EncryptedKey, etc
if needsSaveProjectStackAfterSecretManger(oldConfig, ps) {
if err = workspace.SaveProjectStack(stack.Ref().Name().Q(), ps); err != nil {
return fmt.Errorf("saving stack config: %w", err)
}
}
return nil
}
// Creates a secrets manager for a new stack. If a stack configuration already exists (e.g. the user has created a
// Pulumi.<stack>.yaml file themselves, prior to stack initialisation), try to respect the settings therein. Otherwise,
// fall back to a default defined by the backend that will manage the stack.
func createSecretsManagerForNewStack(
ws pkgWorkspace.Context,
b backend.Backend,
stackRef backend.StackReference,
secretsProvider string,
) (*workspace.ProjectStack, bool, secrets.Manager, error) {
var sm secrets.Manager
// Attempt to read a stack configuration, since it's possible that the user may have supplied one even though the
// stack has not actually been created yet. If we fail to read one, that's OK -- we'll just create a new one and
// populate it as we go.
var ps *workspace.ProjectStack
project, _, err := ws.ReadProject()
if err != nil {
ps = &workspace.ProjectStack{}
} else {
ps, err = loadProjectStackByReference(project, stackRef)
if err != nil {
ps = &workspace.ProjectStack{}
}
}
oldConfig := deepcopy.Copy(ps).(*workspace.ProjectStack)
isDefaultSecretsProvider := secretsProvider == "" || secretsProvider == "default"
if isDefaultSecretsProvider {
sm, err = b.DefaultSecretManager(ps)
} else if secretsProvider == passphrase.Type {
sm, err = passphrase.NewPromptingPassphraseSecretsManager(ps, false /*rotateSecretsProvider*/)
} else {
sm, err = cloud.NewCloudSecretsManager(ps, secretsProvider, false /*rotateSecretsProvider*/)
}
if err != nil {
return nil, false, nil, err
}
needsSave := needsSaveProjectStackAfterSecretManger(oldConfig, ps)
return ps, needsSave, sm, err
}
// createStack creates a stack with the given name, and optionally selects it as the current.
func createStack(ctx context.Context, ws pkgWorkspace.Context,
b backend.Backend, stackRef backend.StackReference,
root string, opts *backend.CreateStackOptions, setCurrent bool,
secretsProvider string,
) (backend.Stack, error) {
ps, needsSave, sm, err := createSecretsManagerForNewStack(ws, b, stackRef, secretsProvider)
if err != nil {
return nil, fmt.Errorf("could not create secrets manager for new stack: %w", err)
}
// If we have a non-empty secrets manager, we'll send it off to the backend as part of the initial state to be stored
// for the stack.
var initialState *apitype.UntypedDeployment
if sm != nil {
m := deploy.Manifest{
Time: time.Now(),
Version: version.Version,
Plugins: nil,
}
m.Magic = m.NewMagic()
d := &apitype.DeploymentV3{
Manifest: m.Serialize(),
SecretsProviders: &apitype.SecretsProvidersV1{
Type: sm.Type(),
State: sm.State(),
},
}
dJSON, err := json.Marshal(d)
if err != nil {
return nil, fmt.Errorf("could not serialize initial state for new stack: %w", err)
}
initialState = &apitype.UntypedDeployment{
Version: 3,
Deployment: dJSON,
}
}
stack, err := b.CreateStack(ctx, stackRef, root, initialState, opts)
if err != nil {
// If it's a well-known error, don't wrap it.
if _, ok := err.(*backend.StackAlreadyExistsError); ok {
return nil, err
}
if _, ok := err.(*backend.OverStackLimitError); ok {
return nil, err
}
return nil, fmt.Errorf("could not create stack: %w", err)
}
// Now that we've created the stack, we'll write out any necessary configuration changes.
if needsSave {
err = workspace.SaveProjectStack(stack.Ref().Name().Q(), ps)
if err != nil {
return nil, fmt.Errorf("saving stack config: %w", err)
}
}
if setCurrent {
if err = state.SetCurrentStack(stack.Ref().String()); err != nil {
return nil, err
}
}
return stack, nil
}
type stackLoadOption int
const (
// stackLoadOnly specifies that we should stop after loading the stack.
stackLoadOnly stackLoadOption = 1 << iota
// stackOfferNew is set if we want to allow the user
// to create a stack if one was not found.
stackOfferNew
// stackSetCurrent is set if we want to change the current stack
// once one is found or created.
stackSetCurrent
)
// OfferNew reports whether the stackOfferNew flag is set.
func (o stackLoadOption) OfferNew() bool {
return o&stackOfferNew != 0
}
// SetCurrent reports whether the stackSetCurrent flag is set.
func (o stackLoadOption) SetCurrent() bool {
return o&stackSetCurrent != 0
}
// requireStack will require that a stack exists. If stackName is blank, the currently selected stack from
// the workspace is returned. If no stack with either the given name, or a currently selected stack, exists,
// and we are in an interactive terminal, the user will be prompted to create a new stack.
func requireStack(ctx context.Context, ws pkgWorkspace.Context, lm backend.LoginManager,
stackName string, lopt stackLoadOption, opts display.Options,
) (backend.Stack, error) {
if stackName == "" {
return requireCurrentStack(ctx, ws, lm, lopt, opts)
}
// Try to read the current project
project, root, err := ws.ReadProject()
if err != nil && !errors.Is(err, workspace.ErrProjectNotFound) {
return nil, err
}
b, err := currentBackend(ctx, ws, lm, project, opts)
if err != nil {
return nil, err
}
stackRef, err := b.ParseStackReference(stackName)
if err != nil {
return nil, err
}
stack, err := b.GetStack(ctx, stackRef)
if err != nil {
return nil, err
}
if stack != nil {
return stack, err
}
// No stack was found. If we're in a terminal, prompt to create one.
if lopt.OfferNew() && cmdutil.Interactive() {
fmt.Printf("The stack '%s' does not exist.\n", stackName)
fmt.Printf("\n")
_, err = cmdutil.ReadConsole("If you would like to create this stack now, please press <ENTER>, otherwise " +
"press ^C")
if err != nil {
return nil, err
}
return createStack(ctx, ws, b, stackRef, root, nil, lopt.SetCurrent(), "")
}
return nil, fmt.Errorf("no stack named '%s' found", stackName)
}
func requireCurrentStack(
ctx context.Context, ws pkgWorkspace.Context, lm backend.LoginManager, lopt stackLoadOption, opts display.Options,
) (backend.Stack, error) {
// Try to read the current project
project, _, err := ws.ReadProject()
if err != nil && !errors.Is(err, workspace.ErrProjectNotFound) {
return nil, err
}
// Search for the current stack.
b, err := currentBackend(ctx, ws, lm, project, opts)
if err != nil {
return nil, err
}
stack, err := state.CurrentStack(ctx, b)
if err != nil {
return nil, err
} else if stack != nil {
return stack, nil
}
// If no current stack exists, and we are interactive, prompt to select or create one.
return chooseStack(ctx, ws, b, lopt, opts)
}
// chooseStack will prompt the user to choose amongst the full set of stacks in the given backend. If offerNew is
// true, then the option to create an entirely new stack is provided and will create one as desired.
func chooseStack(ctx context.Context, ws pkgWorkspace.Context,
b backend.Backend, lopt stackLoadOption, opts display.Options,
) (backend.Stack, error) {
// Prepare our error in case we need to issue it. Bail early if we're not interactive.
var chooseStackErr string
if lopt.OfferNew() {
chooseStackErr = "no stack selected; please use `pulumi stack select` or `pulumi stack init` to choose one"
} else {
chooseStackErr = "no stack selected; please use `pulumi stack select` to choose one"
}
if !cmdutil.Interactive() {
return nil, errors.New(chooseStackErr)
}
proj, root, err := ws.ReadProject()
if err != nil {
return nil, err
}
// List stacks as available options.
project := string(proj.Name)
var (
allSummaries []backend.StackSummary
inContToken backend.ContinuationToken
)
for {
summaries, outContToken, err := b.ListStacks(ctx, backend.ListStacksFilter{Project: &project}, inContToken)
if err != nil {
return nil, fmt.Errorf("could not query backend for stacks: %w", err)
}
allSummaries = append(allSummaries, summaries...)
if outContToken == nil {
break
}
inContToken = outContToken
}
options := slice.Prealloc[string](len(allSummaries))
for _, summary := range allSummaries {
name := summary.Name().String()
options = append(options, name)
}
sort.Strings(options)
// If a stack is already selected, make that the default.
var defaultOption string
currStack, currErr := state.CurrentStack(ctx, b)
contract.IgnoreError(currErr)
if currStack != nil {
defaultOption = currStack.Ref().String()
}
// If we are offering to create a new stack, add that to the end of the list.
// Otherwise, default to a stack if one exists otherwise pressing enter will result in
// the empty string being passed (see https://github.com/go-survey/survey/issues/342).
const newOption = "<create a new stack>"
if lopt.OfferNew() {
options = append(options, newOption)
// If we're offering the option to make a new stack AND we don't have a default current stack then
// make the new option the default
if defaultOption == "" {
defaultOption = newOption
}
} else if len(options) == 0 {
// If no options are available, we can't offer a choice!
return nil, errors.New("this command requires a stack, but there are none")
} else if defaultOption == "" {
defaultOption = options[0]
}
// Customize the prompt a little bit (and disable color since it doesn't match our scheme).
surveycore.DisableColor = true
message := "\rPlease choose a stack"
if lopt.OfferNew() {
message += ", or create a new one:"
} else {
message += ":"
}
message = opts.Color.Colorize(colors.SpecPrompt + message + colors.Reset)
var option string
if err = survey.AskOne(&survey.Select{
Message: message,
Options: options,
Default: defaultOption,
}, &option, surveyIcons(opts.Color)); err != nil {
return nil, errors.New(chooseStackErr)
}
if option == newOption {
hint := "Please enter your desired stack name"
if b.SupportsOrganizations() {
hint += ".\nTo create a stack in an organization, " +
"use the format <org-name>/<stack-name> (e.g. `acmecorp/dev`)"
}
stackName, readErr := cmdutil.ReadConsole(hint)
if readErr != nil {
return nil, readErr
}
stackRef, parseErr := b.ParseStackReference(stackName)
if parseErr != nil {
return nil, parseErr
}
return createStack(ctx, ws, b, stackRef, root, nil, lopt.SetCurrent(), "")
}
// With the stack name selected, look it up from the backend.
stackRef, err := b.ParseStackReference(option)
if err != nil {
return nil, fmt.Errorf("parsing selected stack: %w", err)
}
// GetStack may return (nil, nil) if the stack isn't found.
stack, err := b.GetStack(ctx, stackRef)
if err != nil {
return nil, fmt.Errorf("getting selected stack: %w", err)
}
if stack == nil {
return nil, fmt.Errorf("no stack named '%s' found", stackRef)
}
// If setCurrent is true, we'll persist this choice so it'll be used for future CLI operations.
if lopt.SetCurrent() {
if err = state.SetCurrentStack(stackRef.String()); err != nil {
return nil, err
}
}
return stack, nil
}
// parseAndSaveConfigArray parses the config array and saves it as a config for
// the provided stack.
func parseAndSaveConfigArray(ws pkgWorkspace.Context, s backend.Stack, configArray []string, path bool) error {
if len(configArray) == 0 {
return nil
}
commandLineConfig, err := parseConfig(configArray, path)
if err != nil {
return err
}
if err = saveConfig(ws, s, commandLineConfig); err != nil {
return fmt.Errorf("saving config: %w", err)
}
return nil
}
// readProjectForUpdate attempts to detect and read a Pulumi project for the current workspace. If
// the project is successfully detected and read, it is returned along with the path to its
// containing directory, which will be used as the root of the project's Pulumi program. If a
// client address is present, the returned project will always have the runtime set to "client"
// with the address option set to the client address.
func readProjectForUpdate(ws pkgWorkspace.Context, clientAddress string) (*workspace.Project, string, error) {
proj, root, err := ws.ReadProject()
if err != nil {
return nil, "", err
}
if clientAddress != "" {
proj.Runtime = workspace.NewProjectRuntimeInfo("client", map[string]interface{}{
"address": clientAddress,
})
}
return proj, root, nil
}
// readPolicyProject attempts to detect and read a Pulumi PolicyPack project for the current
// workspace. If the project is successfully detected and read, it is returned along with the path
// to its containing directory, which will be used as the root of the project's Pulumi program.
func readPolicyProject(pwd string) (*workspace.PolicyPackProject, string, string, error) {
// Now that we got here, we have a path, so we will try to load it.
path, err := workspace.DetectPolicyPackPathFrom(pwd)
if err != nil {
return nil, "", "", fmt.Errorf("failed to find current Pulumi project because of "+
"an error when searching for the PulumiPolicy.yaml file (searching upwards from %s)"+": %w", pwd, err)
} else if path == "" {
return nil, "", "", fmt.Errorf("no PulumiPolicy.yaml project file found (searching upwards from %s)", pwd)
}
proj, err := workspace.LoadPolicyPack(path)
if err != nil {
return nil, "", "", fmt.Errorf("failed to load Pulumi policy project located at %q: %w", path, err)
}
return proj, path, filepath.Dir(path), nil
}
// anyWriter is an io.Writer that will set itself to `true` iff any call to `anyWriter.Write` is made with a
// non-zero-length slice. This can be used to determine whether or not any data was ever written to the writer.
type anyWriter bool
func (w *anyWriter) Write(d []byte) (int, error) {
if len(d) > 0 {
*w = true
}
return len(d), nil
}
// isGitWorkTreeDirty returns true if the work tree for the current directory's repository is dirty.
func isGitWorkTreeDirty(repoRoot string) (bool, error) {
gitBin, err := exec.LookPath("git")
if err != nil {
return false, err
}
gitStatusCmd := exec.Command(gitBin, "status", "--porcelain", "-z")
var anyOutput anyWriter
var stderr bytes.Buffer
gitStatusCmd.Dir = repoRoot
gitStatusCmd.Stdout = &anyOutput
gitStatusCmd.Stderr = &stderr
if err = gitStatusCmd.Run(); err != nil {
if ee, ok := err.(*exec.ExitError); ok {
ee.Stderr = stderr.Bytes()
}
return false, fmt.Errorf("'git status' failed: %w", err)
}
return bool(anyOutput), nil
}
// getUpdateMetadata returns an UpdateMetadata object, with optional data about the environment
// performing the update.
func getUpdateMetadata(
msg, root, execKind, execAgent string, updatePlan bool, flags *pflag.FlagSet,
) (*backend.UpdateMetadata, error) {
m := &backend.UpdateMetadata{
Message: msg,
Environment: make(map[string]string),
}
addPulumiCLIMetadataToEnvironment(m.Environment, flags, os.Environ)
if err := addGitMetadata(root, m); err != nil {
logging.V(3).Infof("errors detecting git metadata: %s", err)
}
addCIMetadataToEnvironment(m.Environment)
addExecutionMetadataToEnvironment(m.Environment, execKind, execAgent)
addUpdatePlanMetadataToEnvironment(m.Environment, updatePlan)
return m, nil
}
// addPulumiCLIMetadataToEnvironment enriches updates with metadata to provide context about
// the system, environment, and options that an update is being performed with.
func addPulumiCLIMetadataToEnvironment(env map[string]string, flags *pflag.FlagSet, getEnviron func() []string) {
// Pulumi Environment Variables (Name and Set/Truthiness Only)
// Set up a map of all known bool environment variables.
boolVars := map[string]struct{}{}
for _, e := range declared.Variables() {
_, ok := e.Value.(declared.BoolValue)
if !ok {
continue
}
boolVars[e.Name()] = struct{}{}
}
// Add all environment variables that start with "PULUMI_" and their set-ness or truthiness.
for _, envvar := range getEnviron() {
if !strings.HasPrefix(envvar, "PULUMI_") {
// Not a Pulumi environment variable. Skip.
continue
}
name, value, ok := strings.Cut(envvar, "=")
if !ok {
// Invalid environment variable. Skip.
continue
}
flag := "set"
if _, isBoolVar := boolVars[name]; isBoolVar {
flag = "false"
if cmdutil.IsTruthy(value) {
flag = "true"
}
}
// Only store the variable name and set-ness or truthiness not the value.
env["pulumi.env."+name] = flag
}
// System information
env["pulumi.version"] = version.Version
env["pulumi.os"] = runtime.GOOS
env["pulumi.arch"] = runtime.GOARCH
// Guard against nil pointer dereference.
if flags == nil {
// No command was provided. Don't add flags.
return
}
// Pulumi CLI Flags (Name Only)
flags.Visit(func(f *pflag.Flag) {
env["pulumi.flag."+f.Name] = func() string {
truth, err := flags.GetBool(f.Name)
switch {
case err != nil:
return "set" // not a bool flag
case truth:
return "true"
default:
return "false"
}
}()
})
}
// addGitMetadata populate's the environment metadata bag with Git-related values.
func addGitMetadata(projectRoot string, m *backend.UpdateMetadata) error {
var allErrors *multierror.Error
// Gather git-related data as appropriate. (Returns nil, nil if no repo found.)
repo, err := gitutil.GetGitRepository(projectRoot)
if err != nil {
return fmt.Errorf("detecting Git repository: %w", err)
}
if repo == nil {
return nil
}
if err := AddGitRemoteMetadataToMap(repo, projectRoot, m.Environment); err != nil {
allErrors = multierror.Append(allErrors, err)
}
if err := addGitCommitMetadata(repo, projectRoot, m); err != nil {
allErrors = multierror.Append(allErrors, err)
}
return allErrors.ErrorOrNil()
}
// AddGitRemoteMetadataToMap reads the given git repo and adds its metadata to the given map bag.
func AddGitRemoteMetadataToMap(repo *git.Repository, projectRoot string, env map[string]string) error {
var allErrors *multierror.Error
// Get the remote URL for this repo.
remoteURL, err := gitutil.GetGitRemoteURL(repo, "origin")
if err != nil {
return fmt.Errorf("detecting Git remote URL: %w", err)
}
if remoteURL == "" {
return nil
}
// Check if the remote URL is a GitHub or a GitLab URL.
if err := addVCSMetadataToEnvironment(remoteURL, env); err != nil {
allErrors = multierror.Append(allErrors, err)
}
// Add the repository root path.
tree, err := repo.Worktree()
if err != nil {
allErrors = multierror.Append(allErrors, fmt.Errorf("detecting VCS root: %w", err))
} else {
rel, err := filepath.Rel(tree.Filesystem.Root(), projectRoot)
if err != nil {
allErrors = multierror.Append(allErrors, fmt.Errorf("detecting project root: %w", err))
} else if !strings.HasPrefix(rel, "..") {
env[backend.VCSRepoRoot] = filepath.ToSlash(rel)
}
}
return allErrors.ErrorOrNil()
}
func addVCSMetadataToEnvironment(remoteURL string, env map[string]string) error {
// GitLab, Bitbucket, Azure DevOps etc. repo slug if applicable.
// We don't require a cloud-hosted VCS, so swallow errors.
vcsInfo, err := gitutil.TryGetVCSInfo(remoteURL)
if err != nil {
return fmt.Errorf("detecting VCS project information: %w", err)
}
env[backend.VCSRepoOwner] = vcsInfo.Owner
env[backend.VCSRepoName] = vcsInfo.Repo
env[backend.VCSRepoKind] = vcsInfo.Kind
return nil
}
func addGitCommitMetadata(repo *git.Repository, repoRoot string, m *backend.UpdateMetadata) error {
// When running in a CI/CD environment, the current git repo may be running from a
// detached HEAD and may not have have the latest commit message. We fall back to
// CI-system specific environment variables when possible.
ciVars := ciutil.DetectVars()
// Commit at HEAD
head, err := repo.Head()
if err != nil {
return fmt.Errorf("getting repository HEAD: %w", err)
}
hash := head.Hash()
m.Environment[backend.GitHead] = hash.String()
commit, commitErr := repo.CommitObject(hash)
if commitErr != nil {
return fmt.Errorf("getting HEAD commit info: %w", commitErr)
}
// If in detached head, will be "HEAD", and fallback to use value from CI/CD system if possible.
// Otherwise, the value will be like "refs/heads/master".
headName := head.Name().String()
if headName == "HEAD" && ciVars.BranchName != "" {
headName = ciVars.BranchName
}
if headName != "HEAD" {
m.Environment[backend.GitHeadName] = headName
}
// If there is no message set manually, default to the Git commit's title.
msg := strings.TrimSpace(commit.Message)
if msg == "" && ciVars.CommitMessage != "" {
msg = ciVars.CommitMessage
}
if m.Message == "" {
m.Message = gitCommitTitle(msg)
}
// Store committer and author information.
m.Environment[backend.GitCommitter] = commit.Committer.Name
m.Environment[backend.GitCommitterEmail] = commit.Committer.Email
m.Environment[backend.GitAuthor] = commit.Author.Name
m.Environment[backend.GitAuthorEmail] = commit.Author.Email
// If the worktree is dirty, set a bit, as this could be a mistake.
isDirty, err := isGitWorkTreeDirty(repoRoot)
if err != nil {
return fmt.Errorf("checking git worktree dirty state: %w", err)
}
m.Environment[backend.GitDirty] = strconv.FormatBool(isDirty)
return nil
}
// gitCommitTitle turns a commit message into its title, simply by taking the first line.
func gitCommitTitle(s string) string {
if ixCR := strings.Index(s, "\r"); ixCR != -1 {
s = s[:ixCR]
}
if ixLF := strings.Index(s, "\n"); ixLF != -1 {
s = s[:ixLF]
}
return s
}
// addCIMetadataToEnvironment populates the environment metadata bag with CI/CD-related values.
func addCIMetadataToEnvironment(env map[string]string) {
// Add the key/value pair to env, if there actually is a value.
addIfSet := func(key, val string) {
if val != "" {
env[key] = val
}
}
// Use our built-in CI/CD detection logic.
vars := ciutil.DetectVars()
if vars.Name == "" {
return
}
env[backend.CISystem] = string(vars.Name)
addIfSet(backend.CIBuildID, vars.BuildID)
addIfSet(backend.CIBuildNumer, vars.BuildNumber)
addIfSet(backend.CIBuildType, vars.BuildType)
addIfSet(backend.CIBuildURL, vars.BuildURL)
addIfSet(backend.CIPRHeadSHA, vars.SHA)
addIfSet(backend.CIPRNumber, vars.PRNumber)
}
// addExecutionMetadataToEnvironment populates the environment metadata bag with execution-related values.
func addExecutionMetadataToEnvironment(env map[string]string, execKind, execAgent string) {
// this comes from a hidden flag, so we restrict the set of allowed values
switch execKind {
case constant.ExecKindAutoInline:
break
case constant.ExecKindAutoLocal:
break
case constant.ExecKindCLI:
break
default:
execKind = constant.ExecKindCLI
}
env[backend.ExecutionKind] = execKind
if execAgent != "" {
env[backend.ExecutionAgent] = execAgent
}
}
// addUpdatePlanMetadataToEnvironment populates the environment metadata bag with update plan related values.
func addUpdatePlanMetadataToEnvironment(env map[string]string, updatePlan bool) {
env[backend.UpdatePlan] = strconv.FormatBool(updatePlan)
}
// makeJSONString turns the given value into a JSON string.
// If multiline is true, the JSON will be formatted with indentation and a trailing newline.
func makeJSONString(v interface{}, multiline bool) (string, error) {
var out bytes.Buffer
// json.Marshal escapes HTML characters, which we don't want,
// so change that with json.NewEncoder.
encoder := json.NewEncoder(&out)
encoder.SetEscapeHTML(false)
if multiline {
encoder.SetIndent("", " ")
}
if err := encoder.Encode(v); err != nil {
return "", err
}
// json.NewEncoder always adds a trailing newline. Remove it.
bs := out.Bytes()
if !multiline {
if n := len(bs); n > 0 && bs[n-1] == '\n' {
bs = bs[:n-1]
}
}
return string(bs), nil
}
// printJSON simply prints out some object, formatted as JSON, using standard indentation.
func printJSON(v interface{}) error {
return fprintJSON(os.Stdout, v)
}
// fprintJSON simply prints out some object, formatted as JSON, using standard indentation.
func fprintJSON(w io.Writer, v interface{}) error {
jsonStr, err := makeJSONString(v, true /* multi line */)
if err != nil {
return err
}
_, err = fmt.Fprint(w, jsonStr)
return err
}
// updateFlagsToOptions ensures that the given update flags represent a valid combination. If so, an UpdateOptions
// is returned with a nil-error; otherwise, the non-nil error contains information about why the combination is invalid.
func updateFlagsToOptions(interactive, skipPreview, yes, previewOnly bool) (backend.UpdateOptions, error) {
switch {
case !interactive && !yes && !skipPreview && !previewOnly:
return backend.UpdateOptions{},
errors.New("one of --yes, --skip-preview, or --preview-only must be specified in non-interactive mode")
case skipPreview && previewOnly:
return backend.UpdateOptions{},
errors.New("--skip-preview and --preview-only cannot be used together")
case yes && previewOnly:
return backend.UpdateOptions{},
errors.New("--yes and --preview-only cannot be used together")
default:
return backend.UpdateOptions{
AutoApprove: yes,
SkipPreview: skipPreview,
PreviewOnly: previewOnly,
}, nil
}
}
func checkDeploymentVersionError(err error, stackName string) error {
switch err {
case stack.ErrDeploymentSchemaVersionTooOld:
return fmt.Errorf("the stack '%s' is too old to be used by this version of the Pulumi CLI",
stackName)
case stack.ErrDeploymentSchemaVersionTooNew:
return fmt.Errorf("the stack '%s' is newer than what this version of the Pulumi CLI understands. "+
"Please update your version of the Pulumi CLI", stackName)
}
return fmt.Errorf("could not deserialize deployment: %w", err)
}
func getRefreshOption(proj *workspace.Project, refresh string) (bool, error) {
// we want to check for an explicit --refresh or a --refresh=true or --refresh=false
// refresh is assigned the empty string by default to distinguish the difference between
// when the user actually interacted with the cli argument (`NoOptDefVal`)
// and the default functionality today
if refresh != "" {
refreshDetails, boolErr := strconv.ParseBool(refresh)
if boolErr != nil {
// the user has passed a --refresh but with a random value that we don't support
return false, errors.New("unable to determine value for --refresh")
}
return refreshDetails, nil
}
// the user has not specifically passed an argument on the cli to refresh but has set a Project option to refresh
if proj.Options != nil && proj.Options.Refresh == "always" {
return true, nil
}
// the default functionality right now is to always skip a refresh
return false, nil
}
func writePlan(path string, plan *deploy.Plan, enc config.Encrypter, showSecrets bool) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer contract.IgnoreClose(f)
deploymentPlan, err := stack.SerializePlan(plan, enc, showSecrets)
if err != nil {
return err
}
encoder := json.NewEncoder(f)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
return encoder.Encode(deploymentPlan)
}
func readPlan(path string, dec config.Decrypter, enc config.Encrypter) (*deploy.Plan, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer contract.IgnoreClose(f)
var deploymentPlan apitype.DeploymentPlanV1
if err := json.NewDecoder(f).Decode(&deploymentPlan); err != nil {
return nil, err
}
return stack.DeserializePlan(deploymentPlan, dec, enc)
}
func buildStackName(stackName string) (string, error) {
// If we already have a slash (e.g. org/stack, or org/proj/stack) don't add the default org.
if strings.Contains(stackName, "/") {
return stackName, nil
}
// We never have a project at the point of calling buildStackName (only called from new), so we just pass
// nil for the project and only check the global settings.
defaultOrg, err := pkgWorkspace.GetBackendConfigDefaultOrg(nil)
if err != nil {
return "", err
}
if defaultOrg != "" {
return fmt.Sprintf("%s/%s", defaultOrg, stackName), nil
}
return stackName, nil
}
// we only want to log a secrets decryption for a Pulumi Cloud backend project
// we will allow any secrets provider to be used (Pulumi Cloud or passphrase/cloud/etc)
// we will log the message and not worry about the response. The types
// of messages we will log here will range from single secret decryption events
// to requesting a list of secrets in an individual event e.g. stack export
// the logging event will only happen during the `--show-secrets` path within the cli
func log3rdPartySecretsProviderDecryptionEvent(ctx context.Context, backend backend.Stack,
secretName, commandName string,
) {
if stack, ok := backend.(httpstate.Stack); ok {
// we only want to do something if this is a Pulumi Cloud backend
if be, ok := stack.Backend().(httpstate.Backend); ok {
client := be.Client()
if client != nil {
id := backend.(httpstate.Stack).StackIdentifier()
// we don't really care if these logging calls fail as they should not stop the execution
if secretName != "" {
contract.Assertf(commandName == "", "Command name must be empty if secret name is set")
err := client.Log3rdPartySecretsProviderDecryptionEvent(ctx, id, secretName)
contract.IgnoreError(err)
}
if commandName != "" {
contract.Assertf(secretName == "", "Secret name must be empty if command name is set")
err := client.LogBulk3rdPartySecretsProviderDecryptionEvent(ctx, id, commandName)
contract.IgnoreError(err)
}
}
}
}
}
func installPolicyPackDependencies(ctx context.Context, root string, proj *workspace.PolicyPackProject) error {
span := opentracing.SpanFromContext(ctx)
// Bit of a hack here. Creating a plugin context requires a "program project", but we've only got a
// policy project. Ideally we should be able to make a plugin context without any related project. But
// fow now this works.
projinfo := &engine.Projinfo{Proj: &workspace.Project{
Main: proj.Main,
Runtime: proj.Runtime,
}, Root: root}
_, main, pluginCtx, err := engine.ProjectInfoContext(
projinfo,
nil,
cmdutil.Diag(),
cmdutil.Diag(),
nil,
false,
span,
nil,
)
if err != nil {
return err
}
defer pluginCtx.Close()
programInfo := plugin.NewProgramInfo(pluginCtx.Root, pluginCtx.Pwd, main, proj.Runtime.Options())
lang, err := pluginCtx.Host.LanguageRuntime(proj.Runtime.Name(), programInfo)
if err != nil {
return fmt.Errorf("failed to load language plugin %s: %w", proj.Runtime.Name(), err)
}
if err = lang.InstallDependencies(plugin.InstallDependenciesRequest{Info: programInfo}); err != nil {
return fmt.Errorf("installing dependencies failed: %w", err)
}
return nil
}
func surveyIcons(color colors.Colorization) survey.AskOpt {
return survey.WithIcons(func(icons *survey.IconSet) {
icons.Question = survey.Icon{}
icons.SelectFocus = survey.Icon{Text: color.Colorize(colors.BrightGreen + ">" + colors.Reset)}
})
}
// Ask multiple survey based questions.
//
// Ctrl-C will go back in the stack, and valid answers will go forward.
func surveyStack(interactions ...func() error) error {
for i := 0; i < len(interactions); {
err := interactions[i]()
switch err {
// No error, so go to the next interaction.
case nil:
i++
// We have received an interrupt, so go back to the previous interaction.
case terminal.InterruptErr:
// If we have received in interrupt at the beginning of the stack,
// the user has asked to go back to before the stack. We can't do
// that, so we just return the interrupt.
if i == 0 {
return err
}
i--
// We have received an unexpected error, so return it.
default:
return err
}
}
return nil
}
// Format a non-nil error that indicates some arguments are missing for a
// non-interactive session.
func missingNonInteractiveArg(args ...string) error {
switch len(args) {
case 0:
panic("cannot create an error message for missing zero args")
case 1:
return fmt.Errorf("Must supply <%s> unless pulumi is run interactively", args[0])
default:
for i, s := range args {
args[i] = "<" + s + ">"
}
return fmt.Errorf("Must supply %s and %s unless pulumi is run interactively",
strings.Join(args[:len(args)-1], ", "), args[len(args)-1])
}
}
// promptUserSkippable wraps over promptUser making it skippable through the "yes" parameter
// commonly being the value of the --yes flag used in each command.
// If yes is true, defaultValue is returned without prompting.
func promptUserSkippable(yes bool, msg string, options []string, defaultOption string,
colorization colors.Colorization,
) string {
if yes {
return defaultOption
}
return promptUser(msg, options, defaultOption, colorization)
}
// promptUser prompts the user for a value with a list of options. Hitting enter accepts the
// default.
func promptUser(
msg string,
options []string,
defaultOption string,
colorization colors.Colorization,
surveyAskOpts ...survey.AskOpt,
) string {
prompt := "\b" + colorization.Colorize(colors.SpecPrompt+msg+colors.Reset)
surveycore.DisableColor = true
allSurveyAskOpts := append(
surveyAskOpts,
survey.WithIcons(func(icons *survey.IconSet) {
icons.Question = survey.Icon{}
icons.SelectFocus = survey.Icon{Text: colorization.Colorize(colors.BrightGreen + ">" + colors.Reset)}
}),
)
var response string
if err := survey.AskOne(&survey.Select{
Message: prompt,
Options: options,
Default: defaultOption,
}, &response, allSurveyAskOpts...); err != nil {
return ""
}
return response
}
// promptUserMultiSkippable wraps over promptUserMulti making it skippable through the "yes" parameter
// commonly being the value of the --yes flag used in each command.
// If yes is true, defaultValue is returned without prompting.
func promptUserMultiSkippable(yes bool, msg string, options []string, defaultOptions []string,
colorization colors.Colorization,
) []string {
if yes {
return defaultOptions
}
return promptUserMulti(msg, options, defaultOptions, colorization)
}
// promptUserMulti prompts the user for a value with a list of options, allowing to select none or multiple options.
// defaultOptions is a set of values to be selected by default.
func promptUserMulti(msg string, options []string, defaultOptions []string, colorization colors.Colorization) []string {
confirmationHint := " (use enter to accept the current selection)"
prompt := "\b" + colorization.Colorize(colors.SpecPrompt+msg+colors.Reset) + confirmationHint
surveycore.DisableColor = true
surveyIcons := survey.WithIcons(func(icons *survey.IconSet) {
icons.Question = survey.Icon{}
icons.SelectFocus = survey.Icon{Text: colorization.Colorize(colors.BrightGreen + ">" + colors.Reset)}
})
var response []string
if err := survey.AskOne(&survey.MultiSelect{
Message: prompt,
Options: options,
Default: defaultOptions,
}, &response, surveyIcons); err != nil {
return []string{}
}
return response
}
func printTable(table cmdutil.Table, opts *cmdutil.TableRenderOptions) {
fprintTable(os.Stdout, table, opts)
}
func fprintTable(out io.Writer, table cmdutil.Table, opts *cmdutil.TableRenderOptions) {
fmt.Fprint(out, renderTable(table, opts))
}
func renderTable(table cmdutil.Table, opts *cmdutil.TableRenderOptions) string {
if opts == nil {
opts = &cmdutil.TableRenderOptions{}
}
if len(opts.HeaderStyle) == 0 {
style := make([]colors.Color, len(table.Headers))
for i := range style {
style[i] = colors.SpecHeadline
}
opts.HeaderStyle = style
}
if opts.Color == "" {
opts.Color = cmdutil.GetGlobalColorization()
}
return table.Render(opts)
}