Add "pulumi history" command (#826)
This PR adds a new `pulumi history` command, which prints the update history for a stack. The local backend stores the update history in a JSON file on disk, next to the checkpoint file. The cloud backend simply provides the update metadata, and expects to receive all the data from a (NYI) `/history` REST endpoint. `pkg/backend/updates.go` defines the data that is being persisted. The way the data is wired through the system is adding a new `backend.UpdateMetadata` parameter to a Stack/Backend's `Update` and `Destroy` methods. I use `tests/integration/stack_outputs/` as the simple app for the related tests, hence the addition to the `.gitignore` and fixing the name in the `Pulumi.yaml`. Fixes #636.
This commit is contained in:
parent
ce05cce77f
commit
4c217fd358
46
Gopkg.lock
generated
46
Gopkg.lock
generated
|
@ -91,17 +91,35 @@
|
|||
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
|
||||
version = "v1.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/jbenet/go-context"
|
||||
packages = ["io"]
|
||||
revision = "d14ea06fba99483203c19d92cfcd13ebe73135f4"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/jmespath/go-jmespath"
|
||||
packages = ["."]
|
||||
revision = "0b12d6b5"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/kevinburke/ssh_config"
|
||||
packages = ["."]
|
||||
revision = "fa48d7ff1cfb9f26c514b80d520880394293bf08"
|
||||
version = "0.2"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/mattn/go-runewidth"
|
||||
packages = ["."]
|
||||
revision = "9e777a8366cce605130a531d2cd6363d07ad7317"
|
||||
version = "v0.0.2"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mitchellh/go-homedir"
|
||||
packages = ["."]
|
||||
revision = "b8bc1bf767474819792c23f32d8286a45736f1c6"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/mitchellh/go-ps"
|
||||
|
@ -120,6 +138,12 @@
|
|||
revision = "1949ddbfd147afd4d964a9f00b24eb291e0e7c38"
|
||||
version = "v1.0.2"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pelletier/go-buffruneio"
|
||||
packages = ["."]
|
||||
revision = "c37440a7cf42ac63b919c752ca73a85067e05992"
|
||||
version = "v0.2.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/pkg/errors"
|
||||
packages = ["."]
|
||||
|
@ -186,10 +210,16 @@
|
|||
revision = "3b2a9ad2a045881ab7a0f81d465be54c8292ee4f"
|
||||
version = "v1.1.0"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/xanzy/ssh-agent"
|
||||
packages = ["."]
|
||||
revision = "ba9c9e33906f58169366275e3450db66139a31a9"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
packages = ["curve25519","ed25519","ed25519/internal/edwards25519","pbkdf2","ssh","ssh/agent","ssh/terminal"]
|
||||
packages = ["cast5","curve25519","ed25519","ed25519/internal/edwards25519","openpgp","openpgp/armor","openpgp/elgamal","openpgp/errors","openpgp/packet","openpgp/s2k","pbkdf2","ssh","ssh/agent","ssh/knownhosts","ssh/terminal"]
|
||||
revision = "6a293f2d4b14b8e6d3f0539e383f6d0d30fce3fd"
|
||||
|
||||
[[projects]]
|
||||
|
@ -222,11 +252,17 @@
|
|||
revision = "5ffe3083946d5603a0578721101dc8165b1d5b5f"
|
||||
version = "v1.7.2"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/src-d/go-billy.v4"
|
||||
packages = [".","helper/chroot","helper/polyfill","osfs","util"]
|
||||
revision = "e940f8b62a8e61adc71f69802c1cc8305b64ec96"
|
||||
version = "v4.0.2"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/src-d/go-git.v4"
|
||||
packages = [".","config","plumbing","plumbing/format/config","plumbing/format/idxfile","plumbing/format/index","plumbing/format/objfile","plumbing/format/packfile","plumbing/format/pktline","plumbing/object","plumbing/protocol/packp","plumbing/protocol/packp/capability","plumbing/protocol/packp/sideband","plumbing/storer","plumbing/transport","plumbing/transport/client","plumbing/transport/file","plumbing/transport/git","plumbing/transport/http","plumbing/transport/internal/common","plumbing/transport/ssh","storage/filesystem","storage/filesystem/internal/dotgit","storage/memory","utils/binary","utils/diff","utils/fs","utils/fs/os","utils/ioutil"]
|
||||
revision = "c9353b2bd7c1cbdf8f78dad6deac64ed2f2ed9eb"
|
||||
version = "v4.0.0-rc5"
|
||||
packages = [".","config","internal/revision","plumbing","plumbing/cache","plumbing/filemode","plumbing/format/config","plumbing/format/diff","plumbing/format/gitignore","plumbing/format/idxfile","plumbing/format/index","plumbing/format/objfile","plumbing/format/packfile","plumbing/format/pktline","plumbing/object","plumbing/protocol/packp","plumbing/protocol/packp/capability","plumbing/protocol/packp/sideband","plumbing/revlist","plumbing/storer","plumbing/transport","plumbing/transport/client","plumbing/transport/file","plumbing/transport/git","plumbing/transport/http","plumbing/transport/internal/common","plumbing/transport/server","plumbing/transport/ssh","storage","storage/filesystem","storage/filesystem/internal/dotgit","storage/memory","utils/binary","utils/diff","utils/ioutil","utils/merkletrie","utils/merkletrie/filesystem","utils/merkletrie/index","utils/merkletrie/internal/frame","utils/merkletrie/noder"]
|
||||
revision = "e9247ce9c5ce12126f646ca3ddf0066e4829bd14"
|
||||
version = "v4.1.0"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/warnings.v0"
|
||||
|
@ -243,6 +279,6 @@
|
|||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "d03e73559b3e1af69207eeae805c237c8bef6f836a2322e883d428e0abef1810"
|
||||
inputs-digest = "406689f2111986438fab15c1c0e770b08c857bdbe8b16e72f89502b32c77cffe"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/engine"
|
||||
|
@ -15,6 +16,8 @@ func newDestroyCmd() *cobra.Command {
|
|||
var stack string
|
||||
var yes bool
|
||||
|
||||
var message string
|
||||
|
||||
// Flags for engine.UpdateOptions.
|
||||
var analyzers []string
|
||||
var color colorFlag
|
||||
|
@ -48,9 +51,14 @@ func newDestroyCmd() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
|
||||
m, err := getUpdateMetadata(message, root)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "gathering environment metadata")
|
||||
}
|
||||
|
||||
if preview || yes ||
|
||||
confirmPrompt("This will permanently destroy all resources in the '%v' stack!", string(s.Name())) {
|
||||
return s.Destroy(pkg, root, debug, engine.UpdateOptions{
|
||||
return s.Destroy(pkg, root, debug, m, engine.UpdateOptions{
|
||||
Analyzers: analyzers,
|
||||
DryRun: preview,
|
||||
Color: color.Colorization(),
|
||||
|
@ -76,6 +84,10 @@ func newDestroyCmd() *cobra.Command {
|
|||
&yes, "yes", false,
|
||||
"Skip confirmation prompts, and proceed with the destruction anyway")
|
||||
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&message, "message", "m", "",
|
||||
"Optional message to associate with the destroy operation")
|
||||
|
||||
// Flags for engine.UpdateOptions.
|
||||
cmd.PersistentFlags().VarP(
|
||||
&color, "color", "c", "Colorize output. Choices are: always, never, raw, auto")
|
||||
|
|
81
cmd/history.go
Normal file
81
cmd/history.go
Normal file
|
@ -0,0 +1,81 @@
|
|||
// Copyright 2016-2017, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/backend"
|
||||
"github.com/pulumi/pulumi/pkg/tokens"
|
||||
"github.com/pulumi/pulumi/pkg/util/cmdutil"
|
||||
)
|
||||
|
||||
func newHistoryCmd() *cobra.Command {
|
||||
var stack string
|
||||
var outputJSON bool // Requires PULUMI_DEBUG_COMMANDS
|
||||
|
||||
var cmd = &cobra.Command{
|
||||
Use: "history",
|
||||
Aliases: []string{"hist"},
|
||||
SuggestFor: []string{"updates"},
|
||||
Short: "Update history for a stack",
|
||||
Long: "Update history for a stack\n" +
|
||||
"\n" +
|
||||
"This command lists data about previous updates for a stack.",
|
||||
Args: cmdutil.NoArgs,
|
||||
Run: cmdutil.RunFunc(func(cmd *cobra.Command, args []string) error {
|
||||
s, err := requireStack(tokens.QName(stack))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := s.Backend()
|
||||
updates, err := b.GetHistory(s.Name())
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting history")
|
||||
}
|
||||
|
||||
// Sort the updates to ensure the most recent updates come first.
|
||||
backend.Sort(updates)
|
||||
|
||||
if outputJSON {
|
||||
b, err := json.MarshalIndent(updates, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(string(b))
|
||||
} else {
|
||||
printUpdateHistory(updates)
|
||||
}
|
||||
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&stack, "stack", "s", "",
|
||||
"Choose an stack other than the currently selected one")
|
||||
|
||||
// pulumi/issues/496 tracks adding a --format option across all commands. Rather than expose a partial solution
|
||||
// for just `history`, we put the JSON flag behind an env var so we can use in tests w/o making public.
|
||||
if cmdutil.IsTruthy(os.Getenv("PULUMI_DEBUG_COMMANDS")) {
|
||||
cmd.PersistentFlags().BoolVar(&outputJSON, "output-json", false, "Output stack history as JSON")
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printUpdateHistory(updates []backend.UpdateInfo) {
|
||||
if len(updates) == 0 {
|
||||
fmt.Println("Stack has never been updated")
|
||||
return
|
||||
}
|
||||
for _, update := range updates {
|
||||
fmt.Printf("%8v %8v %v\n", update.Kind, update.Result, update.Message)
|
||||
}
|
||||
}
|
|
@ -82,6 +82,7 @@ func NewPulumiCmd() *cobra.Command {
|
|||
cmd.AddCommand(newVersionCmd())
|
||||
cmd.AddCommand(newInitCmd())
|
||||
cmd.AddCommand(newLogsCmd())
|
||||
cmd.AddCommand(newHistoryCmd())
|
||||
|
||||
// Commands specific to the Pulumi Cloud Management Console.
|
||||
cmd.AddCommand(newLoginCmd())
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/engine"
|
||||
|
@ -14,6 +15,8 @@ func newUpdateCmd() *cobra.Command {
|
|||
var debug bool
|
||||
var stack string
|
||||
|
||||
var message string
|
||||
|
||||
// Flags for engine.UpdateOptions.
|
||||
var analyzers []string
|
||||
var color colorFlag
|
||||
|
@ -51,7 +54,12 @@ func newUpdateCmd() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
|
||||
return s.Update(pkg, root, debug, engine.UpdateOptions{
|
||||
m, err := getUpdateMetadata(message, root)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "gathering environment metadata")
|
||||
}
|
||||
|
||||
return s.Update(pkg, root, debug, m, engine.UpdateOptions{
|
||||
Analyzers: analyzers,
|
||||
DryRun: preview,
|
||||
Color: color.Colorization(),
|
||||
|
@ -71,6 +79,10 @@ func newUpdateCmd() *cobra.Command {
|
|||
&stack, "stack", "s", "",
|
||||
"Choose an stack other than the currently selected one")
|
||||
|
||||
cmd.PersistentFlags().StringVarP(
|
||||
&message, "message", "m", "",
|
||||
"Optional message to associate with the update operation")
|
||||
|
||||
// Flags for engine.UpdateOptions.
|
||||
cmd.PersistentFlags().VarP(
|
||||
&color, "color", "c", "Colorize output. Choices are: always, never, raw, auto")
|
||||
|
|
88
cmd/util.go
88
cmd/util.go
|
@ -6,11 +6,13 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
git "gopkg.in/src-d/go-git.v4"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/backend"
|
||||
"github.com/pulumi/pulumi/pkg/backend/cloud"
|
||||
|
@ -83,26 +85,51 @@ func detectOwnerAndName(dir string) (string, string, error) {
|
|||
return user.Username, filepath.Base(dir), nil
|
||||
}
|
||||
|
||||
func getGitHubProjectForOrigin(dir string) (string, string, error) {
|
||||
// getGitRepository returns the git repository by walking up from the provided directory.
|
||||
// If no repository is found, will return (nil, nil).
|
||||
func getGitRepository(dir string) (*git.Repository, error) {
|
||||
gitRoot, err := fsutil.WalkUp(dir, func(s string) bool { return filepath.Base(s) == ".git" }, nil)
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "could not detect git repository")
|
||||
return nil, errors.Wrapf(err, "searching for git repository from %v", dir)
|
||||
}
|
||||
if gitRoot == "" {
|
||||
return "", "", errors.Errorf("could not locate git repository starting at: %s", dir)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
repo, err := git.NewFilesystemRepository(gitRoot)
|
||||
// Open the git repo in the .git folder's parent, not the .git folder itself.
|
||||
repo, err := git.PlainOpen(path.Join(gitRoot, ".."))
|
||||
if err == git.ErrRepositoryNotExists {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "reading git repository")
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func getGitHubProjectForOrigin(dir string) (string, string, error) {
|
||||
repo, err := getGitRepository(dir)
|
||||
if repo == nil {
|
||||
return "", "", fmt.Errorf("no git repository found from %v", dir)
|
||||
}
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return getGitHubProjectForOriginByRepo(repo)
|
||||
}
|
||||
|
||||
// Returns the GitHub login, and GitHub repo name if the "origin" remote is
|
||||
// a GitHub URL.
|
||||
func getGitHubProjectForOriginByRepo(repo *git.Repository) (string, string, error) {
|
||||
remote, err := repo.Remote("origin")
|
||||
if err != nil {
|
||||
return "", "", errors.Wrap(err, "could not read origin information")
|
||||
}
|
||||
|
||||
remoteURL := remote.Config().URL
|
||||
remoteURL := ""
|
||||
if len(remote.Config().URLs) > 0 {
|
||||
remoteURL = remote.Config().URLs[0]
|
||||
}
|
||||
project := ""
|
||||
|
||||
const GitHubSSHPrefix = "git@github.com:"
|
||||
|
@ -190,3 +217,54 @@ func (cf *colorFlag) Colorization() colors.Colorization {
|
|||
|
||||
return cf.value
|
||||
}
|
||||
|
||||
// getUpdateMetadata returns an UpdateMetadata object, with optional data about the environment
|
||||
// performing the update.
|
||||
func getUpdateMetadata(msg, root string) (backend.UpdateMetadata, error) {
|
||||
m := backend.UpdateMetadata{
|
||||
Message: msg,
|
||||
Environment: make(map[string]string),
|
||||
}
|
||||
|
||||
// Gather git-related data as appropriate. (Returns nil, nil if no repo found.)
|
||||
repo, err := getGitRepository(root)
|
||||
if err != nil {
|
||||
return m, errors.Wrap(err, "looking for git repository")
|
||||
}
|
||||
if repo != nil && err == nil {
|
||||
const gitErrCtx = "reading git repo (%v)" // Message passed with wrapped errors from go-git.
|
||||
|
||||
// Commit at HEAD.
|
||||
head, err := repo.Head()
|
||||
if err == nil {
|
||||
m.Environment[backend.GitHead] = head.Hash().String()
|
||||
} else {
|
||||
// Ignore "reference not found" in the odd case where the HEAD commit doesn't exist.
|
||||
// (git init, but no commits yet.)
|
||||
if err != plumbing.ErrReferenceNotFound {
|
||||
return m, errors.Wrapf(err, gitErrCtx, "getting head")
|
||||
}
|
||||
}
|
||||
|
||||
// If the current commit is dirty.
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return m, errors.Wrapf(err, gitErrCtx, "getting worktree")
|
||||
}
|
||||
s, err := w.Status()
|
||||
if err != nil {
|
||||
return m, errors.Wrapf(err, gitErrCtx, "getting worktree status")
|
||||
}
|
||||
dirty := !s.IsClean()
|
||||
m.Environment[backend.GitDirty] = fmt.Sprint(dirty)
|
||||
|
||||
// GitHub repo slug if applicable. We don't require GitHub, so swallow errors.
|
||||
ghLogin, ghRepo, err := getGitHubProjectForOriginByRepo(repo)
|
||||
if err == nil {
|
||||
m.Environment[backend.GitHubLogin] = ghLogin
|
||||
m.Environment[backend.GitHubRepo] = ghRepo
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
|
82
pkg/apitype/history.go
Normal file
82
pkg/apitype/history.go
Normal file
|
@ -0,0 +1,82 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package apitype
|
||||
|
||||
// UpdateKind is an enum for the type of update performed.
|
||||
//
|
||||
// Should generally mirror backend.UpdateKind, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the backend-type.
|
||||
type UpdateKind string
|
||||
|
||||
const (
|
||||
// DeployUpdate is the prototypical Pulumi program update.
|
||||
DeployUpdate UpdateKind = "update"
|
||||
// PreviewUpdate is a preview of an update, without impacting resources.
|
||||
PreviewUpdate UpdateKind = "preview"
|
||||
// DestroyUpdate is an update which removes all resources.
|
||||
DestroyUpdate UpdateKind = "destroy"
|
||||
)
|
||||
|
||||
// UpdateResult is an enum for the result of the update.
|
||||
//
|
||||
// Should generally mirror backend.UpdateResult, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the backend-type.
|
||||
type UpdateResult string
|
||||
|
||||
const (
|
||||
// InProgressResult is for updates that have not yet completed.
|
||||
InProgressResult UpdateResult = "in-progress"
|
||||
// SucceededResult is for updates that completed successfully.
|
||||
SucceededResult UpdateResult = "succeeded"
|
||||
// FailedResult is for updates that have failed.
|
||||
FailedResult UpdateResult = "failed"
|
||||
)
|
||||
|
||||
// OpType describes the type of operation performed to a resource managed by Pulumi.
|
||||
//
|
||||
// Should generally mirror deploy.StepOp, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the backend-type.
|
||||
type OpType string
|
||||
|
||||
const (
|
||||
// OpSame indiciates no change was made.
|
||||
OpSame OpType = "same"
|
||||
// OpCreate indiciates a new resource was created.
|
||||
OpCreate OpType = "create"
|
||||
// OpUpdate indicates an existing resource was updated.
|
||||
OpUpdate OpType = "update"
|
||||
// OpDelete indiciates an existing resource was deleted.
|
||||
OpDelete OpType = "delete"
|
||||
// OpReplace indicates an existing resource was replaced with a new one.
|
||||
OpReplace OpType = "replace"
|
||||
// OpCreateReplacement indiciates a new resource was created for a replacement.
|
||||
OpCreateReplacement OpType = "create-replacement"
|
||||
// OpDeleteReplaced indiciates an existing resource was deleted after replacement.
|
||||
OpDeleteReplaced OpType = "delete-replaced"
|
||||
)
|
||||
|
||||
// UpdateInfo describes a previous update.
|
||||
//
|
||||
// Should generally mirror backend.UpdateInfo, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the backend-type.
|
||||
type UpdateInfo struct {
|
||||
// Information known before an update is started.
|
||||
|
||||
Kind UpdateKind `json:"kind"`
|
||||
StartTime int64 `json:"startTime"`
|
||||
Message string `json:"message"`
|
||||
Environment map[string]string `json:"environment"`
|
||||
Config map[string]ConfigValue `json:"config"`
|
||||
|
||||
// Information obtained from an update completing.
|
||||
|
||||
Result UpdateResult `json:"result"`
|
||||
EndTime int64 `json:"endTime"`
|
||||
ResourceChanges map[OpType]int `json:"resourceChanges"`
|
||||
}
|
||||
|
||||
// GetHistoryResponse is the response from the Pulumi Service when requesting
|
||||
// a stack's history.
|
||||
type GetHistoryResponse struct {
|
||||
Updates []UpdateInfo `json:"updates"`
|
||||
}
|
|
@ -1,6 +1,10 @@
|
|||
// Copyright 2016-2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package apitype
|
||||
|
||||
import "github.com/pulumi/pulumi/pkg/diag/colors"
|
||||
import (
|
||||
"github.com/pulumi/pulumi/pkg/diag/colors"
|
||||
)
|
||||
|
||||
// ConfigValue describes a single (possibly secret) configuration value.
|
||||
type ConfigValue struct {
|
||||
|
@ -28,11 +32,14 @@ type UpdateProgramRequest struct {
|
|||
|
||||
// Configuration values.
|
||||
Config map[string]ConfigValue `json:"config"`
|
||||
|
||||
Metadata UpdateMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
// UpdateOptions is the set of operations for configuring the output of an update. Should mirror
|
||||
// engine.UpdateOptions exactly; we put it in this package to add flexibility in case there is a
|
||||
// breaking change in the engine-type.
|
||||
// UpdateOptions is the set of operations for configuring the output of an update.
|
||||
//
|
||||
// Should generally mirror engine.UpdateOptions, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the engine-type.
|
||||
type UpdateOptions struct {
|
||||
Analyzers []string `json:"analyzers"`
|
||||
Color colors.Colorization `json:"color"`
|
||||
|
@ -44,6 +51,18 @@ type UpdateOptions struct {
|
|||
Summary bool `json:"summary"`
|
||||
}
|
||||
|
||||
// UpdateMetadata describes optional metadata about an update.
|
||||
//
|
||||
// Should generally mirror backend.UpdateMetadata, but we clone it in this package to add
|
||||
// flexibility in case there is a breaking change in the backend-type.
|
||||
type UpdateMetadata struct {
|
||||
// Message is an optional message associated with the update.
|
||||
Message string `json:"message"`
|
||||
// Environment contains optional data from the deploying environment. e.g. the current
|
||||
// source code control commit information.
|
||||
Environment map[string]string `json:"environment"`
|
||||
}
|
||||
|
||||
// UpdateProgramRequestUntyped is a legacy type: see comment in pulumi-service stacks_update.go
|
||||
// unmarshalConfig()
|
||||
// TODO(#478): remove support for string-only config.
|
||||
|
|
|
@ -36,10 +36,15 @@ type Backend interface {
|
|||
// Preview initiates a preview of the current workspace's contents.
|
||||
Preview(stackName tokens.QName, pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
// Update updates the target stack with the current workspace's contents (config and code).
|
||||
Update(stackName tokens.QName, pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
Update(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, m UpdateMetadata, opts engine.UpdateOptions) error
|
||||
// Destroy destroys all of this stack's resources.
|
||||
Destroy(stackName tokens.QName, pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
Destroy(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, m UpdateMetadata, opts engine.UpdateOptions) error
|
||||
|
||||
// GetHistory returns all updates for the stack. The returned UpdateInfo slice will be in
|
||||
// descending order by Version.
|
||||
GetHistory(stackName tokens.QName) ([]UpdateInfo, error)
|
||||
// GetLogs fetches a list of log entries for the given stack, with optional filtering/querying.
|
||||
GetLogs(stackName tokens.QName, query operations.LogQuery) ([]operations.LogEntry, error)
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ import (
|
|||
"github.com/pulumi/pulumi/pkg/operations"
|
||||
"github.com/pulumi/pulumi/pkg/pack"
|
||||
"github.com/pulumi/pulumi/pkg/resource/config"
|
||||
"github.com/pulumi/pulumi/pkg/resource/deploy"
|
||||
"github.com/pulumi/pulumi/pkg/tokens"
|
||||
"github.com/pulumi/pulumi/pkg/util/archive"
|
||||
"github.com/pulumi/pulumi/pkg/util/cmdutil"
|
||||
|
@ -200,24 +201,24 @@ const (
|
|||
func (b *cloudBackend) Preview(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, opts engine.UpdateOptions) error {
|
||||
|
||||
return b.updateStack(preview, stackName, pkg, root, debug, opts)
|
||||
return b.updateStack(preview, stackName, pkg, root, debug, backend.UpdateMetadata{}, opts)
|
||||
}
|
||||
|
||||
func (b *cloudBackend) Update(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, opts engine.UpdateOptions) error {
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
|
||||
return b.updateStack(update, stackName, pkg, root, debug, opts)
|
||||
return b.updateStack(update, stackName, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
func (b *cloudBackend) Destroy(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, opts engine.UpdateOptions) error {
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
|
||||
return b.updateStack(destroy, stackName, pkg, root, debug, opts)
|
||||
return b.updateStack(destroy, stackName, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
// updateStack performs a the provided type of update on a stack hosted in the Pulumi Cloud.
|
||||
func (b *cloudBackend) updateStack(action updateKind, stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, opts engine.UpdateOptions) error {
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
|
||||
// Print a banner so it's clear this is going to the cloud.
|
||||
var actionLabel string
|
||||
|
@ -241,7 +242,7 @@ func (b *cloudBackend) updateStack(action updateKind, stackName tokens.QName, pk
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updateRequest, err := b.makeProgramUpdateRequest(stackName, pkg, opts)
|
||||
updateRequest, err := b.makeProgramUpdateRequest(stackName, pkg, m, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -331,6 +332,75 @@ func uploadProgram(pkg *pack.Package, programFolder, uploadURL string, progress
|
|||
return nil
|
||||
}
|
||||
|
||||
func (b *cloudBackend) GetHistory(stackName tokens.QName) ([]backend.UpdateInfo, error) {
|
||||
projID, err := getCloudProjectIdentifier()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response apitype.GetHistoryResponse
|
||||
path := fmt.Sprintf("/orgs/%s/programs/%s/%s/stacks/%s/history",
|
||||
projID.Owner, projID.Repository, projID.Project, string(stackName))
|
||||
if err = pulumiRESTCall(b.cloudURL, "GET", path, nil, nil, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert apitype.UpdateInfo objects to the backend type.
|
||||
var beUpdates []backend.UpdateInfo
|
||||
for _, update := range response.Updates {
|
||||
// Convert types from the apitype package into their internal counterparts.
|
||||
cfg, err := convertConfig(update.Config)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "converting configuration")
|
||||
}
|
||||
changes := convertResourceChanges(update.ResourceChanges)
|
||||
|
||||
beUpdate := backend.UpdateInfo{
|
||||
Kind: backend.UpdateKind(update.Kind),
|
||||
StartTime: update.StartTime,
|
||||
|
||||
Message: update.Message,
|
||||
Environment: update.Environment,
|
||||
|
||||
Config: cfg,
|
||||
|
||||
Result: backend.UpdateResult(update.Result),
|
||||
EndTime: update.EndTime,
|
||||
|
||||
ResourceChanges: changes,
|
||||
}
|
||||
beUpdates = append(beUpdates, beUpdate)
|
||||
}
|
||||
|
||||
return beUpdates, nil
|
||||
}
|
||||
|
||||
// convertResourceChanges converts the apitype version of engine.ResourceChanges into the internal version.
|
||||
func convertResourceChanges(changes map[apitype.OpType]int) engine.ResourceChanges {
|
||||
b := make(engine.ResourceChanges)
|
||||
for k, v := range changes {
|
||||
b[deploy.StepOp(k)] = v
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// convertResourceChanges converts the apitype version of config.Map into the internal version.
|
||||
func convertConfig(apiConfig map[string]apitype.ConfigValue) (config.Map, error) {
|
||||
c := make(config.Map)
|
||||
for k, v := range apiConfig {
|
||||
mm, err := tokens.ParseModuleMember(k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if v.Secret {
|
||||
c[mm] = config.NewSecureValue(v.String)
|
||||
} else {
|
||||
c[mm] = config.NewValue(v.String)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (b *cloudBackend) GetLogs(stackName tokens.QName,
|
||||
logQuery operations.LogQuery) ([]operations.LogEntry, error) {
|
||||
|
||||
|
@ -440,7 +510,7 @@ func getCloudProjectIdentifier() (*cloudProjectIdentifier, error) {
|
|||
|
||||
// makeProgramUpdateRequest constructs the apitype.UpdateProgramRequest based on the local machine state.
|
||||
func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName,
|
||||
pkg *pack.Package, opts engine.UpdateOptions) (apitype.UpdateProgramRequest, error) {
|
||||
pkg *pack.Package, m backend.UpdateMetadata, opts engine.UpdateOptions) (apitype.UpdateProgramRequest, error) {
|
||||
|
||||
// Convert the configuration into its wire form.
|
||||
cfg, err := state.Configuration(b.d, stackName)
|
||||
|
@ -469,6 +539,10 @@ func (b *cloudBackend) makeProgramUpdateRequest(stackName tokens.QName,
|
|||
Description: description,
|
||||
Config: wireConfig,
|
||||
Options: apitype.UpdateOptions(opts), // Convert type to the apitype package version.
|
||||
Metadata: apitype.UpdateMetadata{
|
||||
Message: m.Message,
|
||||
Environment: m.Environment,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -83,12 +83,14 @@ func (s *cloudStack) Preview(pkg *pack.Package, root string, debug bool, opts en
|
|||
return backend.PreviewStack(s, pkg, root, debug, opts)
|
||||
}
|
||||
|
||||
func (s *cloudStack) Update(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return backend.UpdateStack(s, pkg, root, debug, opts)
|
||||
func (s *cloudStack) Update(pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return backend.UpdateStack(s, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
func (s *cloudStack) Destroy(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return backend.DestroyStack(s, pkg, root, debug, opts)
|
||||
func (s *cloudStack) Destroy(pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return backend.DestroyStack(s, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
func (s *cloudStack) GetLogs(query operations.LogQuery) ([]operations.LogEntry, error) {
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
"github.com/pkg/errors"
|
||||
|
@ -126,8 +127,8 @@ func (b *localBackend) Preview(stackName tokens.QName, pkg *pack.Package, root s
|
|||
return nil
|
||||
}
|
||||
|
||||
func (b *localBackend) Update(stackName tokens.QName, pkg *pack.Package, root string, debug bool,
|
||||
opts engine.UpdateOptions) error {
|
||||
func (b *localBackend) Update(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
|
||||
update, err := b.newUpdate(stackName, pkg, root)
|
||||
if err != nil {
|
||||
|
@ -139,18 +140,44 @@ func (b *localBackend) Update(stackName tokens.QName, pkg *pack.Package, root st
|
|||
|
||||
go displayEvents(events, done, debug)
|
||||
|
||||
if _, err = engine.Deploy(update, events, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
// Perform the update
|
||||
start := time.Now().Unix()
|
||||
changes, updateErr := engine.Deploy(update, events, opts)
|
||||
end := time.Now().Unix()
|
||||
|
||||
<-done
|
||||
close(events)
|
||||
close(done)
|
||||
return nil
|
||||
|
||||
// Save update results.
|
||||
result := backend.SucceededResult
|
||||
if updateErr != nil {
|
||||
result = backend.FailedResult
|
||||
}
|
||||
info := backend.UpdateInfo{
|
||||
Kind: backend.DeployUpdate,
|
||||
StartTime: start,
|
||||
Message: m.Message,
|
||||
Environment: m.Environment,
|
||||
Config: update.GetTarget().Config,
|
||||
Result: result,
|
||||
EndTime: end,
|
||||
ResourceChanges: changes,
|
||||
}
|
||||
var saveErr error
|
||||
if !opts.DryRun {
|
||||
saveErr = addToHistory(stackName, info)
|
||||
}
|
||||
|
||||
if updateErr != nil {
|
||||
// We swallow saveErr as it is less important than the updateErr.
|
||||
return updateErr
|
||||
}
|
||||
return errors.Wrap(saveErr, "saving update info")
|
||||
}
|
||||
|
||||
func (b *localBackend) Destroy(stackName tokens.QName, pkg *pack.Package, root string, debug bool,
|
||||
opts engine.UpdateOptions) error {
|
||||
func (b *localBackend) Destroy(stackName tokens.QName, pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
|
||||
update, err := b.newUpdate(stackName, pkg, root)
|
||||
if err != nil {
|
||||
|
@ -162,15 +189,48 @@ func (b *localBackend) Destroy(stackName tokens.QName, pkg *pack.Package, root s
|
|||
|
||||
go displayEvents(events, done, debug)
|
||||
|
||||
if _, err := engine.Destroy(update, events, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
// Perform the destroy
|
||||
start := time.Now().Unix()
|
||||
changes, updateErr := engine.Destroy(update, events, opts)
|
||||
end := time.Now().Unix()
|
||||
|
||||
<-done
|
||||
close(events)
|
||||
close(done)
|
||||
|
||||
return nil
|
||||
// Save update results.
|
||||
result := backend.SucceededResult
|
||||
if updateErr != nil {
|
||||
result = backend.FailedResult
|
||||
}
|
||||
info := backend.UpdateInfo{
|
||||
Kind: backend.DestroyUpdate,
|
||||
StartTime: start,
|
||||
Message: m.Message,
|
||||
Environment: m.Environment,
|
||||
Config: update.GetTarget().Config,
|
||||
Result: result,
|
||||
EndTime: end,
|
||||
ResourceChanges: changes,
|
||||
}
|
||||
var saveErr error
|
||||
if !opts.DryRun {
|
||||
saveErr = addToHistory(stackName, info)
|
||||
}
|
||||
|
||||
if updateErr != nil {
|
||||
// We swallow saveErr as it is less important than the updateErr.
|
||||
return updateErr
|
||||
}
|
||||
return errors.Wrap(saveErr, "saving update info")
|
||||
}
|
||||
|
||||
func (b *localBackend) GetHistory(stackName tokens.QName) ([]backend.UpdateInfo, error) {
|
||||
updates, err := getHistory(stackName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (b *localBackend) GetLogs(stackName tokens.QName, query operations.LogQuery) ([]operations.LogEntry, error) {
|
||||
|
|
|
@ -54,12 +54,14 @@ func (s *localStack) Preview(pkg *pack.Package, root string, debug bool, opts en
|
|||
return backend.PreviewStack(s, pkg, root, debug, opts)
|
||||
}
|
||||
|
||||
func (s *localStack) Update(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return backend.UpdateStack(s, pkg, root, debug, opts)
|
||||
func (s *localStack) Update(pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return backend.UpdateStack(s, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
func (s *localStack) Destroy(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return backend.DestroyStack(s, pkg, root, debug, opts)
|
||||
func (s *localStack) Destroy(pkg *pack.Package, root string,
|
||||
debug bool, m backend.UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return backend.DestroyStack(s, pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
func (s *localStack) GetLogs(query operations.LogQuery) ([]operations.LogEntry, error) {
|
||||
|
|
|
@ -3,15 +3,19 @@
|
|||
package local
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang/glog"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pulumi/pulumi/pkg/backend"
|
||||
"github.com/pulumi/pulumi/pkg/backend/state"
|
||||
"github.com/pulumi/pulumi/pkg/encoding"
|
||||
"github.com/pulumi/pulumi/pkg/engine"
|
||||
|
@ -222,7 +226,9 @@ func removeStack(name tokens.QName) error {
|
|||
// Just make a backup of the file and don't write out anything new.
|
||||
file := w.StackPath(name)
|
||||
backupTarget(file)
|
||||
return nil
|
||||
|
||||
historyDir := w.HistoryDirectory(name)
|
||||
return os.RemoveAll(historyDir)
|
||||
}
|
||||
|
||||
// backupTarget makes a backup of an existing file, in preparation for writing a new one. Instead of a copy, it
|
||||
|
@ -235,3 +241,85 @@ func backupTarget(file string) string {
|
|||
// IDEA: consider multiple backups (.bak.bak.bak...etc).
|
||||
return bck
|
||||
}
|
||||
|
||||
// getHistory returns locally stored update history. The first element of the result will be
|
||||
// the most recent update record.
|
||||
func getHistory(name tokens.QName) ([]backend.UpdateInfo, error) {
|
||||
w, err := workspace.New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contract.Require(name != "", "name")
|
||||
|
||||
dir := w.HistoryDirectory(name)
|
||||
allFiles, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
// History doesn't exist until a stack has been updated.
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var updates []backend.UpdateInfo
|
||||
for _, file := range allFiles {
|
||||
filepath := path.Join(dir, file.Name())
|
||||
|
||||
// Open all of the history files, ignoring the checkpoints.
|
||||
if !strings.HasSuffix(filepath, ".history.json") {
|
||||
continue
|
||||
}
|
||||
|
||||
var update backend.UpdateInfo
|
||||
b, err := ioutil.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "reading history file %s", filepath)
|
||||
}
|
||||
err = json.Unmarshal(b, &update)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "reading history file %s", filepath)
|
||||
}
|
||||
|
||||
updates = append(updates, update)
|
||||
}
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// addToHistory saves the UpdateInfo and makes a copy of the current Checkpoint file.
|
||||
func addToHistory(name tokens.QName, update backend.UpdateInfo) error {
|
||||
contract.Require(name != "", "name")
|
||||
|
||||
w, err := workspace.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := w.HistoryDirectory(name)
|
||||
if err = os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Prefix for the update and checkpoint files.
|
||||
pathPrefix := path.Join(dir, fmt.Sprintf("%s-%d", name, update.StartTime))
|
||||
|
||||
// Save the history file.
|
||||
b, err := json.MarshalIndent(&update, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
historyFile := fmt.Sprintf("%s.history.json", pathPrefix)
|
||||
if err = ioutil.WriteFile(historyFile, b, os.ModePerm); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Make a copy of the checkpoint file. (Assuming it aleady exists.)
|
||||
b, err = ioutil.ReadFile(w.StackPath(name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
checkpointFile := fmt.Sprintf("%s.checkpoint.json", pathPrefix)
|
||||
return ioutil.WriteFile(checkpointFile, b, os.ModePerm)
|
||||
}
|
||||
|
|
|
@ -20,12 +20,12 @@ type Stack interface {
|
|||
Snapshot() *deploy.Snapshot // the latest deployment snapshot.
|
||||
Backend() Backend // the backend this stack belongs to.
|
||||
|
||||
// preview changes to this stack.
|
||||
// Preview changes to this stack.
|
||||
Preview(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
// update this stack.
|
||||
Update(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
// destroy this stack's resources.
|
||||
Destroy(pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error
|
||||
// Update this stack.
|
||||
Update(pkg *pack.Package, root string, debug bool, m UpdateMetadata, opts engine.UpdateOptions) error
|
||||
// Destroy this stack's resources.
|
||||
Destroy(pkg *pack.Package, root string, debug bool, m UpdateMetadata, opts engine.UpdateOptions) error
|
||||
|
||||
Remove(force bool) (bool, error) // remove this stack.
|
||||
GetLogs(query operations.LogQuery) ([]operations.LogEntry, error) // list log entries for this stack.
|
||||
|
@ -44,13 +44,15 @@ func PreviewStack(s Stack, pkg *pack.Package, root string, debug bool, opts engi
|
|||
}
|
||||
|
||||
// UpdateStack updates the target stack with the current workspace's contents (config and code).
|
||||
func UpdateStack(s Stack, pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return s.Backend().Update(s.Name(), pkg, root, debug, opts)
|
||||
func UpdateStack(s Stack, pkg *pack.Package, root string,
|
||||
debug bool, m UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return s.Backend().Update(s.Name(), pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
// DestroyStack destroys all of this stack's resources.
|
||||
func DestroyStack(s Stack, pkg *pack.Package, root string, debug bool, opts engine.UpdateOptions) error {
|
||||
return s.Backend().Destroy(s.Name(), pkg, root, debug, opts)
|
||||
func DestroyStack(s Stack, pkg *pack.Package, root string,
|
||||
debug bool, m UpdateMetadata, opts engine.UpdateOptions) error {
|
||||
return s.Backend().Destroy(s.Name(), pkg, root, debug, m, opts)
|
||||
}
|
||||
|
||||
// GetStackCrypter fetches the encrypter/decrypter for a stack.
|
||||
|
|
103
pkg/backend/updates.go
Normal file
103
pkg/backend/updates.go
Normal file
|
@ -0,0 +1,103 @@
|
|||
// Copyright 2018, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package backend
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/engine"
|
||||
"github.com/pulumi/pulumi/pkg/resource/config"
|
||||
)
|
||||
|
||||
// UpdateMetadata describes optional metadata about an update.
|
||||
type UpdateMetadata struct {
|
||||
// Message is an optional message associated with the update.
|
||||
Message string `json:"message"`
|
||||
// Environment contains optional data from the deploying environment. e.g. the current
|
||||
// source code control commit information.
|
||||
Environment map[string]string `json:"environment"`
|
||||
}
|
||||
|
||||
// UpdateKind is an enum for the type of update performed.
|
||||
type UpdateKind string
|
||||
|
||||
const (
|
||||
// DeployUpdate is the prototypical Pulumi program update.
|
||||
DeployUpdate UpdateKind = "update"
|
||||
// PreviewUpdate is a preview of an update, without impacting resources.
|
||||
PreviewUpdate = "preview"
|
||||
// DestroyUpdate is an update which removes all resources.
|
||||
DestroyUpdate = "destroy"
|
||||
)
|
||||
|
||||
// UpdateResult is an enum for the result of the update.
|
||||
type UpdateResult string
|
||||
|
||||
const (
|
||||
// InProgressResult is for updates that have not yet completed.
|
||||
InProgressResult = "in-progress"
|
||||
// SucceededResult is for updates that completed successfully.
|
||||
SucceededResult UpdateResult = "succeeded"
|
||||
// FailedResult is for updates that have failed.
|
||||
FailedResult = "failed"
|
||||
)
|
||||
|
||||
// Keys we use for values put into UpdateInfo.Environment.
|
||||
const (
|
||||
// GitHead is the commit hash of HEAD.
|
||||
GitHead = "git.head"
|
||||
// GitDirty ("true", "false") indiciates if there are any unstaged or modified files in the local repo.
|
||||
GitDirty = "git.dirty"
|
||||
|
||||
// GitHubLogin is the user/organization who owns the local repo, if the origin remote is hosted on GitHub.com.
|
||||
GitHubLogin = "github.login"
|
||||
// GitHubRepo is the name of the GitHub repo, if the local git repo's remote origin is hosted on GitHub.com.
|
||||
GitHubRepo = "github.repo"
|
||||
)
|
||||
|
||||
// UpdateInfo describes a previous update.
|
||||
type UpdateInfo struct {
|
||||
// Information known before an update is started.
|
||||
|
||||
Kind UpdateKind `json:"kind"`
|
||||
StartTime int64 `json:"startTime"`
|
||||
// Message is an optional message associated with the update.
|
||||
Message string `json:"message"`
|
||||
// Environment contains optional data from the deploying environment. e.g. the current
|
||||
// source code control commit information.
|
||||
Environment map[string]string `json:"environment"`
|
||||
|
||||
// Config used for the update.
|
||||
Config config.Map `json:"config"`
|
||||
|
||||
// Information obtained from an update completing.
|
||||
|
||||
Result UpdateResult `json:"result"`
|
||||
EndTime int64 `json:"endTime"`
|
||||
|
||||
ResourceChanges engine.ResourceChanges `json:"resourceChanges"`
|
||||
}
|
||||
|
||||
// updateSorter implements the sort.Interface interface.
|
||||
// Sorts by StartTime in *descending* order (more recent first).
|
||||
type updateSorter struct {
|
||||
u []UpdateInfo
|
||||
}
|
||||
|
||||
func (us *updateSorter) Len() int {
|
||||
return len(us.u)
|
||||
}
|
||||
|
||||
func (us *updateSorter) Swap(i, j int) {
|
||||
us.u[i], us.u[j] = us.u[j], us.u[i]
|
||||
}
|
||||
|
||||
func (us *updateSorter) Less(i, j int) bool {
|
||||
return us.u[i].StartTime > us.u[j].StartTime
|
||||
}
|
||||
|
||||
// Sort orders the UpdateInfo slice by StartTime descending.
|
||||
func Sort(updates []UpdateInfo) {
|
||||
us := updateSorter{u: updates}
|
||||
sort.Sort(&us)
|
||||
}
|
|
@ -87,8 +87,9 @@ func deployLatest(info *planContext, opts deployOptions) (ResourceChanges, error
|
|||
defer done()
|
||||
|
||||
if opts.DryRun {
|
||||
// If a dry run, just print the plan, don't actually carry out the deployment. (Reporting 0 changes.)
|
||||
if err := printPlan(result); err != nil {
|
||||
// If a dry run, just print the plan, don't actually carry out the deployment.
|
||||
resourceChanges, err = printPlan(result)
|
||||
if err != nil {
|
||||
return resourceChanges, err
|
||||
}
|
||||
} else {
|
||||
|
@ -232,9 +233,6 @@ func (acts *deployActions) OnResourceOutputs(step deploy.Step) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = mutation.End(step.Iterator().Snap()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return mutation.End(step.Iterator().Snap())
|
||||
}
|
||||
|
|
|
@ -158,7 +158,7 @@ func (res *planResult) Close() error {
|
|||
}
|
||||
|
||||
// printPlan prints the plan's result to the plan's Options.Events stream.
|
||||
func printPlan(result *planResult) error {
|
||||
func printPlan(result *planResult) (ResourceChanges, error) {
|
||||
// First print config/unchanged/etc. if necessary.
|
||||
var prelude bytes.Buffer
|
||||
printPrelude(&prelude, result, true)
|
||||
|
@ -170,20 +170,21 @@ func printPlan(result *planResult) error {
|
|||
actions := newPreviewActions(result.Options)
|
||||
_, _, _, err := result.Walk(actions, true)
|
||||
if err != nil {
|
||||
return errors.Errorf("An error occurred while advancing the preview: %v", err)
|
||||
return nil, errors.Errorf("An error occurred while advancing the preview: %v", err)
|
||||
}
|
||||
|
||||
if !result.Options.Diag.Success() {
|
||||
// If any error occurred while walking the plan, be sure to let the developer know. Otherwise,
|
||||
// although error messages may have spewed to the output, the final lines of the plan may look fine.
|
||||
return errors.New("One or more errors occurred during this preview")
|
||||
return nil, errors.New("One or more errors occurred during this preview")
|
||||
}
|
||||
|
||||
// Print a summary of operation counts.
|
||||
var summary bytes.Buffer
|
||||
printChangeSummary(&summary, ResourceChanges(actions.Ops), true)
|
||||
changes := ResourceChanges(actions.Ops)
|
||||
printChangeSummary(&summary, changes, true)
|
||||
result.Options.Events <- stdOutEventWithColor(&summary, result.Options.Color)
|
||||
return nil
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
// shouldShow returns true if a step should show in the output.
|
||||
|
|
|
@ -52,7 +52,7 @@ func previewLatest(info *planContext, opts deployOptions) error {
|
|||
}
|
||||
defer done()
|
||||
|
||||
if err := printPlan(result); err != nil {
|
||||
if _, err := printPlan(result); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -652,11 +652,7 @@ func (pt *programTester) testEdit(dir string, i int, edit EditDir) error {
|
|||
if err = pt.previewAndUpdate(dir, fmt.Sprintf("edit-%d", i), edit.ExpectFailure); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = pt.performExtraRuntimeValidation(edit.ExtraRuntimeValidation, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return pt.performExtraRuntimeValidation(edit.ExtraRuntimeValidation, dir)
|
||||
}
|
||||
|
||||
func (pt *programTester) performExtraRuntimeValidation(
|
||||
|
|
|
@ -18,6 +18,7 @@ const ProjectFile = "Pulumi" // the base name of a project file.
|
|||
const GitDir = ".git" // the name of the folder git uses to store information.
|
||||
const BookkeepingDir = ".pulumi" // the name of our bookeeping folder, we store state here (like .git for git).
|
||||
const StackDir = "stacks" // the name of the directory that holds stack information for projects.
|
||||
const HistoryDir = "history" // the name of the directory that holds historical information for projects.
|
||||
const WorkspaceDir = "workspaces" // the name of the directory that holds workspace information for projects.
|
||||
const RepoFile = "settings.json" // the name of the file that holds information specific to the entire repository.
|
||||
const ConfigDir = "config" // the name of the folder that holds local configuration information.
|
||||
|
|
|
@ -17,11 +17,12 @@ import (
|
|||
|
||||
// W offers functionality for interacting with Pulumi workspaces.
|
||||
type W interface {
|
||||
Settings() *Settings // returns a mutable pointer to the optional workspace settings info.
|
||||
Repository() *Repository // returns the repository this project belongs to.
|
||||
StackPath(stack tokens.QName) string // returns the path to store stack information.
|
||||
GetPackage() (*pack.Package, error) // returns a copy of the package associated with this workspace.
|
||||
Save() error // saves any modifications to the workspace.
|
||||
Settings() *Settings // returns a mutable pointer to the optional workspace settings info.
|
||||
Repository() *Repository // returns the repository this project belongs to.
|
||||
StackPath(stack tokens.QName) string // returns the path to store stack information.
|
||||
HistoryDirectory(stack tokens.QName) string // returns the directory to store a stack's history information.
|
||||
GetPackage() (*pack.Package, error) // returns a copy of the package associated with this workspace.
|
||||
Save() error // saves any modifications to the workspace.
|
||||
}
|
||||
|
||||
type projectWorkspace struct {
|
||||
|
@ -121,6 +122,14 @@ func (pw *projectWorkspace) StackPath(stack tokens.QName) string {
|
|||
return path
|
||||
}
|
||||
|
||||
func (pw *projectWorkspace) HistoryDirectory(stack tokens.QName) string {
|
||||
path := filepath.Join(pw.Repository().Root, HistoryDir, pw.name.String())
|
||||
if stack != "" {
|
||||
return filepath.Join(path, qnamePath(stack))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (pw *projectWorkspace) readSettings() error {
|
||||
settingsPath := pw.settingsPath()
|
||||
|
||||
|
|
275
tests/history_test.go
Normal file
275
tests/history_test.go
Normal file
|
@ -0,0 +1,275 @@
|
|||
// Copyright 2016-2017, Pulumi Corporation. All rights reserved.
|
||||
|
||||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/pulumi/pulumi/pkg/backend"
|
||||
ptesting "github.com/pulumi/pulumi/pkg/testing"
|
||||
"github.com/pulumi/pulumi/pkg/testing/integration"
|
||||
)
|
||||
|
||||
// deleteIfNotFailed deletes the files in the testing environment if the testcase has
|
||||
// not failed. (Otherwise they are left to aid debugging.)
|
||||
func deleteIfNotFailed(e *ptesting.Environment) {
|
||||
if !e.T.Failed() {
|
||||
e.DeleteEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
// assertHasNoHistory runs `pulumi history` and confirms an error that the stack has not
|
||||
// ever been updated.
|
||||
func assertHasNoHistory(e *ptesting.Environment) {
|
||||
// NOTE: pulumi returns with exit code 0 in this scenario.
|
||||
out, err := e.RunCommand("pulumi", "history")
|
||||
assert.Equal(e.T, "", err)
|
||||
assert.Equal(e.T, "Stack has never been updated\n", out)
|
||||
}
|
||||
|
||||
func assertEnvValue(t *testing.T, update backend.UpdateInfo, key, val string) {
|
||||
t.Helper()
|
||||
v, ok := update.Environment[key]
|
||||
if !ok {
|
||||
t.Errorf("Did not find key %q in update environment", key)
|
||||
} else {
|
||||
assert.Equal(t, val, v, "Comparing Environment values for key %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnvKeyNotFound(t *testing.T, update backend.UpdateInfo, key string) {
|
||||
t.Helper()
|
||||
_, found := update.Environment[key]
|
||||
assert.False(t, found, "Did not expect to find key %q in update environment", key)
|
||||
}
|
||||
|
||||
func TestHistoryCommand(t *testing.T) {
|
||||
// The --output-json flag on pulumi history is hidden behind an environment variable.
|
||||
os.Setenv("PULUMI_DEBUG_COMMANDS", "1")
|
||||
defer os.Unsetenv("PULUMI_DEBUG_COMMANDS")
|
||||
|
||||
// We fail if no stack is selected.
|
||||
t.Run("NoStackSelected", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
integration.CreateBasicPulumiRepo(e)
|
||||
|
||||
out, err := e.RunCommandExpectError("pulumi", "history")
|
||||
assert.Equal(t, "", out)
|
||||
assert.Contains(t, err, "error: no current stack detected")
|
||||
})
|
||||
|
||||
// We don't display any history for a stack that has never been updated.
|
||||
t.Run("NoUpdates", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
integration.CreateBasicPulumiRepo(e)
|
||||
|
||||
e.RunCommand("pulumi", "stack", "init", "no-updates-test", "--local")
|
||||
assertHasNoHistory(e)
|
||||
})
|
||||
|
||||
// The "history" command uses the currently selected stack.
|
||||
t.Run("CurrentlySelectedStack", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
integration.CreateBasicPulumiRepo(e)
|
||||
e.ImportDirectory("integration/stack_outputs")
|
||||
|
||||
e.RunCommand("pulumi", "stack", "init", "stack-without-updates", "--local")
|
||||
e.RunCommand("pulumi", "stack", "init", "history-test", "--local")
|
||||
|
||||
// Update the history-test stack.
|
||||
e.RunCommand("yarn", "install")
|
||||
e.RunCommand("yarn", "run", "build")
|
||||
e.RunCommand("pulumi", "update", "-m", "updating stack...")
|
||||
|
||||
// Confirm we see the update message in thie history output.
|
||||
out, err := e.RunCommand("pulumi", "history")
|
||||
assert.Equal(t, "", err)
|
||||
assert.Contains(t, out, "updating stack...")
|
||||
|
||||
// Change stack and confirm the history command honors the selected stack.
|
||||
e.RunCommand("pulumi", "stack", "select", "stack-without-updates")
|
||||
assertHasNoHistory(e)
|
||||
|
||||
// Change stack back, and confirm still has history.
|
||||
e.RunCommand("pulumi", "stack", "select", "history-test")
|
||||
out, err = e.RunCommand("pulumi", "history")
|
||||
assert.Equal(t, "", err)
|
||||
assert.Contains(t, out, "updating stack...")
|
||||
})
|
||||
|
||||
// That the history command contains accurate data about the update history.
|
||||
t.Run("Data(Deploy,Kind,Result)", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
integration.CreateBasicPulumiRepo(e)
|
||||
e.ImportDirectory("integration/stack_outputs")
|
||||
|
||||
e.RunCommand("pulumi", "stack", "init", "history-test", "--local")
|
||||
|
||||
// Update the history-test stack.
|
||||
e.RunCommand("yarn", "install")
|
||||
e.RunCommand("yarn", "run", "build")
|
||||
e.RunCommand("pulumi", "update", "-m", "first update (successful)")
|
||||
|
||||
// Now we "break" the program, by adding gibberish to bin/index.js.
|
||||
indexJS := path.Join(e.CWD, "bin", "index.js")
|
||||
origContents, err := ioutil.ReadFile(indexJS)
|
||||
assert.NoError(t, err, "Reading bin/index.js")
|
||||
|
||||
var invalidJS bytes.Buffer
|
||||
invalidJS.Write(origContents)
|
||||
invalidJS.WriteString("\n\n... with random text -> syntax error, too")
|
||||
err = ioutil.WriteFile(indexJS, invalidJS.Bytes(), os.ModePerm)
|
||||
assert.NoError(t, err, "Writing bin/index.js")
|
||||
|
||||
e.RunCommandExpectError("pulumi", "update", "-m", "second update (failure)")
|
||||
|
||||
// Fix it
|
||||
err = ioutil.WriteFile(indexJS, origContents, os.ModePerm)
|
||||
assert.NoError(t, err, "Writing bin/index.js")
|
||||
e.RunCommand("pulumi", "update", "-m", "third update (successful)")
|
||||
|
||||
// Destroy
|
||||
e.RunCommand("pulumi", "destroy", "-m", "fourth update (destroy)", "--yes")
|
||||
|
||||
// Confirm the history is as expected. Output as JSON and parse the result.
|
||||
stdout, stderr := e.RunCommand("pulumi", "history", "--output-json")
|
||||
assert.Equal(t, "", stderr)
|
||||
|
||||
var updateRecords []backend.UpdateInfo
|
||||
err = json.Unmarshal([]byte(stdout), &updateRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("Error marshalling `pulumi history` output as JSON: %v", err)
|
||||
}
|
||||
if len(updateRecords) != 4 {
|
||||
t.Fatalf("didn't get expected number of updates from testcase. Raw history output:\n%v", stdout)
|
||||
}
|
||||
|
||||
// The most recent updates are listed first.
|
||||
update := updateRecords[0]
|
||||
assert.Equal(t, "fourth update (destroy)", update.Message)
|
||||
assert.True(t, backend.DestroyUpdate == update.Kind)
|
||||
assert.True(t, backend.SucceededResult == update.Result)
|
||||
|
||||
update = updateRecords[1]
|
||||
assert.Equal(t, "third update (successful)", update.Message)
|
||||
assert.True(t, backend.DeployUpdate == update.Kind)
|
||||
assert.True(t, backend.SucceededResult == update.Result)
|
||||
|
||||
update = updateRecords[2]
|
||||
assert.Equal(t, "second update (failure)", update.Message)
|
||||
assert.True(t, backend.DeployUpdate == update.Kind)
|
||||
assert.True(t, backend.FailedResult == update.Result)
|
||||
|
||||
update = updateRecords[3]
|
||||
assert.Equal(t, "first update (successful)", update.Message)
|
||||
assert.True(t, backend.DeployUpdate == update.Kind)
|
||||
assert.True(t, backend.SucceededResult == update.Result)
|
||||
|
||||
if t.Failed() {
|
||||
t.Logf("Test failed. Printing raw history output:\n%v", stdout)
|
||||
}
|
||||
|
||||
// Call stack rm to run the "delete history file too" codepath.
|
||||
e.RunCommand("pulumi", "stack", "rm", "--yes")
|
||||
})
|
||||
|
||||
// We include git-related data in the environment metadata.
|
||||
t.Run("Data(Environment[Git])", func(t *testing.T) {
|
||||
e := ptesting.NewEnvironment(t)
|
||||
defer deleteIfNotFailed(e)
|
||||
integration.CreateBasicPulumiRepo(e)
|
||||
e.ImportDirectory("integration/stack_outputs")
|
||||
|
||||
e.RunCommand("pulumi", "stack", "init", "history-test", "--local")
|
||||
e.RunCommand("yarn", "install")
|
||||
e.RunCommand("yarn", "run", "build")
|
||||
|
||||
// Update 1, git repo that has no commits.
|
||||
e.RunCommand("pulumi", "update", "-m", "first update (git repo has no commits)")
|
||||
|
||||
// Update 2, repo has commit, but no remote.
|
||||
e.RunCommand("git", "add", ".")
|
||||
e.RunCommand("git", "commit", "-m", "First commit of test files")
|
||||
e.RunCommand("pulumi", "update", "-m", "second update (git commit, no remote)")
|
||||
|
||||
// Update 3, repo has remote and is dirty (by rewriting index.ts).
|
||||
indexTS := path.Join(e.CWD, "index.ts")
|
||||
origContents, err := ioutil.ReadFile(indexTS)
|
||||
assert.NoError(t, err, "Reading index.ts")
|
||||
|
||||
err = ioutil.WriteFile(indexTS, []byte("change to file..."), os.ModePerm)
|
||||
assert.NoError(t, err, "writing index.ts")
|
||||
|
||||
e.RunCommand("git", "remote", "add", "origin", "git@github.com:rick/c-132")
|
||||
e.RunCommand("pulumi", "update", "-m", "third update (is dirty, has remote)")
|
||||
|
||||
// Update 4, repo is now clean again.
|
||||
err = ioutil.WriteFile(indexTS, origContents, os.ModePerm)
|
||||
assert.NoError(t, err, "writing index.ts")
|
||||
|
||||
e.RunCommand("pulumi", "update", "-m", "fourth update (is clean)")
|
||||
|
||||
// Confirm the history is as expected. Output as JSON and parse the result.
|
||||
stdout, stderr := e.RunCommand("pulumi", "history", "--output-json")
|
||||
assert.Equal(t, "", stderr)
|
||||
|
||||
var updateRecords []backend.UpdateInfo
|
||||
err = json.Unmarshal([]byte(stdout), &updateRecords)
|
||||
if err != nil {
|
||||
t.Fatalf("Error marshalling `pulumi history` output as JSON: %v", err)
|
||||
}
|
||||
if len(updateRecords) != 4 {
|
||||
t.Fatalf("didn't get expected number of updates from testcase. Raw history output:\n%v", stdout)
|
||||
}
|
||||
|
||||
// The first update doesn't have any git information, since
|
||||
// nothing has been committed yet.
|
||||
t.Log("Checking first update...")
|
||||
update := updateRecords[3]
|
||||
assertEnvKeyNotFound(e.T, update, backend.GitHead)
|
||||
|
||||
// The second update has a commit.
|
||||
t.Log("Checking second update...")
|
||||
update = updateRecords[2]
|
||||
// We don't know what the SHA will be ahead of time, since the code we use to call `pulumi init`
|
||||
// uses the current time as part of the --name flag.
|
||||
headSHA, ok := update.Environment[backend.GitHead]
|
||||
assert.True(t, ok, "Didn't find %s in environment", backend.GitHead)
|
||||
assert.Equal(t, 40, len(headSHA), "Commit SHA was not expected length")
|
||||
assertEnvValue(e.T, update, backend.GitDirty, "false")
|
||||
// The github-related info is still not set though.
|
||||
assertEnvKeyNotFound(e.T, update, backend.GitHubLogin)
|
||||
assertEnvKeyNotFound(e.T, update, backend.GitHubRepo)
|
||||
|
||||
// The third commit sets a remote (which we detect as a GitHub repo) and is now dirty.
|
||||
t.Log("Checking third update...")
|
||||
update = updateRecords[1]
|
||||
assertEnvValue(e.T, update, backend.GitHead, headSHA)
|
||||
assertEnvValue(e.T, update, backend.GitDirty, "true")
|
||||
assertEnvValue(e.T, update, backend.GitHubLogin, "rick")
|
||||
assertEnvValue(e.T, update, backend.GitHubRepo, "c-132")
|
||||
|
||||
// The fourth commit is clean (by restoring to the previous commit).
|
||||
t.Log("Checking fourth update...")
|
||||
update = updateRecords[0]
|
||||
assertEnvValue(e.T, update, backend.GitHead, headSHA)
|
||||
assertEnvValue(e.T, update, backend.GitDirty, "false")
|
||||
assertEnvValue(e.T, update, backend.GitHubLogin, "rick")
|
||||
assertEnvValue(e.T, update, backend.GitHubRepo, "c-132")
|
||||
|
||||
if t.Failed() {
|
||||
t.Logf("Test failed. Printing raw history output:\n%v\n", stdout)
|
||||
}
|
||||
})
|
||||
}
|
1
tests/integration/stack_outputs/.gitignore
vendored
1
tests/integration/stack_outputs/.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
/bin/
|
||||
/node_modules/
|
||||
yarn.lock
|
||||
.pulumi/
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "stack_project_name",
|
||||
"name": "stack_outputs",
|
||||
"main": "bin/index.js",
|
||||
"typings": "bin/index.d.ts",
|
||||
"scripts": {
|
||||
|
|
Loading…
Reference in a new issue