diff --git a/app/app.go b/app/app.go index 130df6c..20e833c 100644 --- a/app/app.go +++ b/app/app.go @@ -730,8 +730,31 @@ func (a *App) ConfigPrefix() string { } // GetConfig returns a list of config parameters for the app -func (a *App) GetConfig() ([]*ssm.Parameter, error) { - return SsmParameters(a.Session, a.ConfigPrefix()) +func (a *App) GetConfig() (ConfigVariables, error) { + prefix := a.ConfigPrefix() + parameters, err := SsmParameters(a.Session, prefix) + if err != nil { + return nil, err + } + return NewConfigVariables(parameters), nil +} + +// GetConfigWithManaged returns a list of config parameters for the app with managed value populated +func (a *App) GetConfigWithManaged() (ConfigVariables, error) { + configVars, err := a.GetConfig() + if err != nil { + return nil, err + } + + ssmSvc := ssm.New(a.Session) + err = configVars.Transform(func(v *ConfigVariable) error { + return v.LoadManaged(ssmSvc.ListTagsForResource) + }) + if err != nil { + return nil, err + } + + return configVars, nil } // SetConfig sets a config value for the app diff --git a/app/config.go b/app/config.go new file mode 100644 index 0000000..0f430a1 --- /dev/null +++ b/app/config.go @@ -0,0 +1,119 @@ +package app + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/juju/ansiterm" + "github.com/sirupsen/logrus" +) + +type ConfigVariable struct { + Name string + Value string + Managed bool + parameterName string +} + +// LoadManaged loads the Managed value for the ConfigVariable from SSM tags +func (v *ConfigVariable) LoadManaged(ssmListTagsForResource func(*ssm.ListTagsForResourceInput) (*ssm.ListTagsForResourceOutput, error)) error { + logrus.WithFields(logrus.Fields{"parameter": v.parameterName}).Debug("loading parameter tag") + resp, err := ssmListTagsForResource(&ssm.ListTagsForResourceInput{ + ResourceId: &v.parameterName, + ResourceType: aws.String(ssm.ResourceTypeForTaggingParameter), + }) + if err != nil { + return err + } + + for _, tag := range resp.TagList { + if *tag.Key == "aws:cloudformation:stack-id" || *tag.Key == "apppack:cloudformation:stack-id" { + v.Managed = true + + return nil + } + } + + v.Managed = false + + return nil +} + +type ConfigVariables []*ConfigVariable + +// NewConfigVariables creates a new AppConfigVariables from the provided SSM parameters +func NewConfigVariables(parameters []*ssm.Parameter) ConfigVariables { + var configVars ConfigVariables + + for _, parameter := range parameters { + parts := strings.Split(*parameter.Name, "/") + name := parts[len(parts)-1] + configVars = append(configVars, &ConfigVariable{ + Name: name, + Value: *parameter.Value, + parameterName: *parameter.Name, + }) + } + + sort.Slice(configVars, func(i, j int) bool { + return configVars[i].Name < configVars[j].Name + }) + + return configVars +} + +// Transform runs the provided function on each config variable +func (a *ConfigVariables) Transform(transformer func(*ConfigVariable) error) error { + for _, configVar := range *a { + if err := transformer(configVar); err != nil { + return err + } + } + + return nil +} + +// ToJSON returns a JSON representation of the config variables +func (a *ConfigVariables) ToJSON() (*bytes.Buffer, error) { + results := map[string]string{} + + for _, configVar := range *a { + results[configVar.Name] = configVar.Value + } + + return toJSON(results) +} + +// ToJSONUnmanaged returns a JSON representation of the unmanaged config variables +func (a *ConfigVariables) ToJSONUnmanaged() (*bytes.Buffer, error) { + results := map[string]string{} + + for _, configVar := range *a { + if configVar.Managed { + continue + } + + results[configVar.Name] = configVar.Value + } + + return toJSON(results) +} + +// printRow prints a single row of the table to the TabWriter +func printRow(w *ansiterm.TabWriter, name, value string) { + w.SetForeground(ansiterm.Green) + fmt.Fprintf(w, "%s:", name) + w.SetForeground(ansiterm.Default) + fmt.Fprintf(w, "\t%s\n", value) +} + +// ToConsole prints the config vars to the console via the TabWriter +func (a *ConfigVariables) ToConsole(w *ansiterm.TabWriter) { + for _, configVar := range *a { + printRow(w, configVar.Name, configVar.Value) + } +} diff --git a/app/config_test.go b/app/config_test.go new file mode 100644 index 0000000..bed46f5 --- /dev/null +++ b/app/config_test.go @@ -0,0 +1,162 @@ +package app_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/apppackio/apppack/app" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/ssm" + "github.com/juju/ansiterm" +) + +var errMock = errors.New("mock error") + +func TestConfigVariablesToJSON(t *testing.T) { + t.Parallel() + + c := app.NewConfigVariables([]*ssm.Parameter{ + {Name: aws.String("/apppack/apps/myapp/FOO"), Value: aws.String("bar")}, + }) + + js, err := c.ToJSON() + if err != nil { + t.Error(err) + } + + expected := `{ + "FOO": "bar" +}` + actual := js.String() + + if actual != expected { + t.Errorf("expected %s, got %s", expected, actual) + } +} + +func TestConfigVariablesToJSONUnmanaged(t *testing.T) { + t.Parallel() + + c := app.ConfigVariables([]*app.ConfigVariable{ + {Name: "FOO", Value: "bar", Managed: false}, + {Name: "BAZ", Value: "qux", Managed: true}, + }) + js, err := c.ToJSONUnmanaged() + if err != nil { + t.Error(err) + } + + expected := `{ + "FOO": "bar" +}` + actual := js.String() + + if actual != expected { + t.Errorf("expected %s, got %s", expected, actual) + } +} + +func TestConfigVariablesToConsole(t *testing.T) { + t.Parallel() + + c := app.NewConfigVariables([]*ssm.Parameter{ + {Name: aws.String("/apppack/apps/myapp/config/FOO"), Value: aws.String("bar")}, + {Name: aws.String("/apppack/apps/myapp/LONGERVARIABLEFOO"), Value: aws.String("baz")}, + }) + out := &bytes.Buffer{} + w := ansiterm.NewTabWriter(out, 8, 8, 0, '\t', 0) + c.ToConsole(w) + + expected := []byte("FOO:\t\t\tbar\nLONGERVARIABLEFOO:\tbaz\n") + + w.Flush() + + actual := out.Bytes() + if !bytes.Equal(actual, expected) { + t.Errorf("expected %b, got %b", expected, actual) + } +} + +func TestConfigVariablesTransform(t *testing.T) { + t.Parallel() + + c := app.NewConfigVariables([]*ssm.Parameter{ + {Name: aws.String("/apppack/apps/myapp/config/FOO"), Value: aws.String("bar")}, + {Name: aws.String("/apppack/apps/myapp/config/BAZ"), Value: aws.String("qux")}, + }) + transformedVals := map[string]string{ + "FOO": "newvalue_foo", + "BAZ": "newvalue_qux", + } + + err := c.Transform(func(v *app.ConfigVariable) error { + v.Value = transformedVals[v.Name] + + return nil + }) + if err != nil { + t.Error(err) + } + + for _, v := range c { + if v.Value != transformedVals[v.Name] { + t.Errorf("expected %s, got %s", transformedVals[v.Name], v.Value) + } + } + + err = c.Transform(func(*app.ConfigVariable) error { return errMock }) + + if !errors.Is(err, errMock) { + t.Errorf("expected %s, got %s", errMock, err) + } +} + +func TestConfigVariableLoadManaged(t *testing.T) { + t.Parallel() + + managedVar := app.NewConfigVariables([]*ssm.Parameter{ + {Name: aws.String("/apppack/apps/myapp/config/FOO"), Value: aws.String("bar")}, + })[0] + + unmanagedVar := managedVar + + managedFunc := func(*ssm.ListTagsForResourceInput) (*ssm.ListTagsForResourceOutput, error) { + return &ssm.ListTagsForResourceOutput{ + TagList: []*ssm.Tag{{Key: aws.String("aws:cloudformation:stack-id"), Value: aws.String("stackid")}}, + }, nil + } + + unmanagedFunc := func(*ssm.ListTagsForResourceInput) (*ssm.ListTagsForResourceOutput, error) { + return &ssm.ListTagsForResourceOutput{TagList: []*ssm.Tag{}}, nil + } + + errorFunc := func(*ssm.ListTagsForResourceInput) (*ssm.ListTagsForResourceOutput, error) { + return nil, errMock + } + + scenarios := []struct { + cVar *app.ConfigVariable + f func(*ssm.ListTagsForResourceInput) (*ssm.ListTagsForResourceOutput, error) + expected bool + }{ + {cVar: managedVar, f: managedFunc, expected: true}, + {cVar: unmanagedVar, f: unmanagedFunc, expected: false}, + } + + for _, s := range scenarios { + err := s.cVar.LoadManaged(s.f) + if err != nil { + t.Error(err) + } + + if s.cVar.Managed != s.expected { + t.Errorf("expected %t, got %t", s.expected, s.cVar.Managed) + } + } + + err := managedVar.LoadManaged(errorFunc) + if !errors.Is(err, errMock) { + t.Errorf("expected %s, got %s", errMock, err) + } +} diff --git a/app/utils.go b/app/utils.go index 86793b1..155890a 100644 --- a/app/utils.go +++ b/app/utils.go @@ -1,6 +1,8 @@ package app import ( + "bytes" + "encoding/json" "fmt" "io" "strings" @@ -45,6 +47,7 @@ func SsmParameters(sess *session.Session, path string) ([]*ssm.Parameter, error) WithDecryption: aws.Bool(true), } err := ssmSvc.GetParametersByPathPages(&input, func(resp *ssm.GetParametersByPathOutput, lastPage bool) bool { + logrus.WithFields(logrus.Fields{"path": *input.Path}).Debug("loading parameter by path page") for _, parameter := range resp.Parameters { if parameter == nil { continue @@ -80,3 +83,20 @@ func S3FromURL(sess *session.Session, logURL string) (*strings.Builder, error) { } return buf, nil } + +var JSONIndent = " " + +func toJSON(v interface{}) (*bytes.Buffer, error) { + j, err := json.Marshal(v) + if err != nil { + return nil, err + } + + buf := bytes.NewBuffer([]byte{}) + + if err = json.Indent(buf, j, "", JSONIndent); err != nil { + return nil, err + } + + return buf, nil +} diff --git a/bridge/ssm.go b/bridge/ssm.go deleted file mode 100644 index cf20b9b..0000000 --- a/bridge/ssm.go +++ /dev/null @@ -1,13 +0,0 @@ -package bridge - -import ( - "sort" - - "github.com/aws/aws-sdk-go/service/ssm" -) - -func SortParameters(parameters []*ssm.Parameter) { - sort.Slice(parameters, func(i, j int) bool { - return *parameters[i].Name < *parameters[j].Name - }) -} diff --git a/cmd/config.go b/cmd/config.go index bbba757..e43d7b1 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -24,7 +24,6 @@ import ( "strings" "github.com/apppackio/apppack/app" - "github.com/apppackio/apppack/bridge" "github.com/apppackio/apppack/ui" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/awserr" @@ -108,51 +107,46 @@ var unsetCmd = &cobra.Command{ }, } -func printRow(w *ansiterm.TabWriter, name, value string) { - w.SetForeground(ansiterm.Green) - fmt.Fprintf(w, "%s:", name) - w.SetForeground(ansiterm.Default) - fmt.Fprintf(w, "\t%s\n", value) -} - -// listCmd represents the list command -var listCmd = &cobra.Command{ +// configListCmd represents the list command +var configListCmd = &cobra.Command{ Use: "list", Short: "list all config variables and values", DisableFlagsInUseLine: true, Args: cobra.ExactArgs(0), Run: func(cmd *cobra.Command, args []string) { - // minwidth, tabwidth, padding, padchar, flags - w := ansiterm.NewTabWriter(os.Stdout, 8, 8, 0, '\t', 0) - if isatty.IsTerminal(os.Stdout.Fd()) { - w.SetColorCapable(true) - } ui.StartSpinner() a, err := app.Init(AppName, UseAWSCredentials, SessionDurationSeconds) checkErr(err) - parameters, err := a.GetConfig() + configVars, err := a.GetConfig() checkErr(err) - bridge.SortParameters(parameters) ui.Spinner.Stop() - ui.PrintHeaderln(fmt.Sprintf("%s Config Vars", AppName)) - for _, value := range parameters { - parts := strings.Split(*value.Name, "/") - varname := parts[len(parts)-1] - printRow(w, varname, *value.Value) + + if AsJSON { + buf, err := configVars.ToJSON() + checkErr(err) + fmt.Println(buf.String()) + return + } + + // minwidth, tabwidth, padding, padchar, flags + w := ansiterm.NewTabWriter(os.Stdout, 8, 8, 0, '\t', 0) + + if isatty.IsTerminal(os.Stdout.Fd()) { + w.SetColorCapable(true) } + + ui.PrintHeaderln(fmt.Sprintf("%s Config Vars", AppName)) + configVars.ToConsole(w) checkErr(w.Flush()) + if a.IsReviewApp() { fmt.Println() a.ReviewApp = nil + ui.StartSpinner() parameters, err := a.GetConfig() checkErr(err) - bridge.SortParameters(parameters) ui.Spinner.Stop() - for _, value := range parameters { - parts := strings.Split(*value.Name, "/") - varname := parts[len(parts)-1] - printRow(w, varname, *value.Value) - } + parameters.ToConsole(w) ui.PrintHeaderln(fmt.Sprintf("%s Config Vars (inherited)", a.Name)) checkErr(w.Flush()) } @@ -161,23 +155,6 @@ var listCmd = &cobra.Command{ var includeManagedVars bool -// parameterIsManaged checks is the parameter was created by a Cloudformation stack -func parameterIsManaged(ssmSvc *ssm.SSM, parameter *ssm.Parameter) (*bool, error) { - resp, err := ssmSvc.ListTagsForResource(&ssm.ListTagsForResourceInput{ - ResourceId: parameter.Name, - ResourceType: aws.String(ssm.ResourceTypeForTaggingParameter), - }) - if err != nil { - return nil, err - } - for _, tag := range resp.TagList { - if *tag.Key == "aws:cloudformation:stack-id" || *tag.Key == "apppack:cloudformation:stack-id" { - return aws.Bool(true), nil - } - } - return aws.Bool(false), nil -} - // configExportCmd represents the config export command var configExportCmd = &cobra.Command{ Use: "export", @@ -188,29 +165,17 @@ var configExportCmd = &cobra.Command{ ui.StartSpinner() a, err := app.Init(AppName, UseAWSCredentials, SessionDurationSeconds) checkErr(err) - parameters, err := a.GetConfig() - bridge.SortParameters(parameters) + configVars, err := a.GetConfigWithManaged() checkErr(err) - config := make(map[string]string) - ssmSvc := ssm.New(a.Session) - for _, p := range parameters { - parts := strings.Split(*p.Name, "/") - varname := parts[len(parts)-1] - if !includeManagedVars { - isManaged, err := parameterIsManaged(ssmSvc, p) - checkErr(err) - if *isManaged { - continue - } - } - config[varname] = *p.Value + ui.Spinner.Stop() + var buf *bytes.Buffer + if includeManagedVars { + buf, err = configVars.ToJSON() + } else { + buf, err = configVars.ToJSONUnmanaged() } - j, err := json.Marshal(config) checkErr(err) - ui.Spinner.Stop() - b := bytes.NewBuffer([]byte{}) - checkErr(json.Indent(b, j, "", " ")) - fmt.Println(b.String()) + fmt.Println(buf.String()) }, } @@ -274,7 +239,8 @@ func init() { configCmd.AddCommand(getCmd) configCmd.AddCommand(setCmd) configCmd.AddCommand(unsetCmd) - configCmd.AddCommand(listCmd) + configCmd.AddCommand(configListCmd) + configListCmd.Flags().BoolVarP(&AsJSON, "json", "j", false, "output as JSON") configCmd.AddCommand(configExportCmd) configExportCmd.Flags().BoolVar(&includeManagedVars, "all", diff --git a/cmd/root.go b/cmd/root.go index b71c6f3..3f11394 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,17 +34,16 @@ import ( "github.com/spf13/cobra" ) -var debug bool +const timeFmt = "Jan 02, 2006 15:04:05 -0700" -const ( - timeFmt = "Jan 02, 2006 15:04:05 -0700" -) - -// AppName is the `--app-name` flag -var AppName string - -// AccountIDorAlias is the `--account` flag var ( + // debug is the `--debug` flag + debug bool + // AppName is the `--app-name` flag + AppName string + // AsJSON is the `--json` flag + AsJSON bool + // AccountIDorAlias is the `--account` flag AccountIDorAlias string UseAWSCredentials = false SessionDurationSeconds = 900