pulumi/pkg/cmd/pulumi/config/io.go

347 lines
10 KiB
Go

// Copyright 2024, 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 config
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/pulumi/esc"
"github.com/pulumi/esc/cmd/esc/cli"
"github.com/pulumi/pulumi/pkg/v3/backend"
cmdStack "github.com/pulumi/pulumi/pkg/v3/cmd/pulumi/stack"
"github.com/pulumi/pulumi/pkg/v3/secrets"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource/config"
"github.com/pulumi/pulumi/sdk/v3/go/common/tokens"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/logging"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
)
// Attempts to load configuration for the given stack.
func GetStackConfiguration(
ctx context.Context,
ssml cmdStack.SecretsManagerLoader,
stack backend.Stack,
project *workspace.Project,
) (backend.StackConfiguration, secrets.Manager, error) {
return getStackConfigurationWithFallback(ctx, ssml, stack, project, nil)
}
// GetStackConfigurationOrLatest attempts to load a current stack configuration
// using getStackConfiguration. If that fails due to not being run within a
// valid project, the latest configuration from the backend is returned. This is
// primarily for use in commands like `pulumi destroy`, where it is useful to be
// able to clean up a stack whose configuration has already been deleted as part
// of that cleanup.
func GetStackConfigurationOrLatest(
ctx context.Context,
ssml cmdStack.SecretsManagerLoader,
stack backend.Stack,
project *workspace.Project,
) (backend.StackConfiguration, secrets.Manager, error) {
return getStackConfigurationWithFallback(
ctx, ssml, stack, project,
func(err error) (config.Map, error) {
if errors.Is(err, workspace.ErrProjectNotFound) {
// This error indicates that we're not being run in a project directory.
// We should fallback on the backend.
return backend.GetLatestConfiguration(ctx, stack)
}
return nil, err
})
}
func getStackConfigurationWithFallback(
ctx context.Context,
ssml cmdStack.SecretsManagerLoader,
s backend.Stack,
project *workspace.Project,
fallbackGetConfig func(err error) (config.Map, error), // optional
) (backend.StackConfiguration, secrets.Manager, error) {
workspaceStack, err := cmdStack.LoadProjectStack(project, s)
if err != nil || workspaceStack == nil {
if fallbackGetConfig == nil {
return backend.StackConfiguration{}, nil, err
}
// On first run or the latest configuration is unavailable, fallback to check the project's configuration
cfg, err := fallbackGetConfig(err)
if err != nil {
return backend.StackConfiguration{}, nil, fmt.Errorf(
"stack configuration could not be loaded from either Pulumi.yaml or the backend: %w", err)
}
workspaceStack = &workspace.ProjectStack{
Config: cfg,
}
}
sm, err := getAndSaveSecretsManager(ctx, ssml, s, workspaceStack)
if err != nil {
return backend.StackConfiguration{}, nil, err
}
config, err := getStackConfigurationFromProjectStack(ctx, s, project, sm, workspaceStack)
if err != nil {
return backend.StackConfiguration{}, nil, err
}
return config, sm, nil
}
func getStackConfigurationFromProjectStack(
ctx context.Context,
stack backend.Stack,
project *workspace.Project,
sm secrets.Manager,
workspaceStack *workspace.ProjectStack,
) (backend.StackConfiguration, error) {
env, diags, err := openStackEnv(ctx, stack, workspaceStack)
if err != nil {
return backend.StackConfiguration{}, fmt.Errorf("opening environment: %w", err)
}
if len(diags) != 0 {
printESCDiagnostics(os.Stderr, diags)
return backend.StackConfiguration{}, errors.New("opening environment: too many errors")
}
var pulumiEnv esc.Value
if env != nil {
warnOnNoEnvironmentEffects(os.Stdout, env)
pulumiEnv = env.Properties["pulumiConfig"]
_, environ, secrets, err := cli.PrepareEnvironment(env, nil)
if err != nil {
return backend.StackConfiguration{}, fmt.Errorf("preparing environment: %w", err)
}
if len(secrets) != 0 {
logging.AddGlobalFilter(logging.CreateFilter(secrets, "[secret]"))
}
for _, kvp := range environ {
if name, value, ok := strings.Cut(kvp, "="); ok {
if err := os.Setenv(name, value); err != nil {
return backend.StackConfiguration{}, fmt.Errorf("setting environment variable %v: %w", name, err)
}
}
}
}
// If there are no secrets in the configuration, we should never use the decrypter, so it is safe to return
// one which panics if it is used. This provides for some nice UX in the common case (since, for example, building
// the correct decrypter for the diy backend would involve prompting for a passphrase)
if !needsCrypter(workspaceStack.Config, pulumiEnv) {
return backend.StackConfiguration{
EnvironmentImports: workspaceStack.Environment.Imports(),
Environment: pulumiEnv,
Config: workspaceStack.Config,
Decrypter: config.NewPanicCrypter(),
}, nil
}
crypter, err := sm.Decrypter()
if err != nil {
return backend.StackConfiguration{}, fmt.Errorf("getting configuration decrypter: %w", err)
}
return backend.StackConfiguration{
EnvironmentImports: workspaceStack.Environment.Imports(),
Environment: pulumiEnv,
Config: workspaceStack.Config,
Decrypter: crypter,
}, nil
}
func getAndSaveSecretsManager(
ctx context.Context,
ssml cmdStack.SecretsManagerLoader,
stack backend.Stack,
workspaceStack *workspace.ProjectStack,
) (secrets.Manager, error) {
sm, state, err := ssml.GetSecretsManager(ctx, stack, workspaceStack)
if err != nil {
return nil, fmt.Errorf("get stack secrets manager: %w", err)
}
if state != cmdStack.SecretsManagerUnchanged {
if err = cmdStack.SaveProjectStack(stack, workspaceStack); err != nil && state == cmdStack.SecretsManagerMustSave {
return nil, fmt.Errorf("save stack config: %w", err)
}
}
return sm, nil
}
func needsCrypter(cfg config.Map, env esc.Value) bool {
var hasSecrets func(v esc.Value) bool
hasSecrets = func(v esc.Value) bool {
if v.Secret {
return true
}
switch v := v.Value.(type) {
case []esc.Value:
for _, v := range v {
if hasSecrets(v) {
return true
}
}
case map[string]esc.Value:
for _, v := range v {
if hasSecrets(v) {
return true
}
}
}
return false
}
return cfg.HasSecureValue() || hasSecrets(env)
}
func openStackEnv(
ctx context.Context,
stack backend.Stack,
workspaceStack *workspace.ProjectStack,
) (*esc.Environment, []apitype.EnvironmentDiagnostic, error) {
yaml := workspaceStack.EnvironmentBytes()
if len(yaml) == 0 {
return nil, nil, nil
}
envs, ok := stack.Backend().(backend.EnvironmentsBackend)
if !ok {
return nil, nil, fmt.Errorf("backend %v does not support environments", stack.Backend().Name())
}
orgNamer, ok := stack.(interface{ OrgName() string })
if !ok {
return nil, nil, fmt.Errorf("cannot determine organzation for stack %v", stack.Ref())
}
orgName := orgNamer.OrgName()
return envs.OpenYAMLEnvironment(ctx, orgName, yaml, 2*time.Hour)
}
func copySingleConfigKey(
ctx context.Context,
ssml cmdStack.SecretsManagerLoader,
configKey string,
path bool,
currentStack backend.Stack,
currentProjectStack *workspace.ProjectStack,
destinationStack backend.Stack,
destinationProjectStack *workspace.ProjectStack,
) error {
var decrypter config.Decrypter
key, err := ParseConfigKey(configKey)
if err != nil {
return fmt.Errorf("invalid configuration key: %w", err)
}
v, ok, err := currentProjectStack.Config.Get(key, path)
if err != nil {
return err
} else if !ok {
return fmt.Errorf("configuration key '%s' not found for stack '%s'", PrettyKey(key), currentStack.Ref())
}
if v.Secure() {
var err error
var state cmdStack.SecretsManagerState
if decrypter, state, err = ssml.GetDecrypter(ctx, currentStack, currentProjectStack); err != nil {
return fmt.Errorf("could not create a decrypter: %w", err)
}
contract.Assertf(
state == cmdStack.SecretsManagerUnchanged,
"We're reading a secure value so the encryption information must be present already",
)
} else {
decrypter = config.NewPanicCrypter()
}
encrypter, _, cerr := ssml.GetEncrypter(ctx, destinationStack, destinationProjectStack)
if cerr != nil {
return cerr
}
val, err := v.Copy(decrypter, encrypter)
if err != nil {
return err
}
err = destinationProjectStack.Config.Set(key, val, path)
if err != nil {
return err
}
return cmdStack.SaveProjectStack(destinationStack, destinationProjectStack)
}
func parseKeyValuePair(pair string) (config.Key, string, error) {
// Split the arg on the first '=' to separate key and value.
splitArg := strings.SplitN(pair, "=", 2)
// Check if the key is wrapped in quote marks and split on the '=' following the wrapping quote.
firstChar := string([]rune(pair)[0])
if firstChar == "\"" || firstChar == "'" {
pair = strings.TrimPrefix(pair, firstChar)
splitArg = strings.SplitN(pair, firstChar+"=", 2)
}
if len(splitArg) < 2 {
return config.Key{}, "", errors.New("config value must be in the form [key]=[value]")
}
key, err := ParseConfigKey(splitArg[0])
if err != nil {
return config.Key{}, "", fmt.Errorf("invalid configuration key: %w", err)
}
value := splitArg[1]
return key, value, nil
}
func ParseConfigKey(key string) (config.Key, error) {
// As a convenience, we'll treat any key with no delimiter as if:
// <program-name>:<key> had been written instead
if !strings.Contains(key, tokens.TokenDelimiter) {
proj, err := workspace.DetectProject()
if err != nil {
return config.Key{}, err
}
return config.ParseKey(fmt.Sprintf("%s:%s", proj.Name, key))
}
return config.ParseKey(key)
}
func PrettyKey(k config.Key) string {
proj, err := workspace.DetectProject()
if err != nil {
return fmt.Sprintf("%s:%s", k.Namespace(), k.Name())
}
return prettyKeyForProject(k, proj)
}
func prettyKeyForProject(k config.Key, proj *workspace.Project) string {
if k.Namespace() == string(proj.Name) {
return k.Name()
}
return fmt.Sprintf("%s:%s", k.Namespace(), k.Name())
}