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:
Chris Smith 2018-01-24 18:22:41 -08:00 committed by GitHub
parent ce05cce77f
commit 4c217fd358
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1017 additions and 79 deletions

46
Gopkg.lock generated
View file

@ -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

View file

@ -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
View 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)
}
}

View file

@ -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())

View file

@ -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")

View file

@ -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
View 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"`
}

View file

@ -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.

View file

@ -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)

View file

@ -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
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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) {

View file

@ -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)
}

View file

@ -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
View 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)
}

View file

@ -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())
}

View file

@ -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.

View file

@ -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
}
}

View file

@ -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(

View file

@ -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.

View file

@ -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
View 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)
}
})
}

View file

@ -1,3 +1,4 @@
/bin/
/node_modules/
yarn.lock
.pulumi/

View file

@ -1,5 +1,5 @@
{
"name": "stack_project_name",
"name": "stack_outputs",
"main": "bin/index.js",
"typings": "bin/index.d.ts",
"scripts": {