pulumi/tests/stack_test.go
2021-11-15 15:17:20 -05:00

508 lines
16 KiB
Go

// Copyright 2016-2019, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package tests
import (
cryptorand "crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/pulumi/pulumi/pkg/v3/backend/filestate"
"github.com/pulumi/pulumi/pkg/v3/resource/stack"
"github.com/pulumi/pulumi/pkg/v3/testing/integration"
"github.com/pulumi/pulumi/sdk/v3/go/common/apitype"
"github.com/pulumi/pulumi/sdk/v3/go/common/resource"
ptesting "github.com/pulumi/pulumi/sdk/v3/go/common/testing"
"github.com/pulumi/pulumi/sdk/v3/go/common/util/contract"
"github.com/pulumi/pulumi/sdk/v3/go/common/workspace"
"github.com/stretchr/testify/assert"
)
func TestStackCommands(t *testing.T) {
// stack init, stack ls, stack rm, stack ls
t.Run("SanityTest", func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", "foo")
stacks, current := integration.GetStacks(e)
assert.Equal(t, 1, len(stacks))
assert.NotNil(t, current)
if current == nil {
t.Logf("stacks: %v, current: %v", stacks, current)
t.Fatalf("No current stack?")
}
assert.Equal(t, "foo", *current)
assert.Contains(t, stacks, "foo")
e.RunCommand("pulumi", "stack", "rm", "foo", "--yes")
stacks, _ = integration.GetStacks(e)
assert.Equal(t, 0, len(stacks))
})
t.Run("StackSelect", func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", "blighttown")
e.RunCommand("pulumi", "stack", "init", "majula")
e.RunCommand("pulumi", "stack", "init", "lothric")
// Last one created is always selected.
stacks, current := integration.GetStacks(e)
if current == nil {
t.Fatalf("No stack was labeled as current among: %v", stacks)
}
assert.Equal(t, "lothric", *current)
// Select works
e.RunCommand("pulumi", "stack", "select", "blighttown")
stacks, current = integration.GetStacks(e)
if current == nil {
t.Fatalf("No stack was labeled as current among: %v", stacks)
}
assert.Equal(t, "blighttown", *current)
// Error
out, err := e.RunCommandExpectError("pulumi", "stack", "select", "anor-londo")
assert.Empty(t, out)
// local: "no stack with name 'anor-londo' found"
// cloud: "Stack 'integration-test-59f645ba/pulumi-test/anor-londo' not found"
assert.Contains(t, err, "anor-londo")
e.RunCommand("pulumi", "stack", "rm", "--yes")
})
t.Run("StackRm", func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", "blighttown")
e.RunCommand("pulumi", "stack", "init", "majula")
e.RunCommand("pulumi", "stack", "init", "lothric")
stacks, _ := integration.GetStacks(e)
assert.Equal(t, 3, len(stacks))
e.RunCommand("pulumi", "stack", "rm", "majula", "--yes")
stacks, _ = integration.GetStacks(e)
assert.Equal(t, 2, len(stacks))
assert.Contains(t, stacks, "blighttown")
assert.Contains(t, stacks, "lothric")
e.RunCommand("pulumi", "stack", "rm", "lothric", "--yes")
stacks, _ = integration.GetStacks(e)
assert.Equal(t, 1, len(stacks))
assert.Contains(t, stacks, "blighttown")
e.RunCommand("pulumi", "stack", "rm", "blighttown", "--yes")
stacks, _ = integration.GetStacks(e)
assert.Equal(t, 0, len(stacks))
// Error
out, err := e.RunCommandExpectError("pulumi", "stack", "rm", "anor-londo", "--yes")
assert.Empty(t, out)
// local: .pulumi/stacks/pulumi-test/anor-londo.json: no such file or directory
// cloud: Stack 'integration-test-59f645ba/pulumi-test/anor-londo' not found
assert.Contains(t, err, "anor-londo")
})
// Test that stack import fails if the version of the deployment we give it is not
// one that the CLI supports.
t.Run("CheckpointVersioning", func(t *testing.T) {
versions := []int{
apitype.DeploymentSchemaVersionCurrent + 1,
stack.DeploymentSchemaVersionOldestSupported - 1,
}
for _, deploymentVersion := range versions {
t.Run(fmt.Sprintf("Version%d", deploymentVersion), func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", "the-abyss")
stacks, _ := integration.GetStacks(e)
assert.Equal(t, 1, len(stacks))
stackFile := path.Join(e.RootPath, "stack.json")
e.RunCommand("pulumi", "stack", "export", "--file", "stack.json")
stackJSON, err := ioutil.ReadFile(stackFile)
if !assert.NoError(t, err) {
t.FailNow()
}
var deployment apitype.UntypedDeployment
err = json.Unmarshal(stackJSON, &deployment)
if !assert.NoError(t, err) {
t.FailNow()
}
deployment.Version = deploymentVersion
bytes, err := json.Marshal(deployment)
assert.NoError(t, err)
err = ioutil.WriteFile(stackFile, bytes, os.FileMode(os.O_CREATE))
if !assert.NoError(t, err) {
t.FailNow()
}
stdout, stderr := e.RunCommandExpectError("pulumi", "stack", "import", "--file", "stack.json")
assert.Empty(t, stdout)
switch {
case deploymentVersion > apitype.DeploymentSchemaVersionCurrent:
assert.Contains(t, stderr, "the stack 'the-abyss' is newer than what this version of the Pulumi CLI understands")
case deploymentVersion < stack.DeploymentSchemaVersionOldestSupported:
assert.Contains(t, stderr, "the stack 'the-abyss' is too old")
}
})
}
})
t.Run("FixingInvalidResources", func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
stackName := addRandomSuffix("invalid-resources")
integration.CreateBasicPulumiRepo(e)
e.ImportDirectory("integration/stack_dependencies")
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", stackName)
e.RunCommand("yarn", "install")
e.RunCommand("yarn", "link", "@pulumi/pulumi")
e.RunCommand("pulumi", "up", "--non-interactive", "--yes", "--skip-preview")
// We're going to futz with the stack a little so that one of the resources we just created
// becomes invalid.
stackFile := path.Join(e.RootPath, "stack.json")
e.RunCommand("pulumi", "stack", "export", "--file", "stack.json")
stackJSON, err := ioutil.ReadFile(stackFile)
if !assert.NoError(t, err) {
t.FailNow()
}
var deployment apitype.UntypedDeployment
err = json.Unmarshal(stackJSON, &deployment)
if !assert.NoError(t, err) {
t.FailNow()
}
os.Setenv("PULUMI_CONFIG_PASSPHRASE", "correct horse battery staple")
snap, err := stack.DeserializeUntypedDeployment(&deployment, stack.DefaultSecretsProvider)
if !assert.NoError(t, err) {
t.FailNow()
}
// Let's say that the the CLI crashed during the deletion of the last resource and we've now got
// invalid resources in the snapshot.
res := snap.Resources[len(snap.Resources)-1]
snap.PendingOperations = append(snap.PendingOperations, resource.Operation{
Resource: res,
Type: resource.OperationTypeDeleting,
})
v3deployment, err := stack.SerializeDeployment(snap, nil, false /* showSecrets */)
if !assert.NoError(t, err) {
t.FailNow()
}
data, err := json.Marshal(&v3deployment)
if !assert.NoError(t, err) {
t.FailNow()
}
deployment.Deployment = data
bytes, err := json.Marshal(&deployment)
if !assert.NoError(t, err) {
t.FailNow()
}
err = ioutil.WriteFile(stackFile, bytes, os.FileMode(os.O_CREATE))
if !assert.NoError(t, err) {
t.FailNow()
}
os.Unsetenv("PULUMI_CONFIG_PASSPHRASE")
_, stderr := e.RunCommand("pulumi", "stack", "import", "--file", "stack.json")
assert.Contains(t, stderr, fmt.Sprintf("removing pending operation 'deleting' on '%s'", res.URN))
// The engine should be happy now that there are no invalid resources.
e.RunCommand("pulumi", "up", "--non-interactive", "--yes", "--skip-preview")
e.RunCommand("pulumi", "stack", "rm", "--yes", "--force")
})
}
func TestStackBackups(t *testing.T) {
t.Run("StackBackupCreatedSanityTest", func(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.ImportDirectory("integration/stack_outputs/nodejs")
// We're testing that backups are created so ensure backups aren't disabled.
if env := os.Getenv(filestate.DisableCheckpointBackupsEnvVar); env != "" {
os.Unsetenv(filestate.DisableCheckpointBackupsEnvVar)
defer os.Setenv(filestate.DisableCheckpointBackupsEnvVar, env)
}
const stackName = "imulup"
// Get the path to the backup directory for this project.
backupDir, err := getStackProjectBackupDir(e, stackName)
assert.NoError(t, err, "getting stack project backup path")
defer func() {
if !t.Failed() {
// Cleanup the backup directory.
os.RemoveAll(backupDir)
}
}()
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", stackName)
// Build the project.
e.RunCommand("yarn", "install")
e.RunCommand("yarn", "link", "@pulumi/pulumi")
// Now run pulumi up.
before := time.Now().UnixNano()
e.RunCommand("pulumi", "up", "--non-interactive", "--yes", "--skip-preview")
after := time.Now().UnixNano()
// Verify the backup directory contains a single backup.
files, err := ioutil.ReadDir(backupDir)
assert.NoError(t, err, "getting the files in backup directory")
files = filterOutAttrsFiles(files)
fileNames := getFileNames(files)
assert.Equal(t, 1, len(files), "Files: %s", strings.Join(fileNames, ", "))
fileName := files[0].Name()
// Verify the backup file.
assertBackupStackFile(t, stackName, files[0], before, after)
// Now run pulumi destroy.
before = time.Now().UnixNano()
e.RunCommand("pulumi", "destroy", "--non-interactive", "--yes", "--skip-preview")
after = time.Now().UnixNano()
// Verify the backup directory has been updated with 1 additional backups.
files, err = ioutil.ReadDir(backupDir)
assert.NoError(t, err, "getting the files in backup directory")
files = filterOutAttrsFiles(files)
fileNames = getFileNames(files)
assert.Equal(t, 2, len(files), "Files: %s", strings.Join(fileNames, ", "))
// Verify the new backup file.
for _, file := range files {
// Skip the file we previously verified.
if file.Name() == fileName {
continue
}
assertBackupStackFile(t, stackName, file, before, after)
}
e.RunCommand("pulumi", "stack", "rm", "--yes")
})
}
func TestStackRenameAfterCreate(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
stackName := addRandomSuffix("stack-rename")
integration.CreateBasicPulumiRepo(e)
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", stackName)
newName := addRandomSuffix("renamed-stack")
e.RunCommand("pulumi", "stack", "rename", newName)
}
// TestStackRenameServiceAfterCreateBackend tests a few edge cases about renaming
// stacks owned by organizations in the service backend.
func TestStackRenameAfterCreateServiceBackend(t *testing.T) {
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
// Use the current username as the "organization" in certain operations.
username, _ := e.RunCommand("pulumi", "whoami")
orgName := strings.TrimSpace(username)
// Create a basic project.
stackName := addRandomSuffix("stack-rename-svcbe")
stackRenameBase := addRandomSuffix("renamed-stack-svcbe")
integration.CreateBasicPulumiRepo(e)
e.RunCommand("pulumi", "stack", "init", stackName)
// Create some configuration so that a per-project YAML file is generated.
e.RunCommand("pulumi", "config", "set", "xyz", "abc")
// Try to rename the stack to itself. This should fail.
e.RunCommandExpectError("pulumi", "stack", "rename", stackName)
// Try to rename this stack to a name outside of the current "organization".
// This should fail since it is not currently legal to do so.
e.RunCommandExpectError("pulumi", "stack", "rename", "fakeorg/"+stackRenameBase)
// Next perform a legal rename. This should work.
e.RunCommand("pulumi", "stack", "rename", stackRenameBase)
stdoutXyz1, _ := e.RunCommand("pulumi", "config", "get", "xyz")
assert.Equal(t, "abc", strings.Trim(stdoutXyz1, "\r\n"))
// Now perform another legal rename, this time explicitly specifying the
// "organization" for the stack (which should match the default).
e.RunCommand("pulumi", "stack", "rename", orgName+"/"+stackRenameBase+"2")
stdoutXyz2, _ := e.RunCommand("pulumi", "config", "get", "xyz")
assert.Equal(t, "abc", strings.Trim(stdoutXyz2, "\r\n"))
}
func TestLocalStateLocking(t *testing.T) {
t.Skip() // TODO[pulumi/pulumi#7269] flaky test
e := ptesting.NewEnvironment(t)
defer func() {
if !t.Failed() {
e.DeleteEnvironment()
}
}()
integration.CreateBasicPulumiRepo(e)
e.ImportDirectory("integration/single_resource")
e.SetBackend(e.LocalURL())
e.RunCommand("pulumi", "stack", "init", "foo")
e.RunCommand("yarn", "install")
e.RunCommand("yarn", "link", "@pulumi/pulumi")
// Enable self-managed backend locking
e.SetEnvVars([]string{fmt.Sprintf("%s=1", filestate.PulumiFilestateLockingEnvVar)})
// Run 10 concurrent updates
count := 10
stderrs := make(chan string, count)
for i := 0; i < count; i++ {
go func() {
_, stderr, err := e.GetCommandResults("pulumi", "up", "--non-interactive", "--skip-preview", "--yes")
if err == nil {
stderrs <- "" // success marker
} else {
stderrs <- stderr
}
}()
}
// Ensure that only one of the concurrent updates succeeded, and that failures
// were due to locking (and not state file corruption)
numsuccess := 0
numerrors := 0
for i := 0; i < count; i++ {
stderr := <-stderrs
if stderr == "" {
assert.Equal(t, 0, numsuccess, "more than one concurrent update succeeded")
numsuccess++
} else {
phrase := "the stack is currently locked by 1 lock(s)"
if !strings.Contains(stderr, phrase) {
numerrors++
t.Logf("unexplaiend stderr::\n%s", stderr)
assert.Lessf(t, numerrors, 2, "More than one unexplained error has occurred")
}
}
}
}
func getFileNames(infos []os.FileInfo) []string {
var result []string
for _, i := range infos {
result = append(result, i.Name())
}
return result
}
func filterOutAttrsFiles(files []os.FileInfo) []os.FileInfo {
var result []os.FileInfo
for _, f := range files {
if filepath.Ext(f.Name()) != ".attrs" {
result = append(result, f)
}
}
return result
}
func assertBackupStackFile(t *testing.T, stackName string, file os.FileInfo, before int64, after int64) {
assert.False(t, file.IsDir())
assert.True(t, file.Size() > 0)
split := strings.Split(file.Name(), ".")
assert.Equal(t, 3, len(split), "Split: %s", strings.Join(split, ", "))
assert.Equal(t, stackName, split[0])
parsedTime, err := strconv.ParseInt(split[1], 10, 64)
assert.NoError(t, err, "parsing the time in the stack backup filename")
assert.True(t, parsedTime > before, "False: %v > %v", parsedTime, before)
assert.True(t, parsedTime < after, "False: %v < %v", parsedTime, after)
}
func getStackProjectBackupDir(e *ptesting.Environment, stackName string) (string, error) {
return filepath.Join(e.RootPath,
workspace.BookkeepingDir,
workspace.BackupDir,
stackName,
), nil
}
func addRandomSuffix(s string) string {
b := make([]byte, 4)
_, err := cryptorand.Read(b)
contract.AssertNoError(err)
return s + "-" + hex.EncodeToString(b)
}