diff --git a/example/actions/objects/Dockerfile b/example/actions/objects/Dockerfile new file mode 100644 index 0000000..5162dfd --- /dev/null +++ b/example/actions/objects/Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:3.20 +RUN env diff --git a/example/actions/objects/action.yaml b/example/actions/objects/action.yaml new file mode 100644 index 0000000..f966441 --- /dev/null +++ b/example/actions/objects/action.yaml @@ -0,0 +1,26 @@ +action: + title: Credentials object + description: Test credentials processor with object + options: + - name: creds + title: Credentials opt + type: object + default: {} + process: + - processor: keyring.GetCredentials + options: + url_from_key: "url" + url_from_config: "my_action.url" + +runtime: + type: container + image: envvars:latest + build: + context: ./ + command: + - sh + - /action/main.sh + - "{{ .creds }}" + - "{{ .creds.url }}" + - "{{ .creds.username }}" + - "{{ .creds.password }}" diff --git a/example/actions/objects/main.sh b/example/actions/objects/main.sh new file mode 100755 index 0000000..3481c29 --- /dev/null +++ b/example/actions/objects/main.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +echo +# Print the total count of arguments +echo "Total number of arguments passed to the script: $#" +count=1 +for arg in "$@" +do + if [ -z "$arg" ]; then + echo "- Argument $count: \"\"" + else + echo "- Argument $count: $arg" + fi + count=$((count + 1)) +done diff --git a/go.mod b/go.mod index e7dadee..513d5eb 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.24.1 require ( filippo.io/age v1.2.1 - github.com/launchrctl/launchr v0.21.0 + github.com/launchrctl/launchr v0.21.2-0.20250625094547-5e88fd38c550 github.com/stretchr/testify v1.10.0 golang.org/x/term v0.31.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 4dbff58..a029076 100644 --- a/go.sum +++ b/go.sum @@ -253,8 +253,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/launchrctl/launchr v0.21.0 h1:TruuJToGh4nvinoG6z4AgDtONrlshVws7tzUQak/8J8= -github.com/launchrctl/launchr v0.21.0/go.mod h1:C7H4FHMSjNi4fUt36rzfyE/2xSpdBdUuq7n7IPAzqEo= +github.com/launchrctl/launchr v0.21.2-0.20250625094547-5e88fd38c550 h1:aQRn5Zcym/pH1kHdhWJ3AwE17Om1RC0WMnj9S33Wm9w= +github.com/launchrctl/launchr v0.21.2-0.20250625094547-5e88fd38c550/go.mod h1:C7H4FHMSjNi4fUt36rzfyE/2xSpdBdUuq7n7IPAzqEo= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= diff --git a/plugin.go b/plugin.go index c3f40a7..9b69e23 100644 --- a/plugin.go +++ b/plugin.go @@ -18,9 +18,10 @@ import ( ) const ( - procGetKeyValue = "keyring.GetKeyValue" - errTplNotFoundURL = "%s not found in keyring. Use `%s keyring:login` to add it." - errTplNotFoundKey = "%s not found in keyring. Use `%s keyring:set` to add it." + procGetKeyValue = "keyring.GetKeyValue" + procGetCredentials = "keyring.GetCredentials" //nolint:gosec // G101: Processor name with masked credentials. + errTplNotFoundURL = "%s not found in keyring. Use `%s keyring:login` to add it." + errTplNotFoundKey = "%s not found in keyring. Use `%s keyring:set` to add it." envVarPassphrase = launchr.EnvVar("keyring_passphrase") envVarPassphraseFile = launchr.EnvVar("keyring_passphrase_file") @@ -72,7 +73,7 @@ func (p *Plugin) OnAppInit(app launchr.App) error { var m action.Manager app.GetService(&m) - addValueProcessors(m, p.k) + addValueProcessors(m, p.k, p.cfg) return nil } @@ -81,14 +82,27 @@ type GetKeyValueProcessorOptions = *action.GenericValueProcessorOptions[struct { Key string `yaml:"key" validate:"not-empty"` }] +// GetCredentialsProcessorOptions is a [action.ValueProcessorOptions] struct. +type GetCredentialsProcessorOptions = *action.GenericValueProcessorOptions[struct { + URLFromKey string `yaml:"url_from_key"` + URLFromConfig string `yaml:"url_from_config"` +}] + // addValueProcessors adds a keyring [action.ValueProcessor] to [action.Manager]. -func addValueProcessors(m action.Manager, keyring Keyring) { +func addValueProcessors(m action.Manager, keyring Keyring, cfg launchr.Config) { m.AddValueProcessor(procGetKeyValue, action.GenericValueProcessor[GetKeyValueProcessorOptions]{ Types: []jsonschema.Type{jsonschema.String}, Fn: func(v any, opts GetKeyValueProcessorOptions, ctx action.ValueProcessorContext) (any, error) { return processGetByKey(v, opts, ctx, keyring) }, }) + + m.AddValueProcessor(procGetCredentials, action.GenericValueProcessor[GetCredentialsProcessorOptions]{ + Types: []jsonschema.Type{jsonschema.Object}, + Fn: func(v any, opts GetCredentialsProcessorOptions, ctx action.ValueProcessorContext) (any, error) { + return processGetCredentials(v, opts, ctx, keyring, cfg) + }, + }) } func processGetByKey(value any, opts GetKeyValueProcessorOptions, ctx action.ValueProcessorContext, k Keyring) (any, error) { @@ -131,6 +145,76 @@ func processGetByKey(value any, opts GetKeyValueProcessorOptions, ctx action.Val return value, buildNotFoundError(opts.Fields.Key, errTplNotFoundKey, err) } +func processGetCredentials(value any, opts GetCredentialsProcessorOptions, ctx action.ValueProcessorContext, k Keyring, cfg launchr.Config) (any, error) { + // Init object + if value == nil { + value = make(map[string]interface{}) + } + + m, ok := value.(map[string]interface{}) + if !ok { + return value, fmt.Errorf("%s: invalid value type submitted: %T", procGetCredentials, value) + } + + var url string + if opts.Fields.URLFromKey != "" { + url, ok = m[opts.Fields.URLFromKey].(string) + if !ok { + launchr.Term().Warning().Printfln("%s: specified key `%s` doesn't exist in passed object", procGetCredentials, opts.Fields.URLFromKey) + } + } + + if url == "" && opts.Fields.URLFromConfig != "" { + err := cfg.Get(opts.Fields.URLFromConfig, &url) + if err != nil { + return value, err + } + } + + if url == "" { + return value, fmt.Errorf("%s: credentials URL is not provided in processor options or submitted data", procGetCredentials) + } + + ci, err := k.GetForURL(url) + if err == nil { + m["url"] = ci.URL + m["username"] = ci.Username + m["password"] = ci.Password + + return value, nil + } + + streams := ctx.Input.Streams() + isTerminal := streams != nil && streams.In().IsTerminal() + if errors.Is(err, ErrNotFound) && isTerminal { + item := CredentialsItem{URL: url} + err = RequestCredentialsFromTty(&item) + if err != nil { + return value, err + } + + err = k.AddItem(item) + if err != nil { + return value, err + } + + // Ensure keyring storage will be accessible after save. + defer k.ResetStorage() + err = k.Save() + if err != nil { + return value, err + } + launchr.Term().Info().Printfln("URL credentials %q has been added to keyring", item.URL) + + m["url"] = ci.URL + m["username"] = ci.Username + m["password"] = ci.Password + return value, nil + } + + return value, buildNotFoundError(url, errTplNotFoundURL, err) +} + // DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface. func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { // Action list. diff --git a/plugin_test.go b/plugin_test.go index 4ad3bc4..861a4bc 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -64,7 +64,7 @@ func Test_KeyringProcessor(t *testing.T) { mask: &launchr.SensitiveMask{}, } am := action.NewManager() - addValueProcessors(am, k) + addValueProcessors(am, k, nil) // Prepare test data. expected := "my_secret" @@ -92,3 +92,56 @@ func Test_KeyringProcessor(t *testing.T) { }) } } + +const testCredentialsActionYaml = ` +runtime: plugin +action: + title: test credentials + options: + - name: credentials + type: object + process: + - processor: keyring.GetCredentials + options: + url_from_key: "url" +` + +func Test_CredentialsProcessor(t *testing.T) { + // Prepare services. + k := &keyringService{ + store: &dataStoreYaml{file: &plainFile{fname: "teststorage.yaml"}}, + mask: &launchr.SensitiveMask{}, + } + am := action.NewManager() + addValueProcessors(am, k, nil) + + // Prepare test data. + expected := map[string]any{ + "url": "myurl", + "username": "user", + "password": "pass", + } + + err := k.AddItem(CredentialsItem{URL: "myurl", Username: "user", Password: "pass"}) + require.NoError(t, err) + + expConfig := action.InputParams{ + "credentials": expected, + } + expGiven := action.InputParams{ + "credentials": map[string]any{ + "url": "myurl", + }, + } + tt := []action.TestCaseValueProcessor{ + {Name: "get keyring credentials - input with URL given", Yaml: testCredentialsActionYaml, Opts: expGiven, ExpOpts: expConfig}, + } + + for _, tt := range tt { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + tt.Test(t, am) + }) + } +}