diff --git a/app.go b/app.go index 72a42c0..1ae425e 100644 --- a/app.go +++ b/app.go @@ -114,7 +114,6 @@ func (app *appImpl) init() error { actionMngr := action.NewManager( action.WithDefaultRuntime, action.WithContainerRuntimeConfig(config, name+"_"), - action.WithValueProcessors(), ) // Register services for other modules. diff --git a/internal/launchr/config.go b/internal/launchr/config.go index 950bb2a..81ded7c 100644 --- a/internal/launchr/config.go +++ b/internal/launchr/config.go @@ -42,12 +42,12 @@ type ConfigAware interface { type cachedProps = map[string]reflect.Value type config struct { - mx sync.Mutex - root fs.FS - fname fs.DirEntry - rootPath string - cached cachedProps - koanf *koanf.Koanf + mx sync.Mutex // mx is a mutex to read/cache values. + root fs.FS // root is a base dir filesystem. + fname fs.DirEntry // fname is a file storing the config. + rootPath string // rootPath is a base dir path. + cached cachedProps // cached is a map of cached properties read from a file. + koanf *koanf.Koanf // koanf is the driver to read the yaml config. } func findConfigFile(root fs.FS) fs.DirEntry { diff --git a/pkg/action/action.go b/pkg/action/action.go index 27a97b2..80f6964 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -10,8 +10,9 @@ import ( ) var ( - errTplNotApplicableProcessor = "invalid configuration, processor can't be applied to value of type %s" + errTplNotApplicableProcessor = "invalid configuration, processor %q can't be applied to a parameter of type %s" errTplNonExistProcessor = "requested processor %q doesn't exist" + errTplErrorOnProcessor = "failed to process parameter %q with %q: %w" ) // Action is an action definition with a contextual id (name), working directory path @@ -78,8 +79,18 @@ func (a *Action) Clone() *Action { } // SetProcessors sets the value processors for an [Action]. -func (a *Action) SetProcessors(list map[string]ValueProcessor) { - a.processors = list +func (a *Action) SetProcessors(list map[string]ValueProcessor) error { + def := a.ActionDef() + for _, params := range []ParametersList{def.Arguments, def.Options} { + for _, p := range params { + err := p.InitProcessors(list) + if err != nil { + return err + } + } + } + + return nil } // GetProcessors returns processors map. @@ -193,13 +204,13 @@ func (a *Action) SetInput(input *Input) (err error) { def := a.ActionDef() // Process arguments. - err = a.processInputParams(def.Arguments, input.ArgsNamed(), nil) + err = a.processInputParams(def.Arguments, input.Args(), input.ArgsChanged()) if err != nil { return err } // Process options. - err = a.processInputParams(def.Options, input.OptsAll(), input.OptsChanged()) + err = a.processInputParams(def.Options, input.Opts(), input.OptsChanged()) if err != nil { return err } @@ -216,63 +227,32 @@ func (a *Action) SetInput(input *Input) (err error) { } func (a *Action) processInputParams(def ParametersList, inp InputParams, changed InputParams) error { + var err error for _, p := range def { - if _, ok := inp[p.Name]; !ok { - continue - } - - if changed != nil { - if _, ok := changed[p.Name]; ok { - continue + _, isChanged := changed[p.Name] + res := inp[p.Name] + for i, procDef := range p.Process { + handler := p.processors[i] + res, err = handler(res, ValueProcessorContext{ + ValOrig: inp[p.Name], + IsChanged: isChanged, + DefParam: p, + Action: a, + }) + if err != nil { + return fmt.Errorf(errTplErrorOnProcessor, p.Name, procDef.ID, err) } } - - value := inp[p.Name] - toApply := p.Process - - value, err := a.processValue(value, p.Type, toApply) - if err != nil { - return err - } - // Replace the value. - // Check for nil not to override the default value. - if value != nil { - inp[p.Name] = value + // Cast to []any slice because jsonschema validator supports only this type. + if p.Type == jsonschema.Array { + res = CastSliceTypedToAny(res) } + inp[p.Name] = res } return nil } -func (a *Action) processValue(v any, vtype jsonschema.Type, applyProc []DefValueProcessor) (any, error) { - res := v - processors := a.GetProcessors() - - for _, procDef := range applyProc { - proc, ok := processors[procDef.ID] - if !ok { - return v, fmt.Errorf(errTplNonExistProcessor, procDef.ID) - } - - if !proc.IsApplicable(vtype) { - return v, fmt.Errorf(errTplNotApplicableProcessor, vtype) - } - - processedValue, err := proc.Execute(res, procDef.Options) - if err != nil { - return v, err - } - - res = processedValue - } - // Cast to []any slice because jsonschema validator supports only this type. - if vtype == jsonschema.Array { - res = CastSliceTypedToAny(res) - } - - return res, nil -} - // ValidateInput validates action input. func (a *Action) ValidateInput(input *Input) error { if input.IsValidated() { diff --git a/pkg/action/action.input.go b/pkg/action/action.input.go index 9677f18..21a246c 100644 --- a/pkg/action/action.input.go +++ b/pkg/action/action.input.go @@ -1,6 +1,7 @@ package action import ( + "maps" "reflect" "strings" @@ -30,6 +31,8 @@ type Input struct { // argsPos contains raw positional arguments. argsPos []string + // argsRaw contains arguments that were input by a user and without default values. + argsRaw InputParams // optsRaw contains options that were input by a user and without default values. optsRaw InputParams } @@ -45,6 +48,7 @@ func NewInput(a *Action, args InputParams, opts InputParams, io launchr.Streams) return &Input{ action: a, args: setParamDefaults(args, def.Arguments), + argsRaw: args, argsPos: argsPos, opts: setParamDefaults(opts, def.Options), optsRaw: opts, @@ -98,31 +102,32 @@ func (input *Input) SetValidated(v bool) { // Arg returns argument by a name. func (input *Input) Arg(name string) any { - return input.ArgsNamed()[name] + return input.Args()[name] } // SetArg sets an argument value. func (input *Input) SetArg(name string, val any) { - input.optsRaw[name] = val - input.opts[name] = val + input.argsRaw[name] = val + input.args[name] = val } // UnsetArg unsets the arguments and recalculates default and positional values. func (input *Input) UnsetArg(name string) { delete(input.args, name) - input.args = setParamDefaults(input.args, input.action.ActionDef().Arguments) - input.argsPos = argsNamedToPos(input.args, input.action.ActionDef().Arguments) + delete(input.argsRaw, name) + input.args = setParamDefaults(input.argsRaw, input.action.ActionDef().Arguments) + input.argsPos = argsNamedToPos(input.argsRaw, input.action.ActionDef().Arguments) } // IsArgChanged checks if an argument was changed by user. func (input *Input) IsArgChanged(name string) bool { - _, ok := input.args[name] + _, ok := input.argsRaw[name] return ok } // Opt returns option by a name. func (input *Input) Opt(name string) any { - return input.OptsAll()[name] + return input.Opts()[name] } // SetOpt sets an option value. @@ -144,18 +149,23 @@ func (input *Input) IsOptChanged(name string) bool { return ok } -// ArgsNamed returns input named and processed arguments. -func (input *Input) ArgsNamed() InputParams { +// Args returns input named and processed arguments. +func (input *Input) Args() InputParams { return input.args } +// ArgsChanged returns arguments that were set manually by user (not processed). +func (input *Input) ArgsChanged() InputParams { + return input.argsRaw +} + // ArgsPositional returns positional arguments set by user (not processed). func (input *Input) ArgsPositional() []string { return input.argsPos } -// OptsAll returns options with default values and processed. -func (input *Input) OptsAll() InputParams { +// Opts returns options with default values and processed. +func (input *Input) Opts() InputParams { return input.opts } @@ -184,7 +194,10 @@ func argsNamedToPos(args InputParams, argsDef ParametersList) []string { } func setParamDefaults(params InputParams, paramDef ParametersList) InputParams { - res := copyMap(params) + res := maps.Clone(params) + if res == nil { + res = make(InputParams) + } for _, d := range paramDef { k := d.Name v, ok := params[k] diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 688b544..5e3f942 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -58,10 +58,10 @@ func Test_Action(t *testing.T) { require.NotNil(act.input) // Option is not defined, but should be there // because [Action.ValidateInput] decides if the input correct or not. - _, okOpt := act.input.OptsAll()["opt6"] + _, okOpt := act.input.Opts()["opt6"] assert.True(okOpt) - assert.Equal(inputArgs, act.input.ArgsNamed()) - assert.Equal(inputOpts, act.input.OptsAll()) + assert.Equal(inputArgs, act.input.Args()) + assert.Equal(inputOpts, act.input.Opts()) // Test templating in executable. envVar1 := "envval1" @@ -129,22 +129,30 @@ func Test_ActionInput(t *testing.T) { // Check argument is changed. input = NewInput(a, InputParams{"arg_str": "my_string"}, nil, nil) require.NotNil(input) - changed := input.ArgsNamed() - assert.Equal(InputParams{"arg_str": "my_string", "arg_default": "my_default_string"}, changed) + assert.Equal(InputParams{"arg_str": "my_string", "arg_default": "my_default_string"}, input.Args()) + assert.Equal(InputParams{"arg_str": "my_string"}, input.ArgsChanged()) assert.True(input.IsArgChanged("arg_str")) assert.False(input.IsArgChanged("arg_int")) assert.False(input.IsArgChanged("arg_str2")) + input.SetArg("arg_str2", "my_str2") + assert.True(input.IsArgChanged("arg_str2")) + assert.Equal(InputParams{"arg_str": "my_string", "arg_str2": "my_str2"}, input.ArgsChanged()) + input.UnsetArg("arg_str") + assert.Equal(InputParams{"arg_str2": "my_str2"}, input.ArgsChanged()) + assert.False(input.IsArgChanged("arg_str")) // Check option is changed. input = NewInput(a, nil, InputParams{"opt_str": "my_string"}, nil) require.NotNil(input) - changed = input.OptsChanged() - assert.Equal(InputParams{"opt_str": "my_string"}, changed) + assert.Equal(InputParams{"opt_str": "my_string"}, input.OptsChanged()) assert.True(input.IsOptChanged("opt_str")) assert.False(input.IsOptChanged("opt_int")) // Set option and check it's changed. input.SetOpt("opt_int", 24) assert.True(input.IsOptChanged("opt_int")) - assert.Equal(InputParams{"opt_str": "my_string", "opt_int": 24, "opt_str_default": "optdefault"}, input.OptsAll()) + assert.Equal(InputParams{"opt_str": "my_string", "opt_int": 24, "opt_str_default": "optdefault"}, input.Opts()) + input.UnsetOpt("opt_str") + assert.Equal(InputParams{"opt_int": 24}, input.OptsChanged()) + assert.False(input.IsOptChanged("opt_str")) // Test create with positional arguments of different types. argsPos := []string{"42", "str", "str2", "true", "str3", "undstr", "24"} @@ -163,7 +171,7 @@ func Test_ActionInput(t *testing.T) { } _, posKeyOk = input.args[inputMapKeyArgsPos] assert.False(posKeyOk) - assert.Equal(expArgs, input.ArgsNamed()) + assert.Equal(expArgs, input.Args()) assert.Equal(argsPos, input.ArgsPositional()) } @@ -320,6 +328,7 @@ func Test_ActionInputValidate(t *testing.T) { tt.fnInit(t, a, input) } err := a.ValidateInput(input) + assert.Equal(t, err == nil, input.IsValidated()) if tt.expErr == errAny { assert.True(t, assert.Error(t, err)) } else if assert.IsType(t, tt.expErr, err) { diff --git a/pkg/action/jsonschema.go b/pkg/action/jsonschema.go index 73a8ff9..20ce022 100644 --- a/pkg/action/jsonschema.go +++ b/pkg/action/jsonschema.go @@ -2,6 +2,7 @@ package action import ( "fmt" + "maps" "github.com/launchrctl/launchr/pkg/jsonschema" ) @@ -17,8 +18,8 @@ func validateJSONSchema(a *Action, input *Input) error { return jsonschema.Validate( a.JSONSchema(), map[string]any{ - jsonschemaPropArgs: input.ArgsNamed(), - jsonschemaPropOpts: input.OptsAll(), + jsonschemaPropArgs: input.Args(), + jsonschemaPropOpts: input.Opts(), }, ) } @@ -84,5 +85,5 @@ func (l *ParametersList) JSONSchema() (map[string]any, []string) { // JSONSchema returns json schema definition of an option. func (p *DefParameter) JSONSchema() map[string]any { - return copyMap(p.raw) + return maps.Clone(p.raw) } diff --git a/pkg/action/loader.go b/pkg/action/loader.go index a63d3f2..b51468b 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -142,8 +142,8 @@ Action definition is correct, but dashes are not allowed in templates, replace " // ConvertInputToTplVars creates a map with input variables suitable for template engine. func ConvertInputToTplVars(input *Input, ac *DefAction) map[string]any { - args := input.ArgsNamed() - opts := input.OptsAll() + args := input.Args() + opts := input.Opts() values := make(map[string]any, len(args)+len(opts)) // Collect arguments and options values. diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 3a08ff3..a2d1f38 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -3,6 +3,7 @@ package action import ( "context" "fmt" + "maps" "strconv" "sync" "time" @@ -111,6 +112,12 @@ func (m *actionManagerMap) Add(a *Action) error { } m.actionAliases[alias] = a.ID } + // Set action related processors. + err = a.SetProcessors(m.GetValueProcessors()) + if err != nil { + // Skip action because the definition is not correct. + return err + } m.actionStore[a.ID] = a return nil } @@ -118,7 +125,7 @@ func (m *actionManagerMap) Add(a *Action) error { func (m *actionManagerMap) AllUnsafe() map[string]*Action { m.mx.Lock() defer m.mx.Unlock() - return copyMap(m.actionStore) + return maps.Clone(m.actionStore) } func (m *actionManagerMap) GetIDFromAlias(alias string) string { @@ -303,10 +310,3 @@ func WithContainerRuntimeConfig(cfg launchr.Config, prefix string) DecorateWithF } } } - -// WithValueProcessors sets processors for action from manager. -func WithValueProcessors() DecorateWithFn { - return func(m Manager, a *Action) { - a.SetProcessors(m.GetValueProcessors()) - } -} diff --git a/pkg/action/process.go b/pkg/action/process.go index d398c48..e60c267 100644 --- a/pkg/action/process.go +++ b/pkg/action/process.go @@ -1,44 +1,125 @@ package action import ( + "fmt" + "reflect" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/launchrctl/launchr/pkg/jsonschema" ) // ValueProcessor defines an interface for processing a value based on its type and some options. type ValueProcessor interface { IsApplicable(valueType jsonschema.Type) bool - Execute(value any, options map[string]any) (any, error) + OptionsType() ValueProcessorOptions + Handler(opts ValueProcessorOptions) ValueProcessorHandler +} + +// ValueProcessorContext is related context data for ValueProcessor. +type ValueProcessorContext struct { + ValOrig any // ValOrig is the value before processing. + IsChanged bool // IsChanged indicates if the value was input by user. + Options ValueProcessorOptions // Options is the [ValueProcessor] configuration. + DefParam *DefParameter // DefParam is the definition of the currently processed parameter. + Action *Action // Action is the related action definition. +} + +// ValueProcessorHandler is an actual implementation of [ValueProcessor] that processes the incoming value. +type ValueProcessorHandler func(v any, ctx ValueProcessorContext) (any, error) + +// ValueProcessorOptions is a common type for value processor options +type ValueProcessorOptions interface { + Validate() error } -// ValueProcessorFn is a function signature used as a callback in processors. -type ValueProcessorFn func(value any, options map[string]any) (any, error) +// ValueProcessorOptionsEmpty when [ValueProcessor] doesn't have options. +type ValueProcessorOptionsEmpty struct{} -// NewFuncProcessor creates a new instance of [FuncProcessor] with the specified formats and callback. -func NewFuncProcessor(formats []jsonschema.Type, callback ValueProcessorFn) FuncProcessor { - return FuncProcessor{ - applicableFormats: formats, - callback: callback, +// Validate implements [ValueProcessorOptions] interface. +func (o *ValueProcessorOptionsEmpty) Validate() error { + return nil +} + +// GenericValueProcessor is a common [ValueProcessor]. +type GenericValueProcessor[T ValueProcessorOptions] struct { + Types []jsonschema.Type + Fn GenericValueProcessorHandler[T] +} + +// GenericValueProcessorHandler is an extension of [ValueProcessorHandler] to have typed [ValueProcessorOptions]. +type GenericValueProcessorHandler[T ValueProcessorOptions] func(v any, opts T, ctx ValueProcessorContext) (any, error) + +// IsApplicable implements [ValueProcessor] interface. +func (p GenericValueProcessor[T]) IsApplicable(t jsonschema.Type) bool { + if p.Types == nil { + // Allow any type. + return true } + return slices.Contains(p.Types, t) } -// FuncProcessor represents a processor that applies a callback function to values based on certain applicable formats. -type FuncProcessor struct { - applicableFormats []jsonschema.Type - callback ValueProcessorFn +// OptionsType implements [ValueProcessor] interface. +func (p GenericValueProcessor[T]) OptionsType() ValueProcessorOptions { + var t T + // Create a new instance of type. + rtype := reflect.TypeOf(t) + if rtype.Kind() == reflect.Ptr { + return reflect.New(rtype.Elem()).Interface().(T) + } + panic(fmt.Sprintf("type %T does not implement ValueProcessorOptions correctly: its method(s) must use a pointer receiver (*%T).", t, t)) } -// IsApplicable checks if the given valueType is present in the applicableFormats slice of the [FuncProcessor]. -func (p FuncProcessor) IsApplicable(valueType jsonschema.Type) bool { - for _, item := range p.applicableFormats { - if valueType == item { - return true - } +// Handler implements [ValueProcessor] interface. +func (p GenericValueProcessor[T]) Handler(opts ValueProcessorOptions) ValueProcessorHandler { + optsT, ok := opts.(T) + if !ok { + panic(fmt.Sprintf("incorrect options type, expected %T, actual %T, please ensure the code integrity", optsT, opts)) } + return func(v any, ctx ValueProcessorContext) (any, error) { + return p.Fn(v, optsT, ctx) + } +} - return false +// TestCaseValueProcessor is a common test case behavior for [ValueProcessor]. +type TestCaseValueProcessor struct { + Name string + Yaml string + ErrInit error + ErrProc error + Args InputParams + Opts InputParams + ExpArgs InputParams + ExpOpts InputParams } -// Execute applies the callback function of the [FuncProcessor] to the given value with options. -func (p FuncProcessor) Execute(value any, options map[string]any) (any, error) { - return p.callback(value, options) +// Test runs the test for [ValueProcessor]. +func (tt TestCaseValueProcessor) Test(t *testing.T, am Manager) { + a := NewFromYAML(tt.Name, []byte(tt.Yaml)) + // Init processors in the action. + err := a.SetProcessors(am.GetValueProcessors()) + require.Equal(t, err, tt.ErrInit) + if tt.ErrInit != nil { + return + } + // Run processors. + input := NewInput(a, tt.Args, tt.Opts, nil) + err = a.SetInput(input) + require.Equal(t, err, tt.ErrProc) + if tt.ErrProc != nil { + return + } + // Test input is processed. + input = a.Input() + if tt.ExpArgs == nil { + tt.ExpArgs = InputParams{} + } + if tt.ExpOpts == nil { + tt.ExpOpts = InputParams{} + } + assert.Equal(t, tt.ExpArgs, input.Args()) + assert.Equal(t, tt.ExpOpts, input.Opts()) } diff --git a/pkg/action/process_test.go b/pkg/action/process_test.go new file mode 100644 index 0000000..e5bbc26 --- /dev/null +++ b/pkg/action/process_test.go @@ -0,0 +1,204 @@ +package action + +import ( + "fmt" + "strings" + "testing" + + "github.com/launchrctl/launchr/pkg/jsonschema" +) + +const actionProcessWithDefault = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + default: "arg_default" + process: + - processor: test.defaultVal + - processor: test.replace + options: + old: A + new: B + - processor: test.replace + options: + old: C + new: D + options: + - name: opt1 + default: "opt_default" + process: + - processor: test.defaultVal + - processor: test.replace + options: + old: A + new: B + - processor: test.replace + options: + old: C + new: D +` + +const actionProcessNoDefault = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + process: + - processor: test.defaultVal + - processor: test.replace + options: + old: A + new: B + - processor: test.replace + options: + old: C + new: D + options: + - name: opt1 + process: + - processor: test.defaultVal + - processor: test.replace + options: + old: A + new: B + - processor: test.replace + options: + old: C + new: D +` + +const actionProcessBroken = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + process: + - processor: test.broken +` + +const actionProcessWrongOptions = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + process: + - processor: test.replace + options: + old: [1, 2, 3] + new: + obj: str +` + +const actionProcessReturnErr = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + process: + - processor: test.error +` + +const actionProcessUnsupType = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + type: integer + process: + - processor: test.replace + options: + old: A + new: B +` + +const actionProcessArrayType = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg1 + type: array + process: + - processor: test.defaultVal +` + +type procTestReplaceOptions struct { + O string `yaml:"old"` + N string `yaml:"new"` +} + +func (o *procTestReplaceOptions) Validate() error { + if o.O == "" { + return fmt.Errorf("parameter old required") + } + return nil +} + +func addTestValueProcessors(am Manager) { + procDefVal := GenericValueProcessor[*ValueProcessorOptionsEmpty]{ + Fn: func(v any, _ *ValueProcessorOptionsEmpty, ctx ValueProcessorContext) (any, error) { + if ctx.IsChanged { + return v, nil + } + switch ctx.DefParam.Type { + case jsonschema.String: + return "processed_default", nil + case jsonschema.Integer: + return 42, nil + case jsonschema.Array: + return []string{"1", "2", "3"}, nil + default: + return v, nil + } + }, + } + procReplace := GenericValueProcessor[*procTestReplaceOptions]{ + Types: []jsonschema.Type{jsonschema.String}, + Fn: func(v any, opts *procTestReplaceOptions, _ ValueProcessorContext) (any, error) { + return strings.Replace(v.(string), opts.O, opts.N, -1), nil + }, + } + procErr := GenericValueProcessor[*ValueProcessorOptionsEmpty]{ + Fn: func(v any, _ *ValueProcessorOptionsEmpty, ctx ValueProcessorContext) (any, error) { + return v, fmt.Errorf("my_error %q", ctx.DefParam.Name) + }, + } + am.AddValueProcessor("test.defaultVal", procDefVal) + am.AddValueProcessor("test.replace", procReplace) + am.AddValueProcessor("test.error", procErr) +} + +func Test_ActionsValueProcessor(t *testing.T) { + am := NewManager() + addTestValueProcessors(am) + + tt := []TestCaseValueProcessor{ + {"valid processor chain - with defaults, input given", actionProcessWithDefault, nil, nil, + InputParams{"arg1": "AAACCC"}, + InputParams{"opt1": "ACACAC"}, + InputParams{"arg1": "BBBDDD"}, + InputParams{"opt1": "BDBDBD"}, + }, + {Name: "valid processor chain - with default, no input given", Yaml: actionProcessWithDefault, ExpArgs: InputParams{"arg1": "processed_default"}, ExpOpts: InputParams{"opt1": "processed_default"}}, + {Name: "valid processor chain - no defaults, no input given", Yaml: actionProcessNoDefault, ExpArgs: InputParams{"arg1": "processed_default"}, ExpOpts: InputParams{"opt1": "processed_default"}}, + {Name: "valid processor - array processed and cast to []any", Yaml: actionProcessArrayType, ExpArgs: InputParams{"arg1": []any{"1", "2", "3"}}, ExpOpts: InputParams{}}, + {Name: "wrong options", Yaml: actionProcessWrongOptions, ErrInit: yamlMergeErrors(yamlTypeError("line 10: cannot unmarshal !!seq into string"), yamlTypeError("line 12: cannot unmarshal !!map into string"))}, + {Name: "broken processor", Yaml: actionProcessBroken, ErrInit: fmt.Errorf(errTplNonExistProcessor, "test.broken")}, + {Name: "unsupported type", Yaml: actionProcessUnsupType, ErrInit: fmt.Errorf(errTplNotApplicableProcessor, "test.replace", jsonschema.Integer)}, + {Name: "processor return error", Yaml: actionProcessReturnErr, ErrProc: fmt.Errorf(errTplErrorOnProcessor, "arg1", "test.error", fmt.Errorf("my_error %q", "arg1"))}, + } + for _, tt := range tt { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + tt.Test(t, am) + }) + } +} diff --git a/pkg/action/runtime.container_test.go b/pkg/action/runtime.container_test.go index 921765a..0f824bc 100644 --- a/pkg/action/runtime.container_test.go +++ b/pkg/action/runtime.container_test.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "slices" "strings" "testing" "testing/fstest" @@ -620,7 +621,7 @@ func Test_ContainerExec(t *testing.T) { "container create error", nil, append( - copySlice(successSteps[0:1]), + slices.Clone(successSteps[0:1]), mockCallInfo{ "ContainerCreate", 1, 1, @@ -633,7 +634,7 @@ func Test_ContainerExec(t *testing.T) { "container create error - empty container id", nil, append( - copySlice(successSteps[0:1]), + slices.Clone(successSteps[0:1]), mockCallInfo{ "ContainerCreate", 1, 1, @@ -648,7 +649,7 @@ func Test_ContainerExec(t *testing.T) { resCh <- types.ContainerWaitResponse{StatusCode: 0} }, append( - copySlice(successSteps[0:2]), + slices.Clone(successSteps[0:2]), mockCallInfo{ "ContainerAttach", 1, 1, @@ -664,7 +665,7 @@ func Test_ContainerExec(t *testing.T) { resCh <- types.ContainerWaitResponse{StatusCode: 0} }, append( - copySlice(successSteps[0:4]), + slices.Clone(successSteps[0:4]), mockCallInfo{ "ContainerStart", 1, 1, @@ -680,7 +681,7 @@ func Test_ContainerExec(t *testing.T) { resCh <- types.ContainerWaitResponse{StatusCode: 2} }, append( - copySlice(successSteps[0:4]), + slices.Clone(successSteps[0:4]), mockCallInfo{ "ContainerStart", 1, 1, @@ -727,12 +728,6 @@ func Test_ContainerExec(t *testing.T) { } } -func copySlice[T any](arr []T) []T { - c := make([]T, len(arr)) - copy(c, arr) - return c -} - func callContainerDriverMockFn(d *mockdriver.MockContainerRunner, step mockCallInfo, prev *gomock.Call) *gomock.Call { var call *gomock.Call switch step.fn { diff --git a/pkg/action/utils.go b/pkg/action/utils.go index 20badcb..b9240a5 100644 --- a/pkg/action/utils.go +++ b/pkg/action/utils.go @@ -123,11 +123,3 @@ func collectAllNodes(n *yaml.Node) []*yaml.Node { } return res } - -func copyMap[K comparable, V any](m map[K]V) map[K]V { - r := make(map[K]V, len(m)) - for k, v := range m { - r[k] = v - } - return r -} diff --git a/pkg/action/yaml.def.go b/pkg/action/yaml.def.go index b023c8a..aface61 100644 --- a/pkg/action/yaml.def.go +++ b/pkg/action/yaml.def.go @@ -386,6 +386,8 @@ type DefParameter struct { Required bool `yaml:"required"` // Process is an array of [ValueProcessor] to a value. Process []DefValueProcessor `yaml:"process"` + // processors is an instantiated list of processor handlers. + processors []ValueProcessorHandler // raw is a raw parameter declaration to support all JSON Schema features. raw map[string]any } @@ -499,8 +501,8 @@ type DefArrayItems struct { // DefValueProcessor stores information about processor and options that should be applied to processor. type DefValueProcessor struct { - ID string `yaml:"processor"` - Options map[string]any `yaml:"options"` + ID string `yaml:"processor"` + optsRaw *yaml.Node // optsRaw is saved for later processing of options. } // UnmarshalYAML implements [yaml.Unmarshaler] to parse [DefValueProcessor]. @@ -514,5 +516,36 @@ func (p *DefValueProcessor) UnmarshalYAML(n *yaml.Node) (err error) { if p.ID == "" { return yamlTypeErrorLine(sErrEmptyProcessorID, n.Line, n.Column) } + p.optsRaw = yamlFindNodeByKey(n, "options") + return nil +} + +// InitProcessors creates [ValueProcessor] handlers according to the definition. +func (p *DefParameter) InitProcessors(list map[string]ValueProcessor) error { + processors := make([]ValueProcessorHandler, 0, len(p.Process)) + for _, procDef := range p.Process { + proc, ok := list[procDef.ID] + if !ok { + return fmt.Errorf(errTplNonExistProcessor, procDef.ID) + } + + if !proc.IsApplicable(p.Type) { + return fmt.Errorf(errTplNotApplicableProcessor, procDef.ID, p.Type) + } + + opts := proc.OptionsType() + if procDef.optsRaw != nil { + err := procDef.optsRaw.Decode(opts) + if err != nil { + return err + } + } + + if err := opts.Validate(); err != nil { + return err + } + processors = append(processors, proc.Handler(opts)) + } + p.processors = processors return nil } diff --git a/pkg/action/yaml_test.go b/pkg/action/yaml_test.go index 89e98d9..e17c238 100644 --- a/pkg/action/yaml_test.go +++ b/pkg/action/yaml_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/stretchr/testify/assert" + + "github.com/launchrctl/launchr/pkg/jsonschema" ) func Test_CreateFromYaml(t *testing.T) { @@ -42,7 +44,7 @@ func Test_CreateFromYaml(t *testing.T) { {"invalid arguments field - object", invalidArgsObjYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 7, 5)}, {"invalid argument empty name", invalidArgsEmptyNameYaml, yamlTypeErrorLine(sErrEmptyActionParamName, 7, 7)}, {"invalid argument name", invalidArgsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrInvalidActionParamName, "0arg"), 7, 13)}, - {"invalid argument default type", invalidArgsDefaultMismatch, yamlTypeErrorLine("value type and expected type mismatch", 8, 16)}, + {"invalid argument default type", invalidArgsDefaultMismatch, yamlTypeErrorLine(jsonschema.NewErrTypeMismatch(0, "").Error(), 8, 16)}, // Options are incorrectly provided v1 - string, not an array of objects. {"invalid options field - string", invalidOptsStrYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 6, 12)}, diff --git a/pkg/jsonschema/error.go b/pkg/jsonschema/error.go index 7e8fec0..4842bfd 100644 --- a/pkg/jsonschema/error.go +++ b/pkg/jsonschema/error.go @@ -10,6 +10,25 @@ import ( "github.com/launchrctl/launchr/internal/launchr" ) +// ErrTypeMismatch is an error when expected jsonschema type and given values mismatch. +type ErrTypeMismatch struct { + act string + exp string +} + +// NewErrTypeMismatch constructs new ErrTypeMismatch. +func NewErrTypeMismatch(act, exp any) ErrTypeMismatch { + return ErrTypeMismatch{ + act: fmt.Sprintf("%T", act), + exp: fmt.Sprintf("%T", exp), + } +} + +// Error implements error interface. +func (err ErrTypeMismatch) Error() string { + return fmt.Sprintf("given value type (%s) and expected type (%s) mismatch", err.act, err.exp) +} + // ErrSchemaValidationArray is an array of validation errors. type ErrSchemaValidationArray []ErrSchemaValidation diff --git a/pkg/jsonschema/type.go b/pkg/jsonschema/type.go index 6809b30..7c7f9a2 100644 --- a/pkg/jsonschema/type.go +++ b/pkg/jsonschema/type.go @@ -72,9 +72,9 @@ func useValueOrDefault[T any](val any, d T) (T, error) { switch v := val.(type) { case T: return v, nil + default: + return d, NewErrTypeMismatch(v, d) } - - return d, fmt.Errorf("value type and expected type mismatch") } // ConvertStringToType converts a string value to jsonschema type. diff --git a/plugins/builtinprocessors/plugin.go b/plugins/builtinprocessors/plugin.go index 5ad33cb..e0fbed2 100644 --- a/plugins/builtinprocessors/plugin.go +++ b/plugins/builtinprocessors/plugin.go @@ -10,7 +10,9 @@ import ( ) const ( - getConfigValue = "launchr.GetConfigValue" + // Deprecated: update definitions and use procGetConfigValue + procGetConfigValueDeprecated = "launchr.GetConfigValue" + procGetConfigValue = "config.GetValue" ) func init() { @@ -40,35 +42,42 @@ func (p Plugin) OnAppInit(app launchr.App) error { return nil } -// AddValueProcessors submits new ValueProcessors to action.Manager. -func addValueProcessors(m action.Manager, cfg launchr.Config) { - getByKey := func(value any, options map[string]any) (any, error) { - return getByKeyProcessor(value, options, cfg) +// ConfigGetProcessorOptions is an options struct for `config.GetValue`. +type ConfigGetProcessorOptions struct { + Path string `yaml:"path"` +} + +// Validate implements [action.ValueProcessorOptions] interface. +func (o *ConfigGetProcessorOptions) Validate() error { + if o.Path == "" { + return fmt.Errorf(`option "path" is required for %q processor`, procGetConfigValue) } + return nil +} - proc := action.NewFuncProcessor( - []jsonschema.Type{jsonschema.String, jsonschema.Integer, jsonschema.Boolean, jsonschema.Number}, - getByKey, - ) - m.AddValueProcessor(getConfigValue, proc) +// addValueProcessors submits new [action.ValueProcessor] to [action.Manager]. +func addValueProcessors(m action.Manager, cfg launchr.Config) { + procCfg := action.GenericValueProcessor[*ConfigGetProcessorOptions]{ + Fn: func(v any, opts *ConfigGetProcessorOptions, ctx action.ValueProcessorContext) (any, error) { + return processorConfigGetByKey(v, opts, ctx, cfg) + }, + } + m.AddValueProcessor(procGetConfigValueDeprecated, procCfg) + m.AddValueProcessor(procGetConfigValue, procCfg) } -func getByKeyProcessor(value any, options map[string]any, cfg launchr.Config) (any, error) { - path, ok := options["path"].(string) - if !ok { - return value, fmt.Errorf(`option "path" is required for %q processor`, getConfigValue) +func processorConfigGetByKey(v any, opts *ConfigGetProcessorOptions, ctx action.ValueProcessorContext, cfg launchr.Config) (any, error) { + // If value is provided by user, do not override. + if ctx.IsChanged { + return v, nil } + // Get value from the config. var res any - err := cfg.Get(path, &res) + err := cfg.Get(opts.Path, &res) if err != nil { - return value, err - } - - switch res.(type) { - case int, int8, int16, int32, int64, float32, float64, string, bool: - value = res + return v, err } - return value, nil + return jsonschema.EnsureType(ctx.DefParam.Type, res) } diff --git a/plugins/builtinprocessors/plugin_test.go b/plugins/builtinprocessors/plugin_test.go new file mode 100644 index 0000000..c699725 --- /dev/null +++ b/plugins/builtinprocessors/plugin_test.go @@ -0,0 +1,114 @@ +package builtinprocessors + +import ( + "fmt" + "testing" + "testing/fstest" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" + "github.com/launchrctl/launchr/pkg/jsonschema" +) + +const testProcGetConfig = ` +runtime: plugin +action: + title: test config + options: + - name: string + process: + - processor: config.GetValue + options: + path: my.string + - name: int + type: integer + default: 24 + process: + - processor: config.GetValue + options: + path: my.int + - name: bool + type: boolean + process: + - processor: config.GetValue + options: + path: my.bool + - name: array + type: array + process: + - processor: config.GetValue + options: + path: my.array +` + +const testProcGetConfigTypeMismatch = ` +runtime: plugin +action: + title: test config + options: + - name: string + process: + - processor: config.GetValue + options: + path: my.int +` + +const testProcGetConfigWrongDef = ` +runtime: plugin +action: + title: test config + options: + - name: string + process: + - processor: config.GetValue +` + +const testConfig = ` +my: + string: my_str + int: 42 + bool: true + array: ["1", "2", "3"] +` + +func testConfigFS(s string) launchr.Config { + m := fstest.MapFS{ + "config.yaml": &fstest.MapFile{Data: []byte(s)}, + } + return launchr.ConfigFromFS(m) +} + +func Test_ConfigProcessor(t *testing.T) { + // Prepare services. + cfg := testConfigFS(testConfig) + am := action.NewManager() + addValueProcessors(am, cfg) + + expConfig := action.InputParams{ + "string": "my_str", + "int": 42, + "bool": true, + "array": []any{"1", "2", "3"}, + } + expGiven := action.InputParams{ + "string": "my_input_str", + "int": 422, + "bool": false, + "array": []any{"3", "2", "1"}, + } + errType := fmt.Errorf("failed to process parameter %q with %q: %w", "string", procGetConfigValue, jsonschema.NewErrTypeMismatch(0, "")) + errOpts := fmt.Errorf("option %q is required for %q processor", "path", procGetConfigValue) + tt := []action.TestCaseValueProcessor{ + {Name: "get config value - no input given", Yaml: testProcGetConfig, ExpOpts: expConfig}, + {Name: "get config value - input given", Yaml: testProcGetConfig, Opts: expGiven, ExpOpts: expGiven}, + {Name: "get config value - result type mismatch", Yaml: testProcGetConfigTypeMismatch, ErrProc: errType}, + {Name: "get config value - wrong options", Yaml: testProcGetConfigWrongDef, ErrInit: errOpts}, + } + for _, tt := range tt { + tt := tt + t.Run(tt.Name, func(t *testing.T) { + t.Parallel() + tt.Test(t, am) + }) + } +}