diff --git a/Gopkg.lock b/Gopkg.lock index ee8d24a4e..9bd4a2492 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -214,9 +214,12 @@ [[projects]] branch = "custom-extensions" - digest = "1:dfe6ce9e92d2315c432afc126c2527b99b1b2212f4af7df1629c818d0d8dafbe" + digest = "1:9bc1a8079ba8252a4bd75a1e25e9bacb6267d6272d91985f0301d77ad8b1375c" name = "github.com/deislabs/cnab-go" - packages = ["bundle"] + packages = [ + "bundle", + "credentials", + ] pruneopts = "NUT" revision = "1aa435456c4b74dba1d2276cb45a8482e33477a9" @@ -1401,6 +1404,7 @@ "github.com/cbroglie/mustache", "github.com/containerd/containerd", "github.com/deislabs/cnab-go/bundle", + "github.com/deislabs/cnab-go/credentials", "github.com/deislabs/duffle/pkg/action", "github.com/deislabs/duffle/pkg/bundle", "github.com/deislabs/duffle/pkg/claim", diff --git a/cmd/porter/credentials.go b/cmd/porter/credentials.go index 247f23730..2f3e60232 100644 --- a/cmd/porter/credentials.go +++ b/cmd/porter/credentials.go @@ -9,7 +9,7 @@ import ( func buildCredentialsCommand(p *porter.Porter) *cobra.Command { cmd := &cobra.Command{ Use: "credentials", - Aliases: []string{"cred"}, + Aliases: []string{"credential", "cred", "creds"}, Annotations: map[string]string{"group": "resource"}, Short: "Credentials commands", } @@ -102,25 +102,28 @@ will then provide it to the bundle in the correct location. `, } func buildCredentialsListCommand(p *porter.Porter) *cobra.Command { - opts := struct { - rawFormat string - format printer.Format - }{} + opts := porter.ListOptions{} + cmd := &cobra.Command{ - Use: "list", - Short: "List credentials", - Hidden: true, + Use: "list", + Aliases: []string{"ls"}, + Short: "List credentials", + Long: `List named sets of credentials defined by the user.`, + Example: ` porter credentials list [-o table|json|yaml]`, PreRunE: func(cmd *cobra.Command, args []string) error { var err error - opts.format, err = printer.ParseFormat(opts.rawFormat) + opts.Format, err = printer.ParseFormat(opts.RawFormat) return err }, RunE: func(cmd *cobra.Command, args []string) error { - p.PrintVersion() - return nil + return p.ListCredentials(printer.PrintOptions{Format: opts.Format}) }, } + f := cmd.Flags() + f.StringVarP(&opts.RawFormat, "output", "o", "table", + "Specify an output format. Allowed values: table, json, yaml") + return cmd } diff --git a/pkg/credentials/generator.go b/pkg/credentialsgenerator/generator.go similarity index 97% rename from pkg/credentials/generator.go rename to pkg/credentialsgenerator/generator.go index 1747dafc4..c762e5598 100644 --- a/pkg/credentials/generator.go +++ b/pkg/credentialsgenerator/generator.go @@ -1,4 +1,4 @@ -package credentials +package credentialsgenerator import ( "errors" @@ -6,8 +6,8 @@ import ( "sort" "strings" + "github.com/deislabs/cnab-go/credentials" "github.com/deislabs/duffle/pkg/bundle" - "github.com/deislabs/duffle/pkg/credentials" survey "gopkg.in/AlecAivazis/survey.v1" ) diff --git a/pkg/credentials/generator_test.go b/pkg/credentialsgenerator/generator_test.go similarity index 98% rename from pkg/credentials/generator_test.go rename to pkg/credentialsgenerator/generator_test.go index e62a3799b..10bdecac8 100644 --- a/pkg/credentials/generator_test.go +++ b/pkg/credentialsgenerator/generator_test.go @@ -1,4 +1,4 @@ -package credentials +package credentialsgenerator import ( "fmt" diff --git a/pkg/porter/credentials.go b/pkg/porter/credentials.go index de81e99e0..d3e62715b 100644 --- a/pkg/porter/credentials.go +++ b/pkg/porter/credentials.go @@ -2,16 +2,106 @@ package porter import ( "fmt" + "os" + "sort" + "time" "github.com/deislabs/porter/pkg/context" - "github.com/deislabs/porter/pkg/credentials" + "github.com/deislabs/porter/pkg/credentialsgenerator" "github.com/deislabs/porter/pkg/printer" + + dtprinter "github.com/carolynvs/datetime-printer" + credentials "github.com/deislabs/cnab-go/credentials" "github.com/pkg/errors" yaml "gopkg.in/yaml.v2" ) -func (p *Porter) PrintCredentials(opts printer.PrintOptions) error { - return nil +// CredentialsFile represents a CNAB credentials file and corresponding metadata +type CredentialsFile struct { + Name string + Modified time.Time +} + +// CredentialsFileList is a slice of CredentialsFiles +type CredentialsFileList []CredentialsFile + +func (l CredentialsFileList) Len() int { + return len(l) +} +func (l CredentialsFileList) Swap(i, j int) { + l[i], l[j] = l[j], l[i] +} +func (l CredentialsFileList) Less(i, j int) bool { + return l[i].Modified.Before(l[j].Modified) +} + +// fetchCredentials fetches all credentials from the designated credentials dir +func (p *Porter) fetchCredentials() (*CredentialsFileList, error) { + credsDir, err := p.Config.GetCredentialsDir() + if err != nil { + return &CredentialsFileList{}, errors.Wrap(err, "unable to determine credentials directory") + } + + credentialsFiles := CredentialsFileList{} + if ok, _ := p.Context.FileSystem.DirExists(credsDir); ok { + p.Context.FileSystem.Walk(credsDir, func(path string, info os.FileInfo, err error) error { + if !info.IsDir() { + credSet := &credentials.CredentialSet{} + data, err := p.Context.FileSystem.ReadFile(path) + if err != nil { + if p.Debug { + fmt.Fprintf(p.Err, "unable to load credential set from %s: %s\n", path, err) + } + return nil + } + if err = yaml.Unmarshal(data, credSet); err != nil { + if p.Debug { + fmt.Fprintf(p.Err, "unable to unmarshal credential set from file %s: %s\n", info.Name(), err) + } + return nil + } + credentialsFiles = append(credentialsFiles, + CredentialsFile{Name: credSet.Name, Modified: info.ModTime()}) + } + return nil + }) + sort.Sort(sort.Reverse(credentialsFiles)) + } + return &credentialsFiles, nil +} + +// ListCredentials lists credentials using the provided printer.PrintOptions +func (p *Porter) ListCredentials(opts printer.PrintOptions) error { + credentialsFiles, err := p.fetchCredentials() + if err != nil { + return errors.Wrap(err, "unable to fetch credentials") + } + + switch opts.Format { + case printer.FormatJson: + return printer.PrintJson(p.Out, *credentialsFiles) + case printer.FormatYaml: + return printer.PrintYaml(p.Out, *credentialsFiles) + case printer.FormatTable: + // have every row use the same "now" starting ... NOW! + now := time.Now() + tp := dtprinter.DateTimePrinter{ + Now: func() time.Time { return now }, + } + + printCredRow := + func(v interface{}) []interface{} { + cr, ok := v.(CredentialsFile) + if !ok { + return nil + } + return []interface{}{cr.Name, tp.Format(cr.Modified)} + } + return printer.PrintTable(p.Out, *credentialsFiles, printCredRow, + "NAME", "MODIFIED") + default: + return fmt.Errorf("invalid format: %s", opts.Format) + } } type CredentialOptions struct { @@ -56,7 +146,7 @@ func (p *Porter) GenerateCredentials(opts CredentialOptions) error { if name == "" { name = bundle.Name } - genOpts := credentials.GenerateOptions{ + genOpts := credentialsgenerator.GenerateOptions{ Name: name, Credentials: bundle.Credentials, Silent: opts.Silent, @@ -64,7 +154,7 @@ func (p *Porter) GenerateCredentials(opts CredentialOptions) error { fmt.Fprintf(p.Out, "Generating new credential %s from bundle %s\n", genOpts.Name, bundle.Name) fmt.Fprintf(p.Out, "==> %d credentials required for bundle %s\n", len(genOpts.Credentials), bundle.Name) - cs, err := credentials.GenerateCredentials(genOpts) + cs, err := credentialsgenerator.GenerateCredentials(genOpts) if err != nil { return errors.Wrap(err, "unable to generate credentials") } diff --git a/pkg/porter/credentials_test.go b/pkg/porter/credentials_test.go index 8f97b3ef9..e8d9ca350 100644 --- a/pkg/porter/credentials_test.go +++ b/pkg/porter/credentials_test.go @@ -3,8 +3,10 @@ package porter import ( "testing" - "github.com/deislabs/duffle/pkg/bundle" cnabprovider "github.com/deislabs/porter/pkg/cnab/provider" + printer "github.com/deislabs/porter/pkg/printer" + + "github.com/deislabs/duffle/pkg/bundle" "github.com/stretchr/testify/require" ) @@ -81,3 +83,151 @@ func TestGenerateBadNameProvided(t *testing.T) { _, err = p.Porter.Context.FileSystem.Stat(path) require.Error(t, err, "expected the file %s to not exist", path) } + +type CredentialsListTest struct { + name string + format printer.Format + wantContains []string + errorMsg string +} + +func TestCredentialsList_None(t *testing.T) { + testcases := []CredentialsListTest{ + { + name: "invalid format", + format: "wingdings", + wantContains: []string{}, + errorMsg: "invalid format: wingdings", + }, + { + name: "json", + format: printer.FormatJson, + wantContains: []string{"[]\n"}, + errorMsg: "", + }, + { + name: "yaml", + format: printer.FormatYaml, + wantContains: []string{"[]\n\n"}, + errorMsg: "", + }, + { + name: "table", + format: printer.FormatTable, + wantContains: []string{"NAME MODIFIED\n"}, + errorMsg: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + p := NewTestPorter(t) + p.CNAB = &TestCNABProvider{} + + listOpts := printer.PrintOptions{ + Format: tc.format, + } + err := p.ListCredentials(listOpts) + if tc.errorMsg != "" { + require.Equal(t, err.Error(), tc.errorMsg) + } else { + require.NoError(t, err, "no error should have existed") + } + + gotOutput := p.TestConfig.TestContext.GetOutput() + for _, contains := range tc.wantContains { + require.Contains(t, gotOutput, contains) + } + }) + } +} + +func TestCredentialsList(t *testing.T) { + testcases := []CredentialsListTest{ + { + name: "json", + format: printer.FormatJson, + wantContains: []string{`"Name": "kool-kreds"`}, + errorMsg: "", + }, + { + name: "yaml", + format: printer.FormatYaml, + wantContains: []string{`- name: kool-kreds`}, + errorMsg: "", + }, + { + name: "table", + format: printer.FormatTable, + wantContains: []string{`NAME MODIFIED +kool-kreds now`}, + errorMsg: "", + }, + { + name: "error", + format: printer.FormatTable, + wantContains: []string{}, + errorMsg: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + p := NewTestPorter(t) + p.CNAB = &TestCNABProvider{} + + credsDir, err := p.TestConfig.GetCredentialsDir() + require.NoError(t, err, "no error should have existed") + + p.TestConfig.TestContext.AddTestDirectory("testdata/test-creds", credsDir) + + listOpts := printer.PrintOptions{ + Format: tc.format, + } + err = p.ListCredentials(listOpts) + require.NoError(t, err, "no error should have existed") + + gotOutput := p.TestConfig.TestContext.GetOutput() + for _, contains := range tc.wantContains { + require.Contains(t, gotOutput, contains) + } + }) + } +} + +func TestCredentialsList_BadCred(t *testing.T) { + testcases := []CredentialsListTest{ + { + name: "unmarshal error", + format: printer.FormatTable, + wantContains: []string{ + "unable to unmarshal credential set from file bad-creds.yaml: yaml: unmarshal errors", + `NAME MODIFIED +good-creds now`}, + errorMsg: "", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + p := NewTestPorter(t) + p.CNAB = &TestCNABProvider{} + + credsDir, err := p.TestConfig.GetCredentialsDir() + require.NoError(t, err, "no error should have existed") + + p.TestConfig.TestContext.AddTestDirectory("testdata/good-and-bad-test-creds", credsDir) + + listOpts := printer.PrintOptions{ + Format: tc.format, + } + err = p.ListCredentials(listOpts) + require.NoError(t, err, "no error should have existed") + + gotOutput := p.TestConfig.TestContext.GetOutput() + for _, contains := range tc.wantContains { + require.Contains(t, gotOutput, contains) + } + }) + } +} diff --git a/pkg/porter/testdata/good-and-bad-test-creds/bad-creds.yaml b/pkg/porter/testdata/good-and-bad-test-creds/bad-creds.yaml new file mode 100644 index 000000000..30ec8e59a --- /dev/null +++ b/pkg/porter/testdata/good-and-bad-test-creds/bad-creds.yaml @@ -0,0 +1 @@ +these are some bad creds \ No newline at end of file diff --git a/pkg/porter/testdata/good-and-bad-test-creds/good-creds.yaml b/pkg/porter/testdata/good-and-bad-test-creds/good-creds.yaml new file mode 100644 index 000000000..7eedcb0dc --- /dev/null +++ b/pkg/porter/testdata/good-and-bad-test-creds/good-creds.yaml @@ -0,0 +1,5 @@ +name: good-creds +credentials: +- name: good-cred + source: + env: GOOD_CRED diff --git a/pkg/porter/testdata/test-creds/kool-kreds.yaml b/pkg/porter/testdata/test-creds/kool-kreds.yaml new file mode 100644 index 000000000..43775b6ea --- /dev/null +++ b/pkg/porter/testdata/test-creds/kool-kreds.yaml @@ -0,0 +1,5 @@ +name: kool-kreds +credentials: +- name: kool-config + source: + path: /path/to/kool-config diff --git a/vendor/github.com/deislabs/cnab-go/credentials/credentialset.go b/vendor/github.com/deislabs/cnab-go/credentials/credentialset.go new file mode 100644 index 000000000..aa42467a9 --- /dev/null +++ b/vendor/github.com/deislabs/cnab-go/credentials/credentialset.go @@ -0,0 +1,183 @@ +package credentials + +import ( + "fmt" + "io/ioutil" + "os" + "os/exec" + "strings" + + "github.com/deislabs/cnab-go/bundle" + + yaml "gopkg.in/yaml.v2" +) + +// Set is an actual set of resolved credentials. +// This is the output of resolving a credentialset file. +type Set map[string]string + +// Expand expands the set into env vars and paths per the spec in the bundle. +// +// This matches the credentials required by the bundle to the credentials present +// in the credentialset, and then expands them per the definition in the Bundle. +func (s Set) Expand(b *bundle.Bundle, stateless bool) (env, files map[string]string, err error) { + env, files = map[string]string{}, map[string]string{} + for name, val := range b.Credentials { + src, ok := s[name] + if !ok { + if stateless { + continue + } + err = fmt.Errorf("credential %q is missing from the user-supplied credentials", name) + return + } + if val.EnvironmentVariable != "" { + env[val.EnvironmentVariable] = src + } + if val.Path != "" { + files[val.Path] = src + } + } + return +} + +// Merge merges a second Set into the base. +// +// Duplicate credential names are not allow and will result in an +// error, this is the case even if the values are identical. +func (s Set) Merge(s2 Set) error { + for k, v := range s2 { + if _, ok := s[k]; ok { + return fmt.Errorf("ambiguous credential resolution: %q is already present in base credential sets, cannot merge", k) + } + s[k] = v + } + return nil +} + +// CredentialSet represents a collection of credentials +type CredentialSet struct { + // Name is the name of the credentialset. + Name string `json:"name" yaml:"name"` + // Creadentials is a list of credential specs. + Credentials []CredentialStrategy `json:"credentials" yaml:"credentials"` +} + +// Load a CredentialSet from a file at a given path. +// +// It does not load the individual credentials. +func Load(path string) (*CredentialSet, error) { + cset := &CredentialSet{} + data, err := ioutil.ReadFile(path) + if err != nil { + return cset, err + } + return cset, yaml.Unmarshal(data, cset) +} + +// Validate compares the given credentials with the spec. +// +// This will result in an error only if: +// - a parameter in the spec is not present in the given set +// - a parameter in the given set does not match the format required by the spec +// +// It is allowed for spec to specify both an env var and a file. In such case, if +// the givn set provides either, it will be considered valid. +func Validate(given Set, spec map[string]bundle.Location) error { + for name := range spec { + if !isValidCred(given, name) { + return fmt.Errorf("bundle requires credential for %s", name) + } + } + return nil +} + +func isValidCred(haystack Set, needle string) bool { + for name := range haystack { + if name == needle { + return true + } + } + return false +} + +// Resolve looks up the credentials as described in Source, then copies +// the resulting value into the Value field of each credential strategy. +// +// The typical workflow for working with a credential set is: +// +// - Load the set +// - Validate the credentials against a spec +// - Resolve the credentials +// - Expand them into bundle values +func (c *CredentialSet) Resolve() (Set, error) { + l := len(c.Credentials) + res := make(map[string]string, l) + for i := 0; i < l; i++ { + cred := c.Credentials[i] + src := cred.Source + // Precedence is Command, Path, EnvVar, Value + switch { + case src.Command != "": + data, err := execCmd(src.Command) + if err != nil { + return res, err + } + cred.Value = string(data) + case src.Path != "": + data, err := ioutil.ReadFile(os.ExpandEnv(src.Path)) + if err != nil { + return res, fmt.Errorf("credential %q: %s", c.Credentials[i].Name, err) + } + cred.Value = string(data) + case src.EnvVar != "": + var ok bool + cred.Value, ok = os.LookupEnv(src.EnvVar) + if ok { + break + } + fallthrough + default: + cred.Value = src.Value + } + res[c.Credentials[i].Name] = cred.Value + } + return res, nil +} + +func execCmd(cmd string) ([]byte, error) { + parts := strings.Split(cmd, " ") + c := parts[0] + args := parts[1:] + run := exec.Command(c, args...) + + return run.CombinedOutput() +} + +// CredentialStrategy represents a source credential and the destination to which it should be sent. +type CredentialStrategy struct { + // Name is the name of the credential. + // Name is used to match a credential strategy to a bundle's credential. + Name string `json:"name" yaml:"name"` + // Source is the location of the credential. + // During resolution, the source will be loaded, and the result temporarily placed + // into Value. + Source Source `json:"source,omitempty" yaml:"source,omitempty"` + // Value holds the credential value. + // When a credential is loaded, it is loaded into this field. In all + // other cases, it is empty. This field is omitted during serialization. + Value string `json:"-" yaml:"-"` +} + +// Source represents a strategy for loading a credential from local host. +type Source struct { + Path string `json:"path,omitempty" yaml:"path,omitempty"` + Command string `json:"command,omitempty" yaml:"command,omitempty"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` + EnvVar string `json:"env,omitempty" yaml:"env,omitempty"` +} + +// Destination reprents a strategy for injecting a credential into an image. +type Destination struct { + Value string `json:"value,omitempty" yaml:"value,omitempty"` +}