From 7c31e4dc4598eb57c12f09b0920e8e6e2892f12a Mon Sep 17 00:00:00 2001 From: p4u1 Date: Wed, 27 Aug 2025 15:37:43 +0000 Subject: [PATCH] feat(secrets): Reading from stdin and reproducible secret list(#614) Reviewed-on: https://git.coopcloud.tech/toolshed/abra/pulls/614 Reviewed-by: decentral1se Co-authored-by: p4u1 Co-committed-by: p4u1 --- cli/app/secret.go | 87 ++++++++++++++++++++++--------- tests/integration/app_secret.bats | 24 ++++++++- 2 files changed, 85 insertions(+), 26 deletions(-) diff --git a/cli/app/secret.go b/cli/app/secret.go index 5653c9e9..c95f24ef 100644 --- a/cli/app/secret.go +++ b/cli/app/secret.go @@ -2,8 +2,11 @@ package app import ( "context" + "errors" "fmt" + "io" "os" + "sort" "strconv" "strings" @@ -157,7 +160,10 @@ environment. Typically, you can let Abra generate them for you on app creation 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`), + 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, @@ -191,12 +197,9 @@ environment. Typically, you can let Abra generate them for you on app creation name := args[1] version := args[2] - data := "" - - if len(args) > 3 { - data = args[3] - } else if internal.NoInput { - log.Fatal(i18n.G("must provide argument if --no-input is passed")) + data, err := readSecretData(args) + if err != nil { + log.Fatal(err) } composeFiles, err := app.Recipe.GetComposeFiles(app.Env) @@ -219,23 +222,6 @@ environment. Typically, you can let Abra generate them for you on app creation log.Fatal(i18n.G("no secret %s available for recipe %s?", name, app.Recipe.Name)) } - if data == "" && !internal.NoInput { - log.Debug(i18n.G("secret data not provided on command-line, 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"), - } - } - if err := survey.AskOne(prompt, &data); err != nil { - log.Fatal(err) - } - } - if insertFromFile { raw, err := os.ReadFile(data) if err != nil { @@ -263,6 +249,55 @@ environment. Typically, you can let Abra generate them for you on app creation }, } +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 { @@ -432,6 +467,10 @@ var AppSecretLsCommand = &cobra.Command{ 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{ diff --git a/tests/integration/app_secret.bats b/tests/integration/app_secret.bats index ce4a0cd7..9c5b63e4 100644 --- a/tests/integration/app_secret.bats +++ b/tests/integration/app_secret.bats @@ -206,10 +206,10 @@ teardown(){ run $ABRA app secret insert "$TEST_APP_DOMAIN" bar assert_failure - run $ABRA app secret insert "$TEST_APP_DOMAIN" bar baz + run $ABRA app secret insert "$TEST_APP_DOMAIN" bar baz --no-input assert_failure - run $ABRA app secret insert "$TEST_APP_DOMAIN" test_pass_one v1 -n + run bash -c "echo foo | $ABRA app secret insert $TEST_APP_DOMAIN bar baz -f" assert_failure } @@ -251,6 +251,20 @@ teardown(){ assert_output --partial 'true' } +@test "insert: create secret from stdin" { + run $ABRA app secret ls "$TEST_APP_DOMAIN" + assert_success + assert_output --partial 'false' + + run bash -c "echo foo | $ABRA app secret insert $TEST_APP_DOMAIN test_pass_one v1" + assert_success + assert_output --partial 'successfully stored on server' + + run $ABRA app secret ls "$TEST_APP_DOMAIN" + assert_success + assert_output --partial 'true' +} + @test "rm: validate arguments" { run $ABRA app secret rm assert_failure @@ -343,6 +357,12 @@ teardown(){ | jq -r ".[] | select(.name==\"test_pass_two\") | .version"' assert_success assert_output --partial 'v1' + + # Can always expect the secret at this position + run bash -c '$ABRA app secret ls "$TEST_APP_DOMAIN" --machine \ + | jq -r ".[1] | .name"' + assert_success + assert_output --partial 'test_pass_two' } @test "ls: bail if unstaged changes and no --chaos" {