pulumi/sdk/go/x/auto/stack.go
2020-08-21 09:49:46 -07:00

532 lines
15 KiB
Go

// Package auto contains the Pulumi Automation API, the programmatic interface for driving Pulumi programs
// without the CLI.
// Generally this can be thought of as encapsulating the functionality of the CLI but with more flexibility
// (`pulumi up`, `pulumi preview`, pulumi destroy`, `pulumi stack init`, etc.). This still requires a
// CLI binary to be installed and available on your $PATH.
//
// The Automation API provides a natural way to orchestrate multiple stacks,
// feeding the output of one stack as an input to the next as shown in the example below.
// The package can be used for a number of use cases:
//
// - Driving pulumi deployments within CI/CD workflows
//
// - Integration testing
//
// - Multi-stage deployments such as blue-green deployment patterns
//
// - Deployments involving application code like database migrations
//
// - Building higher level tools, custom CLIs over pulumi, etc.
//
// - Using pulumi behind a REST or GRPC API.
package auto
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"os"
"os/exec"
"runtime/debug"
"strings"
"github.com/pkg/errors"
"github.com/pulumi/pulumi/sdk/v2/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v2/go/common/resource"
"github.com/pulumi/pulumi/sdk/v2/go/pulumi"
)
type Stack struct {
workspace Workspace
fqsn string
}
func FullyQualifiedStackName(org, project, stack string) string {
return fmt.Sprintf("%s/%s/%s", org, project, stack)
}
// NewStack creates a new stack using the given workspace, and fully qualified stack name (org/project/name).
// It fails if a stack with that name already exists
func NewStack(fqsn string, ws Workspace) (Stack, error) {
var s Stack
s = Stack{
workspace: ws,
fqsn: fqsn,
}
err := ws.CreateStack(fqsn)
if err != nil {
return s, err
}
return s, nil
}
// SelectStack creates a new stack using the given workspace, and fully qualified stack name (org/project/name).
func SelectStack(fqsn string, ws Workspace) (Stack, error) {
var s Stack
s = Stack{
workspace: ws,
fqsn: fqsn,
}
err := ws.SelectStack(fqsn)
if err != nil {
return s, err
}
return s, nil
}
// Name returns the fully qualified stack name in the form "org/project/stack"
func (s *Stack) Name() string {
return s.fqsn
}
// Workspace returns the underlying Workspace backing the Stack.
// This handles state associated with the Project and child Stacks including
// settings, configuration, and environment.
func (s *Stack) Workspace() Workspace {
return s.workspace
}
// Preview preforms a dry-run update to a stack, returning pending changes.
// https://www.pulumi.com/docs/reference/cli/pulumi_preview/
func (s *Stack) Preview() (PreviewResult, error) {
var res PreviewResult
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return res, errors.Wrap(err, "failed to run preview")
}
var stdout, stderr string
var code int
if s.Workspace().Program() != nil {
stdout, stderr, err = s.host(true /*isPreview*/)
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to run preview"), stdout, stderr, code)
}
} else {
stdout, stderr, code, err = s.runPulumiCmdSync("preview", "--json")
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to run preview"), stdout, stderr, code)
}
}
err = json.Unmarshal([]byte(stdout), &res)
if err != nil {
return res, errors.Wrap(err, "unable to unmarshal preview result")
}
return res, nil
}
// Up creates or updates the resources in a stack by executing the program in the Workspace.
// https://www.pulumi.com/docs/reference/cli/pulumi_up/
func (s *Stack) Up() (UpResult, error) {
var res UpResult
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return res, errors.Wrap(err, "failed to run update")
}
var stdout, stderr string
var code int
if s.Workspace().Program() != nil {
// TODO need to figure out how to get error code...
stdout, stderr, err = s.host(false /*isPreview*/)
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to run update"), stdout, stderr, code)
}
} else {
stdout, stderr, code, err = s.runPulumiCmdSync("up", "--yes")
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to run update"), stdout, stderr, code)
}
}
outs, err := s.Outputs()
if err != nil {
return res, err
}
history, err := s.History()
if err != nil {
return res, err
}
res = UpResult{
Outputs: outs,
StdOut: stdout,
StdErr: stderr,
}
if len(history) > 0 {
res.Summary = history[0]
}
return res, nil
}
func (s *Stack) Refresh() (RefreshResult, error) {
var res RefreshResult
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return res, errors.Wrap(err, "failed to refresh stack")
}
stdout, stderr, code, err := s.runPulumiCmdSync("refresh", "--yes")
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to refresh stack"), stdout, stderr, code)
}
history, err := s.History()
if err != nil {
return res, errors.Wrap(err, "failed to refresh stack")
}
var summary UpdateSummary
if len(history) > 0 {
summary = history[0]
}
res = RefreshResult{
Summary: summary,
StdOut: stdout,
StdErr: stderr,
}
return res, nil
}
func (s *Stack) Destroy() (DestroyResult, error) {
var res DestroyResult
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return res, errors.Wrap(err, "failed to destroy stack")
}
stdout, stderr, code, err := s.runPulumiCmdSync("destroy", "--yes")
if err != nil {
return res, newAutoError(errors.Wrap(err, "failed to destroy stack"), stdout, stderr, code)
}
history, err := s.History()
if err != nil {
return res, errors.Wrap(err, "failed to destroy stack")
}
var summary UpdateSummary
if len(history) > 0 {
summary = history[0]
}
res = DestroyResult{
Summary: summary,
StdOut: stdout,
StdErr: stderr,
}
return res, nil
}
// Outputs get the current set of Stack outputs from the last Stack.Up()
func (s *Stack) Outputs() (OutputMap, error) {
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return nil, errors.Wrap(err, "failed to get stack outputs")
}
// standard outputs
outStdout, outStderr, code, err := s.runPulumiCmdSync("stack", "output", "--json")
if err != nil {
return nil, newAutoError(errors.Wrap(err, "could not get outputs"), outStdout, outStderr, code)
}
// secret outputs
secretStdout, secretStderr, code, err := s.runPulumiCmdSync("stack", "output", "--json", "--show-secrets")
if err != nil {
return nil, newAutoError(errors.Wrap(err, "could not get secret outputs"), outStdout, outStderr, code)
}
var outputs map[string]string
var secrets map[string]string
if err = json.Unmarshal([]byte(outStdout), &outputs); err != nil {
return nil, errors.Wrapf(err, "error unmarshalling outputs: %s", secretStderr)
}
if err = json.Unmarshal([]byte(secretStdout), &secrets); err != nil {
return nil, errors.Wrapf(err, "error unmarshalling secret outputs: %s", secretStderr)
}
res := make(OutputMap)
for k, v := range secrets {
isSecret := outputs[k] == secretSentinel
res[k] = OutputValue{
Value: v,
Secret: isSecret,
}
}
return res, nil
}
// History returns a list summarizing all previous and current results from Stack.Up()
func (s *Stack) History() ([]UpdateSummary, error) {
err := s.Workspace().SelectStack(s.Name())
if err != nil {
return nil, errors.Wrap(err, "failed to get stack history")
}
stdout, stderr, errCode, err := s.runPulumiCmdSync("history", "--json", "--show-secrets")
if err != nil {
return nil, newAutoError(errors.Wrap(err, "failed to get stack history"), stdout, stderr, errCode)
}
var history []UpdateSummary
err = json.Unmarshal([]byte(stdout), &history)
if err != nil {
return nil, errors.Wrap(err, "unable to unmarshal history result")
}
return history, nil
}
// GetConfig returns the config value associated with the specified key
func (s *Stack) GetConfig(key string) (ConfigValue, error) {
return s.Workspace().GetConfig(s.Name(), key)
}
// GetAllConfig returns the full config map.
func (s *Stack) GetAllConfig() (ConfigMap, error) {
return s.Workspace().GetAllConfig(s.Name())
}
// SetConfig sets the specified config KVP.
func (s *Stack) SetConfig(key string, val ConfigValue) error {
return s.Workspace().SetConfig(s.Name(), key, val)
}
// SetAllConfig sets all values in the provided config map.
func (s *Stack) SetAllConfig(config ConfigMap) error {
return s.Workspace().SetAllConfig(s.Name(), config)
}
// RemoveConfig removes the specified config KVP.
func (s *Stack) RemoveConfig(key string) error {
return s.Workspace().RemoveConfig(s.Name(), key)
}
// RemoveAllConfig removes all values in the provided list of keys.
func (s *Stack) RemoveAllConfig(keys []string) error {
return s.Workspace().RemoveAllConfig(s.Name(), keys)
}
// RefreshConfig gets and sets the config map used with the last Update.
func (s *Stack) RefreshConfig() (ConfigMap, error) {
return s.Workspace().RefreshConfig(s.Name())
}
// UpdateSummary provides an summary of a Stack.Up() operation
type UpdateSummary struct {
Kind string `json:"kind"`
StartTime string `json:"startTime"`
Message string `json:"message"`
Environment map[string]string `json:"environment"`
Config ConfigMap `json:"config"`
Result string `json:"result,omitempty"`
// These values are only present once the update finishes
EndTime *string `json:"endTime,omitempty"`
ResourceChanges *map[string]int `json:"resourceChanges,omitempty"`
}
// OutputValue models a Pulumi Stack output
type OutputValue struct {
Value string
Secret bool
}
type UpResult struct {
StdOut string
StdErr string
Outputs OutputMap
Summary UpdateSummary
}
// OutputMap is the output result of running a Pulumi program
type OutputMap map[string]OutputValue
// PreviewStep is summary of the expected state transition of a given resource based on running the current program
type PreviewStep struct {
// Op is the kind of operation being performed.
Op string `json:"op"`
// URN is the resource being affected by this operation.
URN resource.URN `json:"urn"`
// Provider is the provider that will perform this step.
Provider string `json:"provider,omitempty"`
// OldState is the old state for this resource, if appropriate given the operation type.
OldState *apitype.ResourceV3 `json:"oldState,omitempty"`
// NewState is the new state for this resource, if appropriate given the operation type.
NewState *apitype.ResourceV3 `json:"newState,omitempty"`
// DiffReasons is a list of keys that are causing a diff (for updating steps only).
DiffReasons []resource.PropertyKey `json:"diffReasons,omitempty"`
// ReplaceReasons is a list of keys that are causing replacement (for replacement steps only).
ReplaceReasons []resource.PropertyKey `json:"replaceReasons,omitempty"`
// DetailedDiff is a structured diff that indicates precise per-property differences.
DetailedDiff map[string]PropertyDiff `json:"detailedDiff"`
}
// PropertyDiff contains information about the difference in a single property value.
type PropertyDiff struct {
// Kind is the kind of difference.
Kind string `json:"kind"`
// InputDiff is true if this is a difference between old and new inputs instead of old state and new inputs.
InputDiff bool `json:"inputDiff"`
}
// PreviewResult is the output of Stack.Preview() describing the expected set of changes from the next Stack.Up()
type PreviewResult struct {
Steps []PreviewStep `json:"steps"`
ChangeSummary map[string]int `json:"changeSummary"`
}
type RefreshResult struct {
StdOut string
StdErr string
Summary UpdateSummary
}
type DestroyResult struct {
StdOut string
StdErr string
Summary UpdateSummary
}
// secretSentinel represents the CLI response for an output marked as "secret"
const secretSentinel = "[secret]"
func (s *Stack) runPulumiCmdSync(args ...string) (string, string, int, error) {
var env []string
if s.Workspace().PulumiHome() != nil {
env = append(env, *s.Workspace().PulumiHome())
}
return runPulumiCommandSync(s.Workspace().WorkDir(), env, args...)
}
// TODO set pulumi home env var, etc
func (s *Stack) host(isPreview bool) (string, string, error) {
proj, err := s.Workspace().ProjectSettings()
if err != nil {
return "", "", errors.Wrap(err, "could not start run program, failed to start host")
}
var stdout bytes.Buffer
var errBuff bytes.Buffer
args := []string{"host"}
if isPreview {
args = append(args, "preview")
}
cmd := exec.Command("pulumi", args...)
cmd.Dir = s.Workspace().WorkDir()
cmd.Stdout = &stdout
stderr, _ := cmd.StderrPipe()
cmd.Start()
scanner := bufio.NewScanner(stderr)
scanner.Split(bufio.ScanLines)
resMonAddrChan := make(chan string)
engineAddrChan := make(chan string)
failChan := make(chan bool)
go func() {
numAddrs := 0
for scanner.Scan() {
m := scanner.Text()
errBuff.WriteString(m)
if strings.HasPrefix(m, "resmon: ") {
numAddrs++
// resmon: 127.0.0.1:23423
resMonAddrChan <- strings.Split(m, " ")[1]
}
if strings.HasPrefix(m, "engine: ") {
numAddrs++
// engine: 127.0.0.1:23423
engineAddrChan <- strings.Split(m, " ")[1]
}
}
if numAddrs < 2 {
failChan <- true
}
}()
var monitorAddr string
var engineAddr string
for i := 0; i < 2; i++ {
select {
case <-failChan:
return stdout.String(), errBuff.String(), errors.New("failed to launch host")
case monitorAddr = <-resMonAddrChan:
case engineAddr = <-engineAddrChan:
}
}
os.Setenv(pulumi.EnvMonitor, monitorAddr)
os.Setenv(pulumi.EnvProject, proj.Name.String())
os.Setenv(pulumi.EnvStack, s.Name())
os.Setenv(pulumi.EnvEngine, engineAddr)
cfg, err := s.GetAllConfig()
if err != nil {
return stdout.String(), errBuff.String(), errors.Wrap(err, "failed to serialize config for inline program")
}
cfgMap := make(map[string]string)
for k, v := range cfg {
cfgMap[k] = v.Value
}
cfgStr, err := json.Marshal(cfgMap)
if err != nil {
return stdout.String(), errBuff.String(), errors.Wrap(err, "unable to marshal config")
}
os.Setenv(pulumi.EnvConfig, string(cfgStr))
err = execUserCode(s.Workspace().Program())
if err != nil {
cmd.Process.Signal(os.Interrupt)
waitErr := cmd.Wait()
if waitErr != nil {
return stdout.String(), errBuff.String(), errors.Wrap(err, "failed to run inline program and shutdown gracefully")
}
return stdout.String(), errBuff.String(), errors.Wrap(err, "error running inline pulumi program")
}
err = cmd.Process.Signal(os.Interrupt)
if err != nil {
return stdout.String(), errBuff.String(), errors.Wrap(err, "failed to shutdown host gracefully")
}
cmd.Wait()
return stdout.String(), errBuff.String(), nil
}
func execUserCode(fn pulumi.RunFunc) (err error) {
defer func() {
if r := recover(); r != nil {
if pErr, ok := r.(error); ok {
err = errors.Wrap(pErr, "go inline source runtime error, an unhandled error occurred:")
} else {
err = errors.New("go inline source runtime error, an unhandled error occurred: unknown error")
}
}
}()
stack := string(debug.Stack())
if strings.Contains(stack, "github.com/pulumi/pulumi/sdk/go/pulumi/run.go") {
return errors.New("nested stack operations are not supported https://github.com/pulumi/pulumi/issues/5058")
}
err = pulumi.RunErr(fn)
return err
}