From ab9e3f811b803be8e1bf1d8e91f31dd205c3e177 Mon Sep 17 00:00:00 2001 From: Igor Ignatyev Date: Mon, 14 Apr 2025 13:17:41 +0300 Subject: [PATCH] change behavior of runtime and globals opts for action --- internal/launchr/streams.go | 5 + internal/launchr/term.go | 18 +-- pkg/action/action.flags.go | 136 +++++++++++++++++++++++ pkg/action/action.go | 84 +------------- pkg/action/action.input.go | 26 +++++ pkg/action/action_test.go | 6 +- pkg/action/jsonschema.go | 27 ++++- pkg/action/manager.go | 153 ++++++++++++++++++++++++-- pkg/action/runtime.container.go | 158 +++++++++++++++------------ pkg/action/runtime.container_test.go | 2 + pkg/action/runtime.fn.go | 30 +++-- pkg/action/runtime.go | 93 +++++++++++++++- pkg/action/runtime.shell.go | 3 +- pkg/action/test_utils.go | 5 +- plugins/actionscobra/cobra.go | 46 +++++--- plugins/actionscobra/plugin.go | 2 +- plugins/actionscobra/usage.go | 5 +- plugins/builder/builder.go | 23 ++-- plugins/builder/environment.go | 6 +- plugins/builder/plugin.go | 18 +++ plugins/verbosity/plugin.go | 145 +++++++++++++++++++++--- 21 files changed, 759 insertions(+), 232 deletions(-) create mode 100644 pkg/action/action.flags.go diff --git a/internal/launchr/streams.go b/internal/launchr/streams.go index ef45c34..0e590be 100644 --- a/internal/launchr/streams.go +++ b/internal/launchr/streams.go @@ -31,6 +31,11 @@ func (s *commonStream) FD() uintptr { return s.fd } +// Fd returns the file descriptor number for this stream. +func (s *commonStream) Fd() uintptr { + return s.fd +} + // IsDiscard returns if read/write is discarded. func (s *commonStream) IsDiscard() bool { return s.isDiscard diff --git a/internal/launchr/term.go b/internal/launchr/term.go index 377eaed..bb72dc0 100644 --- a/internal/launchr/term.go +++ b/internal/launchr/term.go @@ -2,7 +2,6 @@ package launchr import ( "io" - "log" "reflect" "github.com/pterm/pterm" @@ -19,7 +18,14 @@ var DefaultTextPrinter = message.NewPrinter(language.English) func init() { // Initialize the default printer. - defaultTerm = &Terminal{ + defaultTerm = NewTerminal() + // Do not output anything when not in the app, e.g. in tests. + defaultTerm.DisableOutput() +} + +// NewTerminal creates a new instance of [Terminal] +func NewTerminal() *Terminal { + return &Terminal{ p: []TextPrinter{ printerBasic: newPTermBasicPrinter(pterm.DefaultBasicText), printerInfo: newPTermPrefixPrinter(pterm.Info), @@ -29,8 +35,6 @@ func init() { }, enabled: true, } - // Do not output anything when not in the app, e.g. in tests. - defaultTerm.DisableOutput() } // Predefined keys of terminal printers. @@ -84,7 +88,9 @@ func (p *ptermPrinter) SetOutput(w io.Writer) { if !method.IsValid() { panic("WithWriter is not implemented for this pterm.TextPrinter") } - method.Call([]reflect.Value{reflect.ValueOf(w)}) + result := method.Call([]reflect.Value{reflect.ValueOf(w)}) + // Replace old printer by new one as WithWriter returns fresh copy of struct. + p.pterm = result[0].Interface().(pterm.TextPrinter) } // Terminal prints formatted text to the console. @@ -115,8 +121,6 @@ func (t *Terminal) DisableOutput() { // SetOutput sets an output to target writer. func (t *Terminal) SetOutput(w io.Writer) { t.w = w - // If some library uses std log, redirect as well. - log.SetOutput(w) // Ensure underlying printers use self. // Used to simplify update of writers in the printers. for i := 0; i < len(t.p); i++ { diff --git a/pkg/action/action.flags.go b/pkg/action/action.flags.go new file mode 100644 index 0000000..ab71b49 --- /dev/null +++ b/pkg/action/action.flags.go @@ -0,0 +1,136 @@ +package action + +import ( + "fmt" + + "github.com/launchrctl/launchr/pkg/jsonschema" +) + +// FlagsGroup holds definitions, current state, and default values of flags. +// @todo think about moving it to new input validation service alongside. See notes in actionManagerMap.ValidateFlags. +type FlagsGroup struct { + name string + definitions ParametersList + values map[string]any + defaults map[string]any +} + +// NewFlagsGroup returns a new instance of [FlagsGroup] +func NewFlagsGroup(name string) *FlagsGroup { + return &FlagsGroup{ + name: name, + definitions: make(ParametersList, 0), + values: make(map[string]any), + defaults: make(map[string]any), + } +} + +// GetName returns the name of the flags group. +func (p *FlagsGroup) GetName() string { + return p.name +} + +// GetAll returns the latest state of flags. +func (p *FlagsGroup) GetAll() InputParams { + result := make(InputParams) + for name, value := range p.defaults { + if _, ok := p.values[name]; !ok { + result[name] = value + } else { + result[name] = p.values[name] + } + } + + return result +} + +func (p *FlagsGroup) exists(name string) bool { + _, ok := p.defaults[name] + return ok +} + +// Get returns state of a named flag. +// Return false if a flag doesn't exist. +func (p *FlagsGroup) Get(name string) (any, bool) { + if !p.exists(name) { + return nil, false + } + + var value any + if v, ok := p.values[name]; ok { + value = v + } else { + value = p.defaults[name] + } + + return value, true +} + +// Set sets new state value for a flag. Does nothing if flag doesn't exist. +func (p *FlagsGroup) Set(name string, value any) { + if !p.exists(name) { + return + } + + p.values[name] = value +} + +// Unset removes the flag value. +// The default value will be returned during [FlagsGroup.GetAll] if flag is not set. +func (p *FlagsGroup) Unset(name string) { + delete(p.values, name) +} + +// GetDefinitions returns [ParametersList] with flags definitions. +func (p *FlagsGroup) GetDefinitions() ParametersList { + return p.definitions +} + +// AddDefinitions adds new flag definition. +func (p *FlagsGroup) AddDefinitions(opts ParametersList) { + registered := make(map[string]struct{}) + + for _, def := range p.definitions { + registered[def.Name] = struct{}{} + } + + for _, opt := range opts { + if opt.Name == "" { + panic(fmt.Sprintf("%s flag name cannot be empty", p.name)) + } + + if _, exists := registered[opt.Name]; exists { + panic(fmt.Sprintf("duplicate %s flag has been detected %s", p.name, opt.Name)) + } + + p.definitions = append(p.definitions, opt) + } + + for _, d := range p.definitions { + p.defaults[d.Name] = d.Default + } +} + +// JSONSchema returns JSON schema of a flags group. +func (p *FlagsGroup) JSONSchema() jsonschema.Schema { + opts, optsReq := p.definitions.JSONSchema() + return jsonschema.Schema{ + Type: jsonschema.Object, + Required: []string{p.name}, + Properties: map[string]any{ + p.name: map[string]any{ + "type": "object", + "title": p.name, + "properties": opts, + "required": optsReq, + "additionalProperties": false, + }, + }, + } +} + +// ValidateFlags validates input flags. +func (p *FlagsGroup) ValidateFlags(params InputParams) error { + s := p.JSONSchema() + return jsonschema.Validate(s, map[string]any{p.name: params}) +} diff --git a/pkg/action/action.go b/pkg/action/action.go index 2df9608..5d3cc3f 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -10,7 +10,6 @@ import ( "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/driver" - "github.com/launchrctl/launchr/pkg/jsonschema" ) // Action is an action definition with a contextual id (name), working directory path @@ -29,9 +28,8 @@ type Action struct { def *Definition // def is an action definition. Loaded by [Loader], may be nil when not initialized. defRaw *Definition // defRaw is a raw action definition. Loaded by [Loader], may be nil when not initialized. - runtime Runtime // runtime is the [Runtime] to execute the action. - input *Input // input is a storage for arguments and options used in runtime. - processors map[string]ValueProcessor // processors are [ValueProcessor] for manipulating input. + runtime Runtime // runtime is the [Runtime] to execute the action. + input *Input // input is a storage for arguments and options used in runtime. } // New creates a new action. @@ -110,11 +108,6 @@ func (a *Action) SetProcessors(list map[string]ValueProcessor) error { return nil } -// GetProcessors returns processors map. -func (a *Action) GetProcessors() map[string]ValueProcessor { - return a.processors -} - // Reset unsets loaded action to force reload. func (a *Action) Reset() { a.def = nil } @@ -256,23 +249,8 @@ func (a *Action) ImageBuildInfo(image string) *driver.BuildDefinition { // SetInput saves arguments and options for later processing in run, templates, etc. func (a *Action) SetInput(input *Input) (err error) { - def := a.ActionDef() - - // Process arguments. - err = a.processInputParams(def.Arguments, input.Args(), input.ArgsChanged(), input) - if err != nil { - return err - } - - // Process options. - err = a.processInputParams(def.Options, input.Opts(), input.OptsChanged(), input) - if err != nil { - return err - } - - // Validate the new input. - if err = a.ValidateInput(input); err != nil { - return err + if !input.IsValidated() { + return fmt.Errorf("input is not validated") } a.input = input @@ -281,60 +259,6 @@ func (a *Action) SetInput(input *Input) (err error) { return a.EnsureLoaded() } -func (a *Action) processInputParams(def ParametersList, inp InputParams, changed InputParams, input *Input) error { - var err error - for _, p := range def { - _, 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, - Input: input, - DefParam: p, - Action: a, - }) - if err != nil { - return ErrValueProcessorHandler{ - Processor: procDef.ID, - Param: p.Name, - Err: err, - } - } - } - // Cast to []any slice because jsonschema validator supports only this type. - if p.Type == jsonschema.Array { - res = CastSliceTypedToAny(res) - } - // If the value was changed, we can safely override the value. - // If the value was not changed and processed is nil, do not add it. - if isChanged || res != nil { - inp[p.Name] = res - } - } - - return nil -} - -// ValidateInput validates action input. -func (a *Action) ValidateInput(input *Input) error { - if input.IsValidated() { - return nil - } - argsDefLen := len(a.ActionDef().Arguments) - argsPosLen := len(input.ArgsPositional()) - if argsPosLen > argsDefLen { - return fmt.Errorf("accepts %d arg(s), received %d", argsDefLen, argsPosLen) - } - err := validateJSONSchema(a, input) - if err != nil { - return err - } - input.SetValidated(true) - return nil -} - // Execute runs action in the specified environment. func (a *Action) Execute(ctx context.Context) error { // @todo maybe it shouldn't be here. diff --git a/pkg/action/action.input.go b/pkg/action/action.input.go index 5c7d947..a8edfc7 100644 --- a/pkg/action/action.input.go +++ b/pkg/action/action.input.go @@ -26,6 +26,8 @@ type Input struct { args InputParams // opts contains parsed options with default values. opts InputParams + // groups contains input parameters grouped by unique name + groups map[string]InputParams // io contains out/in/err destinations. io launchr.Streams @@ -52,6 +54,7 @@ func NewInput(a *Action, args InputParams, opts InputParams, io launchr.Streams) argsPos: argsPos, opts: setParamDefaults(opts, def.Options), optsRaw: opts, + groups: make(map[string]InputParams), io: io, } } @@ -149,6 +152,29 @@ func (input *Input) IsOptChanged(name string) bool { return ok } +// GroupFlags returns stored group flags values. +func (input *Input) GroupFlags(group string) InputParams { + if gp, ok := input.groups[group]; ok { + return gp + } + return make(InputParams) +} + +// GetFlagInGroup returns a group flag by name. +func (input *Input) GetFlagInGroup(group, name string) any { + return input.GroupFlags(group)[name] +} + +// SetFlagInGroup sets group flag value. +func (input *Input) SetFlagInGroup(group, name string, val any) { + gp, ok := input.groups[group] + if !ok { + gp = make(InputParams) + input.groups[group] = gp + } + gp[name] = val +} + // Args returns input named and processed arguments. func (input *Input) Args() InputParams { return input.args diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index 53997b8..3fe7fa9 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -56,7 +56,7 @@ 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. + // because [manager.ValidateInput] decides if the input correct or not. _, okOpt := act.input.Opts()["opt6"] assert.True(okOpt) assert.Equal(inputArgs, act.input.Args()) @@ -231,6 +231,8 @@ func Test_ActionInputValidate(t *testing.T) { expErr error } + am := NewManager() + // Extra input preparation and testing. setValidatedInput := func(t *testing.T, _ *Action, input *Input) { input.SetValidated(true) @@ -371,7 +373,7 @@ func Test_ActionInputValidate(t *testing.T) { if tt.fnInit != nil { tt.fnInit(t, a, input) } - err := a.ValidateInput(input) + err := am.ValidateInput(a, input) assert.Equal(t, err == nil, input.IsValidated()) assertIsSameError(t, tt.expErr, err) }) diff --git a/pkg/action/jsonschema.go b/pkg/action/jsonschema.go index 20ce022..fe88575 100644 --- a/pkg/action/jsonschema.go +++ b/pkg/action/jsonschema.go @@ -8,8 +8,10 @@ import ( ) const ( - jsonschemaPropArgs = "arguments" - jsonschemaPropOpts = "options" + jsonschemaPropArgs = "arguments" + jsonschemaPropOpts = "options" + jsonschemaPropRuntime = "runtime" + jsonschemaPropPersistent = "persistent" ) // validateJSONSchema validates arguments and options according to @@ -85,5 +87,24 @@ func (l *ParametersList) JSONSchema() (map[string]any, []string) { // JSONSchema returns json schema definition of an option. func (p *DefParameter) JSONSchema() map[string]any { - return maps.Clone(p.raw) + if len(p.raw) != 0 { + return maps.Clone(p.raw) + } + + // We copy to raw because the DefParameter can be created directly in runtime/persistent flags. + // It is different from when we parse a yaml file, that's why the raw may be empty here and needs to be recreated. + // TODO: Refactor how DefParameter is created in Persistent, Runtime and yaml, unify 2 cases. Maybe rethink how we create a DefParameter. + raw := make(map[string]any) + raw["title"] = p.Title + raw["type"] = p.Type + raw["default"] = p.Default + + if len(p.Enum) > 0 { + raw["enum"] = p.Enum + } + if p.Description != "" { + raw["description"] = p.Description + } + + return raw } diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 26a94b5..74d04cf 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -11,6 +11,7 @@ import ( "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/driver" + "github.com/launchrctl/launchr/pkg/jsonschema" ) // DiscoverActionsFn defines a function to discover actions. @@ -27,9 +28,13 @@ type Manager interface { Add(*Action) error // Delete deletes the action from the manager. Delete(id string) - // Decorate decorates an action with given behaviors and returns its copy. + + // AddDecorators adds new decorators to manager. + AddDecorators(withFns ...DecorateWithFn) + // Decorate decorates an action with given behaviors. // If functions withFn are not provided, default functions are applied. - Decorate(a *Action, withFn ...DecorateWithFn) *Action + Decorate(a *Action, withFn ...DecorateWithFn) + // GetIDFromAlias returns a real action ID by its alias. If not, returns alias. GetIDFromAlias(alias string) string @@ -39,6 +44,10 @@ type Manager interface { // This id provider will be used as default on [Action] discovery process. SetActionIDProvider(p IDProvider) + // GetPersistentFlags retrieves the instance of FlagsGroup containing global flag definitions and their + // current state. + GetPersistentFlags() *FlagsGroup + // AddValueProcessor adds processor to list of available processors AddValueProcessor(name string, vp ValueProcessor) // GetValueProcessors returns list of available processors @@ -49,6 +58,10 @@ type Manager interface { // SetDiscoveryTimeout sets discovery timeout to stop on long-running callbacks. SetDiscoveryTimeout(timeout time.Duration) + // ValidateInput validates an action input. + // @todo think about decoupling it from manager to separate service + ValidateInput(a *Action, input *Input) error + RunManager } @@ -95,6 +108,8 @@ type actionManagerMap struct { discoverySeq *launchr.SliceSeqStateful[DiscoverActionsFn] discTimeout time.Duration + persistentFlags *FlagsGroup + runManagerMap } @@ -106,6 +121,8 @@ func NewManager(withFns ...DecorateWithFn) Manager { dwFns: withFns, processors: make(map[string]ValueProcessor), + persistentFlags: NewFlagsGroup(jsonschemaPropPersistent), + discTimeout: 10 * time.Second, runManagerMap: runManagerMap{ @@ -189,7 +206,9 @@ func (m *actionManagerMap) Delete(id string) { func (m *actionManagerMap) All() map[string]*Action { ret := m.AllUnsafe() for k, v := range ret { - ret[k] = m.Decorate(v, m.dwFns...) + a := v.Clone() + m.Decorate(a, m.dwFns...) + ret[k] = a } return ret } @@ -197,7 +216,9 @@ func (m *actionManagerMap) All() map[string]*Action { func (m *actionManagerMap) Get(id string) (*Action, bool) { a, ok := m.GetUnsafe(id) // Process action with default decorators and return a copy to have an isolated scope. - return m.Decorate(a, m.dwFns...), ok + a = a.Clone() + m.Decorate(a, m.dwFns...) + return a, ok } func (m *actionManagerMap) GetUnsafe(id string) (a *Action, ok bool) { @@ -282,18 +303,21 @@ func (m *actionManagerMap) GetValueProcessors() map[string]ValueProcessor { return m.processors } -func (m *actionManagerMap) Decorate(a *Action, withFns ...DecorateWithFn) *Action { +func (m *actionManagerMap) AddDecorators(withFns ...DecorateWithFn) { + m.dwFns = append(m.dwFns, withFns...) +} + +func (m *actionManagerMap) Decorate(a *Action, withFns ...DecorateWithFn) { if a == nil { - return nil + return } if withFns == nil { withFns = m.dwFns } - a = a.Clone() + for _, fn := range withFns { fn(m, a) } - return a } func (m *actionManagerMap) GetActionIDProvider() IDProvider { @@ -310,6 +334,119 @@ func (m *actionManagerMap) SetActionIDProvider(p IDProvider) { m.idProvider = p } +func (m *actionManagerMap) GetPersistentFlags() *FlagsGroup { + return m.persistentFlags +} + +func (m *actionManagerMap) ValidateInput(a *Action, input *Input) error { + // @todo move to a separate service with full input validation. See notes below. + // @todo think about a more elegant solution as right now it forces us to build workarounds for validation. + // Currently, input validation includes 3 types of validations: + // 1) Validation of runtime flags + // 2) Validation of persistent flags + // 3) Validation of arguments and options + // + // At present, this approach is neither flexible nor elegant enough. + // Ideally, all 3 steps should be validated with a single jsonschema.validate call, + // but each part of the input has unique properties that must be respected. + // + // For example, some runtimes may allow skipping further validation and proceeding without + // executing the action. + // + // Persistent flags are not related to the action itself; they exist separately and cannot + // be combined with runtime flags due to the partial validation described above. + // + // The ideal solution would be to combine all properties within a JSON schema and validate it. + // Runtime properties that allow skipping validation and provide completely different behavior + // should be implemented differently - such as through a special launcher flag, action, or + // new functionality specifically for debugging runtimes. + if r, ok := a.Runtime().(RuntimeFlags); ok { + err := r.ValidateInput(input) + if err != nil { + return err + } + + if err = r.SetFlags(input); err != nil { + return err + } + } + + if input.IsValidated() { + return nil + } + + persistentFlags := m.GetPersistentFlags() + err := persistentFlags.ValidateFlags(input.GroupFlags(persistentFlags.GetName())) + if err != nil { + return err + } + + def := a.ActionDef() + + // Process arguments. + err = m.processInputParams(def.Arguments, input.Args(), input.ArgsChanged(), input) + if err != nil { + return err + } + + // Process options. + err = m.processInputParams(def.Options, input.Opts(), input.OptsChanged(), input) + if err != nil { + return err + } + + argsDefLen := len(a.ActionDef().Arguments) + argsPosLen := len(input.ArgsPositional()) + if argsPosLen > argsDefLen { + return fmt.Errorf("accepts %d arg(s), received %d", argsDefLen, argsPosLen) + } + err = validateJSONSchema(a, input) + if err != nil { + return err + } + input.SetValidated(true) + + return nil +} + +// processInputParams applies value processors to input parameters. +func (m *actionManagerMap) processInputParams(def ParametersList, inp InputParams, changed InputParams, input *Input) error { + // @todo move to a separate service with full input validation. See notes in actionManagerMap.ValidateFlags. + var err error + for _, p := range def { + _, 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, + Input: input, + DefParam: p, + Action: input.action, + }) + if err != nil { + return ErrValueProcessorHandler{ + Processor: procDef.ID, + Param: p.Name, + Err: err, + } + } + } + // Cast to []any slice because jsonschema validator supports only this type. + if p.Type == jsonschema.Array { + res = CastSliceTypedToAny(res) + } + // If the value was changed, we can safely override the value. + // If the value was not changed and processed is nil, do not add it. + if isChanged || res != nil { + inp[p.Name] = res + } + } + + return nil +} + // RunInfo stores information about a running action. type RunInfo struct { ID string diff --git a/pkg/action/runtime.container.go b/pkg/action/runtime.container.go index ece2c20..bc8ae57 100644 --- a/pkg/action/runtime.container.go +++ b/pkg/action/runtime.container.go @@ -31,12 +31,14 @@ const ( ) type runtimeContainer struct { + WithLogger + WithTerm + WithFlagsGroup + // crt is a container runtime. crt driver.ContainerRunner // rtype is a container runtime type string. rtype driver.Type - // logWith contains context arguments for a structured logger. - logWith []any // isRemoteRuntime checks if a container is run remotely. isRemoteRuntime bool @@ -86,63 +88,90 @@ func NewContainerRuntimeKubernetes() ContainerRuntime { // NewContainerRuntime creates a new action container runtime. func NewContainerRuntime(t driver.Type) ContainerRuntime { - return &runtimeContainer{ + rc := &runtimeContainer{ rtype: t, nameprv: ContainerNameProvider{Prefix: "launchr_", RandomSuffix: true}, } + + rc.SetFlagsGroup(NewFlagsGroup(jsonschemaPropRuntime)) + return rc } func (c *runtimeContainer) Clone() Runtime { return NewContainerRuntime(c.rtype) } -func (c *runtimeContainer) FlagsDefinition() ParametersList { - return ParametersList{ - &DefParameter{ - Name: containerFlagRemote, - Title: "Remote runtime", - Description: "Forces the container runtime to be used as remote. Copies the working directory to a container volume. Local binds are not used.", - Type: jsonschema.Boolean, - Default: false, - }, - &DefParameter{ - Name: containerFlagCopyBack, - Title: "Remote copy back", - Description: "Copies the working directory back from the container. Works only if the runtime is remote.", - Type: jsonschema.Boolean, - }, - &DefParameter{ - Name: containerFlagRemoveImage, - Title: "Remove Image", - Description: "Remove an image after execution of action", - Type: jsonschema.Boolean, - Default: false, - }, - &DefParameter{ - Name: containerFlagNoCache, - Title: "No cache", - Description: "Send command to build container without cache", - Type: jsonschema.Boolean, - Default: false, - }, - &DefParameter{ - Name: containerFlagEntrypoint, - Title: "Image Entrypoint", - Description: `Overwrite the default ENTRYPOINT of the image. Example: --entrypoint "/bin/sh"`, - Type: jsonschema.String, - Default: "", - }, - &DefParameter{ - Name: containerFlagExec, - Title: "Exec command", - Description: "Overwrite the command of the action. Argument and options are not validated, sets container CMD directly. Example usage: --exec -- ls -lah", - Type: jsonschema.Boolean, - Default: false, - }, +func (c *runtimeContainer) GetFlags() *FlagsGroup { + flags := c.GetFlagsGroup() + if len(flags.GetDefinitions()) == 0 { + definitions := ParametersList{ + &DefParameter{ + Name: containerFlagRemote, + Title: "Remote runtime", + Description: "Forces the container runtime to be used as remote. Copies the working directory to a container volume. Local binds are not used.", + Type: jsonschema.Boolean, + Default: false, + }, + &DefParameter{ + Name: containerFlagCopyBack, + Title: "Remote copy back", + Description: "Copies the working directory back from the container. Works only if the runtime is remote.", + Type: jsonschema.Boolean, + }, + &DefParameter{ + Name: containerFlagRemoveImage, + Title: "Remove Image", + Description: "Remove an image after execution of action", + Type: jsonschema.Boolean, + Default: false, + }, + &DefParameter{ + Name: containerFlagNoCache, + Title: "No cache", + Description: "Send command to build container without cache", + Type: jsonschema.Boolean, + Default: false, + }, + &DefParameter{ + Name: containerFlagEntrypoint, + Title: "Image Entrypoint", + Description: `Overwrite the default ENTRYPOINT of the image. Example: --entrypoint "/bin/sh"`, + Type: jsonschema.String, + Default: "", + }, + &DefParameter{ + Name: containerFlagExec, + Title: "Exec command", + Description: "Overwrite the command of the action. Argument and options are not validated, sets container CMD directly. Example usage: --exec -- ls -lah", + Type: jsonschema.Boolean, + Default: false, + }, + } + + flags.AddDefinitions(definitions) + } + + return flags +} + +func (c *runtimeContainer) ValidateInput(input *Input) error { + err := c.flags.ValidateFlags(input.GroupFlags(c.flags.GetName())) + if err != nil { + return err + } + + // early peak for an exec flag. + if c.exec { + // Mark input as validated because arguments are passed directly to exec. + input.SetValidated(true) } + + return nil } -func (c *runtimeContainer) UseFlags(flags InputParams) error { +func (c *runtimeContainer) SetFlags(input *Input) error { + flags := input.GroupFlags(c.flags.GetName()) + if v, ok := flags[containerFlagRemote]; ok { c.isSetRemote = v.(bool) } @@ -170,13 +199,7 @@ func (c *runtimeContainer) UseFlags(flags InputParams) error { return nil } -func (c *runtimeContainer) ValidateInput(_ *Action, input *Input) error { - if c.exec { - // Mark input as validated because arguments are passed directly to exec. - input.SetValidated(true) - } - return nil -} + func (c *runtimeContainer) AddImageBuildResolver(r ImageBuildResolver) { c.imgres = append(c.imgres, r) } @@ -209,19 +232,12 @@ func (c *runtimeContainer) Init(ctx context.Context, _ *Action) (err error) { "This process may take time or potentially break existing permissions.", c.volumeFlags, ) - c.log().Warn("using selinux flags", "flags", c.volumeFlags) + c.Log().Warn("using selinux flags", "flags", c.volumeFlags) } return nil } -func (c *runtimeContainer) log(attrs ...any) *launchr.Slog { - if attrs != nil { - c.logWith = append(c.logWith, attrs...) - } - return launchr.Log().With(c.logWith...) -} - func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { ctx, cancelFn := context.WithCancel(ctx) defer cancelFn() @@ -232,7 +248,7 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { if runDef.Container == nil { return errors.New("action container configuration is not set, use different runtime") } - log := c.log("action_id", a.ID) + log := c.LogWith("action_id", a.ID) log.Debug("starting execution of the action") // Generate a container name. @@ -244,7 +260,7 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { // Create a container. runConfig := c.createContainerDef(a, name) - log = c.log("image", runConfig.Image, "command", runConfig.Command, "entrypoint", runConfig.Entrypoint) + log = c.LogWith("image", runConfig.Image, "command", runConfig.Command, "entrypoint", runConfig.Entrypoint) log.Debug("creating a container for an action") cid, err := c.containerCreate(ctx, a, &runConfig) if err != nil { @@ -279,7 +295,7 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { } }() - log = c.log("container_id", cid) + log = c.LogWith("container_id", cid) log.Debug("successfully created a container for an action") // Copy working dirs to the container. @@ -398,7 +414,7 @@ func (c *runtimeContainer) isRebuildRequired(bi *driver.BuildDefinition) (bool, } if errCache := c.imgccres.Save(); errCache != nil { - c.log().Warn("failed to update actions.sum file", "error", errCache) + c.Log().Warn("failed to update actions.sum file", "error", errCache) } return doRebuild, nil @@ -430,7 +446,7 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { return err } - log := c.log() + log := c.Log() switch status.Status { case driver.ImageExists: log.Debug("image exists locally") @@ -441,12 +457,12 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { defer func() { _ = status.Progress.Close() }() - launchr.Term().Printfln("Image %q doesn't exist locally, pulling from the registry...", image) + c.Term().Printfln("Image %q doesn't exist locally, pulling from the registry...", image) log.Info("image doesn't exist locally, pulling from the registry") // Output docker status only in Debug. err = status.Progress.Stream(streams.Out()) if err != nil { - launchr.Term().Error().Println("Error occurred while pulling the image %q", image) + c.Term().Error().Println("Error occurred while pulling the image %q", image) log.Error("error while pulling the image", "error", err) } case driver.ImageBuild: @@ -456,12 +472,12 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { defer func() { _ = status.Progress.Close() }() - launchr.Term().Printfln("Image %q doesn't exist locally, building...", image) + c.Term().Printfln("Image %q doesn't exist locally, building...", image) log.Info("image doesn't exist locally, building the image") // Output docker status only in Debug. err = status.Progress.Stream(streams.Out()) if err != nil { - launchr.Term().Error().Println("Error occurred while building the image %q", image) + c.Term().Error().Println("Error occurred while building the image %q", image) log.Error("error while building the image", "error", err) } } diff --git a/pkg/action/runtime.container_test.go b/pkg/action/runtime.container_test.go index 256ee47..04af250 100644 --- a/pkg/action/runtime.container_test.go +++ b/pkg/action/runtime.container_test.go @@ -53,6 +53,8 @@ func prepareContainerTestSuite(t *testing.T) (*gomock.Controller, *containermock d := containermock.NewMockContainerRuntime(ctrl) d.EXPECT().Close() r := &runtimeContainer{crt: d, rtype: "mock"} + r.SetLogger(launchr.Log()) + r.SetTerm(launchr.Term()) r.AddImageBuildResolver(cfgImgRes) r.SetContainerNameProvider(ContainerNameProvider{Prefix: containerNamePrefix}) diff --git a/pkg/action/runtime.fn.go b/pkg/action/runtime.fn.go index 5c530d5..7026e86 100644 --- a/pkg/action/runtime.fn.go +++ b/pkg/action/runtime.fn.go @@ -2,35 +2,41 @@ package action import ( "context" - - "github.com/launchrctl/launchr/internal/launchr" ) +// FnRuntimeCallback is a function type used in [FnRuntime]. +type FnRuntimeCallback func(ctx context.Context, a *Action) error + // FnRuntime is a function type implementing [Runtime]. -type FnRuntime func(ctx context.Context, a *Action) error +type FnRuntime struct { + WithLogger + WithTerm + + fn FnRuntimeCallback +} // NewFnRuntime creates runtime as a go function. -func NewFnRuntime(fn FnRuntime) Runtime { - return fn +func NewFnRuntime(fn FnRuntimeCallback) Runtime { + return &FnRuntime{fn: fn} } // Clone implements [Runtime] interface. -func (fn FnRuntime) Clone() Runtime { - return fn +func (fn *FnRuntime) Clone() Runtime { + return NewFnRuntime(fn.fn) } // Init implements [Runtime] interface. -func (fn FnRuntime) Init(_ context.Context, _ *Action) error { +func (fn *FnRuntime) Init(_ context.Context, _ *Action) error { return nil } // Execute implements [Runtime] interface. -func (fn FnRuntime) Execute(ctx context.Context, a *Action) error { - launchr.Log().Debug("starting execution of the action", "run_env", "fn", "action_id", a.ID) - return fn(ctx, a) +func (fn *FnRuntime) Execute(ctx context.Context, a *Action) error { + fn.Log().Debug("starting execution of the action", "run_env", "fn", "action_id", a.ID) + return fn.fn(ctx, a) } // Close implements [Runtime] interface. -func (fn FnRuntime) Close() error { +func (fn *FnRuntime) Close() error { return nil } diff --git a/pkg/action/runtime.go b/pkg/action/runtime.go index 9f4cda7..1e1482a 100644 --- a/pkg/action/runtime.go +++ b/pkg/action/runtime.go @@ -2,13 +2,15 @@ package action import ( "context" + + "github.com/launchrctl/launchr/internal/launchr" ) // Runtime is an interface for action execution environment. type Runtime interface { // Init prepares the runtime. Init(ctx context.Context, a *Action) error - // Execute runs action a in the environment and operates with io through streams. + // Execute runs action `a` in the environment and operates with io through streams. Execute(ctx context.Context, a *Action) error // Close does wrap up operations. Close() error @@ -19,12 +21,12 @@ type Runtime interface { // RuntimeFlags is an interface to define environment specific runtime configuration. type RuntimeFlags interface { Runtime - // FlagsDefinition provides definitions for action environment specific flags. - FlagsDefinition() ParametersList - // UseFlags sets environment configuration. - UseFlags(flags InputParams) error + // GetFlags returns flags group of runtime configuration. + GetFlags() *FlagsGroup + // SetFlags sets environment configuration. + SetFlags(input *Input) error // ValidateInput validates input arguments in action definition. - ValidateInput(a *Action, input *Input) error + ValidateInput(input *Input) error } // ContainerRuntime is an interface for container runtime. @@ -38,3 +40,82 @@ type ContainerRuntime interface { // to check when image must be rebuilt. SetImageBuildCacheResolver(*ImageBuildCacheResolver) } + +// RuntimeLoggerAware is an interface for logger runtime. +type RuntimeLoggerAware interface { + Runtime + // SetLogger adds runtime logger + SetLogger(l *launchr.Logger) + // Log returns runtime logger + Log() *launchr.Logger + // LogWith returns runtime logger with attributes + LogWith(attrs ...any) *launchr.Logger +} + +// WithLogger provides a composition with log utilities. +type WithLogger struct { + logger *launchr.Logger + // logWith contains context arguments for a structured logger. + logWith []any +} + +// SetLogger implements [RuntimeLoggerAware] interface +func (c *WithLogger) SetLogger(l *launchr.Logger) { + c.logger = l +} + +// Log implements [RuntimeLoggerAware] interface +func (c *WithLogger) Log() *launchr.Logger { + return c.logger +} + +// LogWith implements [RuntimeLoggerAware] interface +func (c *WithLogger) LogWith(attrs ...any) *launchr.Logger { + if attrs != nil { + c.logWith = append(c.logWith, attrs...) + } + + return &launchr.Logger{ + Slog: c.logger.With(c.logWith...), + LogOptions: c.logger.LogOptions, + } +} + +// RuntimeTermAware is an interface for term runtime. +type RuntimeTermAware interface { + Runtime + // SetTerm adds runtime terminal + SetTerm(t *launchr.Terminal) + // Term returns runtime terminal. + Term() *launchr.Terminal +} + +// WithTerm provides a composition with term utilities. +type WithTerm struct { + term *launchr.Terminal +} + +// SetTerm implements [RuntimeTermAware] interface +func (c *WithTerm) SetTerm(t *launchr.Terminal) { + c.term = t +} + +// Term implements [RuntimeTermAware] interface +func (c *WithTerm) Term() *launchr.Terminal { + return c.term +} + +// WithFlagsGroup provides a composition with flags utilities. +type WithFlagsGroup struct { + flags *FlagsGroup +} + +// SetFlagsGroup sets flags group to work with +func (c *WithFlagsGroup) SetFlagsGroup(group *FlagsGroup) { + c.flags = group +} + +// GetFlagsGroup returns flags group +func (c *WithFlagsGroup) GetFlagsGroup() *FlagsGroup { + return c.flags +} diff --git a/pkg/action/runtime.shell.go b/pkg/action/runtime.shell.go index 4268797..8edf9f3 100644 --- a/pkg/action/runtime.shell.go +++ b/pkg/action/runtime.shell.go @@ -12,6 +12,7 @@ import ( ) type runtimeShell struct { + WithLogger } // NewShellRuntime creates a new action shell runtime. @@ -31,7 +32,7 @@ func (r *runtimeShell) Init(_ context.Context, _ *Action) (err error) { } func (r *runtimeShell) Execute(ctx context.Context, a *Action) (err error) { - log := launchr.Log().With("run_env", "shell", "action_id", a.ID) + log := r.LogWith("run_env", "shell", "action_id", a.ID) log.Debug("starting execution of the action") streams := a.Input().Streams() diff --git a/pkg/action/test_utils.go b/pkg/action/test_utils.go index 9b39f30..4aa9492 100644 --- a/pkg/action/test_utils.go +++ b/pkg/action/test_utils.go @@ -8,6 +8,7 @@ import ( "testing/fstest" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/launchrctl/launchr/internal/launchr" ) @@ -103,11 +104,13 @@ func (tt TestCaseValueProcessor) Test(t *testing.T, am Manager) { } // Run processors. input := NewInput(a, tt.Args, tt.Opts, nil) - err = a.SetInput(input) + err = am.ValidateInput(a, input) assertIsSameError(t, tt.ErrProc, err) if tt.ErrProc != nil { return } + err = a.SetInput(input) + require.NoError(t, err) // Test input is processed. input = a.Input() if tt.ExpArgs == nil { diff --git a/plugins/actionscobra/cobra.go b/plugins/actionscobra/cobra.go index f7648cb..bafe5c2 100644 --- a/plugins/actionscobra/cobra.go +++ b/plugins/actionscobra/cobra.go @@ -14,7 +14,7 @@ import ( ) // CobraImpl returns cobra command implementation for an action command. -func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, error) { +func CobraImpl(a *action.Action, streams launchr.Streams, manager action.Manager) (*launchr.Command, error) { def := a.ActionDef() options := make(action.InputParams) runOpts := make(action.InputParams) @@ -30,31 +30,47 @@ func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, err } optsChanged := derefOpts(filterChangedFlags(cmd, options)) input := action.NewInput(a, argsNamed, optsChanged, streams) - // Pass to the runtime its flags. - if r, ok := a.Runtime().(action.RuntimeFlags); ok { + + // Store runtime flags in the input. + if rt, ok := a.Runtime().(action.RuntimeFlags); ok { + runtimeFlagsGroup := rt.GetFlags() runOpts = derefOpts(filterChangedFlags(cmd, runOpts)) - err = r.UseFlags(runOpts) - if err != nil { - return err - } - if err = r.ValidateInput(a, input); err != nil { - return err + for k, v := range runOpts { + input.SetFlagInGroup(runtimeFlagsGroup.GetName(), k, v) } } - // Set and validate input. + // Retrieve the current persistent flags state and pass to action. It will be later used during decorating + // or other action steps. + // Flags are immutable in action. + persistentFlagsGroup := manager.GetPersistentFlags() + for k, v := range persistentFlagsGroup.GetAll() { + input.SetFlagInGroup(persistentFlagsGroup.GetName(), k, v) + } + + // Validate input before setting to action. + if err = manager.ValidateInput(a, input); err != nil { + return err + } + + // Set input. if err = a.SetInput(input); err != nil { return err } + // Re-apply all registered decorators to action before its execution. + // Triggered after action.SetInput to ensure decorators have access to all necessary data from the input + // to proceed. + manager.Decorate(a) + return nil }, RunE: func(cmd *launchr.Command, _ []string) (err error) { // Don't show usage help on a runtime error. cmd.SilenceUsage = true - // @todo can we use action manager here and Manager.Run() - return a.Execute(cmd.Context()) + _, err = manager.Run(cmd.Context(), a) + return err }, } @@ -64,9 +80,9 @@ func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, err return nil, err } - if env, ok := a.Runtime().(action.RuntimeFlags); ok { - runtimeFlags := env.FlagsDefinition() - err = setCmdFlags(cmd.Flags(), runtimeFlags, runOpts) + if rt, ok := a.Runtime().(action.RuntimeFlags); ok { + runtimeFlagsGroup := rt.GetFlags() + err = setCmdFlags(cmd.Flags(), runtimeFlagsGroup.GetDefinitions(), runOpts) if err != nil { return nil, err } diff --git a/plugins/actionscobra/plugin.go b/plugins/actionscobra/plugin.go index b03df2e..f7105c1 100644 --- a/plugins/actionscobra/plugin.go +++ b/plugins/actionscobra/plugin.go @@ -95,7 +95,7 @@ func (p *Plugin) CobraAddCommands(rootCmd *launchr.Command) error { } streams := p.app.Streams() for _, a := range actions { - cmd, err := CobraImpl(a, streams) + cmd, err := CobraImpl(a, streams, p.am) if err != nil { launchr.Log().Warn("action was skipped due to error", "action_id", a.ID, "error", err) launchr.Term().Warning().Printfln("Action %q was skipped:\n%v", a.ID, err) diff --git a/plugins/actionscobra/usage.go b/plugins/actionscobra/usage.go index f4acc86..3e81887 100644 --- a/plugins/actionscobra/usage.go +++ b/plugins/actionscobra/usage.go @@ -39,8 +39,9 @@ func usageTplFn(a *action.Action) func(*cobra.Command) error { def := a.ActionDef() var runtimeFlags action.ParametersList - if env, ok := a.Runtime().(action.RuntimeFlags); ok { - runtimeFlags = env.FlagsDefinition() + if rt, ok := a.Runtime().(action.RuntimeFlags); ok { + runtimeFlagsGroup := rt.GetFlags() + runtimeFlags = runtimeFlagsGroup.GetDefinitions() } t := template.New("top") diff --git a/plugins/builder/builder.go b/plugins/builder/builder.go index bbfd3cf..c1e1604 100644 --- a/plugins/builder/builder.go +++ b/plugins/builder/builder.go @@ -9,10 +9,14 @@ import ( "strings" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" ) // Builder is the orchestrator to fetch dependencies and build launchr. type Builder struct { + action.WithLogger + action.WithTerm + *BuildOptions wd string env *buildEnvironment @@ -91,7 +95,7 @@ func NewBuilder(opts *BuildOptions) (*Builder, error) { // Build prepares build environment, generates go files and build the binary. func (b *Builder) Build(ctx context.Context, streams launchr.Streams) error { - launchr.Term().Info().Printfln("Starting to build %s", b.PkgName) + b.Term().Info().Printfln("Starting to build %s", b.PkgName) // Prepare build environment dir and go executable. var err error b.env, err = newBuildEnvironment(streams) @@ -99,16 +103,19 @@ func (b *Builder) Build(ctx context.Context, streams launchr.Streams) error { return err } + b.env.SetLogger(b.Log()) + b.env.SetTerm(b.Term()) + // Delete temp files in case of error. defer func() { if err != nil { _ = b.Close() } }() - launchr.Log().Debug("creating build environment", "temp_dir", b.env.wd, "env", b.env.env) + b.Log().Debug("creating build environment", "temp_dir", b.env.wd, "env", b.env.env) // Write files to dir and generate go mod. - launchr.Term().Info().Println("Creating the project files and fetching dependencies") + b.Term().Info().Println("Creating the project files and fetching dependencies") b.env.SetEnv("CGO_ENABLED", "0") err = b.env.CreateModFile(ctx, b.BuildOptions) if err != nil { @@ -129,7 +136,7 @@ func (b *Builder) Build(ctx context.Context, streams launchr.Streams) error { {launchr.Template{Tmpl: tmplGen, Data: &mainVars}, "gen.go"}, } - launchr.Term().Info().Println("Generating the go files") + b.Term().Info().Println("Generating the go files") for _, f := range files { // Generate the file. err = f.WriteFile(filepath.Join(b.env.wd, f.file)) @@ -139,27 +146,27 @@ func (b *Builder) Build(ctx context.Context, streams launchr.Streams) error { } // Generate code for provided plugins. - launchr.Term().Info().Println("Running plugin generation") + b.Term().Info().Println("Running plugin generation") err = b.runGoRun(ctx, b.env.wd, "gen.go", "--work-dir="+b.wd, "--build-dir="+b.env.wd, "--release") if err != nil { return err } // Build the main go package. - launchr.Term().Info().Printfln("Building %s", b.PkgName) + b.Term().Info().Printfln("Building %s", b.PkgName) err = b.goBuild(ctx) if err != nil { return err } - launchr.Term().Success().Printfln("Build complete: %s", b.BuildOutput) + b.Term().Success().Printfln("Build complete: %s", b.BuildOutput) return nil } // Close does cleanup after build. func (b *Builder) Close() error { if b.env != nil && !b.Debug { - launchr.Log().Debug("cleaning build environment directory", "dir", b.env.wd) + b.Log().Debug("cleaning build environment directory", "dir", b.env.wd) return b.env.Close() } return nil diff --git a/plugins/builder/environment.go b/plugins/builder/environment.go index 58ab6bd..a9a2937 100644 --- a/plugins/builder/environment.go +++ b/plugins/builder/environment.go @@ -10,6 +10,7 @@ import ( "time" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" ) type envVars []string @@ -53,6 +54,9 @@ func (a *envVars) Unset(k string) { } type buildEnvironment struct { + action.WithLogger + action.WithTerm + wd string env envVars streams launchr.Streams @@ -160,7 +164,7 @@ func (env *buildEnvironment) execGoGet(ctx context.Context, args ...string) erro } func (env *buildEnvironment) RunCmd(ctx context.Context, cmd *exec.Cmd) error { - launchr.Log().Debug("executing shell", "cmd", cmd) + env.Log().Debug("executing shell", "cmd", cmd) err := cmd.Start() if err != nil { return err diff --git a/plugins/builder/plugin.go b/plugins/builder/plugin.go index 0f29f61..59793cf 100644 --- a/plugins/builder/plugin.go +++ b/plugins/builder/plugin.go @@ -35,6 +35,9 @@ func (p *Plugin) PluginInfo() launchr.PluginInfo { } type builderInput struct { + action.WithLogger + action.WithTerm + name string out string timeout string @@ -70,6 +73,18 @@ func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { nocache: input.Opt("no-cache").(bool), } + log := launchr.Log() + if rt, ok := a.Runtime().(action.RuntimeLoggerAware); ok { + log = rt.LogWith() + } + flags.SetLogger(log) + + term := launchr.Term() + if rt, ok := a.Runtime().(action.RuntimeTermAware); ok { + term = rt.Term() + } + flags.SetTerm(term) + return Execute(ctx, p.app.Streams(), &flags) })) return []*action.Action{a}, nil @@ -137,6 +152,9 @@ func Execute(ctx context.Context, streams launchr.Streams, flags *builderInput) if err != nil { return err } + builder.WithLogger = flags.WithLogger + builder.WithTerm = flags.WithTerm + defer builder.Close() return builder.Build(ctx, streams) } diff --git a/plugins/verbosity/plugin.go b/plugins/verbosity/plugin.go index 2412401..5938937 100644 --- a/plugins/verbosity/plugin.go +++ b/plugins/verbosity/plugin.go @@ -3,9 +3,13 @@ package verbosity import ( "fmt" + "io" + "log" "math" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" + "github.com/launchrctl/launchr/pkg/jsonschema" ) func init() { @@ -87,7 +91,7 @@ func (e *logLevelStr) Type() string { func (p Plugin) OnAppInit(app launchr.App) error { verbosity := 0 quiet := false - var logFormat LogFormat + var logFormatStr LogFormat var logLvlStr logLevelStr // Assert we are able to access internal functionality. @@ -103,7 +107,7 @@ func (p Plugin) OnAppInit(app launchr.App) error { pflags.ParseErrorsWhitelist.UnknownFlags = true pflags.CountVarP(&verbosity, "verbose", "v", "log verbosity level, use -vvvv DEBUG, -vvv INFO, -vv WARN, -v ERROR") pflags.VarP(&logLvlStr, "log-level", "", "log level, same as -v, can be: DEBUG, INFO, WARN, ERROR or NONE (default NONE)") - pflags.VarP(&logFormat, "log-format", "", "log format, can be: pretty, plain or json (default pretty)") + pflags.VarP(&logFormatStr, "log-format", "", "log format, can be: pretty, plain or json (default pretty)") pflags.BoolVarP(&quiet, "quiet", "q", false, "disable output to the console") // Parse available flags. @@ -138,16 +142,56 @@ func (p Plugin) OnAppInit(app launchr.App) error { logLevel = launchr.LogLevelFromString(logLvlEnv) } + // ensure logFormat always has a value + logFormat := LogFormatPretty + if pflags.Changed("log-format") { + logFormat = logFormatStr + } + streams := app.Streams() out := streams.Out() // Set terminal output. launchr.Term().SetOutput(out) + // if some library, that we don't control, uses a std log + // We ensure that std lib logger has the same output level as the Terminal. It is NOT our app specific logger. + log.SetOutput(out) + // Enable logger. + logger := NewLogger(logFormat, logLevel, out) + launchr.SetLogger(logger) + if logLevel != launchr.LogLevelDisabled { - if logFormat == "" && launchr.EnvVarLogFormat.Get() != "" { - logFormat = LogFormat(launchr.EnvVarLogFormat.Get()) - } - var logger *launchr.Logger + _ = launchr.EnvVarLogLevel.Set(logLevel.String()) + _ = launchr.EnvVarLogFormat.Set(logFormat.String()) + } + + cmd.SetOut(out) + cmd.SetErr(streams.Err()) + + var am action.Manager + app.GetService(&am) + + // Retrieve and expand application persistent flags with new log and term-related options. + persistentFlags := am.GetPersistentFlags() + persistentFlags.AddDefinitions(getVerbosityPersistentFlags()) + + // Store initial values of persistent flags. + persistentFlags.Set("log-level", logger.Level().String()) + persistentFlags.Set("log-format", logFormat.String()) + persistentFlags.Set("quiet", quiet) + + // Add new decorators which provide automatic logger and term creation for action based on persistent flags state. + am.AddDecorators(withCustomLogger, withCustomTerm) + + return nil +} + +// NewLogger creates and initializes a new logger with the specified format, log level, and output stream. +func NewLogger(logFormat LogFormat, logLevel launchr.LogLevel, out *launchr.Out) *launchr.Logger { + var logger *launchr.Logger + if logLevel == launchr.LogLevelDisabled { + logger = launchr.NewTextHandlerLogger(io.Discard) + } else { switch logFormat { case LogFormatPlain: logger = launchr.NewTextHandlerLogger(out) @@ -156,15 +200,11 @@ func (p Plugin) OnAppInit(app launchr.App) error { default: logger = launchr.NewConsoleLogger(out) } - launchr.SetLogger(logger) - // Save env variable for subprocesses. - _ = launchr.EnvVarLogLevel.Set(logLevel.String()) - _ = launchr.EnvVarLogFormat.Set(logFormat.String()) } - launchr.Log().SetLevel(logLevel) - cmd.SetOut(out) - cmd.SetErr(streams.Err()) - return nil + + logger.SetLevel(logLevel) + + return logger } func logLevelFlagInt(v int) launchr.LogLevel { @@ -183,3 +223,80 @@ func logLevelFlagInt(v int) launchr.LogLevel { return launchr.LogLevelDisabled } } + +// withCustomLogger decorator adds a new logger for [RuntimeLoggerAware] runtime. +func withCustomLogger(m action.Manager, a *action.Action) { + if a.Runtime() == nil { + return + } + + if !a.Input().IsValidated() { + return + } + + persistentFlags := m.GetPersistentFlags() + if rt, ok := a.Runtime().(action.RuntimeLoggerAware); ok { + var logFormat LogFormat + if lfStr, ok := a.Input().GetFlagInGroup(persistentFlags.GetName(), "log-format").(string); ok { + logFormat = LogFormat(lfStr) + } + + var logLevel launchr.LogLevel + if llStr, ok := a.Input().GetFlagInGroup(persistentFlags.GetName(), "log-level").(string); ok { + logLevel = launchr.LogLevelFromString(llStr) + } + + logger := NewLogger(logFormat, logLevel, a.Input().Streams().Out()) + rt.SetLogger(logger) + } +} + +// withCustomTerm decorator adds a new term for [RuntimeTermAware] runtime. +func withCustomTerm(m action.Manager, a *action.Action) { + if a.Runtime() == nil { + return + } + + if !a.Input().IsValidated() { + return + } + + persistentFlags := m.GetPersistentFlags() + if rt, ok := a.Runtime().(action.RuntimeTermAware); ok { + term := launchr.NewTerminal() + term.SetOutput(a.Input().Streams().Out()) + if quiet, ok := a.Input().GetFlagInGroup(persistentFlags.GetName(), "log-level").(bool); ok && quiet { + term.DisableOutput() + } + + rt.SetTerm(term) + } +} + +func getVerbosityPersistentFlags() action.ParametersList { + return action.ParametersList{ + &action.DefParameter{ + Name: "log-level", + Title: "Log level", + Description: "Log level, can be: DEBUG, INFO, WARN, ERROR or NONE", + Type: jsonschema.String, + Default: "NONE", + Enum: []any{"DEBUG", "INFO", "WARN", "ERROR", "NONE"}, + }, + &action.DefParameter{ + Name: "log-format", + Title: "Log format", + Description: "Log format, can be: pretty, plain or json", + Type: jsonschema.String, + Default: "pretty", + Enum: []any{"pretty", "plain", "json"}, + }, + &action.DefParameter{ + Name: "quiet", + Title: "Quiet", + Description: "Disable output to the console", + Type: jsonschema.Boolean, + Default: false, + }, + } +}