package app import ( "context" "errors" "fmt" "io" "os" "sort" "strconv" "strings" "coopcloud.tech/abra/cli/internal" appPkg "coopcloud.tech/abra/pkg/app" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" "coopcloud.tech/abra/pkg/secret" "github.com/AlecAivazis/survey/v2" "github.com/docker/docker/api/types" dockerClient "github.com/docker/docker/client" "github.com/spf13/cobra" ) var AppSecretGenerateCommand = &cobra.Command{ Use: i18n.G("generate [[secret] [version] | --all] [flags]"), Aliases: []string{i18n.G("g")}, Short: i18n.G("Generate secrets"), Args: cobra.RangeArgs(1, 3), ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string, ) ([]string, cobra.ShellCompDirective) { switch l := len(args); l { case 0: return autocomplete.AppNameComplete() case 1: app, err := appPkg.Get(args[0]) if err != nil { return []string{i18n.G("autocomplete failed: %s", err)}, cobra.ShellCompDirectiveError } return autocomplete.SecretComplete(app.Recipe.Name) default: return nil, cobra.ShellCompDirectiveDefault } }, Run: func(cmd *cobra.Command, args []string) { app := internal.ValidateApp(args) if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil { log.Fatal(err) } if len(args) <= 2 && !generateAllSecrets { log.Fatal(i18n.G("missing arguments [secret]/[version] or '--all'")) } if len(args) > 2 && generateAllSecrets { log.Fatal(i18n.G("cannot use '[secret] [version]' and '--all' together")) } composeFiles, err := app.Recipe.GetComposeFiles(app.Env) if err != nil { log.Fatal(err) } secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName()) if err != nil { log.Fatal(err) } if !generateAllSecrets { secretName := args[1] secretVersion := args[2] s, ok := secrets[secretName] if !ok { log.Fatal(i18n.G("%s doesn't exist in the env config?", secretName)) } s.Version = secretVersion secrets = map[string]secret.Secret{ secretName: s, } } cl, err := client.New(app.Server) if err != nil { log.Fatal(err) } secretVals, err := secret.GenerateSecrets(cl, secrets, app.Server) if err != nil { log.Fatal(err) } if storeInPass { for name, data := range secretVals { if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil { log.Fatal(err) } } } if len(secretVals) == 0 { log.Warn(i18n.G("no secrets generated")) os.Exit(1) } headers := []string{i18n.G("NAME"), i18n.G("VALUE")} table, err := formatter.CreateTable() if err != nil { log.Fatal(err) } table.Headers(headers...) var rows [][]string for name, val := range secretVals { row := []string{name, val} rows = append(rows, row) table.Row(row...) } if internal.MachineReadable { out, err := formatter.ToJSON(headers, rows) if err != nil { log.Fatal(i18n.G("unable to render to JSON: %s", err)) } fmt.Println(out) return } if err := formatter.PrintTable(table); err != nil { log.Fatal(err) } log.Warn(i18n.G( "generated secrets %s shown again, please take note of them %s", formatter.BoldStyle.Render(i18n.G("NOT")), formatter.BoldStyle.Render(i18n.G("NOW")), )) }, } var AppSecretInsertCommand = &cobra.Command{ Use: i18n.G("insert [] [flags]"), Aliases: []string{i18n.G("i")}, Short: i18n.G("Insert secret"), Long: i18n.G(`This command inserts a secret into an app environment. Arbitrary secret insertion is not supported. Secrets that are inserted must match those configured in the recipe beforehand. This can be useful when you want to manually generate secrets for an app environment. Typically, you can let Abra generate them for you on app creation (see "abra app new --secrets/-S" for more).`), Example: i18n.G(` # insert regular secret abra app secret insert 1312.net my_secret v1 mySuperSecret # insert secret as file abra app secret insert 1312.net my_secret v1 secret.txt -f # insert secret from stdin echo "mmySuperSecret" | abra app secret insert 1312.net my_secret v1`), Args: cobra.MinimumNArgs(3), ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string, ) ([]string, cobra.ShellCompDirective) { switch l := len(args); l { case 0: return autocomplete.AppNameComplete() case 1: app, err := appPkg.Get(args[0]) if err != nil { return []string{i18n.G("autocomplete failed: %s", err)}, cobra.ShellCompDirectiveError } return autocomplete.SecretComplete(app.Recipe.Name) default: return nil, cobra.ShellCompDirectiveDefault } }, Run: func(cmd *cobra.Command, args []string) { app := internal.ValidateApp(args) if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil { log.Fatal(err) } cl, err := client.New(app.Server) if err != nil { log.Fatal(err) } name := args[1] version := args[2] data, err := readSecretData(args) if err != nil { log.Fatal(err) } composeFiles, err := app.Recipe.GetComposeFiles(app.Env) if err != nil { log.Fatal(err) } secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName()) if err != nil { log.Fatal(err) } var isRecipeSecret bool for secretName := range secrets { if secretName == name { isRecipeSecret = true } } if !isRecipeSecret { log.Fatal(i18n.G("no secret %s available for recipe %s?", name, app.Recipe.Name)) } if insertFromFile { raw, err := os.ReadFile(data) if err != nil { log.Fatal(i18n.G("reading secret from file: %s", err)) } data = string(raw) } if trimInput { data = strings.TrimSpace(data) } secretName := fmt.Sprintf("%s_%s_%s", app.StackName(), name, version) if err := client.StoreSecret(cl, secretName, data, app.Server); err != nil { log.Fatal(err) } log.Info(i18n.G("%s successfully stored on server", secretName)) if storeInPass { if err := secret.PassInsertSecret(data, name, app.Name, app.Server); err != nil { log.Fatal(err) } } }, } func readSecretData(args []string) (string, error) { if len(args) == 4 { return args[3], nil } if len(args) != 3 { return "", errors.New(i18n.G("need 3 or 4 arguments")) } // First check if data is provided by stdin fi, err := os.Stdin.Stat() if err != nil { return "", err } if fi.Mode()&os.ModeNamedPipe != 0 { // Can't insert from stdin and read from file if insertFromFile { return "", errors.New(i18n.G("can not insert from file and read from stdin")) } log.Debug(i18n.G("reading secret data from stdin")) bytes, err := io.ReadAll(os.Stdin) if err != nil { return "", errors.New(i18n.G("reading data from stdin: %s", err)) } return string(bytes), nil } if internal.NoInput { return "", errors.New(i18n.G("must provide argument if --no-input is passed")) } log.Debug(i18n.G("secret data not provided on command-line or stdin, prompting")) var prompt survey.Prompt if !insertFromFile { prompt = &survey.Password{ Message: i18n.G("specify secret value"), } } else { prompt = &survey.Input{ Message: i18n.G("specify secret file"), } } var data string if err := survey.AskOne(prompt, &data); err != nil { return "", err } return data, nil } // secretRm removes a secret. func secretRm(cl *dockerClient.Client, app appPkg.App, secretName, parsed string) error { if err := cl.SecretRemove(context.Background(), secretName); err != nil { return err } log.Info(i18n.G("deleted %s successfully from server", secretName)) if removeFromPass { if err := secret.PassRmSecret(parsed, app.StackName(), app.Server); err != nil { return err } log.Info(i18n.G("deleted %s successfully from local pass store", secretName)) } return nil } var AppSecretRmCommand = &cobra.Command{ Use: i18n.G("remove [[secret] | --all] [flags]"), Aliases: []string{i18n.G("rm")}, Short: i18n.G("Remove a secret"), Long: i18n.G(`This command removes a secret from an app environment. Arbitrary secret removal is not supported. Secrets that are removed must match those configured in the recipe beforehand.`), Example: i18n.G(" abra app secret rm 1312.net oauth_key"), Args: cobra.RangeArgs(1, 2), ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string, ) ([]string, cobra.ShellCompDirective) { switch l := len(args); l { case 0: return autocomplete.AppNameComplete() case 1: if !rmAllSecrets { app, err := appPkg.Get(args[0]) if err != nil { return []string{i18n.G("autocomplete failed: %s", err)}, cobra.ShellCompDirectiveError } return autocomplete.SecretComplete(app.Recipe.Name) } return nil, cobra.ShellCompDirectiveDefault default: return nil, cobra.ShellCompDirectiveError } }, Run: func(cmd *cobra.Command, args []string) { app := internal.ValidateApp(args) if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil { log.Fatal(err) } composeFiles, err := app.Recipe.GetComposeFiles(app.Env) if err != nil { log.Fatal(err) } secrets, err := secret.ReadSecretsConfig(app.Path, composeFiles, app.StackName()) if err != nil { log.Fatal(err) } if len(args) == 2 && rmAllSecrets { log.Fatal(i18n.G("cannot use [secret] and --all/-a together")) } if len(args) != 2 && !rmAllSecrets { log.Fatal(i18n.G("no secret(s) specified?")) } cl, err := client.New(app.Server) if err != nil { log.Fatal(err) } filters, err := app.Filters(false, false) if err != nil { log.Fatal(err) } secretList, err := cl.SecretList(context.Background(), types.SecretListOptions{Filters: filters}) if err != nil { log.Fatal(err) } remoteSecretNames := make(map[string]bool) for _, cont := range secretList { remoteSecretNames[cont.Spec.Annotations.Name] = true } var secretToRm string if len(args) == 2 { secretToRm = args[1] } match := false for secretName, val := range secrets { secretRemoteName := fmt.Sprintf("%s_%s_%s", app.StackName(), secretName, val.Version) if _, ok := remoteSecretNames[secretRemoteName]; ok { if secretToRm != "" { if secretName == secretToRm { if err := secretRm(cl, app, secretRemoteName, secretName); err != nil { log.Fatal(err) } return } } else { match = true if err := secretRm(cl, app, secretRemoteName, secretName); err != nil { log.Fatal(err) } } } } if !match && secretToRm != "" { log.Fatal(i18n.G("%s doesn't exist on server?", secretToRm)) } if !match { log.Fatal(i18n.G("no secrets to remove?")) } }, } var AppSecretLsCommand = &cobra.Command{ Use: i18n.G("list "), Aliases: []string{i18n.G("ls")}, Short: i18n.G("List all secrets"), Args: cobra.MinimumNArgs(1), ValidArgsFunction: func( cmd *cobra.Command, args []string, toComplete string, ) ([]string, cobra.ShellCompDirective) { return autocomplete.AppNameComplete() }, Run: func(cmd *cobra.Command, args []string) { app := internal.ValidateApp(args) if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil { log.Fatal(err) } cl, err := client.New(app.Server) if err != nil { log.Fatal(err) } headers := []string{i18n.G("NAME"), i18n.G("VERSION"), i18n.G("GENERATED NAME"), i18n.G("CREATED ON SERVER")} table, err := formatter.CreateTable() if err != nil { log.Fatal(err) } table.Headers(headers...) secStats, err := secret.PollSecretsStatus(cl, app) if err != nil { log.Fatal(err) } // Sort secrets to ensure reproducible output sort.Slice(secStats, func(i, j int) bool { return secStats[i].LocalName < secStats[j].LocalName }) var rows [][]string for _, secStat := range secStats { row := []string{ secStat.LocalName, secStat.Version, secStat.RemoteName, strconv.FormatBool(secStat.CreatedOnRemote), } rows = append(rows, row) table.Row(row...) } if len(rows) > 0 { if internal.MachineReadable { out, err := formatter.ToJSON(headers, rows) if err != nil { log.Fatal(i18n.G("unable to render to JSON: %s", err)) } fmt.Println(out) return } if err := formatter.PrintTable(table); err != nil { log.Fatal(err) } return } log.Warn(i18n.G("no secrets stored for %s", app.Name)) }, } var AppSecretCommand = &cobra.Command{ Use: i18n.G("secret [cmd] [args] [flags]"), Aliases: []string{i18n.G("s")}, Short: i18n.G("Manage app secrets"), } var ( storeInPass bool insertFromFile bool trimInput bool rmAllSecrets bool generateAllSecrets bool removeFromPass bool ) func init() { AppSecretGenerateCommand.Flags().BoolVarP( &internal.MachineReadable, i18n.G("machine"), i18n.G("m"), false, i18n.G("print machine-readable output"), ) AppSecretGenerateCommand.Flags().BoolVarP( &storeInPass, i18n.G("pass"), i18n.G("p"), false, i18n.G("store generated secrets in a local pass store"), ) AppSecretGenerateCommand.Flags().BoolVarP( &internal.Chaos, i18n.G("chaos"), i18n.G("C"), false, i18n.G("ignore uncommitted recipes changes"), ) AppSecretGenerateCommand.Flags().BoolVarP( &generateAllSecrets, i18n.G("all"), i18n.G("a"), false, i18n.G("generate all secrets"), ) AppSecretInsertCommand.Flags().BoolVarP( &storeInPass, i18n.G("pass"), i18n.G("p"), false, i18n.G("store generated secrets in a local pass store"), ) AppSecretInsertCommand.Flags().BoolVarP( &insertFromFile, i18n.G("file"), i18n.G("f"), false, i18n.G("treat input as a file"), ) AppSecretInsertCommand.Flags().BoolVarP( &trimInput, i18n.G("trim"), i18n.G("t"), false, i18n.G("trim input"), ) AppSecretInsertCommand.Flags().BoolVarP( &internal.Chaos, i18n.G("chaos"), i18n.G("C"), false, i18n.G("ignore uncommitted recipes changes"), ) AppSecretRmCommand.Flags().BoolVarP( &rmAllSecrets, i18n.G("all"), i18n.G("a"), false, i18n.G("remove all secrets"), ) AppSecretRmCommand.Flags().BoolVarP( &removeFromPass, i18n.G("pass"), i18n.G("p"), false, i18n.G("remove generated secrets from a local pass store"), ) AppSecretRmCommand.Flags().BoolVarP( &internal.Chaos, i18n.G("chaos"), i18n.G("C"), false, i18n.G("ignore uncommitted recipes changes"), ) AppSecretLsCommand.Flags().BoolVarP( &internal.Chaos, i18n.G("chaos"), i18n.G("C"), false, i18n.G("ignore uncommitted recipes changes"), ) AppSecretLsCommand.Flags().BoolVarP( &internal.MachineReadable, i18n.G("machine"), i18n.G("m"), false, i18n.G("print machine-readable output"), ) }