Repository: getporter/porter
Pull request #1715 (2021-08-11 19:42:18)

Add apply command for creds and params
# What does this change
* Add a new command for credential and parameter sets which allow you toapply changes (or create net new) from a file.
* Fixes how we resolve params and credentials to allow referencing a global cs/ps from within a namespace during install.

# What issue does it fix
This is part of #1703. Next up is for installations which is a bit more complex.

# Notes for the reviewer
N/A

# Checklist
- [x] Unit Tests
- [x] Documentation
- [ ] Schema (porter.yaml)



Patch:
diff --git a/cmd/porter/credentials.go b/cmd/porter/credentials.go
index 4f0b03d2..8e25ec28 100644
--- a/cmd/porter/credentials.go
+++ b/cmd/porter/credentials.go
@@ -13,6 +13,7 @@ func buildCredentialsCommands(p *porter.Porter) *cobra.Command {
 		Short:       "Credentials commands",
 	}
 
+	cmd.AddCommand(buildCredentialsApplyCommand(p))
 	cmd.AddCommand(buildCredentialsEditCommand(p))
 	cmd.AddCommand(buildCredentialsGenerateCommand(p))
 	cmd.AddCommand(buildCredentialsListCommand(p))
@@ -22,6 +23,36 @@ func buildCredentialsCommands(p *porter.Porter) *cobra.Command {
 	return cmd
 }
 
+func buildCredentialsApplyCommand(p *porter.Porter) *cobra.Command {
+	opts := porter.ApplyOptions{}
+
+	cmd := &cobra.Command{
+		Use:   "apply FILE",
+		Short: "Apply changes to a credential set",
+		Long: `Apply changes from the specified file to a credential set. If the credential set doesn't already exist, it is created.
+
+Supported file extensions: json and yaml.
+
+You can use the generate and show commands to create the initial file:
+  porter credentials generate mycreds --reference SOME_BUNDLE
+  porter credentials show mycreds --output yaml > mycreds.yaml
+`,
+		Example: `  porter credentials apply mycreds.yaml`,
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return opts.Validate(p.Context, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return p.CredentialsApply(opts)
+		},
+	}
+
+	f := cmd.Flags()
+	f.StringVarP(&opts.Namespace, "namespace", "n", "",
+		"Namespace in which the credential set is defined. The namespace in the file, if set, takes precedence.")
+
+	return cmd
+}
+
 func buildCredentialsEditCommand(p *porter.Porter) *cobra.Command {
 	opts := porter.CredentialEditOptions{}
 
diff --git a/cmd/porter/parameters.go b/cmd/porter/parameters.go
index 00e7af21..f99d9488 100644
--- a/cmd/porter/parameters.go
+++ b/cmd/porter/parameters.go
@@ -13,6 +13,7 @@ func buildParametersCommands(p *porter.Porter) *cobra.Command {
 		Short:       "Parameter set commands",
 	}
 
+	cmd.AddCommand(buildParametersApplyCommand(p))
 	cmd.AddCommand(buildParametersEditCommand(p))
 	cmd.AddCommand(buildParametersGenerateCommand(p))
 	cmd.AddCommand(buildParametersListCommand(p))
@@ -22,6 +23,36 @@ func buildParametersCommands(p *porter.Porter) *cobra.Command {
 	return cmd
 }
 
+func buildParametersApplyCommand(p *porter.Porter) *cobra.Command {
+	opts := porter.ApplyOptions{}
+
+	cmd := &cobra.Command{
+		Use:   "apply FILE",
+		Short: "Apply changes to a parameter set",
+		Long: `Apply changes from the specified file to a parameter set. If the parameter set doesn't already exist, it is created.
+
+Supported file extensions: json and yaml.
+
+You can use the generate and show commands to create the initial file:
+  porter parameters generate myparams --reference SOME_BUNDLE
+  porter parameters show myparams --output yaml > myparams.yaml
+`,
+		Example: `  porter parameters apply myparams.yaml`,
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			return opts.Validate(p.Context, args)
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return p.ParametersApply(opts)
+		},
+	}
+
+	f := cmd.Flags()
+	f.StringVarP(&opts.Namespace, "namespace", "n", "",
+		"Namespace in which the parameter set is defined. The namespace in the file, if set, takes precedence.")
+
+	return cmd
+}
+
 func buildParametersEditCommand(p *porter.Porter) *cobra.Command {
 	opts := porter.ParameterEditOptions{}
 
diff --git a/docs/content/cli/credentials.md b/docs/content/cli/credentials.md
index 6810d8b1..39c94828 100644
--- a/docs/content/cli/credentials.md
+++ b/docs/content/cli/credentials.md
@@ -24,6 +24,7 @@ Credentials commands
 ### SEE ALSO
 
 * [porter](/cli/porter/)	 - I am porter 👩🏽‍✈️, the friendly neighborhood CNAB authoring tool
+* [porter credentials apply](/cli/porter_credentials_apply/)	 - Apply changes to a credential set
 * [porter credentials delete](/cli/porter_credentials_delete/)	 - Delete a Credential
 * [porter credentials edit](/cli/porter_credentials_edit/)	 - Edit Credential
 * [porter credentials generate](/cli/porter_credentials_generate/)	 - Generate Credential Set
diff --git a/docs/content/cli/credentials_apply.md b/docs/content/cli/credentials_apply.md
new file mode 100644
index 00000000..e039f19c
--- /dev/null
+++ b/docs/content/cli/credentials_apply.md
@@ -0,0 +1,49 @@
+---
+title: "porter credentials apply"
+slug: porter_credentials_apply
+url: /cli/porter_credentials_apply/
+---
+## porter credentials apply
+
+Apply changes to a credential set
+
+### Synopsis
+
+Apply changes from the specified file to a credential set. If the credential set doesn't already exist, it is created.
+
+Supported file extensions: json and yaml.
+
+You can use the generate and show commands to create the initial file:
+  porter credentials generate mycreds --reference SOME_BUNDLE
+  porter credentials show mycreds --output yaml > mycreds.yaml
+
+
+```
+porter credentials apply FILE [flags]
+```
+
+### Examples
+
+```
+  porter credentials apply mycreds.yaml
+```
+
+### Options
+
+```
+  -h, --help               help for apply
+  -n, --namespace string   Namespace in which the credential set is defined. The namespace in the file, if set, takes precedence.
+```
+
+### Options inherited from parent commands
+
+```
+      --debug                  Enable debug logging
+      --debug-plugins          Enable plugin debug logging
+      --experimental strings   Comma separated list of experimental features to enable. See https://porter.sh/configuration/#experimental-feature-flags for available feature flags.
+```
+
+### SEE ALSO
+
+* [porter credentials](/cli/porter_credentials/)	 - Credentials commands
+
diff --git a/docs/content/cli/parameters.md b/docs/content/cli/parameters.md
index 30b8249e..16dafc90 100644
--- a/docs/content/cli/parameters.md
+++ b/docs/content/cli/parameters.md
@@ -24,6 +24,7 @@ Parameter set commands
 ### SEE ALSO
 
 * [porter](/cli/porter/)	 - I am porter 👩🏽‍✈️, the friendly neighborhood CNAB authoring tool
+* [porter parameters apply](/cli/porter_parameters_apply/)	 - Apply changes to a parameter set
 * [porter parameters delete](/cli/porter_parameters_delete/)	 - Delete a Parameter Set
 * [porter parameters edit](/cli/porter_parameters_edit/)	 - Edit Parameter Set
 * [porter parameters generate](/cli/porter_parameters_generate/)	 - Generate Parameter Set
diff --git a/docs/content/cli/parameters_apply.md b/docs/content/cli/parameters_apply.md
new file mode 100644
index 00000000..1793381d
--- /dev/null
+++ b/docs/content/cli/parameters_apply.md
@@ -0,0 +1,49 @@
+---
+title: "porter parameters apply"
+slug: porter_parameters_apply
+url: /cli/porter_parameters_apply/
+---
+## porter parameters apply
+
+Apply changes to a parameter set
+
+### Synopsis
+
+Apply changes from the specified file to a parameter set. If the parameter set doesn't already exist, it is created.
+
+Supported file extensions: json and yaml.
+
+You can use the generate and show commands to create the initial file:
+  porter parameters generate myparams --reference SOME_BUNDLE
+  porter parameters show myparams --output yaml > myparams.yaml
+
+
+```
+porter parameters apply FILE [flags]
+```
+
+### Examples
+
+```
+  porter parameters apply myparams.yaml
+```
+
+### Options
+
+```
+  -h, --help               help for apply
+  -n, --namespace string   Namespace in which the parameter set is defined. The namespace in the file, if set, takes precedence.
+```
+
+### Options inherited from parent commands
+
+```
+      --debug                  Enable debug logging
+      --debug-plugins          Enable plugin debug logging
+      --experimental strings   Comma separated list of experimental features to enable. See https://porter.sh/configuration/#experimental-feature-flags for available feature flags.
+```
+
+### SEE ALSO
+
+* [porter parameters](/cli/porter_parameters/)	 - Parameter set commands
+
diff --git a/docs/content/configuration.md b/docs/content/configuration.md
index d701f750..e1b7222d 100644
--- a/docs/content/configuration.md
+++ b/docs/content/configuration.md
@@ -19,6 +19,7 @@ You may set a default value for a configuration value in the config file,
 override it in a shell session with an environment variable and then override
 both in a particular command with a flag.
 
+* [Set Current Namespace](#namespace)
 * [Enable Debug Output](#debug)
 * [Debug Plugins](#debug-plugins)
 * [Output Formatting](#output)
@@ -26,6 +27,9 @@ both in a particular command with a flag.
 
 ## Flags
 
+### Namespace
+`--namespace` specifies the current namespace.
+
 ### Debug
 
 `--debug` is a flag that is understood not only by the porter client but also the
@@ -86,6 +90,7 @@ Below is an example configuration file in TOML
 
 **~/.porter/config.toml**
 ```toml
+namespace = "dev"
 debug = true
 debug-plugins = true
 output = "json"
diff --git a/pkg/cnab/provider/credentials.go b/pkg/cnab/provider/credentials.go
index 4d5c19ab..743a2615 100644
--- a/pkg/cnab/provider/credentials.go
+++ b/pkg/cnab/provider/credentials.go
@@ -1,13 +1,15 @@
 package cnabprovider
 
 import (
-	"encoding/json"
 	"path/filepath"
 	"strings"
 
 	"get.porter.sh/porter/pkg/credentials"
+	"get.porter.sh/porter/pkg/encoding"
 	"get.porter.sh/porter/pkg/secrets"
+	"get.porter.sh/porter/pkg/storage"
 	"github.com/cnabio/cnab-go/bundle"
+	"github.com/globalsign/mgo/bson"
 	"github.com/pkg/errors"
 )
 
@@ -26,8 +28,21 @@ func (r *Runtime) loadCredentials(b bundle.Bundle, args ActionArguments) (secret
 		if r.isPathy(name) {
 			cset, err = r.loadCredentialFromFile(name)
 		} else {
-			cset, err = r.credentials.GetCredentialSet(args.Namespace, name)
+			// Try to get the creds in the local namespace first, fallback to the global creds
+			query := storage.FindOptions{
+				Sort: []string{"-namespace"},
+				Filter: bson.M{
+					"name": name,
+					"$or": []bson.M{
+						{"namespace": ""},
+						{"namespace": args.Namespace},
+					},
+				},
+			}
+			store := r.credentials.GetDataStore()
+			err = store.FindOne(credentials.CollectionCredentials, query, &cset)
 		}
+
 		if err != nil {
 			return nil, err
 		}
@@ -50,12 +65,7 @@ func (r *Runtime) isPathy(name string) bool {
 }
 
 func (r *Runtime) loadCredentialFromFile(path string) (credentials.CredentialSet, error) {
-	data, err := r.FileSystem.ReadFile(path)
-	if err != nil {
-		return credentials.CredentialSet{}, errors.Wrapf(err, "could not read file %s", path)
-	}
-
 	var cs credentials.CredentialSet
-	err = json.Unmarshal(data, &cs)
+	err := encoding.UnmarshalFile(r.FileSystem, path, &cs)
 	return cs, errors.Wrapf(err, "error loading credential set in %s", path)
 }
diff --git a/pkg/credentials/credential_store.go b/pkg/credentials/credential_store.go
index 0ba59602..be81e635 100644
--- a/pkg/credentials/credential_store.go
+++ b/pkg/credentials/credential_store.go
@@ -45,6 +45,10 @@ func (s CredentialStore) Initialize() error {
 	return err
 }
 
+func (s CredentialStore) GetDataStore() storage.Store {
+	return s.Documents
+}
+
 /*
 	Secrets
 */
diff --git a/pkg/credentials/provider.go b/pkg/credentials/provider.go
index 5522d5ca..c4aac74d 100644
--- a/pkg/credentials/provider.go
+++ b/pkg/credentials/provider.go
@@ -1,9 +1,13 @@
 package credentials
 
-import "get.porter.sh/porter/pkg/secrets"
+import (
+	"get.porter.sh/porter/pkg/secrets"
+	"get.porter.sh/porter/pkg/storage"
+)
 
 // Provider is Porter's interface for managing and resolving credentials.
 type Provider interface {
+	GetDataStore() storage.Store
 	ResolveAll(creds CredentialSet) (secrets.Set, error)
 	Validate(creds CredentialSet) error
 	InsertCredentialSet(creds CredentialSet) error
diff --git a/pkg/parameters/parameter_store.go b/pkg/parameters/parameter_store.go
index 893525b4..050451dc 100644
--- a/pkg/parameters/parameter_store.go
+++ b/pkg/parameters/parameter_store.go
@@ -45,6 +45,10 @@ func (s ParameterStore) Initialize() error {
 	return err
 }
 
+func (s ParameterStore) GetDataStore() storage.Store {
+	return s.Documents
+}
+
 func (s ParameterStore) ResolveAll(params ParameterSet) (secrets.Set, error) {
 	resolvedParams := make(secrets.Set)
 	var resolveErrors error
diff --git a/pkg/parameters/provider.go b/pkg/parameters/provider.go
index 14a02cae..e0d7c9a7 100644
--- a/pkg/parameters/provider.go
+++ b/pkg/parameters/provider.go
@@ -2,10 +2,13 @@ package parameters
 
 import (
 	"get.porter.sh/porter/pkg/secrets"
+	"get.porter.sh/porter/pkg/storage"
 )
 
 // Provider interface for managing sets of parameters.
 type Provider interface {
+	GetDataStore() storage.Store
+
 	// ResolveAll parameter values in the parameter set.
 	ResolveAll(params ParameterSet) (secrets.Set, error)
 
diff --git a/pkg/porter/credentials.go b/pkg/porter/credentials.go
index 3e815081..d14c3f7d 100644
--- a/pkg/porter/credentials.go
+++ b/pkg/porter/credentials.go
@@ -315,3 +315,68 @@ func validateCredentialName(args []string) error {
 		return errors.Errorf("only one positional argument may be specified, the credential name, but multiple were received: %s", args)
 	}
 }
+
+type ApplyOptions struct {
+	Namespace string
+	File      string
+}
+
+func (o *ApplyOptions) Validate(cxt *context.Context, args []string) error {
+	switch len(args) {
+	case 0:
+		return errors.New("a file argument is required")
+	case 1:
+		o.File = args[0]
+	default:
+		return errors.New("only one file argument may be specified")
+	}
+
+	info, err := cxt.FileSystem.Stat(o.File)
+	if err != nil {
+		return errors.Wrapf(err, "invalid file argument %s", o.File)
+	}
+	if info.IsDir() {
+		return errors.Errorf("invalid file argument %s, must be a file not a directory", o.File)
+	}
+
+	return nil
+}
+
+func (p *Porter) CredentialsApply(o ApplyOptions) error {
+	namespace, err := p.getNamespaceFromFile(o)
+
+	var creds credentials.CredentialSet
+	err = encoding.UnmarshalFile(p.FileSystem, o.File, &creds)
+	if err != nil {
+		return errors.Wrapf(err, "could not load %s as a credential set", o.File)
+	}
+
+	creds.Namespace = namespace
+	creds.Modified = time.Now()
+
+	err = p.Credentials.Validate(creds)
+	if err != nil {
+		return errors.Wrap(err, "credential set is invalid")
+	}
+
+	return p.Credentials.UpsertCredentialSet(creds)
+}
+
+func (p *Porter) getNamespaceFromFile(o ApplyOptions) (string, error) {
+	// Check if the namespace was set in the file, if not, use the namespace set on the command
+	var raw map[string]interface{}
+	err := encoding.UnmarshalFile(p.FileSystem, o.File, &raw)
+	if err != nil {
+		return "", errors.Wrapf(err, "invalid --file '%s'", o.File)
+	}
+
+	if rawNamespace, ok := raw["namespace"]; ok {
+		if ns, ok := rawNamespace.(string); ok {
+			return ns, nil
+		} else {
+			return "", errors.New("invalid namespace specified in file, must be a string")
+		}
+	}
+
+	return o.Namespace, nil
+}
diff --git a/pkg/porter/parameters.go b/pkg/porter/parameters.go
index 73389d45..4789f238 100644
--- a/pkg/porter/parameters.go
+++ b/pkg/porter/parameters.go
@@ -18,6 +18,7 @@ import (
 	"get.porter.sh/porter/pkg/secrets"
 	"get.porter.sh/porter/pkg/storage"
 	"github.com/cnabio/cnab-go/bundle"
+	"github.com/globalsign/mgo/bson"
 
 	dtprinter "github.com/carolynvs/datetime-printer"
 	tablewriter "github.com/olekukonko/tablewriter"
@@ -339,7 +340,19 @@ func (p *Porter) loadParameterSets(namespace string, params []string) (secrets.S
 		if strings.Contains(name, string(filepath.Separator)) {
 			pset, err = p.loadParameterFromFile(name)
 		} else {
-			pset, err = p.Parameters.GetParameterSet(namespace, name)
+			// Try to get the params in the local namespace first, fallback to the global creds
+			query := storage.FindOptions{
+				Sort: []string{"-namespace"},
+				Filter: bson.M{
+					"name": name,
+					"$or": []bson.M{
+						{"namespace": ""},
+						{"namespace": namespace},
+					},
+				},
+			}
+			store := p.Parameters.GetDataStore()
+			err = store.FindOne(parameters.CollectionParameters, query, &pset)
 		}
 		if err != nil {
 			return nil, err
@@ -376,13 +389,8 @@ func (p *Porter) loadParameterSets(namespace string, params []string) (secrets.S
 }
 
 func (p *Porter) loadParameterFromFile(path string) (parameters.ParameterSet, error) {
-	data, err := p.FileSystem.ReadFile(path)
-	if err != nil {
-		return parameters.ParameterSet{}, errors.Wrapf(err, "could not read file %s", path)
-	}
-
 	var cs parameters.ParameterSet
-	err = json.Unmarshal(data, &cs)
+	err := encoding.UnmarshalFile(p.FileSystem, path, &cs)
 	return cs, errors.Wrapf(err, "error loading parameter set in %s", path)
 }
 
@@ -483,3 +491,23 @@ func (p *Porter) printDisplayValuesTable(values []DisplayValue) error {
 
 	return nil
 }
+
+func (p *Porter) ParametersApply(o ApplyOptions) error {
+	namespace, err := p.getNamespaceFromFile(o)
+
+	var params parameters.ParameterSet
+	err = encoding.UnmarshalFile(p.FileSystem, o.File, &params)
+	if err != nil {
+		return errors.Wrapf(err, "could not load %s as a parameter set", o.File)
+	}
+
+	params.Namespace = namespace
+	params.Modified = time.Now()
+
+	err = p.Parameters.Validate(params)
+	if err != nil {
+		return errors.Wrap(err, "parameter set is invalid")
+	}
+
+	return p.Parameters.UpsertParameterSet(params)
+}


Test patch:
diff --git a/pkg/porter/credentials_test.go b/pkg/porter/credentials_test.go
index 1ec21b80..cf991c14 100644
--- a/pkg/porter/credentials_test.go
+++ b/pkg/porter/credentials_test.go
@@ -6,6 +6,7 @@ import (
 	"testing"
 	"time"
 
+	"get.porter.sh/porter/pkg/context"
 	"get.porter.sh/porter/pkg/credentials"
 	"get.porter.sh/porter/pkg/printer"
 	"get.porter.sh/porter/pkg/storage"
@@ -420,3 +421,38 @@ func TestCredentialsDelete(t *testing.T) {
 		})
 	}
 }
+
+func TestApplyOptions_Validate(t *testing.T) {
+	t.Run("no file specified", func(t *testing.T) {
+		tc := context.NewTestContext(t)
+		opts := ApplyOptions{}
+		err := opts.Validate(tc.Context, nil)
+		require.EqualError(t, err, "a file argument is required")
+	})
+
+	t.Run("one file specified", func(t *testing.T) {
+		tc := context.NewTestContext(t)
+		tc.AddTestFileFromRoot("tests/testdata/creds/mybuns.yaml", "mybuns.yaml")
+		opts := ApplyOptions{}
+		err := opts.Validate(tc.Context, []string{"mybuns.yaml"})
+		require.NoError(t, err)
+		assert.Equal(t, "mybuns.yaml", opts.File)
+	})
+
+	t.Run("missing file specified", func(t *testing.T) {
+		tc := context.NewTestContext(t)
+		opts := ApplyOptions{}
+		err := opts.Validate(tc.Context, []string{"mybuns.yaml"})
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "invalid file argument")
+	})
+
+	t.Run("two files specified", func(t *testing.T) {
+		tc := context.NewTestContext(t)
+		opts := ApplyOptions{}
+		err := opts.Validate(tc.Context, []string{"mybuns.yaml", "yourbuns.yaml"})
+		require.Error(t, err)
+		require.Contains(t, err.Error(), "only one file argument may be specified")
+	})
+
+}
diff --git a/tests/smoke/hello_test.go b/tests/smoke/hello_test.go
index 7305ff55..71acd991 100644
--- a/tests/smoke/hello_test.go
+++ b/tests/smoke/hello_test.go
@@ -17,6 +17,10 @@ func TestHelloBundle(t *testing.T) {
 	// I am always using require, so that we stop immediately upon an error
 	// A long test is hard to debug when it fails in the middle and keeps going
 
+	// This variable isn't set on windows and the mybuns bundle relies on it
+	os.Setenv("USER", "porterci")
+	defer os.Unsetenv("USER")
+
 	test, err := NewTest(t)
 	defer test.Teardown()
 	require.NoError(t, err, "test setup failed")
@@ -32,10 +36,20 @@ func TestHelloBundle(t *testing.T) {
 	os.Chdir(test.TestDir)
 
 	test.RequirePorter("install", "hello", "--reference", "getporter/porter-hello:v0.1.1", "--namespace=")
-	test.RequirePorter("install", "mybuns", "--reference", ref, "--namespace=dev", "--label", "test=true")
+
+	// Import a parameter and credential set for the bundle
+	shx.Copy(filepath.Join(test.RepoRoot, "tests/testdata/params/mybuns.yaml"), "./myparams.yaml")
+	shx.Copy(filepath.Join(test.RepoRoot, "tests/testdata/creds/mybuns.yaml"), "./mycreds.yaml")
+	test.RequirePorter("parameters", "apply", "myparams.yaml", "--namespace=")
+	test.RequirePorter("credentials", "apply", "mycreds.yaml", "--namespace=")
+
+	// Install the bundle and verify the correct output is printed
+	output, err := test.Porter("install", "mybuns", "--reference", ref, "--label", "test=true", "-p=mybuns", "-c=mybuns").OutputV()
+	require.NoError(t, err)
+	require.Contains(t, output, "Hello, *******")
 
 	// Should not see the mybuns installation in the global namespace
-	output, err := test.Porter("list", "--namespace=", "--output=json").Output()
+	output, err = test.Porter("list", "--namespace=", "--output=json").Output()
 	require.NoError(t, err)
 	var installations []map[string]interface{}
 	require.NoError(t, json.Unmarshal([]byte(output), &installations))
@@ -56,7 +70,7 @@ func TestHelloBundle(t *testing.T) {
 	status := installations[0]["status"].(map[string]interface{})
 	require.Equal(t, true, status["installationCompleted"], "expected the installation to complete successfully")
 
-	test.RequirePorter("install", "mybuns", "--reference", ref, "--namespace=test")
+	test.RequirePorter("install", "mybuns", "--reference", ref, "--namespace=test", "-c=mybuns")
 
 	// Should see the other installation in the test namespace
 	output, err = test.Porter("list", "--namespace=test", "--output=json").Output()
@@ -83,16 +97,16 @@ func TestHelloBundle(t *testing.T) {
 
 	// Validate that we can't accidentally overwrite an installation
 	var outputE bytes.Buffer
-	_, _, err = test.Porter("install", "mybuns", "--reference", ref, "--namespace=dev").Stderr(&outputE).Exec()
+	_, _, err = test.Porter("install", "mybuns", "--reference", ref, "--namespace=dev", "-c=mybuns").Stderr(&outputE).Exec()
 	require.Error(t, err, "porter should have prevented overwriting an installation")
 	require.Contains(t, outputE.String(), "The installation has already been successfully installed")
 
 	test.RequirePorter("installation", "show", "mybuns", "--namespace=dev")
 
-	test.RequirePorter("upgrade", "mybuns", "--namespace=dev")
+	test.RequirePorter("upgrade", "mybuns", "--namespace=dev", "-c=mybuns")
 	test.RequirePorter("installation", "show", "mybuns", "--namespace=dev")
 
-	test.RequirePorter("uninstall", "mybuns", "--namespace=dev")
+	test.RequirePorter("uninstall", "mybuns", "--namespace=dev", "-c=mybuns")
 	test.RequirePorter("installation", "show", "mybuns", "--namespace=dev")
 	test.RequirePorter("installation", "delete", "mybuns", "--namespace=dev")
 
diff --git a/tests/smoke/main_test.go b/tests/smoke/main_test.go
index 4728787c..a9e2639b 100644
--- a/tests/smoke/main_test.go
+++ b/tests/smoke/main_test.go
@@ -25,6 +25,10 @@ type Test struct {
 	// PorterPath is the path to the porter binary used for the test.
 	PorterPath string
 
+	// RepoRoot is the root of the porter repository.
+	// Useful for constructing paths that won't break when the test is moved.
+	RepoRoot string
+
 	// T is the test helper.
 	T *testing.T
 }
@@ -117,6 +121,7 @@ func (t *Test) createPorterHome() error {
 
 	cxt := context.NewTestContext(t.T)
 	cxt.UseFilesystem()
+	t.RepoRoot = cxt.FindRepoRoot()
 
 	ext := ""
 	if runtime.GOOS == "windows" {
@@ -139,5 +144,6 @@ func (t *Test) createPorterHome() error {
 	}
 
 	cxt.CopyFile("testdata/config.toml", filepath.Join(t.PorterHomeDir, "config.toml"))
+
 	return nil
 }
diff --git a/tests/smoke/testdata/config.toml b/tests/smoke/testdata/config.toml
index baf2d1a1..6847e684 100644
--- a/tests/smoke/testdata/config.toml
+++ b/tests/smoke/testdata/config.toml
@@ -1,3 +1,4 @@
+namespace = "dev"
 debug = false
 debug-plugins = false
 default-storage = "testdb"
diff --git a/tests/testdata/creds/mybuns.yaml b/tests/testdata/creds/mybuns.yaml
new file mode 100644
index 00000000..c889156b
--- /dev/null
+++ b/tests/testdata/creds/mybuns.yaml
@@ -0,0 +1,9 @@
+schemaVersion: 1.0.0
+name: mybuns
+created: 2021-08-10T13:55:14.948449-05:00
+modified: 2021-08-10T13:55:14.948449-05:00
+credentials:
+  - name: username
+    source:
+      env: USER
+
diff --git a/tests/testdata/mybuns/Dockerfile.tmpl b/tests/testdata/mybuns/Dockerfile.tmpl
index d701d7d4..552787d1 100644
--- a/tests/testdata/mybuns/Dockerfile.tmpl
+++ b/tests/testdata/mybuns/Dockerfile.tmpl
@@ -1,8 +1,11 @@
+# syntax=docker/dockerfile:1.2
 FROM debian:stretch-slim
 
 ARG BUNDLE_DIR
 
-RUN apt-get update && apt-get install -y ca-certificates
+RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
+RUN --mount=type=cache,target=/var/cache/apt --mount=type=cache,target=/var/lib/apt \
+    apt-get update && apt-get install -y ca-certificates
 
 ENV CUSTOM_VAR=boop
 ENV USERNAME=root
diff --git a/tests/testdata/mybuns/porter.yaml b/tests/testdata/mybuns/porter.yaml
index 75ed787f..43f1431e 100644
--- a/tests/testdata/mybuns/porter.yaml
+++ b/tests/testdata/mybuns/porter.yaml
@@ -10,7 +10,6 @@ credentials:
   - name: username
     description: "The name you want on the audit log"
     env: USERNAME
-    required: false
 
 parameters:
   - name: log_level
diff --git a/tests/testdata/params/mybuns.yaml b/tests/testdata/params/mybuns.yaml
new file mode 100644
index 00000000..398e86f3
--- /dev/null
+++ b/tests/testdata/params/mybuns.yaml
@@ -0,0 +1,8 @@
+schemaVersion: 1.0.0
+name: mybuns
+created: 2021-08-10T13:55:43.139903-05:00
+modified: 2021-08-10T13:55:56.109829-05:00
+parameters:
+  - name: log_level
+    source:
+      value: "11"