From 2530e051cc9110f60dea4666d0d3e89abe34670d Mon Sep 17 00:00:00 2001 From: Aleksandr Britvin Date: Thu, 16 Jan 2025 17:10:43 +0100 Subject: [PATCH] New action (#77) * Refactor actions, add go func implementation, improve action input. --------- Co-authored-by: Igor Ignatyev --- Makefile | 2 +- app.go | 186 +------- example/actions/alias/action.yaml | 3 + example/actions/arguments/action.yaml | 3 + example/actions/buildargs/action.yaml | 3 + example/actions/envvars/action.yaml | 3 + example/actions/extrahosts/action.yaml | 3 + .../software/flatcar/actions/bump/action.yaml | 5 +- .../application/bus/actions/watch/action.yaml | 3 + .../platform/actions/build/action.yaml | 20 +- .../actions/platform/actions/bump/action.yaml | 5 +- gen.go | 26 +- go.mod | 31 +- go.sum | 67 +-- internal/launchr/filepath.go | 87 ++++ .../launchr/filepath_unix.go | 4 +- .../launchr/filepath_windows.go | 3 +- internal/launchr/lockedfile.go | 71 +++ .../launchr}/lockedfile_unix.go | 10 +- .../launchr}/lockedfile_windows.go | 10 +- internal/launchr/log.go | 10 +- internal/launchr/term.go | 15 +- internal/launchr/tools.go | 129 ++++-- internal/launchr/types.go | 6 +- pkg/action/action.go | 277 ++++++------ pkg/action/action.idp.go | 45 ++ pkg/action/action.input.go | 241 +++++++++++ pkg/action/action_test.go | 286 +++++++++++- pkg/action/discover.go | 62 +-- pkg/action/discover.skip.go | 43 -- pkg/action/discover_test.go | 11 +- pkg/action/env.go | 109 ----- pkg/action/jsonschema.go | 84 ++-- pkg/action/loader.go | 56 ++- pkg/action/loader_test.go | 40 +- pkg/action/lockedfile.go | 55 --- pkg/action/manager.go | 39 +- ...{env.container.go => runtime.container.go} | 163 +++---- .../{sum.go => runtime.container.image.go} | 63 ++- ...iner_test.go => runtime.container_test.go} | 143 +++--- pkg/action/runtime.fn.go | 36 ++ pkg/action/runtime.go | 40 ++ pkg/action/utils.go | 51 +-- pkg/action/yaml.def.go | 406 ++++++++++++------ pkg/action/yaml.discovery.go | 110 +++-- pkg/action/yaml_const_test.go | 286 +++++++++--- pkg/action/yaml_test.go | 55 ++- pkg/cli/out.go | 18 - pkg/driver/hijack.go | 7 +- pkg/{action => driver}/signals.go | 14 +- pkg/{action => driver}/signals_unix.go | 4 +- pkg/{action => driver}/signals_windows.go | 2 +- pkg/driver/utils.go | 30 ++ pkg/jsonschema/error.go | 72 ++++ pkg/jsonschema/type.go | 99 +++++ pkg/log/logger.go | 64 --- {pkg/action => plugins/actionscobra}/cobra.go | 127 +++--- plugins/actionscobra/plugin.go | 135 ++++++ plugins/builder/action.yaml | 57 +++ plugins/builder/plugin.go | 53 +-- plugins/builtinprocessors/plugin.go | 12 +- plugins/default.go | 1 + plugins/verbosity/plugin.go | 22 +- plugins/yamldiscovery/plugin.go | 34 +- 64 files changed, 2663 insertions(+), 1494 deletions(-) create mode 100644 internal/launchr/filepath.go rename pkg/action/discover.unix.go => internal/launchr/filepath_unix.go (96%) rename pkg/action/discover.windows.go => internal/launchr/filepath_windows.go (90%) create mode 100644 internal/launchr/lockedfile.go rename {pkg/action => internal/launchr}/lockedfile_unix.go (70%) rename {pkg/action => internal/launchr}/lockedfile_windows.go (72%) create mode 100644 pkg/action/action.idp.go create mode 100644 pkg/action/action.input.go delete mode 100644 pkg/action/discover.skip.go delete mode 100644 pkg/action/env.go delete mode 100644 pkg/action/lockedfile.go rename pkg/action/{env.container.go => runtime.container.go} (78%) rename pkg/action/{sum.go => runtime.container.image.go} (65%) rename pkg/action/{env.container_test.go => runtime.container_test.go} (86%) create mode 100644 pkg/action/runtime.fn.go create mode 100644 pkg/action/runtime.go delete mode 100644 pkg/cli/out.go rename pkg/{action => driver}/signals.go (73%) rename pkg/{action => driver}/signals_unix.go (76%) rename pkg/{action => driver}/signals_windows.go (85%) create mode 100644 pkg/driver/utils.go create mode 100644 pkg/jsonschema/error.go delete mode 100644 pkg/log/logger.go rename {pkg/action => plugins/actionscobra}/cobra.go (63%) create mode 100644 plugins/actionscobra/plugin.go create mode 100644 plugins/builder/action.yaml diff --git a/Makefile b/Makefile index 4302178..a6e5e5e 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ FIRST_GOPATH:=$(firstword $(subst :, ,$(GOPATH))) # Build available information. GIT_HASH:=$(shell git log --format="%h" -n 1 2> /dev/null) -GIT_BRANCH:=$(shell git branch 2> /dev/null | grep '*' | cut -f2 -d' ') +GIT_BRANCH:=$(shell git rev-parse --abbrev-ref HEAD) APP_VERSION:="$(GIT_BRANCH)-$(GIT_HASH)" GOPKG:=github.com/launchrctl/launchr diff --git a/app.go b/app.go index b358174..72a42c0 100644 --- a/app.go +++ b/app.go @@ -1,37 +1,21 @@ package launchr import ( - "context" "errors" "fmt" "os" - "path/filepath" "reflect" - "sort" "strings" - "time" "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/action" _ "github.com/launchrctl/launchr/plugins" // include default plugins ) -var ( - errDiscoveryTimeout = "action discovery timeout exceeded" -) - -// ActionsGroup is a command group definition. -var ActionsGroup = &launchr.CommandGroup{ - ID: "actions", - Title: "Actions:", -} - type appImpl struct { // Cli related. - cmd *Command - flags []string - skipActions bool // skipActions to skip loading if not requested. - reqCmd string // reqCmd to search for the requested command. + cmd *Command + earlyCmd launchr.CmdEarlyParsed // FS related. mFS []ManagedFS @@ -41,37 +25,7 @@ type appImpl struct { // Services. streams Streams services map[ServiceInfo]Service - actionMngr action.Manager pluginMngr PluginManager - config Config -} - -// getPluginByType returns specific plugins from the app. -func getPluginByType[T Plugin](app *appImpl) []launchr.MapItem[PluginInfo, T] { - // Collect plugins according to their weights. - m := make(map[int][]launchr.MapItem[PluginInfo, T]) - cnt := 0 - for pi, p := range app.pluginMngr.All() { - p, ok := p.(T) - if ok { - item := launchr.MapItem[PluginInfo, T]{K: pi, V: p} - m[pi.Weight] = append(m[pi.Weight], item) - cnt++ - } - } - // Sort weight keys. - weights := make([]int, 0, len(m)) - for w := range m { - weights = append(weights, w) - } - sort.Ints(weights) - // Merge all to a sorted list of plugins. - // @todo maybe sort everything on init to optimize. - res := make([]launchr.MapItem[PluginInfo, T], 0, cnt) - for _, w := range weights { - res = append(res, m[w]...) - } - return res } func newApp() *appImpl { @@ -86,8 +40,8 @@ func (app *appImpl) SetStreams(s Streams) { app.streams = s } func (app *appImpl) RegisterFS(fs ManagedFS) { app.mFS = append(app.mFS, fs) } func (app *appImpl) GetRegisteredFS() []ManagedFS { return app.mFS } -func (app *appImpl) GetRootCmd() *Command { return app.cmd } -func (app *appImpl) EarlyParsedFlags() []string { return app.flags } +func (app *appImpl) RootCmd() *Command { return app.cmd } +func (app *appImpl) CmdEarlyParsed() launchr.CmdEarlyParsed { return app.earlyCmd } func (app *appImpl) AddService(s Service) { info := s.ServiceInfo() @@ -123,30 +77,6 @@ func (app *appImpl) GetService(v any) { panic(fmt.Sprintf("service %q does not exist", stype)) } -// earlyPeekFlags tries to parse flags early to allow change behavior before full boot. -func (app *appImpl) earlyPeekFlags(c *Command) { - var err error - args := os.Args[1:] - // Parse args with internal tools. - // We can't guess cmd because nothing has been defined yet. - _, app.flags, err = c.Find(args) - if err != nil { - // There shouldn't be an error when parsing a clean root command. - panic(err) - } - // Quick parse arguments to see if a version or help was requested. - for i := 0; i < len(app.flags); i++ { - // Skip discover actions if we check version. - if app.flags[i] == "--version" { - app.skipActions = true - } - - if app.reqCmd == "" && !strings.HasPrefix(app.flags[i], "-") { - app.reqCmd = args[i] - } - } -} - // init initializes application and plugins. func (app *appImpl) init() error { var err error @@ -161,8 +91,7 @@ func (app *appImpl) init() error { return cmd.Help() }, } - app.earlyPeekFlags(app.cmd) - + app.earlyCmd = launchr.EarlyPeekCommand() // Set io streams. app.SetStreams(StandardStreams()) app.cmd.SetIn(app.streams.In()) @@ -171,125 +100,46 @@ func (app *appImpl) init() error { // Set working dir and config dir. app.cfgDir = "." + name - app.workDir, err = filepath.Abs(".") - if err != nil { - return err - } + app.workDir = launchr.MustAbs(".") + actionsPath := launchr.MustAbs(os.Getenv(strings.ToUpper(name + "_ACTIONS_PATH"))) // Initialize managed FS for action discovery. app.mFS = make([]ManagedFS, 0, 4) - app.RegisterFS(action.NewDiscoveryFS(os.DirFS(app.workDir), app.GetWD())) + app.RegisterFS(action.NewDiscoveryFS(os.DirFS(actionsPath), app.GetWD())) // Prepare dependencies. app.services = make(map[ServiceInfo]Service) app.pluginMngr = launchr.NewPluginManagerWithRegistered() // @todo consider home dir for global config. - app.config = launchr.ConfigFromFS(os.DirFS(app.cfgDir)) - app.actionMngr = action.NewManager( - action.WithDefaultRunEnvironment, - action.WithContainerRunEnvironmentConfig(app.config, name+"_"), + config := launchr.ConfigFromFS(os.DirFS(app.cfgDir)) + actionMngr := action.NewManager( + action.WithDefaultRuntime, + action.WithContainerRuntimeConfig(config, name+"_"), action.WithValueProcessors(), ) // Register services for other modules. - app.AddService(app.actionMngr) + app.AddService(actionMngr) app.AddService(app.pluginMngr) - app.AddService(app.config) + app.AddService(config) // Run OnAppInit hook. - for _, p := range getPluginByType[OnAppInitPlugin](app) { + for _, p := range launchr.GetPluginByType[OnAppInitPlugin](app.pluginMngr) { if err = p.V.OnAppInit(app); err != nil { return err } } - // Discover actions. - if !app.skipActions { - if err = app.discoverActions(); err != nil { - return err - } - } - return nil } -func (app *appImpl) discoverActions() (err error) { - var discovered []*action.Action - idp := app.actionMngr.GetActionIDProvider() - // @todo configure timeout from flags - // Define timeout for cases when we may traverse the whole FS, e.g. in / or home. - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - for _, p := range getPluginByType[action.DiscoveryPlugin](app) { - for _, regfs := range app.GetRegisteredFS() { - actions, errDis := p.V.DiscoverActions(ctx, regfs, idp) - if errDis != nil { - return errDis - } - discovered = append(discovered, actions...) - } - } - // Failed to discover actions in reasonable time. - if errCtx := ctx.Err(); errCtx != nil { - return errors.New(errDiscoveryTimeout) - } - - // Add discovered actions. - for _, a := range discovered { - app.actionMngr.Add(a) - } - - // Alter all registered actions. - for _, p := range getPluginByType[action.AlterActionsPlugin](app) { - err = p.V.AlterActions() - if err != nil { - return err - } - } - // @todo maybe cache discovery result for performance. - return err -} - func (app *appImpl) exec() error { - if app.skipActions { + if app.earlyCmd.IsVersion { app.cmd.SetVersionTemplate(Version().Full()) - } - // Check the requested command to see what actions we must actually load. - var actions map[string]*action.Action - if app.reqCmd != "" { - // Check if an alias was provided to find the real action. - app.reqCmd = app.actionMngr.GetIDFromAlias(app.reqCmd) - a, ok := app.actionMngr.Get(app.reqCmd) - if ok { - // Use only the requested action. - actions = map[string]*action.Action{a.ID: a} - } else { - // Action was not requested, no need to load them. - app.skipActions = true - } - } else { - // Load all. - actions = app.actionMngr.All() - } - // Convert actions to cobra commands. - // @todo consider cobra completion and caching between runs. - if !app.skipActions { - if len(actions) > 0 { - app.cmd.AddGroup(ActionsGroup) - } - for _, a := range actions { - cmd, err := action.CobraImpl(a, app.Streams()) - if err != nil { - Log().Warn("action was skipped due to error", "action_id", a.ID, "error", err) - Term().Warning().Printfln("Action %q was skipped:\n%v", a.ID, err) - continue - } - cmd.GroupID = ActionsGroup.ID - app.cmd.AddCommand(cmd) - } + return app.cmd.Execute() } // Add application commands from plugins. - for _, p := range getPluginByType[CobraPlugin](app) { + for _, p := range launchr.GetPluginByType[CobraPlugin](app.pluginMngr) { if err := p.V.CobraAddCommands(app.cmd); err != nil { return err } diff --git a/example/actions/alias/action.yaml b/example/actions/alias/action.yaml index a373bb1..eac5722 100644 --- a/example/actions/alias/action.yaml +++ b/example/actions/alias/action.yaml @@ -1,6 +1,9 @@ action: title: aliasaction description: Test alias definition + +runtime: + type: container image: buildargs:latest alias: - "alias1" diff --git a/example/actions/arguments/action.yaml b/example/actions/arguments/action.yaml index 7a484a8..b223450 100644 --- a/example/actions/arguments/action.yaml +++ b/example/actions/arguments/action.yaml @@ -10,6 +10,9 @@ action: description: Option to do something type: boolean default: false + +runtime: + type: container image: envvars:latest build: context: ./ diff --git a/example/actions/buildargs/action.yaml b/example/actions/buildargs/action.yaml index 6bdcc55..352c72d 100644 --- a/example/actions/buildargs/action.yaml +++ b/example/actions/buildargs/action.yaml @@ -1,6 +1,9 @@ action: title: buildargs description: Test passing args to Dockerfile + +runtime: + type: container image: buildargs:latest build: context: ./ diff --git a/example/actions/envvars/action.yaml b/example/actions/envvars/action.yaml index 074e136..1916679 100644 --- a/example/actions/envvars/action.yaml +++ b/example/actions/envvars/action.yaml @@ -1,6 +1,9 @@ action: title: envvars description: Test passing static or dynamic environment variables to container + +runtime: + type: container image: envvars:latest env: ACTION_ENV1: value_from_action.yaml # Static value diff --git a/example/actions/extrahosts/action.yaml b/example/actions/extrahosts/action.yaml index 6471e3b..bc8b761 100644 --- a/example/actions/extrahosts/action.yaml +++ b/example/actions/extrahosts/action.yaml @@ -1,6 +1,9 @@ action: title: extrahosts description: Test passing additional entries to container's /etc/hosts + +runtime: + type: container image: extrahosts:latest extra_hosts: - "host.docker.internal:host-gateway" diff --git a/example/actions/foundation/software/flatcar/actions/bump/action.yaml b/example/actions/foundation/software/flatcar/actions/bump/action.yaml index 3240499..7ef2f37 100644 --- a/example/actions/foundation/software/flatcar/actions/bump/action.yaml +++ b/example/actions/foundation/software/flatcar/actions/bump/action.yaml @@ -8,9 +8,12 @@ action: - name: arg2 title: Argument 2 description: Some additional info for arg - options: # TODO Use json schema. By default string. Do not allow complex data. + options: - name: opt1 title: Option 1 description: Some additional info for option + +runtime: + type: container image: python:3.7-slim command: python3 %s diff --git a/example/actions/integration/application/bus/actions/watch/action.yaml b/example/actions/integration/application/bus/actions/watch/action.yaml index cbf72be..7ef2f37 100644 --- a/example/actions/integration/application/bus/actions/watch/action.yaml +++ b/example/actions/integration/application/bus/actions/watch/action.yaml @@ -12,5 +12,8 @@ action: - name: opt1 title: Option 1 description: Some additional info for option + +runtime: + type: container image: python:3.7-slim command: python3 %s diff --git a/example/actions/platform/actions/build/action.yaml b/example/actions/platform/actions/build/action.yaml index 4a72375..6ca1359 100644 --- a/example/actions/platform/actions/build/action.yaml +++ b/example/actions/platform/actions/build/action.yaml @@ -9,11 +9,11 @@ action: - name: arg2 title: Argument 2 description: Some additional info for arg - options: # TODO Use json schema. By default string. Do not allow complex data. + options: - name: opt1 title: Option 1 description: Some additional info for option - default: + default: "" - name: opt2 title: Option 2 description: Some additional info for option @@ -30,6 +30,22 @@ action: title: Option 4 description: Some additional info for option type: array + - name: optenum + title: Option 5 + type: string + enum: [enum1, enum2] + - name: optarrbool + title: Option 6 + type: array + items: + type: boolean + - name: optip + title: Option 7 + type: string + format: "ipv4" + +runtime: + type: container # image: python:3.7-slim image: ubuntu # command: python3 {{ .opt4 }} diff --git a/example/actions/platform/actions/bump/action.yaml b/example/actions/platform/actions/bump/action.yaml index 3240499..7ef2f37 100644 --- a/example/actions/platform/actions/bump/action.yaml +++ b/example/actions/platform/actions/bump/action.yaml @@ -8,9 +8,12 @@ action: - name: arg2 title: Argument 2 description: Some additional info for arg - options: # TODO Use json schema. By default string. Do not allow complex data. + options: - name: opt1 title: Option 1 description: Some additional info for option + +runtime: + type: container image: python:3.7-slim command: python3 %s diff --git a/gen.go b/gen.go index 1c2be35..dc5a94a 100644 --- a/gen.go +++ b/gen.go @@ -2,8 +2,9 @@ package launchr import ( "os" - "path/filepath" "strings" + + "github.com/launchrctl/launchr/internal/launchr" ) func (app *appImpl) gen() error { @@ -13,21 +14,12 @@ func (app *appImpl) gen() error { BuildDir: ".", } isRelease := false - app.cmd.RunE = func(_ *Command, args []string) error { - if len(args) > 0 { - // Save backward compatibility with the previous build implementation. - // @todo delete after release. - config.WorkDir = args[0] - } + app.cmd.RunE = func(cmd *Command, _ []string) error { + // Don't show usage help on a runtime error. + cmd.SilenceUsage = true // Set absolute paths. - config.WorkDir, err = filepath.Abs(config.WorkDir) - if err != nil { - return err - } - config.BuildDir, err = filepath.Abs(config.BuildDir) - if err != nil { - return err - } + config.WorkDir = launchr.MustAbs(config.WorkDir) + config.BuildDir = launchr.MustAbs(config.BuildDir) // Change working directory to the selected. err = os.Chdir(config.WorkDir) if err != nil { @@ -35,7 +27,7 @@ func (app *appImpl) gen() error { } // Call generate functions on plugins. - for _, p := range getPluginByType[GeneratePlugin](app) { + for _, p := range launchr.GetPluginByType[GeneratePlugin](app.pluginMngr) { if !isRelease && strings.HasPrefix(p.K.GetPackagePath(), PkgPath) { // Skip core packages if not requested. // Implemented for development of plugins to prevent generating of main.go. @@ -64,7 +56,6 @@ func (app *appImpl) gen() error { // Generate runs generation of included plugins. func (app *appImpl) Generate() int { // Do not discover actions on generate. - app.skipActions = true var err error if err = app.init(); err != nil { Term().Error().Println(err) @@ -79,6 +70,7 @@ func (app *appImpl) Generate() int { // Gen generates application specific build files and returns os exit code. func Gen() int { + launchr.IsGen = true return newApp().Generate() } diff --git a/go.mod b/go.mod index 459a40b..efca16b 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,23 @@ module github.com/launchrctl/launchr -go 1.23.1 +go 1.23.2 + +toolchain go1.23.3 require ( - github.com/docker/docker v27.3.1+incompatible + github.com/docker/docker v27.4.1+incompatible github.com/knadh/koanf v1.5.0 github.com/moby/sys/signal v0.7.1 github.com/moby/term v0.5.0 - github.com/pterm/pterm v0.12.79 - github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/pterm/pterm v0.12.80 + github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 go.uber.org/mock v0.5.0 - golang.org/x/mod v0.21.0 - golang.org/x/sys v0.26.0 + golang.org/x/mod v0.22.0 + golang.org/x/sys v0.28.0 + golang.org/x/text v0.21.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -54,16 +57,18 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect - golang.org/x/term v0.25.0 // indirect - golang.org/x/text v0.19.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/term v0.27.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect gotest.tools/v3 v3.5.1 // indirect diff --git a/go.sum b/go.sum index e84db7f..953a8d1 100644 --- a/go.sum +++ b/go.sum @@ -71,8 +71,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= -github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= +github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -214,8 +216,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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= @@ -315,22 +318,25 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.79 h1:lH3yrYMhdpeqX9y5Ep1u7DejyHy7NSQg9qrBjF9dFT4= -github.com/pterm/pterm v0.12.79/go.mod h1:1v/gzOF1N0FsjbgTHZ1wVycRkKiatFvJSJC4IGaQAAo= +github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg= +github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo= github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= -github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= @@ -348,8 +354,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= @@ -360,20 +366,22 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A= go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0 h1:K0XaT3DwHAcV4nKLzcQvwAgSyisUghWoY20I7huthMk= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.31.0/go.mod h1:B5Ki776z/MBnVha1Nzwp5arlzBbE3+1jk+pGmaP5HME= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 h1:lUsI2TYsQw2r1IASwoROaCnjdj2cvC2+Jbxvk6nHnWU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0/go.mod h1:2HpZxxQurfGxJlJDblybejHB6RX6pmExPNe517hREw4= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= @@ -388,8 +396,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -400,8 +408,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= -golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -476,15 +484,15 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= -golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -494,8 +502,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 h1:SvFZT6jyqRaOeXpc5h/JSfZenJ2O330aBsf7JfSUXmQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -553,8 +561,9 @@ google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojt gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/launchr/filepath.go b/internal/launchr/filepath.go new file mode 100644 index 0000000..b7e6e42 --- /dev/null +++ b/internal/launchr/filepath.go @@ -0,0 +1,87 @@ +package launchr + +import ( + "io/fs" + "os" + "path/filepath" + "reflect" +) + +// MustAbs returns absolute filepath and panics on error. +func MustAbs(path string) string { + abs, err := filepath.Abs(filepath.Clean(path)) + if err != nil { + panic(err) + } + return abs +} + +// GetFsAbsPath returns absolute path for a [fs.FS] struct. +func GetFsAbsPath(fs fs.FS) string { + cwd := "" + rval := reflect.ValueOf(fs) + if rval.Kind() == reflect.String { + cwd = rval.String() + // @todo Rethink absolute path usage overall. + if !filepath.IsAbs(cwd) { + cwd = MustAbs(cwd) + } + } + return cwd +} + +// EnsurePath creates all directories in the path. +func EnsurePath(parts ...string) error { + p := filepath.Clean(filepath.Join(parts...)) + if _, err := os.Stat(p); os.IsNotExist(err) { + return os.MkdirAll(p, 0750) + } + return nil +} + +// IsHiddenPath checks if a path is hidden path. +func IsHiddenPath(path string) bool { + return isHiddenPath(path) +} + +// IsSystemPath checks if a path is a system path. +func IsSystemPath(root string, path string) bool { + if root == "" { + // We are in virtual FS. + return false + } + + dirs := []string{ + // Python specific. + "__pycache__", + "venv", + // JS specific stuff. + "node_modules", + // Usually project dependencies. + "vendor", + } + + // Check application specific. + if existsInSlice(dirs, path) { + return true + } + // Skip in root. + if isRootPath(root) && existsInSlice(skipRootDirs, path) { + return true + } + // Skip user specific directories. + if isUserHomeDir(path) && existsInSlice(skipUserDirs, path) { + return true + } + + return false +} + +func existsInSlice[T comparable](slice []T, el T) bool { + for _, v := range slice { + if v == el { + return true + } + } + return false +} diff --git a/pkg/action/discover.unix.go b/internal/launchr/filepath_unix.go similarity index 96% rename from pkg/action/discover.unix.go rename to internal/launchr/filepath_unix.go index 03cfc40..9d0241b 100644 --- a/pkg/action/discover.unix.go +++ b/internal/launchr/filepath_unix.go @@ -1,6 +1,6 @@ -//go:build !windows +//go:build unix -package action +package launchr import ( "path/filepath" diff --git a/pkg/action/discover.windows.go b/internal/launchr/filepath_windows.go similarity index 90% rename from pkg/action/discover.windows.go rename to internal/launchr/filepath_windows.go index 8623301..c4491ae 100644 --- a/pkg/action/discover.windows.go +++ b/internal/launchr/filepath_windows.go @@ -1,7 +1,6 @@ //go:build windows -// Package action provides implementations of discovering and running actions. -package action +package launchr import ( "path/filepath" diff --git a/internal/launchr/lockedfile.go b/internal/launchr/lockedfile.go new file mode 100644 index 0000000..4354a3e --- /dev/null +++ b/internal/launchr/lockedfile.go @@ -0,0 +1,71 @@ +package launchr + +import ( + "os" + "path/filepath" +) + +// @todo refactor to use one implementation here and in keyring. + +// LockedFile is file with a lock for other processes. +type LockedFile struct { + fname string + file *os.File + locked bool +} + +// NewLockedFile creates a new LockedFile. +func NewLockedFile(fname string) *LockedFile { + return &LockedFile{fname: fname} +} + +// Filename returns file's name. +func (f *LockedFile) Filename() string { + return f.fname +} + +// Open opens a file and locks it for other.. +func (f *LockedFile) Open(flag int, perm os.FileMode) (err error) { + isCreate := flag&os.O_CREATE == os.O_CREATE + if isCreate { + err = EnsurePath(filepath.Dir(f.fname)) + if err != nil { + return err + } + } + f.file, err = os.OpenFile(f.fname, flag, perm) //nolint:gosec + if err != nil { + return err + } + + err = f.lock(true) + if err != nil { + return err + } + + return nil +} + +// Read implements [io.ReadWriteCloser] interface. +func (f *LockedFile) Read(p []byte) (n int, err error) { return f.file.Read(p) } + +// Write implements [io.ReadWriteCloser] interface. +func (f *LockedFile) Write(p []byte) (n int, err error) { return f.file.Write(p) } + +// Close implements [io.ReadWriteCloser] interface. +func (f *LockedFile) Close() error { + f.unlock() + if f.file != nil { + return f.file.Close() + } + return nil +} + +// Remove deletes the file. +func (f *LockedFile) Remove() (err error) { + err = os.Remove(f.fname) + if os.IsNotExist(err) { + return nil + } + return err +} diff --git a/pkg/action/lockedfile_unix.go b/internal/launchr/lockedfile_unix.go similarity index 70% rename from pkg/action/lockedfile_unix.go rename to internal/launchr/lockedfile_unix.go index d29346a..63550f6 100644 --- a/pkg/action/lockedfile_unix.go +++ b/internal/launchr/lockedfile_unix.go @@ -1,14 +1,12 @@ //go:build unix -package action +package launchr import ( "syscall" - - "github.com/launchrctl/launchr/internal/launchr" ) -func (f *lockedFile) lock(waitToAcquire bool) (err error) { +func (f *LockedFile) lock(waitToAcquire bool) (err error) { if f.locked { // If you get this error, there is racing between goroutines. panic("can't lock already opened file") @@ -26,13 +24,13 @@ func (f *lockedFile) lock(waitToAcquire bool) (err error) { return nil } -func (f *lockedFile) unlock() { +func (f *LockedFile) unlock() { if !f.locked { // If we didn't lock the file, we shouldn't unlock it. return } if err := syscall.Flock(int(f.file.Fd()), syscall.LOCK_UN); err != nil { - launchr.Log().Warn("unlock is called on a not locked file", "error", err) + Log().Warn("unlock is called on a not locked file", "error", err) } f.locked = false } diff --git a/pkg/action/lockedfile_windows.go b/internal/launchr/lockedfile_windows.go similarity index 72% rename from pkg/action/lockedfile_windows.go rename to internal/launchr/lockedfile_windows.go index 7ec5006..8113bae 100644 --- a/pkg/action/lockedfile_windows.go +++ b/internal/launchr/lockedfile_windows.go @@ -1,18 +1,16 @@ //go:build windows -package action +package launchr import ( "golang.org/x/sys/windows" - - "github.com/launchrctl/launchr/internal/launchr" ) const ( allBytes = ^uint32(0) ) -func (f *lockedFile) lock(waitToAcquire bool) (err error) { +func (f *LockedFile) lock(waitToAcquire bool) (err error) { lt := windows.LOCKFILE_EXCLUSIVE_LOCK if !waitToAcquire { lt = lt | windows.LOCKFILE_FAIL_IMMEDIATELY @@ -25,7 +23,7 @@ func (f *lockedFile) lock(waitToAcquire bool) (err error) { return nil } -func (f *lockedFile) unlock() { +func (f *LockedFile) unlock() { if !f.locked { // If we didn't lock the file, we shouldn't unlock it. return @@ -33,7 +31,7 @@ func (f *lockedFile) unlock() { ol := new(windows.Overlapped) err := windows.UnlockFileEx(windows.Handle(f.file.Fd()), 0, allBytes, allBytes, ol) if err != nil { - launchr.Log().Warn("unlock is called on a not locked file: %s", err) + Log().Warn("unlock is called on a not locked file: %s", err) } f.locked = false } diff --git a/internal/launchr/log.go b/internal/launchr/log.go index cb8ab7d..1c3036a 100644 --- a/internal/launchr/log.go +++ b/internal/launchr/log.go @@ -51,20 +51,20 @@ type ptermOpts struct { lvl LogLevel } -func (o ptermOpts) Level() LogLevel { +func (o *ptermOpts) Level() LogLevel { return o.lvl } -func (o ptermOpts) SetLevel(l LogLevel) { +func (o *ptermOpts) SetLevel(l LogLevel) { o.lvl = l o.pterm.Level = o.mapLevel(l) } -func (o ptermOpts) SetOutput(w io.Writer) { +func (o *ptermOpts) SetOutput(w io.Writer) { o.pterm.Writer = w } -func (o ptermOpts) mapLevel(l LogLevel) pterm.LogLevel { +func (o *ptermOpts) mapLevel(l LogLevel) pterm.LogLevel { switch l { case LogLevelDisabled: return pterm.LogLevelDisabled @@ -121,7 +121,7 @@ func (o *slogOpts) mapLevel(l LogLevel) slog.Level { // NewConsoleLogger creates a default console logger. func NewConsoleLogger(w io.Writer) *Logger { l := pterm.DefaultLogger - opts := ptermOpts{pterm: &l} + opts := &ptermOpts{pterm: &l} opts.SetOutput(w) return &Logger{ Slog: slog.New(pterm.NewSlogHandler(opts.pterm)), diff --git a/internal/launchr/term.go b/internal/launchr/term.go index 046a088..377eaed 100644 --- a/internal/launchr/term.go +++ b/internal/launchr/term.go @@ -6,13 +6,20 @@ import ( "reflect" "github.com/pterm/pterm" + "golang.org/x/text/language" + "golang.org/x/text/message" ) -var defaultPrinter *Terminal +var defaultTerm *Terminal + +// DefaultTextPrinter is a printer with a context of language. +// Currently only used in [jsonschema] package and not exported outside the repo. +// Looks promising in the future for translations. +var DefaultTextPrinter = message.NewPrinter(language.English) func init() { // Initialize the default printer. - defaultPrinter = &Terminal{ + defaultTerm = &Terminal{ p: []TextPrinter{ printerBasic: newPTermBasicPrinter(pterm.DefaultBasicText), printerInfo: newPTermPrefixPrinter(pterm.Info), @@ -22,6 +29,8 @@ func init() { }, enabled: true, } + // Do not output anything when not in the app, e.g. in tests. + defaultTerm.DisableOutput() } // Predefined keys of terminal printers. @@ -88,7 +97,7 @@ type Terminal struct { // Term returns default [Terminal] to print application messages to the console. func Term() *Terminal { - return defaultPrinter + return defaultTerm } // EnableOutput enables the output. diff --git a/internal/launchr/tools.go b/internal/launchr/tools.go index d35b3d3..015bf80 100644 --- a/internal/launchr/tools.go +++ b/internal/launchr/tools.go @@ -3,41 +3,17 @@ package launchr import ( "errors" - "io/fs" "os" - "path/filepath" "reflect" + "sort" + "strings" "time" flag "github.com/spf13/pflag" ) -// GetFsAbsPath returns absolute path for a [fs.FS] struct. -func GetFsAbsPath(fs fs.FS) string { - cwd := "" - rval := reflect.ValueOf(fs) - if rval.Kind() == reflect.String { - var err error - cwd = rval.String() - // @todo Rethink absolute path usage overall. - if !filepath.IsAbs(cwd) { - cwd, err = filepath.Abs(cwd) - if err != nil { - panic("can't retrieve absolute path for the path") - } - } - } - return cwd -} - -// EnsurePath creates all directories in the path. -func EnsurePath(parts ...string) error { - p := filepath.Clean(filepath.Join(parts...)) - if _, err := os.Stat(p); os.IsNotExist(err) { - return os.MkdirAll(p, 0750) - } - return nil -} +// IsGen is an internal flag that indicates we are in Generate. +var IsGen = false // GetTypePkgPathName returns type package path and name for internal usage. func GetTypePkgPathName(v any) (string, string) { @@ -48,6 +24,34 @@ func GetTypePkgPathName(v any) (string, string) { return t.PkgPath(), t.Name() } +// GetPluginByType returns specific plugins from the app. +func GetPluginByType[T Plugin](mngr PluginManager) []MapItem[PluginInfo, T] { + // Collect plugins according to their weights. + m := make(map[int][]MapItem[PluginInfo, T]) + cnt := 0 + for pi, p := range mngr.All() { + p, ok := p.(T) + if ok { + item := MapItem[PluginInfo, T]{K: pi, V: p} + m[pi.Weight] = append(m[pi.Weight], item) + cnt++ + } + } + // Sort weight keys. + weights := make([]int, 0, len(m)) + for w := range m { + weights = append(weights, w) + } + sort.Ints(weights) + // Merge all to a sorted list of plugins. + // @todo maybe sort everything on init to optimize. + res := make([]MapItem[PluginInfo, T], 0, cnt) + for _, w := range weights { + res = append(res, m[w]...) + } + return res +} + // IsCommandErrHelp checks if an error is a flag help err used for intercommunication. func IsCommandErrHelp(err error) bool { return errors.Is(err, flag.ErrHelp) @@ -75,3 +79,72 @@ func IsSELinuxEnabled() bool { } return string(data) == "1" } + +// CmdEarlyParsed is all parsed command information on early stage. +type CmdEarlyParsed struct { + Command string // Command is the requested command. + Args []string // Args are all arguments provided in the command line. + IsVersion bool // IsVersion when version was requested. + IsGen bool // IsGen when in generate mod. +} + +// EarlyPeekCommand parses all available information during init stage. +func EarlyPeekCommand() CmdEarlyParsed { + args := os.Args[1:] + var isVersion bool + var reqCmd string + // Quick parse arguments to see if a version or help was requested. + for i := 0; i < len(args); i++ { + if args[i] == "--version" { + isVersion = true + break + } + } + cmds := searchCommand(args) + if len(cmds) > 0 { + reqCmd = cmds[0] + } + + return CmdEarlyParsed{ + Command: reqCmd, + Args: args, + IsVersion: isVersion, + IsGen: IsGen, + } +} + +func searchCommand(args []string) []string { + if len(args) == 0 { + return args + } + + commands := []string{} + +Loop: + for len(args) > 0 { + s := args[0] + args = args[1:] + switch { + case s == "--": + // "--" terminates the flags + break Loop + case strings.HasPrefix(s, "--") && !strings.Contains(s, "="): + // If '--flag arg' then + // delete arg from args. + fallthrough // (do the same as below) + case strings.HasPrefix(s, "-") && !strings.Contains(s, "=") && len(s) == 2: + // If '-f arg' then + // delete 'arg' from args or break the loop if len(args) <= 1. + if len(args) <= 1 { + break Loop + } else { + args = args[1:] + continue + } + case s != "" && !strings.HasPrefix(s, "-") && !strings.Contains(s, "="): + commands = append(commands, s) + } + } + + return commands +} diff --git a/internal/launchr/types.go b/internal/launchr/types.go index 64d7468..183aeeb 100644 --- a/internal/launchr/types.go +++ b/internal/launchr/types.go @@ -49,8 +49,8 @@ type App interface { // It is intended for internal use only to prevent coupling on volatile functionality. type AppInternal interface { App - GetRootCmd() *Command - EarlyParsedFlags() []string + RootCmd() *Command + CmdEarlyParsed() CmdEarlyParsed } // AppVersion stores application version. @@ -224,7 +224,7 @@ func NewExitError(code int, msg string) error { return ExitError{code, msg} } -// Error implements [error] interface. +// Error implements error interface. func (e ExitError) Error() string { return e.msg } diff --git a/pkg/action/action.go b/pkg/action/action.go index 1643d7a..27a97b2 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -1,22 +1,15 @@ package action import ( - "bytes" "context" - "encoding/json" - "errors" "fmt" "path/filepath" - jsvalidate "github.com/santhosh-tekuri/jsonschema/v5" - - "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/jsonschema" "github.com/launchrctl/launchr/pkg/types" ) var ( - errInvalidProcessor = errors.New("invalid configuration, processor is required") errTplNotApplicableProcessor = "invalid configuration, processor can't be applied to value of type %s" errTplNonExistProcessor = "requested processor %q doesn't exist" ) @@ -24,9 +17,11 @@ var ( // Action is an action definition with a contextual id (name), working directory path // and a runtime context such as input arguments and options. type Action struct { - ID string // ID is an action unique id compiled from path. - Loader Loader // Loader is a function to load action definition. Helpful to reload with replaced variables. + ID string // ID is a unique action id provided by [IDProvider]. + // loader is a function to load action definition. + // Helpful to reload with replaced variables. + loader Loader // wd is a working directory set from app level. // Usually current working directory, but may be overridden by a plugin. wd string @@ -35,41 +30,32 @@ 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. - env RunEnvironment // env is the run environment driver to execute the action. - input Input // input is a container for env variables. + 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. } -// Input is a container for action input arguments and options. -type Input struct { - // Args contains parsed and named arguments. - Args TypeArgs - // Opts contains parsed options with default values. - Opts TypeOpts - // IO contains out/in/err destinations. @todo should it be in Input? - IO launchr.Streams - // ArgsRaw contains raw positional arguments. - ArgsRaw []string - // OptsRaw contains options that were input by a user and without default values. - OptsRaw TypeOpts -} - -type ( - // TypeArgs is a type alias for action arguments. - TypeArgs = map[string]any - // TypeOpts is a type alias for action options. - TypeOpts = map[string]any -) - -// NewAction creates a new action. -func NewAction(wd, fsdir, fpath string) *Action { +// New creates a new action. +func New(idp IDProvider, l Loader, fsdir string, fpath string) *Action { // We don't define ID here because we use [Action] object for // context creation to calculate ID later. - return &Action{ - wd: wd, - fsdir: fsdir, - fpath: fpath, + a := &Action{ + loader: l, + fsdir: fsdir, + fpath: fpath, + } + // Assign ID to an action. + a.ID = idp.GetID(a) + if a.ID == "" { + panic(fmt.Errorf("action id cannot be empty, file %q", fpath)) } + a.SetWorkDir(".") + return a +} + +// NewFromYAML creates a new action from yaml content. +func NewFromYAML(id string, b []byte) *Action { + return New(StringID(id), &YamlLoader{Bytes: b}, "", "") } // Clone returns a copy of an action. @@ -78,11 +64,15 @@ func (a *Action) Clone() *Action { return nil } c := &Action{ - ID: a.ID, + ID: a.ID, + + loader: a.loader, wd: a.wd, fsdir: a.fsdir, fpath: a.fpath, - Loader: a.Loader, + } + if a.runtime != nil { + c.runtime = a.runtime.Clone() } return c } @@ -100,8 +90,19 @@ func (a *Action) GetProcessors() map[string]ValueProcessor { // Reset unsets loaded action to force reload. func (a *Action) Reset() { a.def = nil } -// GetInput returns action input. -func (a *Action) GetInput() Input { return a.input } +// Input returns action input. +func (a *Action) Input() *Input { + if a.input == nil { + // Return empty input for consistency to prevent nil call. + return &Input{action: a} + } + return a.input +} + +// SetWorkDir sets action working directory. +func (a *Action) SetWorkDir(wd string) { + a.wd, _ = filepath.Abs(filepath.Clean(wd)) +} // WorkDir returns action working directory. func (a *Action) WorkDir() string { @@ -120,18 +121,21 @@ func (a *Action) Filepath() string { return filepath.Join(a.fsdir, a.fpath) } // Dir returns an action file directory. func (a *Action) Dir() string { return filepath.Dir(a.Filepath()) } -// SetRunEnvironment sets environment to run the action. -func (a *Action) SetRunEnvironment(env RunEnvironment) { a.env = env } +// Runtime returns environment to run the action. +func (a *Action) Runtime() Runtime { return a.runtime } + +// SetRuntime sets environment to run the action. +func (a *Action) SetRuntime(r Runtime) { a.runtime = r } // DefinitionEncoded returns encoded action file content. -func (a *Action) DefinitionEncoded() ([]byte, error) { return a.Loader.Content() } +func (a *Action) DefinitionEncoded() ([]byte, error) { return a.loader.Content() } // Raw returns unprocessed action definition. It is faster and may produce fewer errors. // It may be helpful if needed to peek inside the action file to read header. func (a *Action) Raw() (*Definition, error) { var err error if a.defRaw == nil { - a.defRaw, err = a.Loader.LoadRaw() + a.defRaw, err = a.loader.LoadRaw() } return a.defRaw, err } @@ -141,40 +145,67 @@ func (a *Action) EnsureLoaded() (err error) { if a.def != nil { return err } - a.def, err = a.Loader.Load(LoadContext{Action: a}) + // Load raw definition as well. + _, err = a.Raw() + if err != nil { + return err + } + // Load with replacements. + a.def, err = a.loader.Load(LoadContext{Action: a}) return err } -// ActionDef returns action definition with replaced variables. +// ActionDef returns action definition. func (a *Action) ActionDef() *DefAction { - if a.def == nil { - panic("action data is not available, call \"EnsureLoaded\" method first to load the data") + raw, err := a.Raw() + if err != nil { + // All discovered actions are checked for error. + // It means that normally by this time you shouldn't receive this panic. + // Please, review your code. + // The error may occur if there is a new flow for action. + // You may need to manually check the error of Action.Raw() or Action.EnsureLoaded(). + panic(fmt.Errorf("load error must be checked first: %w", err)) + } + return raw.Action +} + +// RuntimeDef returns runtime definition with replaced variables. +func (a *Action) RuntimeDef() *DefRuntime { + err := a.EnsureLoaded() + if err != nil { + // The error may appear if the action is incorrectly defined. + // Normally EnsureLoaded is called when user input is set and variables are recalculated. + // It means that by this time you shouldn't receive this panic. + // Please, review your code. + // Call SetInput or EnsureLoaded to check for the error before accessing this data. + panic(fmt.Errorf("load error must be checked first: %w", err)) } - return a.def.Action + return a.def.Runtime } // ImageBuildInfo implements [ImageBuildResolver]. func (a *Action) ImageBuildInfo(image string) *types.BuildDefinition { - return a.ActionDef().Build.ImageBuildInfo(image, a.Dir()) + return a.RuntimeDef().Container.Build.ImageBuildInfo(image, a.Dir()) } // SetInput saves arguments and options for later processing in run, templates, etc. -func (a *Action) SetInput(input Input) (err error) { - if err = a.EnsureLoaded(); err != nil { +func (a *Action) SetInput(input *Input) (err error) { + def := a.ActionDef() + + // Process arguments. + err = a.processInputParams(def.Arguments, input.ArgsNamed(), nil) + if err != nil { return err } - // @todo disabled for now until fully tested. - //if err = a.validateJSONSchema(input); err != nil { - // return err - //} - err = a.processArgs(input.Args) + // Process options. + err = a.processInputParams(def.Options, input.OptsAll(), input.OptsChanged()) if err != nil { return err } - err = a.processOptions(input.Opts, input.OptsRaw) - if err != nil { + // Validate the new input. + if err = a.ValidateInput(input); err != nil { return err } @@ -184,125 +215,91 @@ func (a *Action) SetInput(input Input) (err error) { return a.EnsureLoaded() } -func (a *Action) processOptions(opts, optsRaw TypeOpts) error { - for _, optDef := range a.ActionDef().Options { - if _, ok := opts[optDef.Name]; !ok { +func (a *Action) processInputParams(def ParametersList, inp InputParams, changed InputParams) error { + for _, p := range def { + if _, ok := inp[p.Name]; !ok { continue } - value := optsRaw[optDef.Name] - toApply := optDef.Process + if changed != nil { + if _, ok := changed[p.Name]; ok { + continue + } + } + + value := inp[p.Name] + toApply := p.Process - value, err := a.processValue(value, optDef.Type, toApply) + 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 { - opts[optDef.Name] = value - } - } - - return nil -} - -func (a *Action) processArgs(args TypeArgs) error { - for _, argDef := range a.ActionDef().Arguments { - if _, ok := args[argDef.Name]; !ok { - continue + inp[p.Name] = value } - - value := args[argDef.Name] - toApply := argDef.Process - value, err := a.processValue(value, argDef.Type, toApply) - if err != nil { - return err - } - - args[argDef.Name] = value } return nil } -func (a *Action) processValue(value any, valueType jsonschema.Type, toApplyProcessors []ValueProcessDef) (any, error) { - newValue := value +func (a *Action) processValue(v any, vtype jsonschema.Type, applyProc []DefValueProcessor) (any, error) { + res := v processors := a.GetProcessors() - for _, processor := range toApplyProcessors { - if processor.Processor == "" { - return value, errInvalidProcessor - } - - proc, ok := processors[processor.Processor] + for _, procDef := range applyProc { + proc, ok := processors[procDef.ID] if !ok { - return value, fmt.Errorf(errTplNonExistProcessor, processor.Processor) + return v, fmt.Errorf(errTplNonExistProcessor, procDef.ID) } - if !proc.IsApplicable(valueType) { - return value, fmt.Errorf(errTplNotApplicableProcessor, valueType) + if !proc.IsApplicable(vtype) { + return v, fmt.Errorf(errTplNotApplicableProcessor, vtype) } - processedValue, err := proc.Execute(newValue, processor.Options) + processedValue, err := proc.Execute(res, procDef.Options) if err != nil { - return value, err + return v, err } - newValue = processedValue + res = processedValue + } + // Cast to []any slice because jsonschema validator supports only this type. + if vtype == jsonschema.Array { + res = CastSliceTypedToAny(res) } - return newValue, nil + return res, nil } -// validateJSONSchema validates arguments and options according to -// a specified json schema in action definition. -// @todo move to jsonschema -func (a *Action) validateJSONSchema(inp Input) error { //nolint:unused - jsch := a.JSONSchema() - // @todo cache jsonschema and resources. - b, err := json.Marshal(jsch) - if err != nil { - return err - } - buf := bytes.NewBuffer(b) - c := jsvalidate.NewCompiler() - err = c.AddResource(a.Filepath(), buf) - if err != nil { - return err +// ValidateInput validates action input. +func (a *Action) ValidateInput(input *Input) error { + if input.IsValidated() { + return nil } - sch, err := c.Compile(a.Filepath()) - 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 = sch.Validate(map[string]any{ - "arguments": inp.Args, - "options": inp.Opts, - }) + err := validateJSONSchema(a, input) if err != nil { return err } - // @todo validate must have info about which fields failed. - return nil -} - -// ValidateInput validates input arguments in action definition. -func (a *Action) ValidateInput(args TypeArgs) error { - argsInitNum := len(a.ActionDef().Arguments) - argsInputNum := len(args) - if argsInitNum != argsInputNum { - return fmt.Errorf("accepts %d arg(s), received %d", argsInitNum, argsInputNum) - } - + 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. - if a.env == nil { - panic("run environment is not set, call SetRunEnvironment first") + if a.runtime == nil { + panic("runtime is not set, call SetRuntime first") + } + defer a.runtime.Close() + if err := a.runtime.Init(ctx, a); err != nil { + return err } - defer a.env.Close() - return a.env.Execute(ctx, a) + return a.runtime.Execute(ctx, a) } diff --git a/pkg/action/action.idp.go b/pkg/action/action.idp.go new file mode 100644 index 0000000..4678d0d --- /dev/null +++ b/pkg/action/action.idp.go @@ -0,0 +1,45 @@ +package action + +import ( + "path/filepath" + "strings" +) + +// IDProvider provides an ID for an action. +// It is used to generate an ID from an action declaration. +// [DefaultIDProvider] is the default implementation based on action filepath. +type IDProvider interface { + GetID(a *Action) string +} + +// DefaultIDProvider is a default action id provider. +// It generates action id by a filepath. +type DefaultIDProvider struct{} + +// GetID implements [IDProvider] interface. +// It parses action filename and returns CLI command name. +// Empty string if the command name can't be generated. +func (idp DefaultIDProvider) GetID(a *Action) string { + f := a.fpath + s := filepath.Dir(f) + i := strings.LastIndex(s, actionsSubdir) + if i == -1 { + return "" + } + s = s[:i] + strings.Replace(s[i:], actionsSubdir, ":", 1) + s = strings.ReplaceAll(s, string(filepath.Separator), ".") + if s[0] == ':' { + // Root paths are not allowed. + return "" + } + s = strings.Trim(s, ".:") + return s +} + +// StringID is an [IDProvider] with constant string id. +type StringID string + +// GetID implements [IDProvider] interface to return itself. +func (idp StringID) GetID(_ *Action) string { + return string(idp) +} diff --git a/pkg/action/action.input.go b/pkg/action/action.input.go new file mode 100644 index 0000000..9677f18 --- /dev/null +++ b/pkg/action/action.input.go @@ -0,0 +1,241 @@ +package action + +import ( + "reflect" + "strings" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/jsonschema" +) + +// inputMapKeyArgsPos is a special map key to store positional arguments. +const inputMapKeyArgsPos = "__positional_strings" + +type ( + // InputParams is a type alias for action arguments/options. + InputParams = map[string]any +) + +// Input is a container for action input arguments and options. +type Input struct { + action *Action + validated bool + + // args contains parsed and named arguments. + args InputParams + // opts contains parsed options with default values. + opts InputParams + // io contains out/in/err destinations. @todo should it be in Input? + io launchr.Streams + + // argsPos contains raw positional arguments. + argsPos []string + // optsRaw contains options that were input by a user and without default values. + optsRaw InputParams +} + +// NewInput creates new input with named Arguments args and named Options opts. +// Options are filled with default values. +func NewInput(a *Action, args InputParams, opts InputParams, io launchr.Streams) *Input { + def := a.ActionDef() + // Process positional first. + argsPos := argsNamedToPos(args, def.Arguments) + // Make sure the special key doesn't leak. + delete(args, inputMapKeyArgsPos) + return &Input{ + action: a, + args: setParamDefaults(args, def.Arguments), + argsPos: argsPos, + opts: setParamDefaults(opts, def.Options), + optsRaw: opts, + io: io, + } +} + +// ArgsPosToNamed creates named arguments input. +func ArgsPosToNamed(a *Action, args []string) (InputParams, error) { + def := a.ActionDef() + mapped := make(InputParams, len(args)) + for i, arg := range args { + if i < len(def.Arguments) { + var err error + mapped[def.Arguments[i].Name], err = castArgStrToType(arg, def.Arguments[i]) + if err != nil { + return nil, err + } + } + } + // Store a special key to have positional arguments as []string in [NewInput]. + mapped[inputMapKeyArgsPos] = args + return mapped, nil +} + +func castArgStrToType(v string, pdef *DefParameter) (any, error) { + var err error + if pdef.Type != jsonschema.Array { + return jsonschema.ConvertStringToType(v, pdef.Type) + } + items := strings.Split(v, ",") + res := make([]any, len(items)) + for i, item := range items { + res[i], err = jsonschema.ConvertStringToType(item, pdef.Items.Type) + if err != nil { + return nil, err + } + } + return res, nil +} + +// IsValidated returns input status. +func (input *Input) IsValidated() bool { + return input.validated +} + +// SetValidated marks input as validated. +func (input *Input) SetValidated(v bool) { + input.validated = v +} + +// Arg returns argument by a name. +func (input *Input) Arg(name string) any { + return input.ArgsNamed()[name] +} + +// SetArg sets an argument value. +func (input *Input) SetArg(name string, val any) { + input.optsRaw[name] = val + input.opts[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) +} + +// IsArgChanged checks if an argument was changed by user. +func (input *Input) IsArgChanged(name string) bool { + _, ok := input.args[name] + return ok +} + +// Opt returns option by a name. +func (input *Input) Opt(name string) any { + return input.OptsAll()[name] +} + +// SetOpt sets an option value. +func (input *Input) SetOpt(name string, val any) { + input.optsRaw[name] = val + input.opts[name] = val +} + +// UnsetOpt unsets the option and recalculates default values. +func (input *Input) UnsetOpt(name string) { + delete(input.optsRaw, name) + delete(input.opts, name) + input.opts = setParamDefaults(input.opts, input.action.ActionDef().Options) +} + +// IsOptChanged checks if an option was changed by user. +func (input *Input) IsOptChanged(name string) bool { + _, ok := input.optsRaw[name] + return ok +} + +// ArgsNamed returns input named and processed arguments. +func (input *Input) ArgsNamed() InputParams { + return input.args +} + +// 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 { + return input.opts +} + +// OptsChanged returns options that were set manually by user (not processed). +func (input *Input) OptsChanged() InputParams { + return input.optsRaw +} + +// Streams returns input io. +func (input *Input) Streams() launchr.Streams { + return input.io +} + +func argsNamedToPos(args InputParams, argsDef ParametersList) []string { + if args == nil { + return nil + } + if inpArgsPos, ok := args[inputMapKeyArgsPos]; ok { + return inpArgsPos.([]string) + } + res := make([]string, len(argsDef)) + for i := 0; i < len(argsDef); i++ { + res[i], _ = args[argsDef[i].Name].(string) + } + return res +} + +func setParamDefaults(params InputParams, paramDef ParametersList) InputParams { + res := copyMap(params) + for _, d := range paramDef { + k := d.Name + v, ok := params[k] + // Set default values. + if ok { + res[k] = v + } else if d.Default != nil { + res[k] = d.Default + } + // Cast to []any slice because jsonschema validator supports only this type. + if d.Type == jsonschema.Array { + res[k] = CastSliceTypedToAny(res[k]) + } + } + return res +} + +// CastSliceTypedToAny converts an unknown slice to []any slice. +func CastSliceTypedToAny(slice any) []any { + if slice == nil { + return nil + } + if slice, okAny := slice.([]any); okAny { + return slice + } + val := reflect.ValueOf(slice) + if val.Kind() != reflect.Slice { + return nil + } + res := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + res[i] = val.Index(i).Interface() + } + return res +} + +// CastSliceAnyToTyped converts []any slice to a typed slice. +func CastSliceAnyToTyped[T any](orig []any) []T { + res := make([]T, len(orig)) + for i := 0; i < len(orig); i++ { + res[i] = orig[i].(T) + } + return res +} + +// InputArgSlice is a helper function to get an argument of specific type slice. +func InputArgSlice[T any](input *Input, name string) []T { + return CastSliceAnyToTyped[T](input.Arg(name).([]any)) +} + +// InputOptSlice is a helper function to get an option of specific type slice. +func InputOptSlice[T any](input *Input, name string) []T { + return CastSliceAnyToTyped[T](input.Opt(name).([]any)) +} diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index a93d655..688b544 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -2,29 +2,33 @@ package action import ( "context" + "errors" "fmt" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchrctl/launchr/pkg/jsonschema" ) func Test_Action(t *testing.T) { assert := assert.New(t) + require := require.New(t) // Prepare an action. fs := _getFsMapActions(1, validFullYaml, genPathTypeValid) ad := NewYamlDiscovery(NewDiscoveryFS(fs, "")) ctx := context.Background() actions, err := ad.Discover(ctx) - assert.True(assert.NoError(err)) - assert.NotEmpty(actions) + require.NoError(err) + require.NotEmpty(actions) act := actions[0] - err = act.EnsureLoaded() - assert.True(assert.NoError(err)) - actConf := act.ActionDef() + runDef := act.RuntimeDef() // Test image name. - assert.Equal("my/image:v1", actConf.Image) + assert.Equal("my/image:v1", runDef.Container.Image) // Test dir assert.Equal(filepath.Dir(act.fpath), act.Dir()) act.fpath = "test/file/path/action.yaml" @@ -34,10 +38,10 @@ func Test_Action(t *testing.T) { "host.docker.internal:host-gateway", "example.com:127.0.0.1", } - assert.Equal(extraHosts, actConf.ExtraHosts) + assert.Equal(extraHosts, runDef.Container.ExtraHosts) // Test arguments and options. - inputArgs := TypeArgs{"arg1": "arg1", "arg2": "arg2", "arg-1": "arg-1", "arg_12": "arg_12"} - inputOpts := TypeOpts{ + inputArgs := InputParams{"arg1": "arg1", "arg2": "arg2", "arg-1": "arg-1", "arg_12": "arg_12_enum1"} + inputOpts := InputParams{ "opt1": "opt1val", "opt-1": "opt-1", "opt2": true, @@ -46,10 +50,18 @@ func Test_Action(t *testing.T) { "optarr": []any{"opt5.1val", "opt5.2val"}, "opt6": "unexpectedOpt", } - err = act.SetInput(Input{Args: inputArgs, Opts: inputOpts}) - assert.True(assert.NoError(err)) - assert.Equal(inputArgs, act.input.Args) - assert.Equal(inputOpts, act.input.Opts) + input := NewInput(act, inputArgs, inputOpts, nil) + require.NotNil(input) + input.SetValidated(true) + err = act.SetInput(input) + require.NoError(err) + 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"] + assert.True(okOpt) + assert.Equal(inputArgs, act.input.ArgsNamed()) + assert.Equal(inputOpts, act.input.OptsAll()) // Test templating in executable. envVar1 := "envval1" @@ -64,14 +76,12 @@ func Test_Action(t *testing.T) { fmt.Sprintf("%v ", envVar1), } act.Reset() - err = act.EnsureLoaded() - assert.True(assert.NoError(err)) - actConf = act.ActionDef() - assert.Equal(execExp, []string(actConf.Command)) + runDef = act.RuntimeDef() + assert.Equal(execExp, []string(runDef.Container.Command)) assert.NotNil(act.def) // Test build info - b := act.ImageBuildInfo(actConf.Image) + b := act.ImageBuildInfo(runDef.Container.Image) assert.NotNil(b) tags := []string{ "my/image:v2", @@ -79,6 +89,244 @@ func Test_Action(t *testing.T) { "my/image:v1", } assert.Equal(tags, b.Tags) - actConf.Build = nil + runDef.Container.Build = nil assert.Nil(nil) } + +func Test_ActionInput(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + a := NewFromYAML("input_test", []byte(validMultipleArgsAndOpts)) + // Create empty input. + input := NewInput(a, nil, nil, nil) + require.NotNil(input) + + // Test validated. + assert.False(input.IsValidated()) + input.SetValidated(true) + assert.True(input.IsValidated()) + input.SetValidated(false) + assert.False(input.IsValidated()) + + // Test get argument that has a default value. + arg := input.Arg("arg_default") + assert.Equal("my_default_string", arg) + // Get defined argument but not set. + arg = input.Arg("arg_int") + assert.True(assert.Nil(arg)) + // Get undefined argument. + arg = input.Arg("undefined") + assert.True(assert.Nil(arg)) + + // Get defined option. Default value is not set. + opt := input.Opt("opt_str") + assert.Equal(nil, opt) + // Get undefined option, value is not set. + opt = input.Opt("undefined") + assert.True(assert.Nil(opt)) + + // Test user changed input. + // 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.True(input.IsArgChanged("arg_str")) + assert.False(input.IsArgChanged("arg_int")) + assert.False(input.IsArgChanged("arg_str2")) + // 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.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()) + + // Test create with positional arguments of different types. + argsPos := []string{"42", "str", "str2", "true", "str3", "undstr", "24"} + argsNamed, err := ArgsPosToNamed(a, argsPos) + require.NoError(err) + savedPos, posKeyOk := argsNamed[inputMapKeyArgsPos] + assert.True(posKeyOk) + assert.Equal(argsPos, savedPos) + input = NewInput(a, argsNamed, nil, nil) + expArgs := InputParams{ + "arg_int": 42, + "arg_str": "str", + "arg_str2": "str2", + "arg_bool": true, + "arg_default": "str3", + } + _, posKeyOk = input.args[inputMapKeyArgsPos] + assert.False(posKeyOk) + assert.Equal(expArgs, input.ArgsNamed()) + assert.Equal(argsPos, input.ArgsPositional()) +} + +func Test_ActionInputValidate(t *testing.T) { + type inputProcessFn func(_ *testing.T, a *Action, input *Input) + type testCase struct { + name string + yaml string + args InputParams + opts InputParams + fnInit inputProcessFn + expErr error + } + + // Extra input preparation and testing. + setValidatedInput := func(t *testing.T, _ *Action, input *Input) { + input.SetValidated(true) + assert.True(t, input.validated) + } + + setPosArgs := func(args ...string) inputProcessFn { + return func(t *testing.T, a *Action, input *Input) { + argsPos, err := ArgsPosToNamed(a, args) + require.NoError(t, err) + *input = *NewInput(a, argsPos, input.OptsChanged(), input.Streams()) + } + } + + // Checks that argument has expected value. + assertArgValue := func(arg string, exp string) inputProcessFn { + return func(t *testing.T, _ *Action, input *Input) { + actual := input.Arg(arg) + assert.Equal(t, exp, actual) + } + } + + // Argument or option property path. + arg := func(k ...string) []string { return append([]string{jsonschemaPropArgs}, k...) } + opt := func(k ...string) []string { return append([]string{jsonschemaPropOpts}, k...) } + + // JSON Schema errors. + newError := func(path []string, msg string) jsonschema.ErrSchemaValidation { + return jsonschema.NewErrSchemaValidation(path, msg) + } + + // Creates a validation error. + schemaErr := func(err ...jsonschema.ErrSchemaValidation) jsonschema.ErrSchemaValidationArray { + return err + } + + // Error of type mismatch. + newErrExpType := func(path []string, expT string, actT string) jsonschema.ErrSchemaValidation { + return newError(path, fmt.Sprintf("got %s, want %s", actT, expT)) + } + + joinQuoted := func(s []string, sep string) string { + quoted := make([]string, len(s)) + for i := 0; i < len(s); i++ { + quoted[i] = `'` + s[i] + `'` + } + return strings.Join(quoted, sep) + } + + // Error when property is missing. + newErrMissProp := func(path []string, props ...string) jsonschema.ErrSchemaValidation { + if len(props) == 1 { + return newError(path, fmt.Sprintf("missing property %s", joinQuoted(props, ", "))) + } + return newError(path, fmt.Sprintf("missing properties %s", joinQuoted(props, ", "))) + } + + newErrAddProps := func(path []string, props ...string) jsonschema.ErrSchemaValidation { + return newError(path, fmt.Sprintf("additional properties %s not allowed", joinQuoted(props, ", "))) + } + + // Error of enum. + newErrEnum := func(path []string, enums ...string) jsonschema.ErrSchemaValidation { + return newError(path, fmt.Sprintf(`value must be one of %s`, joinQuoted(enums, ", "))) + } + + errAny := errors.New("any") + tt := []testCase{ + {"valid arg string", validArgString, InputParams{"arg_string": "arg1"}, nil, nil, nil}, + {"valid arg string - undefined arg and opt", validArgString, InputParams{"arg_string": "arg1", "arg_undefined": "und"}, InputParams{"opt_undefined": "und"}, nil, schemaErr( + newErrAddProps(arg(), "arg_undefined"), + newErrAddProps(opt(), "opt_undefined"), + )}, + {"valid args positional", validArgString, nil, nil, setPosArgs("arg1"), nil}, + {"invalid args positional - given more than expected", validArgString, nil, nil, setPosArgs("arg1", "arg2"), + fmt.Errorf("accepts 1 arg(s), received 2"), + }, + {"invalid arg string - number given", validArgString, InputParams{"arg_string": 1}, nil, nil, schemaErr( + newErrExpType(arg("arg_string"), "string", "number"), + )}, + {"invalid required - arg not given", validArgString, InputParams{}, nil, nil, schemaErr( + newErrMissProp(arg(), "arg_string"), + )}, + {"invalid required ok - validation skipped", validArgString, InputParams{}, nil, setValidatedInput, nil}, + {"valid arg optional", validArgStringOptional, InputParams{}, nil, nil, nil}, + {"valid arg string enum", validArgStringEnum, InputParams{"arg_enum": "enum1"}, nil, nil, nil}, + {"invalid arg string enum - number given", validArgStringEnum, InputParams{"arg_enum": 1}, nil, nil, schemaErr( + newErrExpType(arg("arg_enum"), "string", "number"), + )}, + {"invalid arg string enum - incorrect enum given", validArgStringEnum, InputParams{"arg_enum": "invalid"}, nil, nil, schemaErr( + newErrEnum(arg("arg_enum"), "enum1", "enum2"), + )}, + {"valid arg boolean", validArgBoolean, InputParams{"arg_boolean": true}, nil, nil, nil}, + {"valid arg default - correct type given", validArgDefault, InputParams{"arg_default": "my_val"}, nil, assertArgValue("arg_default", "my_val"), nil}, + {"invalid arg default - wrong type given", validArgDefault, InputParams{"arg_default": true}, nil, nil, schemaErr( + newErrExpType(arg("arg_default"), "string", "boolean"), + )}, + {"valid arg default - arg not given", validArgDefault, InputParams{}, nil, assertArgValue("arg_default", "default_string"), nil}, + {"valid boolean opt", validOptBoolean, nil, InputParams{"opt_boolean": true}, nil, nil}, + {"invalid boolean opt - string given", validOptBoolean, nil, InputParams{"opt_boolean": "str"}, nil, schemaErr( + newErrExpType(opt("opt_boolean"), "boolean", "string"), + )}, + {"valid array type string - string slice given", validOptArrayImplicitString, nil, InputParams{"opt_array_str": []string{"str1", "str2"}}, nil, nil}, + {"valid array type string - any slice given", validOptArrayImplicitString, nil, InputParams{"opt_array_str": []any{"str1", "str2"}}, nil, nil}, + {"invalid array type string - int slice given", validOptArrayImplicitString, nil, InputParams{"opt_array_str": []int{1, 2, 3}}, nil, schemaErr( + newErrExpType(opt("opt_array_str", "0"), "string", "number"), + newErrExpType(opt("opt_array_str", "1"), "string", "number"), + newErrExpType(opt("opt_array_str", "2"), "string", "number"), + )}, + {"valid array type string enum", validOptArrayStringEnum, nil, InputParams{"opt_array_enum": []string{"enum_arr1", "enum_arr2"}}, nil, nil}, + {"invalid array type string enum - incorrect enum given", validOptArrayStringEnum, nil, InputParams{"opt_array_enum": []string{"enum_arr_incorrect1", "enum_arr_incorrect2"}}, nil, schemaErr( + newErrEnum(opt("opt_array_enum", "0"), "enum_arr1", "enum_arr2"), + newErrEnum(opt("opt_array_enum", "1"), "enum_arr1", "enum_arr2"), + )}, + {"valid array type integer", validOptArrayInt, nil, InputParams{"opt_array_int": []int{1, 2, 3}}, nil, nil}, + {"valid array type integer - default used", validOptArrayIntDefault, nil, nil, nil, nil}, + {"valid multiple args and opts", validMultipleArgsAndOpts, InputParams{"arg_int": 1, "arg_str": "mystr", "arg_str2": "mystr", "arg_bool": true}, InputParams{"opt_str_required": "mystr"}, nil, nil}, + {"invalid multiple args and opts - multiple causes", validMultipleArgsAndOpts, InputParams{"arg_int": "str", "arg_str": 1}, InputParams{"opt_str": 1}, nil, schemaErr( + newErrMissProp(arg(), "arg_str2", "arg_bool"), + newErrExpType(arg("arg_int"), "integer", "string"), + newErrExpType(arg("arg_str"), "string", "number"), + newErrMissProp(opt(), "opt_str_required"), + newErrExpType(opt("opt_str"), "string", "number"), + )}, + {"valid format and pattern - email and uppercase given", validPatternFormat, InputParams{"arg_email": "my@example.com", "arg_pattern": "UPPER"}, nil, nil, nil}, + {"invalid format and pattern - wrong email and lowercase given", validPatternFormat, InputParams{"arg_email": "not_email", "arg_pattern": "lower"}, nil, nil, schemaErr( + newError(arg("arg_email"), "'not_email' is not valid email: missing @"), + newError(arg("arg_pattern"), "'lower' does not match pattern '^[A-Z]+$'"), + )}, + } + + for _, tt := range tt { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + a := NewFromYAML(tt.name, []byte(tt.yaml)) + input := NewInput(a, tt.args, tt.opts, nil) + require.NotNil(t, input) + if tt.fnInit != nil { + tt.fnInit(t, a, input) + } + err := a.ValidateInput(input) + if tt.expErr == errAny { + assert.True(t, assert.Error(t, err)) + } else if assert.IsType(t, tt.expErr, err) { + assert.Equal(t, tt.expErr, err) + } else { + assert.ErrorIs(t, err, tt.expErr) + } + }) + } +} diff --git a/pkg/action/discover.go b/pkg/action/discover.go index 203c93b..7f91907 100644 --- a/pkg/action/discover.go +++ b/pkg/action/discover.go @@ -3,7 +3,6 @@ package action import ( "context" - "fmt" "io/fs" "os" "path/filepath" @@ -22,7 +21,7 @@ var actionsSubdir = strings.Join([]string{"", actionsDirname, ""}, string(filepa // DiscoveryPlugin is a launchr plugin to discover actions. type DiscoveryPlugin interface { launchr.Plugin - DiscoverActions(ctx context.Context, fs launchr.ManagedFS, idp IDProvider) ([]*Action, error) + DiscoverActions(ctx context.Context) ([]*Action, error) } // AlterActionsPlugin is a launchr plugin to alter registered actions. @@ -41,14 +40,21 @@ type DiscoveryFS struct { // and wd - working directory for an action, leave empty for current path. func NewDiscoveryFS(fs fs.FS, wd string) DiscoveryFS { return DiscoveryFS{fs, wd} } -// FS implements launchr.ManagedFS. +// FS implements [launchr.ManagedFS]. func (f DiscoveryFS) FS() fs.FS { return f.fs } -// Open implements fs.FS and decorates the managed fs. +// Open implements [fs.FS] and decorates the [launchr.ManagedFS]. func (f DiscoveryFS) Open(name string) (fs.File, error) { return f.FS().Open(name) } +// OpenCallback returns callback to FileOpen a file. +func (f DiscoveryFS) OpenCallback(name string) FileLoadFn { + return func() (fs.File, error) { + return f.Open(name) + } +} + // FileLoadFn is a type for loading a file. type FileLoadFn func() (fs.File, error) @@ -58,13 +64,6 @@ type DiscoveryStrategy interface { Loader(l FileLoadFn, p ...LoadProcessor) Loader } -// IDProvider provides an ID for an action. -// It is used to generate an ID from an action declaration. -// [DefaultIDProvider] is the default implementation based on action filepath. -type IDProvider interface { - GetID(a *Action) string -} - // Discovery defines a common functionality for discovering action files. type Discovery struct { fs DiscoveryFS @@ -92,7 +91,7 @@ func (ad *Discovery) isValid(path string, d fs.DirEntry) bool { // No "actions" directory in the path. i == -1 || // Must not be hidden itself. - isHiddenPath(path) || + launchr.IsHiddenPath(path) || // Count depth of directories inside actions, must be only 1, not deeper. // Nested actions are not allowed. // dir/actions/1/action.yaml - OK, dir/actions/1/2/action.yaml - NOK. @@ -123,7 +122,7 @@ func (ad *Discovery) findFiles(ctx context.Context) chan string { } // Skip OS specific directories to prevent going too deep. // Skip hidden directories. - if d != nil && d.IsDir() && (isHiddenPath(path) || skipSystemDirs(ad.fsDir, path)) { + if d != nil && d.IsDir() && (launchr.IsHiddenPath(path) || launchr.IsSystemPath(ad.fsDir, path)) { return fs.SkipDir } if err != nil { @@ -189,18 +188,13 @@ func (ad *Discovery) Discover(ctx context.Context) ([]*Action, error) { // parseFile parses file f and returns an action. func (ad *Discovery) parseFile(f string) *Action { - a := NewAction(absPath(ad.fs.wd), ad.fsDir, f) - a.Loader = ad.ds.Loader( - func() (fs.File, error) { return ad.fs.Open(f) }, + loader := ad.ds.Loader( + ad.fs.OpenCallback(f), envProcessor{}, inputProcessor{}, ) - // Assign ID to an action. - a.ID = ad.idp.GetID(a) - if a.ID == "" { - panic(fmt.Errorf("action id cannot be empty, file %q", f)) - } - + a := New(ad.idp, loader, ad.fsDir, f) + a.SetWorkDir(launchr.MustAbs(ad.fs.wd)) return a } @@ -208,27 +202,3 @@ func (ad *Discovery) parseFile(f string) *Action { func (ad *Discovery) SetActionIDProvider(idp IDProvider) { ad.idp = idp } - -// DefaultIDProvider is a default action id provider. -// It generates action id by a filepath. -type DefaultIDProvider struct{} - -// GetID implements [IDProvider] interface. -// It parses action filename and returns CLI command name. -// Empty string if the command name can't be generated. -func (idp DefaultIDProvider) GetID(a *Action) string { - f := a.fpath - s := filepath.Dir(f) - i := strings.LastIndex(s, actionsSubdir) - if i == -1 { - return "" - } - s = s[:i] + strings.Replace(s[i:], actionsSubdir, ":", 1) - s = strings.ReplaceAll(s, string(filepath.Separator), ".") - if s[0] == ':' { - // Root paths are not allowed. - return "" - } - s = strings.Trim(s, ".:") - return s -} diff --git a/pkg/action/discover.skip.go b/pkg/action/discover.skip.go deleted file mode 100644 index 136953e..0000000 --- a/pkg/action/discover.skip.go +++ /dev/null @@ -1,43 +0,0 @@ -// Package action provides implementations of discovering and running actions. -package action - -func skipSystemDirs(root string, path string) bool { - if root == "" { - // We are in virtual FS. - return false - } - - dirs := []string{ - // Python specific. - "__pycache__", - "venv", - // JS specific stuff. - "node_modules", - // Usually project dependencies. - "vendor", - } - - // Check application specific. - if existsInSlice(dirs, path) { - return true - } - // Skip in root. - if isRootPath(root) && existsInSlice(skipRootDirs, path) { - return true - } - // Skip user specific directories. - if isUserHomeDir(path) && existsInSlice(skipUserDirs, path) { - return true - } - - return false -} - -func existsInSlice[T comparable](slice []T, el T) bool { - for _, v := range slice { - if v == el { - return true - } - } - return false -} diff --git a/pkg/action/discover_test.go b/pkg/action/discover_test.go index 357e836..c7a0833 100644 --- a/pkg/action/discover_test.go +++ b/pkg/action/discover_test.go @@ -10,6 +10,9 @@ import ( "github.com/docker/docker/pkg/namesgenerator" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchrctl/launchr/internal/launchr" ) type genPathType int @@ -79,15 +82,15 @@ func Test_Discover_ActionWD(t *testing.T) { ad := NewYamlDiscovery(NewDiscoveryFS(tfs, expectedWD)) ctx := context.Background() actions, err := ad.Discover(ctx) - assert.True(t, assert.NoError(t, err)) + require.NoError(t, err) assert.Equal(t, expFPath, actions[0].fpath) - assert.Equal(t, absPath(expectedWD), actions[0].wd) + assert.Equal(t, launchr.MustAbs(expectedWD), actions[0].wd) ad = NewYamlDiscovery(NewDiscoveryFS(tfs, "")) actions, err = ad.Discover(ctx) - assert.True(t, assert.NoError(t, err)) + require.NoError(t, err) assert.Equal(t, expFPath, actions[0].fpath) - assert.Equal(t, absPath(""), actions[0].wd) + assert.Equal(t, launchr.MustAbs(""), actions[0].wd) } type dirEntry string diff --git a/pkg/action/env.go b/pkg/action/env.go deleted file mode 100644 index 8b2fa93..0000000 --- a/pkg/action/env.go +++ /dev/null @@ -1,109 +0,0 @@ -package action - -import ( - "context" - "fmt" - - "github.com/launchrctl/launchr/internal/launchr" - "github.com/launchrctl/launchr/pkg/types" -) - -// RunStatusError is an execution error also containing command exit code. -type RunStatusError struct { - code int - actionID string -} - -func (e RunStatusError) Error() string { - return fmt.Sprintf("action %q finished with the exit code %d", e.actionID, e.code) -} - -// GetCode returns run result exit code. -func (e RunStatusError) GetCode() int { - return e.code -} - -// RunEnvironment is a common interface for all action run environments. -type RunEnvironment interface { - // Init prepares the run environment. - Init(ctx context.Context) error - // 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 -} - -// RunEnvironmentFlags is an interface to define environment specific runtime configuration. -type RunEnvironmentFlags interface { - RunEnvironment - // FlagsDefinition provides definitions for action environment specific flags. - FlagsDefinition() OptionsList - // UseFlags sets environment configuration. - UseFlags(flags TypeOpts) error - // ValidateInput validates input arguments in action definition. - ValidateInput(a *Action, args TypeArgs) error -} - -// ContainerRunEnvironment is an interface for container run environments. -type ContainerRunEnvironment interface { - RunEnvironment - // SetContainerNameProvider sets container name provider. - SetContainerNameProvider(ContainerNameProvider) - // AddImageBuildResolver adds an image build resolver to a chain. - AddImageBuildResolver(ImageBuildResolver) - // SetImageBuildCacheResolver sets an image build cache resolver - // to check when image must be rebuilt. - SetImageBuildCacheResolver(*ImageBuildCacheResolver) -} - -// ImageBuildResolver is an interface to resolve image build info from its source. -type ImageBuildResolver interface { - // ImageBuildInfo takes image as name and provides build definition for that. - ImageBuildInfo(image string) *types.BuildDefinition -} - -// ChainImageBuildResolver is a image build resolver that takes first available image in the chain. -type ChainImageBuildResolver []ImageBuildResolver - -// ImageBuildInfo implements [ImageBuildResolver]. -func (r ChainImageBuildResolver) ImageBuildInfo(image string) *types.BuildDefinition { - for i := 0; i < len(r); i++ { - if b := r[i].ImageBuildInfo(image); b != nil { - return b - } - } - return nil -} - -// ConfigImagesKey is a field name in launchr config file. -const ConfigImagesKey = "images" - -// ConfigImages is a container to parse launchr config in yaml format. -type ConfigImages map[string]*types.BuildDefinition - -// LaunchrConfigImageBuildResolver is a resolver of image build in launchr config file. -type LaunchrConfigImageBuildResolver struct{ cfg launchr.Config } - -// ImageBuildInfo implements [ImageBuildResolver]. -func (r LaunchrConfigImageBuildResolver) ImageBuildInfo(image string) *types.BuildDefinition { - if r.cfg == nil { - return nil - } - var images ConfigImages - err := r.cfg.Get(ConfigImagesKey, &images) - if err != nil { - launchr.Term().Warning().Printfln("configuration file field %q is malformed", ConfigImagesKey) - return nil - } - if b, ok := images[image]; ok { - return b.ImageBuildInfo(image, r.cfg.DirPath()) - } - for _, b := range images { - for _, t := range b.Tags { - if t == image { - return b.ImageBuildInfo(image, r.cfg.DirPath()) - } - } - } - return nil -} diff --git a/pkg/action/jsonschema.go b/pkg/action/jsonschema.go index 3cb1606..73a8ff9 100644 --- a/pkg/action/jsonschema.go +++ b/pkg/action/jsonschema.go @@ -6,6 +6,23 @@ import ( "github.com/launchrctl/launchr/pkg/jsonschema" ) +const ( + jsonschemaPropArgs = "arguments" + jsonschemaPropOpts = "options" +) + +// validateJSONSchema validates arguments and options according to +// a specified json schema in action definition. +func validateJSONSchema(a *Action, input *Input) error { + return jsonschema.Validate( + a.JSONSchema(), + map[string]any{ + jsonschemaPropArgs: input.ArgsNamed(), + jsonschemaPropOpts: input.OptsAll(), + }, + ) +} + // JSONSchema returns json schema of an action. func (a *Action) JSONSchema() jsonschema.Schema { def := a.ActionDef() @@ -14,6 +31,10 @@ func (a *Action) JSONSchema() jsonschema.Schema { // It's better to override the value, if the ID is needed by a validator. // In launchr, the id is overridden on loader, in web plugin with a server url. s.ID = a.Filepath() + // For plugin defined actions, filepath may be empty. + if s.ID == "" { + s.ID = a.ID + } s.Schema = "https://json-schema.org/draft/2020-12/schema#" s.Title = fmt.Sprintf("%s (%s)", def.Title, a.ID) // @todo provide better title. s.Description = def.Description @@ -22,71 +43,46 @@ func (a *Action) JSONSchema() jsonschema.Schema { // JSONSchema returns [jsonschema.Schema] for the arguments and options of the action. func (a *DefAction) JSONSchema() jsonschema.Schema { - // @todo maybe it should return only properties and not schema. args, argsReq := a.Arguments.JSONSchema() opts, optsReq := a.Options.JSONSchema() return jsonschema.Schema{ Type: jsonschema.Object, - Required: []string{"arguments"}, + Required: []string{jsonschemaPropArgs, jsonschemaPropOpts}, Properties: map[string]any{ - "arguments": map[string]any{ - "type": "object", - "title": "Arguments", - "properties": args, - "required": argsReq, + jsonschemaPropArgs: map[string]any{ + "type": "object", + "title": "Arguments", + "properties": args, + "required": argsReq, + "additionalProperties": false, }, - "options": map[string]any{ - "type": "object", - "title": "Options", - "properties": opts, - "required": optsReq, + jsonschemaPropOpts: map[string]any{ + "type": "object", + "title": "Options", + "properties": opts, + "required": optsReq, + "additionalProperties": false, }, }, } } // JSONSchema collects all arguments json schema definition and also returns fields that are required. -func (l *ArgumentsList) JSONSchema() (map[string]any, []string) { - s := *l - args := make(map[string]any, len(s)) - req := make([]string, 0, len(s)) - for i := 0; i < len(s); i++ { - args[s[i].Name] = s[i].JSONSchema() - req = append(req, s[i].Name) - } - return args, req -} - -// JSONSchema returns argument json schema definition. -func (a *Argument) JSONSchema() map[string]any { - m := copyMap(a.RawMap) - removeRequiredBool(m) - return m -} - -// JSONSchema collects all options json schema definition and also returns fields that are required. -func (l *OptionsList) JSONSchema() (map[string]any, []string) { +func (l *ParametersList) JSONSchema() (map[string]any, []string) { s := *l - opts := make(map[string]any, len(s)) + params := make(map[string]any, len(s)) req := make([]string, 0, len(s)) for i := 0; i < len(s); i++ { - opts[s[i].Name] = s[i].JSONSchema() + params[s[i].Name] = s[i].JSONSchema() if s[i].Required { req = append(req, s[i].Name) } } - return opts, req + return params, req } // JSONSchema returns json schema definition of an option. -func (o *Option) JSONSchema() map[string]any { - m := copyMap(o.RawMap) - removeRequiredBool(m) - return m -} - -func removeRequiredBool(m map[string]any) { - // @todo that's not right, but currently the required field in action yaml doesn't comply with json schema. - delete(m, "required") +func (p *DefParameter) JSONSchema() map[string]any { + return copyMap(p.raw) } diff --git a/pkg/action/loader.go b/pkg/action/loader.go index 8194e77..a63d3f2 100644 --- a/pkg/action/loader.go +++ b/pkg/action/loader.go @@ -93,17 +93,14 @@ func (p inputProcessor) Process(ctx LoadContext, b []byte) ([]byte, error) { return b, nil } a := ctx.Action - def, err := ctx.Action.Raw() - if err != nil { - return nil, err - } + def := ctx.Action.ActionDef() // Collect template variables. - data := ConvertInputToTplVars(a.GetInput(), def.Action) + data := ConvertInputToTplVars(a.Input(), def) addPredefinedVariables(data, a) // Parse action without variables to validate tpl := template.New(a.ID) - _, err = tpl.Parse(string(b)) + _, err := tpl.Parse(string(b)) if err != nil { // Check if variables have dashes to show the error properly. hasDash := false @@ -144,32 +141,14 @@ 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 { - values := make(map[string]any, len(input.Args)+len(input.Opts)) - // Collect argument values. - for _, arg := range ac.Arguments { - key := arg.Name - values[key] = "" - values[replDashes.Replace(key)] = "" - if v, ok := input.Args[arg.Name]; ok { - // Allow usage of dashed variable names like "my-name" by replacing dashes to underscores. - values[key] = v - values[replDashes.Replace(key)] = v - } - } +func ConvertInputToTplVars(input *Input, ac *DefAction) map[string]any { + args := input.ArgsNamed() + opts := input.OptsAll() + values := make(map[string]any, len(args)+len(opts)) - // Collect options values. - for _, o := range ac.Options { - key := o.Name - // Set value default or input option. - values[key] = o.Default - values[replDashes.Replace(key)] = o.Default - if v, ok := input.Opts[o.Name]; ok { - // Allow usage of dashed variable names like "my-name" by replacing dashes to underscores. - values[key] = v - values[replDashes.Replace(key)] = v - } - } + // Collect arguments and options values. + collectInputVars(values, args, ac.Arguments) + collectInputVars(values, opts, ac.Options) // @todo consider boolean, it's strange in output - "true/false" // @todo handle array options @@ -177,6 +156,21 @@ func ConvertInputToTplVars(input Input, ac *DefAction) map[string]any { return values } +func collectInputVars(values map[string]any, params InputParams, def ParametersList) { + for _, pdef := range def { + key := pdef.Name + // Set value: default or input parameter. + dval := fmt.Sprintf("%v", pdef.Default) + values[key] = dval + values[replDashes.Replace(key)] = dval + if v, ok := params[pdef.Name]; ok { + // Allow usage of dashed variable names like "my-name" by replacing dashes to underscores. + values[key] = v + values[replDashes.Replace(key)] = v + } + } +} + func addPredefinedVariables(data map[string]any, a *Action) { cuser := getCurrentUser() // Set zeros for running in environments like Windows diff --git a/pkg/action/loader_test.go b/pkg/action/loader_test.go index 9e73bce..55402ed 100644 --- a/pkg/action/loader_test.go +++ b/pkg/action/loader_test.go @@ -6,31 +6,33 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func testLoaderAction() *Action { af := &Definition{ Version: "1", Action: &DefAction{ - Arguments: ArgumentsList{ - &Argument{ + Arguments: ParametersList{ + &DefParameter{ Name: "arg1", }, }, - Options: OptionsList{ - &Option{ + Options: ParametersList{ + &DefParameter{ Name: "optStr", }, - &Option{ + &DefParameter{ Name: "opt-str", }, }, }, } - return &Action{ + a := &Action{ ID: "my_actions", - Loader: af, + loader: af, } + return a } func Test_EnvProcessor(t *testing.T) { @@ -46,12 +48,14 @@ func Test_InputProcessor(t *testing.T) { act := testLoaderAction() ctx := LoadContext{Action: act} proc := inputProcessor{} - err := act.SetInput(Input{Args: TypeArgs{"arg1": "arg1"}, Opts: TypeOpts{"optStr": "optVal1", "opt-str": "opt-val2"}}) - assert.True(t, assert.NoError(t, err)) + input := NewInput(act, InputParams{"arg1": "arg1"}, InputParams{"optStr": "optVal1", "opt-str": "opt-val2"}, nil) + input.SetValidated(true) + err := act.SetInput(input) + require.NoError(t, err) s := "{{ .arg1 }},{{ .optStr }},{{ .opt_str }}" res, err := proc.Process(ctx, []byte(s)) - assert.True(t, assert.NoError(t, err)) + require.NoError(t, err) assert.Equal(t, "arg1,optVal1,opt-val2", string(res)) s = "{{ .opt-str }}" @@ -72,8 +76,10 @@ func Test_YamlTplCommentsProcessor(t *testing.T) { escapeYamlTplCommentsProcessor{}, inputProcessor{}, ) - err := act.SetInput(Input{Args: TypeArgs{"arg1": "arg1"}, Opts: TypeOpts{"optStr": "optVal1"}}) - assert.True(t, assert.NoError(t, err)) + input := NewInput(act, InputParams{"arg1": "arg1"}, InputParams{"optStr": "optVal1"}, nil) + input.SetValidated(true) + err := act.SetInput(input) + require.NoError(t, err) // Check the commented strings are not considered. s := ` t: "{{ .arg1 }} # {{ .optStr }}" @@ -82,7 +88,7 @@ t: {{ .arg1 }} # {{ .optUnd }} # {{ .optUnd }} {{ .arg1 }} ` res, err := proc.Process(ctx, []byte(s)) - assert.True(t, assert.NoError(t, err)) + require.NoError(t, err) assert.Equal(t, "t: \"arg1 # optVal1\"\nt: 'arg1 # optVal1'\nt: arg1", strings.TrimSpace(string(res))) s = `t: "{{ .arg1 }} # {{ .optUnd }}""` // Check we still have an error on an undefined variable. @@ -100,10 +106,12 @@ func Test_PipeProcessor(t *testing.T) { ) _ = os.Setenv("TEST_ENV1", "VAL1") - err := act.SetInput(Input{Args: TypeArgs{"arg1": "arg1"}, Opts: TypeOpts{"optStr": "optVal1"}}) - assert.True(t, assert.NoError(t, err)) + input := NewInput(act, InputParams{"arg1": "arg1"}, InputParams{"optStr": "optVal1"}, nil) + input.SetValidated(true) + err := act.SetInput(input) + require.NoError(t, err) s := "$TEST_ENV1,{{ .arg1 }},{{ .optStr }}" res, err := proc.Process(ctx, []byte(s)) - assert.True(t, assert.NoError(t, err)) + require.NoError(t, err) assert.Equal(t, "VAL1,arg1,optVal1", string(res)) } diff --git a/pkg/action/lockedfile.go b/pkg/action/lockedfile.go deleted file mode 100644 index 13c879f..0000000 --- a/pkg/action/lockedfile.go +++ /dev/null @@ -1,55 +0,0 @@ -package action - -import ( - "os" - "path/filepath" - - "github.com/launchrctl/launchr/internal/launchr" -) - -// @todo refactor to use one implementation here and in keyring. -type lockedFile struct { - fname string - file *os.File - locked bool -} - -func (f *lockedFile) Open(flag int, perm os.FileMode) (err error) { - isCreate := flag&os.O_CREATE == os.O_CREATE - if isCreate { - err = launchr.EnsurePath(filepath.Dir(f.fname)) - if err != nil { - return err - } - } - f.file, err = os.OpenFile(f.fname, flag, perm) //nolint:gosec - if err != nil { - return err - } - - err = f.lock(true) - if err != nil { - return err - } - - return nil -} - -func (f *lockedFile) Read(p []byte) (n int, err error) { return f.file.Read(p) } -func (f *lockedFile) Write(p []byte) (n int, err error) { return f.file.Write(p) } - -func (f *lockedFile) Close() error { - f.unlock() - if f.file != nil { - return f.file.Close() - } - return nil -} - -func (f *lockedFile) Remove() (err error) { - err = os.Remove(f.fname) - if os.IsNotExist(err) { - return nil - } - return err -} diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 6f1f201..3a08ff3 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -18,7 +18,7 @@ type Manager interface { // Get returns a copy of an action from the manager with default decorators. Get(id string) (*Action, bool) // Add saves an action in the manager. - Add(*Action) + Add(*Action) error // Delete deletes the action from the manager. Delete(id string) // Decorate decorates an action with given behaviors and returns its copy. @@ -38,8 +38,8 @@ type Manager interface { // GetValueProcessors returns list of available processors GetValueProcessors() map[string]ValueProcessor - // DefaultRunEnvironment provides the default action run environment. - DefaultRunEnvironment() RunEnvironment + // DefaultRuntime provides the default action runtime. + DefaultRuntime() Runtime // Run executes an action in foreground. Run(ctx context.Context, a *Action) (RunInfo, error) // RunBackground executes an action in background. @@ -94,24 +94,25 @@ func (m *actionManagerMap) ServiceInfo() launchr.ServiceInfo { return launchr.ServiceInfo{} } -func (m *actionManagerMap) Add(a *Action) { +func (m *actionManagerMap) Add(a *Action) error { m.mx.Lock() defer m.mx.Unlock() - m.actionStore[a.ID] = a - // Collect action aliases. + // Check action loads properly. def, err := a.Raw() if err != nil { - return + return err } + // Collect action aliases. for _, alias := range def.Action.Aliases { id, ok := m.actionAliases[alias] if ok { - launchr.Term().Warning().Printfln("Alias %q is already defined by %q", alias, id) - } else { - m.actionAliases[alias] = a.ID + return fmt.Errorf("alias %q is already defined by %q", alias, id) } + m.actionAliases[alias] = a.ID } + m.actionStore[a.ID] = a + return nil } func (m *actionManagerMap) AllUnsafe() map[string]*Action { @@ -202,8 +203,8 @@ func (m *actionManagerMap) SetActionIDProvider(p IDProvider) { m.idProvider = p } -func (m *actionManagerMap) DefaultRunEnvironment() RunEnvironment { - return NewDockerEnvironment() +func (m *actionManagerMap) DefaultRuntime() Runtime { + return NewContainerRuntimeDocker() } // RunInfo stores information about a running action. @@ -283,17 +284,19 @@ func (m *actionManagerMap) RunInfoByID(id string) (RunInfo, bool) { return ri, ok } -// WithDefaultRunEnvironment adds a default [RunEnvironment] for an action. -func WithDefaultRunEnvironment(m Manager, a *Action) { - a.SetRunEnvironment(m.DefaultRunEnvironment()) +// WithDefaultRuntime adds a default [Runtime] for an action. +func WithDefaultRuntime(m Manager, a *Action) { + if a.Runtime() == nil { + a.SetRuntime(m.DefaultRuntime()) + } } -// WithContainerRunEnvironmentConfig configures a [ContainerRunEnvironment]. -func WithContainerRunEnvironmentConfig(cfg launchr.Config, prefix string) DecorateWithFn { +// WithContainerRuntimeConfig configures a [ContainerRuntime]. +func WithContainerRuntimeConfig(cfg launchr.Config, prefix string) DecorateWithFn { r := LaunchrConfigImageBuildResolver{cfg} ccr := NewImageBuildCacheResolver(cfg) return func(_ Manager, a *Action) { - if env, ok := a.env.(ContainerRunEnvironment); ok { + if env, ok := a.Runtime().(ContainerRuntime); ok { env.AddImageBuildResolver(r) env.SetImageBuildCacheResolver(ccr) env.SetContainerNameProvider(ContainerNameProvider{Prefix: prefix, RandomSuffix: true}) diff --git a/pkg/action/env.container.go b/pkg/action/runtime.container.go similarity index 78% rename from pkg/action/env.container.go rename to pkg/action/runtime.container.go index be926be..0796a4e 100644 --- a/pkg/action/env.container.go +++ b/pkg/action/runtime.container.go @@ -11,10 +11,6 @@ import ( "strings" "github.com/docker/docker/pkg/archive" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/pkg/namesgenerator" - "github.com/moby/sys/signal" - "github.com/moby/term" "github.com/launchrctl/launchr/internal/launchr" "github.com/launchrctl/launchr/pkg/driver" @@ -35,7 +31,7 @@ const ( containerFlagExec = "exec" ) -type containerEnv struct { +type runtimeContainer struct { driver driver.ContainerRunner dtype driver.Type logWith []any @@ -65,56 +61,60 @@ func (p ContainerNameProvider) Get(name string) string { var rpl = strings.NewReplacer("-", "_", ":", "_", ".", "_") suffix := "" if p.RandomSuffix { - suffix = "_" + namesgenerator.GetRandomName(0) + suffix = "_" + driver.GetRandomName(0) } return p.Prefix + rpl.Replace(name) + suffix } -// NewDockerEnvironment creates a new action Docker environment. -func NewDockerEnvironment() RunEnvironment { - return NewContainerEnvironment(driver.Docker) +// NewContainerRuntimeDocker creates a new action Docker runtime. +func NewContainerRuntimeDocker() ContainerRuntime { + return NewContainerRuntime(driver.Docker) } -// NewContainerEnvironment creates a new action container run environment. -func NewContainerEnvironment(t driver.Type) RunEnvironment { - return &containerEnv{ +// NewContainerRuntime creates a new action container runtime. +func NewContainerRuntime(t driver.Type) ContainerRuntime { + return &runtimeContainer{ dtype: t, nameprv: ContainerNameProvider{Prefix: "launchr_", RandomSuffix: true}, } } -func (c *containerEnv) FlagsDefinition() OptionsList { - return OptionsList{ - &Option{ +func (c *runtimeContainer) Clone() Runtime { + return NewContainerRuntime(c.dtype) +} + +func (c *runtimeContainer) FlagsDefinition() ParametersList { + return ParametersList{ + &DefParameter{ Name: containerFlagUseVolumeWD, Title: "Use volume as a WD", Description: "Copy the working directory to a container volume and not bind local paths. Usually used with remote environments.", Type: jsonschema.Boolean, Default: false, }, - &Option{ + &DefParameter{ Name: containerFlagRemoveImage, Title: "Remove Image", Description: "Remove an image after execution of action", Type: jsonschema.Boolean, Default: false, }, - &Option{ + &DefParameter{ Name: containerFlagNoCache, Title: "No cache", Description: "Send command to build container without cache", Type: jsonschema.Boolean, Default: false, }, - &Option{ + &DefParameter{ Name: containerFlagEntrypoint, Title: "Image Entrypoint", Description: "Overwrite the default ENTRYPOINT of the image", Type: jsonschema.String, Default: "", }, - &Option{ + &DefParameter{ Name: containerFlagExec, Title: "Exec command", Description: "Overwrite CMD definition of the container", @@ -124,7 +124,7 @@ func (c *containerEnv) FlagsDefinition() OptionsList { } } -func (c *containerEnv) UseFlags(flags TypeOpts) error { +func (c *runtimeContainer) UseFlags(flags InputParams) error { if v, ok := flags[containerFlagUseVolumeWD]; ok { c.useVolWD = v.(bool) } @@ -148,19 +148,20 @@ func (c *containerEnv) UseFlags(flags TypeOpts) error { return nil } -func (c *containerEnv) ValidateInput(a *Action, args TypeArgs) error { +func (c *runtimeContainer) ValidateInput(_ *Action, input *Input) error { if c.exec { - return nil + // Mark input as validated because arguments are passed directly to exec. + input.SetValidated(true) } - - // Check arguments if no exec flag present. - return a.ValidateInput(args) + return nil +} +func (c *runtimeContainer) AddImageBuildResolver(r ImageBuildResolver) { + c.imgres = append(c.imgres, r) } -func (c *containerEnv) AddImageBuildResolver(r ImageBuildResolver) { c.imgres = append(c.imgres, r) } -func (c *containerEnv) SetImageBuildCacheResolver(s *ImageBuildCacheResolver) { c.imgccres = s } -func (c *containerEnv) SetContainerNameProvider(p ContainerNameProvider) { c.nameprv = p } +func (c *runtimeContainer) SetImageBuildCacheResolver(s *ImageBuildCacheResolver) { c.imgccres = s } +func (c *runtimeContainer) SetContainerNameProvider(p ContainerNameProvider) { c.nameprv = p } -func (c *containerEnv) Init(_ context.Context) (err error) { +func (c *runtimeContainer) Init(_ context.Context, _ *Action) (err error) { c.logWith = nil if c.driver == nil { c.driver, err = driver.New(c.dtype) @@ -168,24 +169,23 @@ func (c *containerEnv) Init(_ context.Context) (err error) { return err } -func (c *containerEnv) log(attrs ...any) *launchr.Slog { +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 *containerEnv) Execute(ctx context.Context, a *Action) (err error) { +func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { ctx, cancelFn := context.WithCancel(ctx) defer cancelFn() - if err = c.Init(ctx); err != nil { - return err + streams := a.Input().Streams() + runDef := a.RuntimeDef() + if runDef.Container == nil { + return errors.New("action container configuration is not set, use different runtime") } - streams := a.GetInput().IO - actConf := a.ActionDef() - log := c.log("run_env", c.dtype, "action_id", a.ID, "image", actConf.Image, "command", actConf.Command) + log := c.log("run_env", c.dtype, "action_id", a.ID, "image", runDef.Container.Image, "command", runDef.Container.Command) log.Debug("starting execution of the action") - // @todo consider reusing the same container and run exec name := c.nameprv.Get(a.ID) existing := c.driver.ContainerList(ctx, types.ContainerListOptions{SearchName: name}) if len(existing) > 0 { @@ -207,7 +207,7 @@ func (c *containerEnv) Execute(ctx context.Context, a *Action) (err error) { // Create container. runConfig := &types.ContainerCreateOptions{ ContainerName: name, - ExtraHosts: actConf.ExtraHosts, + ExtraHosts: runDef.Container.ExtraHosts, AutoRemove: autoRemove, OpenStdin: true, StdinOnce: true, @@ -215,14 +215,14 @@ func (c *containerEnv) Execute(ctx context.Context, a *Action) (err error) { AttachStdout: true, AttachStderr: true, Tty: streams.In().IsTerminal(), - Env: actConf.Env, + Env: runDef.Container.Env, User: getCurrentUser(), Entrypoint: entrypoint, } log.Debug("creating a container for an action") cid, err := c.containerCreate(ctx, a, runConfig) if err != nil { - return err + return fmt.Errorf("failed to create a container: %w", err) } if cid == "" { return errors.New("error on creating a container") @@ -236,11 +236,12 @@ func (c *containerEnv) Execute(ctx context.Context, a *Action) (err error) { launchr.Term().Info().Printfln(`Flag "--%s" is set. Copying the working directory inside the container.`, containerFlagUseVolumeWD) err = c.copyDirToContainer(ctx, cid, a.WorkDir(), containerHostMount) if err != nil { - return err + return fmt.Errorf("failed to copy host directory to the container: %w", err) } + // @todo copy action if the original files are in memory err = c.copyDirToContainer(ctx, cid, a.Dir(), containerActionMount) if err != nil { - return err + return fmt.Errorf("failed to copy action directory to the container: %w", err) } } @@ -251,16 +252,16 @@ func (c *containerEnv) Execute(ctx context.Context, a *Action) (err error) { if !runConfig.Tty { log.Debug("watching container signals") - sigc := notifyAllSignals() - go ForwardAllSignals(ctx, c.driver, cid, sigc) - defer signal.StopCatch(sigc) + sigc := driver.NotifyAllSignals() + go driver.ForwardAllSignals(ctx, c.driver, cid, sigc) + defer driver.StopCatchSignals(sigc) } // Attach streams to the terminal. log.Debug("attaching container streams") cio, errCh, err := c.attachContainer(ctx, streams, cid, runConfig) if err != nil { - return err + return fmt.Errorf("failed to attach to the container: %w", err) } defer func() { _ = cio.Close() @@ -291,7 +292,7 @@ func (c *containerEnv) Execute(ctx context.Context, a *Action) (err error) { log.Debug("waiting execution of the container") if errCh != nil { if err = <-errCh; err != nil { - if _, ok := err.(term.EscapeError); ok { + if _, ok := err.(driver.EscapeError); ok { // The user entered the detach escape sequence. return nil } @@ -355,12 +356,12 @@ func getCurrentUser() string { return curuser } -func (c *containerEnv) Close() error { +func (c *runtimeContainer) Close() error { return c.driver.Close() } -func (c *containerEnv) imageRemove(ctx context.Context, a *Action) error { - _, err := c.driver.ImageRemove(ctx, a.ActionDef().Image, types.ImageRemoveOptions{ +func (c *runtimeContainer) imageRemove(ctx context.Context, a *Action) error { + _, err := c.driver.ImageRemove(ctx, a.RuntimeDef().Container.Image, types.ImageRemoveOptions{ Force: true, PruneChildren: false, }) @@ -368,7 +369,7 @@ func (c *containerEnv) imageRemove(ctx context.Context, a *Action) error { return err } -func (c *containerEnv) isRebuildRequired(bi *types.BuildDefinition) (bool, error) { +func (c *runtimeContainer) isRebuildRequired(bi *types.BuildDefinition) (bool, error) { // @todo test image cache resolution somehow. if c.imgccres == nil || bi == nil { return false, nil @@ -400,9 +401,9 @@ func (c *containerEnv) isRebuildRequired(bi *types.BuildDefinition) (bool, error return doRebuild, nil } -func (c *containerEnv) imageEnsure(ctx context.Context, a *Action) error { - streams := a.GetInput().IO - image := a.ActionDef().Image +func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { + streams := a.Input().Streams() + image := a.RuntimeDef().Container.Image // Prepend action to have the top priority in image build resolution. r := ChainImageBuildResolver{append(ChainImageBuildResolver{a}, c.imgres...)} @@ -436,7 +437,7 @@ func (c *containerEnv) imageEnsure(ctx context.Context, a *Action) error { launchr.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 = displayJSONMessages(status.Progress, streams) + err = driver.DockerDisplayJSONMessages(status.Progress, streams) if err != nil { launchr.Term().Error().Println("Error occurred while pulling the image %q", image) log.Error("error while pulling the image", "error", err) @@ -451,7 +452,7 @@ func (c *containerEnv) imageEnsure(ctx context.Context, a *Action) error { launchr.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 = displayJSONMessages(status.Progress, streams) + err = driver.DockerDisplayJSONMessages(status.Progress, streams) if err != nil { launchr.Term().Error().Println("Error occurred while building the image %q", image) log.Error("error while building the image", "error", err) @@ -461,37 +462,23 @@ func (c *containerEnv) imageEnsure(ctx context.Context, a *Action) error { return err } -func displayJSONMessages(in io.Reader, streams launchr.Streams) error { - err := jsonmessage.DisplayJSONMessagesToStream(in, streams.Out(), nil) - if err != nil { - if jerr, ok := err.(*jsonmessage.JSONError); ok { - // If no error code is set, default to 1 - if jerr.Code == 0 { - jerr.Code = 1 - } - return jerr - } - } - return err -} - -func (c *containerEnv) containerCreate(ctx context.Context, a *Action, opts *types.ContainerCreateOptions) (string, error) { +func (c *runtimeContainer) containerCreate(ctx context.Context, a *Action, opts *types.ContainerCreateOptions) (string, error) { if err := c.imageEnsure(ctx, a); err != nil { return "", err } // Create a container - actConf := a.ActionDef() + runDef := a.RuntimeDef() // Override Cmd with exec command. if c.exec { - actConf.Command = a.GetInput().ArgsRaw + runDef.Container.Command = a.Input().ArgsPositional() } createOpts := types.ContainerCreateOptions{ ContainerName: opts.ContainerName, - Image: actConf.Image, - Cmd: actConf.Command, + Image: runDef.Container.Image, + Cmd: runDef.Container.Command, WorkingDir: containerHostMount, NetworkMode: types.NetworkModeHost, ExtraHosts: opts.ExtraHosts, @@ -527,8 +514,8 @@ func (c *containerEnv) containerCreate(ctx context.Context, a *Action, opts *typ c.log().Warn("using selinux flags", "flags", flags) } createOpts.Binds = []string{ - absPath(a.WorkDir()) + ":" + containerHostMount + flags, - absPath(a.Dir()) + ":" + containerActionMount + flags, + launchr.MustAbs(a.WorkDir()) + ":" + containerHostMount + flags, + launchr.MustAbs(a.Dir()) + ":" + containerActionMount + flags, } } cid, err := c.driver.ContainerCreate(ctx, createOpts) @@ -539,21 +526,13 @@ func (c *containerEnv) containerCreate(ctx context.Context, a *Action, opts *typ return cid, nil } -func absPath(src string) string { - abs, err := filepath.Abs(filepath.Clean(src)) - if err != nil { - panic(err) - } - return abs -} - // copyDirToContainer copies dir content to a container. -func (c *containerEnv) copyDirToContainer(ctx context.Context, cid, srcPath, dstPath string) error { +func (c *runtimeContainer) copyDirToContainer(ctx context.Context, cid, srcPath, dstPath string) error { return c.copyToContainer(ctx, cid, srcPath, filepath.Dir(dstPath), filepath.Base(dstPath)) } // copyToContainer copies dir/file to a container. Directory will be copied as a subdirectory. -func (c *containerEnv) copyToContainer(ctx context.Context, cid, srcPath, dstPath, rebaseName string) error { +func (c *runtimeContainer) copyToContainer(ctx context.Context, cid, srcPath, dstPath, rebaseName string) error { // Prepare destination copy info by stat-ing the container path. dstInfo := archive.CopyInfo{Path: dstPath} dstStat, err := c.driver.ContainerStatPath(ctx, cid, dstPath) @@ -563,7 +542,7 @@ func (c *containerEnv) copyToContainer(ctx context.Context, cid, srcPath, dstPat dstInfo.Exists, dstInfo.IsDir = true, dstStat.Mode.IsDir() // Prepare source copy info. - srcInfo, err := archive.CopyInfoSourcePath(absPath(srcPath), false) + srcInfo, err := archive.CopyInfoSourcePath(launchr.MustAbs(srcPath), false) if err != nil { return err } @@ -595,7 +574,7 @@ func resolveLocalPath(localPath string) (absPath string, err error) { return archive.PreserveTrailingDotOrSeparator(absPath, localPath), nil } -func (c *containerEnv) copyFromContainer(ctx context.Context, cid, srcPath, dstPath, rebaseName string) (err error) { +func (c *runtimeContainer) copyFromContainer(ctx context.Context, cid, srcPath, dstPath, rebaseName string) (err error) { // Get an absolute destination path. dstPath, err = resolveLocalPath(dstPath) if err != nil { @@ -624,7 +603,7 @@ func (c *containerEnv) copyFromContainer(ctx context.Context, cid, srcPath, dstP return archive.CopyTo(preArchive, srcInfo, dstPath) } -func (c *containerEnv) containerWait(ctx context.Context, cid string, opts *types.ContainerCreateOptions) <-chan int { +func (c *runtimeContainer) containerWait(ctx context.Context, cid string, opts *types.ContainerCreateOptions) <-chan int { log := c.log() // Wait for the container to stop or catch error. waitCond := types.WaitConditionNextExit @@ -655,7 +634,7 @@ func (c *containerEnv) containerWait(ctx context.Context, cid string, opts *type return statusC } -func (c *containerEnv) attachContainer(ctx context.Context, streams launchr.Streams, cid string, opts *types.ContainerCreateOptions) (io.Closer, <-chan error, error) { +func (c *runtimeContainer) attachContainer(ctx context.Context, streams launchr.Streams, cid string, opts *types.ContainerCreateOptions) (io.Closer, <-chan error, error) { cio, errAttach := c.driver.ContainerAttach(ctx, cid, types.ContainerAttachOptions{ Stream: true, Stdin: opts.AttachStdin, @@ -673,7 +652,7 @@ func (c *containerEnv) attachContainer(ctx context.Context, streams launchr.Stre return cio, errCh, nil } -func (c *containerEnv) isSELinuxEnabled(ctx context.Context) bool { +func (c *runtimeContainer) isSELinuxEnabled(ctx context.Context) bool { // First, we check if it's enabled at the OS level, then if it's enabled in the container runner. // If the feature is not enabled in the runner environment, // containers will bypass SELinux and will function as if SELinux is disabled in the OS. diff --git a/pkg/action/sum.go b/pkg/action/runtime.container.image.go similarity index 65% rename from pkg/action/sum.go rename to pkg/action/runtime.container.image.go index 7dbb8d2..3c6856b 100644 --- a/pkg/action/sum.go +++ b/pkg/action/runtime.container.image.go @@ -11,14 +11,69 @@ import ( "golang.org/x/mod/sumdb/dirhash" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/types" ) const sumFilename = "actions.sum" +// ConfigImagesKey is a field name in [launchr.Config] file. +const ConfigImagesKey = "images" + +// ImageBuildResolver is an interface to resolve image build info from its source. +type ImageBuildResolver interface { + // ImageBuildInfo takes image as name and provides build definition for that. + ImageBuildInfo(image string) *types.BuildDefinition +} + +// ChainImageBuildResolver is a image build resolver that takes first available image in the chain. +type ChainImageBuildResolver []ImageBuildResolver + +// ImageBuildInfo implements [ImageBuildResolver]. +func (r ChainImageBuildResolver) ImageBuildInfo(image string) *types.BuildDefinition { + for i := 0; i < len(r); i++ { + if b := r[i].ImageBuildInfo(image); b != nil { + return b + } + } + return nil +} + +// ConfigImages is a container to parse [launchr.Config] in yaml format. +type ConfigImages map[string]*types.BuildDefinition + +// LaunchrConfigImageBuildResolver is a resolver of image build in [launchr.Config] file. +type LaunchrConfigImageBuildResolver struct { + cfg launchr.Config +} + +// ImageBuildInfo implements [ImageBuildResolver]. +func (r LaunchrConfigImageBuildResolver) ImageBuildInfo(image string) *types.BuildDefinition { + if r.cfg == nil { + return nil + } + var images ConfigImages + err := r.cfg.Get(ConfigImagesKey, &images) + if err != nil { + launchr.Term().Warning().Printfln("configuration file field %q is malformed", ConfigImagesKey) + return nil + } + if b, ok := images[image]; ok { + return b.ImageBuildInfo(image, r.cfg.DirPath()) + } + for _, b := range images { + for _, t := range b.Tags { + if t == image { + return b.ImageBuildInfo(image, r.cfg.DirPath()) + } + } + } + return nil +} + // ImageBuildCacheResolver is responsible for checking image build hash sums to rebuild images. type ImageBuildCacheResolver struct { fname string - file *lockedFile + file *launchr.LockedFile items map[string]string requireUpdate bool cfg launchr.Config @@ -30,7 +85,7 @@ func NewImageBuildCacheResolver(cfg launchr.Config) *ImageBuildCacheResolver { return &ImageBuildCacheResolver{ cfg: cfg, fname: fname, - file: &lockedFile{fname: fname}, + file: launchr.NewLockedFile(fname), items: nil, } } @@ -58,7 +113,7 @@ func (r *ImageBuildCacheResolver) readSums() (map[string]string, error) { return nil, err } - items, err := parseSums(r.file.fname, r.file) + items, err := parseSums(r.file.Filename(), r.file) if err != nil { return nil, err } @@ -161,7 +216,7 @@ func parseSums(fname string, file io.Reader) (map[string]string, error) { continue } if len(f) > 2 { - return nil, fmt.Errorf("malformed %s:\nline %d: wrong number of fields %v", fname, lineno, len(f)) + return nil, fmt.Errorf("malformed %s:\nline %d: wrong number of fields %d", fname, lineno, len(f)) } items[f[0]] = f[1] diff --git a/pkg/action/env.container_test.go b/pkg/action/runtime.container_test.go similarity index 86% rename from pkg/action/env.container_test.go rename to pkg/action/runtime.container_test.go index ac42805..921765a 100644 --- a/pkg/action/env.container_test.go +++ b/pkg/action/runtime.container_test.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/pkg/stdcopy" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "github.com/launchrctl/launchr/internal/launchr" @@ -43,21 +44,21 @@ func launchrCfg() launchr.Config { return launchr.ConfigFromFS(cfgRoot) } -func prepareContainerTestSuite(t *testing.T) (*assert.Assertions, *gomock.Controller, *mockdriver.MockContainerRunner, *containerEnv) { +func prepareContainerTestSuite(t *testing.T) (*assert.Assertions, *gomock.Controller, *mockdriver.MockContainerRunner, *runtimeContainer) { assert := assert.New(t) ctrl := gomock.NewController(t) d := mockdriver.NewMockContainerRunner(ctrl) d.EXPECT().Close() - r := &containerEnv{driver: d, dtype: "mock"} + r := &runtimeContainer{driver: d, dtype: "mock"} r.AddImageBuildResolver(cfgImgRes) r.SetContainerNameProvider(ContainerNameProvider{Prefix: containerNamePrefix}) return assert, ctrl, d, r } -func testContainerAction(aconf *DefAction) *Action { - if aconf == nil { - aconf = &DefAction{ +func testContainerAction(cdef *DefRuntimeContainer) *Action { + if cdef == nil { + cdef = &DefRuntimeContainer{ Image: "myimage", ExtraHosts: []string{ "my:host1", @@ -69,12 +70,19 @@ func testContainerAction(aconf *DefAction) *Action { }, } } - return &Action{ - ID: "test", - Loader: &Definition{Action: aconf}, - fpath: "my/action/test/action.yaml", - wd: absPath("test"), + a := &Action{ + ID: "test", + loader: &Definition{ + Action: &DefAction{}, + Runtime: &DefRuntime{ + Type: runtimeTypeContainer, + Container: cdef, + }, + }, + fpath: "my/action/test/action.yaml", + wd: launchr.MustAbs("test"), } + return a } func testContainerIO() *driver.ContainerInOut { @@ -92,17 +100,15 @@ func testContainerIO() *driver.ContainerInOut { func Test_ContainerExec_imageEnsure(t *testing.T) { t.Parallel() - actLoc := testContainerAction(&DefAction{ + actLoc := testContainerAction(&DefRuntimeContainer{ Image: "build:local", Build: &types.BuildDefinition{ Context: ".", }, }) - err := actLoc.EnsureLoaded() - assert.True(t, assert.NoError(t, err)) type testCase struct { name string - action *DefAction + action *DefRuntimeContainer expBuild *types.BuildDefinition ret []any } @@ -119,23 +125,23 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { return []any{r, err} } - aconf := actLoc.ActionDef() + aconf := actLoc.RuntimeDef().Container tts := []testCase{ { "image exists", - &DefAction{Image: "exists"}, + &DefRuntimeContainer{Image: "exists"}, nil, imgFn(types.ImageExists, "", nil), }, { "image pulled", - &DefAction{Image: "pull"}, + &DefRuntimeContainer{Image: "pull"}, nil, imgFn(types.ImagePull, `{"stream":"Successfully pulled image\n"}`, nil), }, { "image pulled error", - &DefAction{Image: "pull"}, + &DefRuntimeContainer{Image: "pull"}, nil, imgFn( types.ImagePull, @@ -161,13 +167,13 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { }, { "image build config", - &DefAction{Image: "build:config"}, + &DefRuntimeContainer{Image: "build:config"}, cfgImgRes.ImageBuildInfo("build:config"), imgFn(types.ImageBuild, `{"stream":"Successfully built image \"config\"\n"}`, nil), }, { "driver error", - &DefAction{Image: ""}, + &DefRuntimeContainer{Image: ""}, nil, imgFn(-1, "", fmt.Errorf("incorrect image")), }, @@ -183,17 +189,13 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { defer r.Close() ctx := context.Background() act := testContainerAction(tt.action) - act.input = Input{ - IO: launchr.NoopStreams(), - } - err = act.EnsureLoaded() - assert.True(assert.NoError(err)) - a := act.ActionDef() - imgOpts := types.ImageOptions{Name: a.Image, Build: tt.expBuild} + act.input = NewInput(act, nil, nil, launchr.NoopStreams()) + run := act.RuntimeDef().Container + imgOpts := types.ImageOptions{Name: run.Image, Build: tt.expBuild} d.EXPECT(). ImageEnsure(ctx, eqImageOpts{imgOpts}). Return(tt.ret...) - err = r.imageEnsure(ctx, act) + err := r.imageEnsure(ctx, act) assert.Equal(tt.ret[1], err) }) } @@ -202,17 +204,15 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { func Test_ContainerExec_imageRemove(t *testing.T) { t.Parallel() - actLoc := testContainerAction(&DefAction{ + actLoc := testContainerAction(&DefRuntimeContainer{ Image: "build:local", Build: &types.BuildDefinition{ Context: ".", }, }) - err := actLoc.EnsureLoaded() - assert.True(t, assert.NoError(t, err)) type testCase struct { name string - action *DefAction + action *DefRuntimeContainer expBuild *types.BuildDefinition ret []any } @@ -220,13 +220,13 @@ func Test_ContainerExec_imageRemove(t *testing.T) { tts := []testCase{ { "image removed", - actLoc.ActionDef(), + actLoc.RuntimeDef().Container, nil, []any{&types.ImageRemoveResponse{Status: types.ImageRemoved}, nil}, }, { "failed to remove", - &DefAction{Image: "failed"}, + &DefRuntimeContainer{Image: "failed"}, nil, []any{nil, fmt.Errorf("failed to remove")}, }, @@ -244,19 +244,14 @@ func Test_ContainerExec_imageRemove(t *testing.T) { defer r.driver.Close() act := testContainerAction(tt.action) - act.input = Input{ - IO: launchr.NoopStreams(), - } - - err := act.EnsureLoaded() - assert.True(assert.NoError(err)) + act.input = NewInput(act, nil, nil, launchr.NoopStreams()) - a := act.ActionDef() + run := act.RuntimeDef().Container imgOpts := types.ImageRemoveOptions{Force: true, PruneChildren: false} d.EXPECT(). - ImageRemove(ctx, a.Image, gomock.Eq(imgOpts)). + ImageRemove(ctx, run.Image, gomock.Eq(imgOpts)). Return(tt.ret...) - err = r.imageRemove(ctx, act) + err := r.imageRemove(ctx, act) assert.Equal(err, tt.ret[1]) }) @@ -270,13 +265,12 @@ func Test_ContainerExec_containerCreate(t *testing.T) { defer r.Close() a := testContainerAction(nil) - assert.True(assert.NoError(a.EnsureLoaded())) - act := a.ActionDef() + run := a.RuntimeDef() runCfg := &types.ContainerCreateOptions{ ContainerName: "container", NetworkMode: types.NetworkModeHost, - ExtraHosts: act.ExtraHosts, + ExtraHosts: run.Container.ExtraHosts, AutoRemove: true, OpenStdin: true, StdinOnce: true, @@ -292,44 +286,44 @@ func Test_ContainerExec_containerCreate(t *testing.T) { eqCfg := *runCfg eqCfg.Binds = []string{ - absPath(a.WorkDir()) + ":" + containerHostMount, - absPath(a.Dir()) + ":" + containerActionMount, + launchr.MustAbs(a.WorkDir()) + ":" + containerHostMount, + launchr.MustAbs(a.Dir()) + ":" + containerActionMount, } eqCfg.WorkingDir = containerHostMount - eqCfg.Cmd = act.Command - eqCfg.Image = act.Image + eqCfg.Cmd = run.Container.Command + eqCfg.Image = run.Container.Image ctx := context.Background() // Normal create. expCid := "container_id" d.EXPECT(). - ImageEnsure(ctx, types.ImageOptions{Name: act.Image}). + ImageEnsure(ctx, types.ImageOptions{Name: run.Container.Image}). Return(&types.ImageStatusResponse{Status: types.ImageExists}, nil) d.EXPECT(). ContainerCreate(ctx, gomock.Eq(eqCfg)). Return(expCid, nil) cid, err := r.containerCreate(ctx, a, runCfg) - assert.True(assert.NoError(err)) + require.NoError(t, err) assert.Equal(expCid, cid) // Create with a custom wd a.def.WD = "../myactiondir" - wd := absPath(a.def.WD) + wd := launchr.MustAbs(a.def.WD) eqCfg.Binds = []string{ wd + ":" + containerHostMount, - absPath(a.Dir()) + ":" + containerActionMount, + launchr.MustAbs(a.Dir()) + ":" + containerActionMount, } d.EXPECT(). - ImageEnsure(ctx, types.ImageOptions{Name: act.Image}). + ImageEnsure(ctx, types.ImageOptions{Name: run.Container.Image}). Return(&types.ImageStatusResponse{Status: types.ImageExists}, nil) d.EXPECT(). ContainerCreate(ctx, gomock.Eq(eqCfg)). Return(expCid, nil) cid, err = r.containerCreate(ctx, a, runCfg) - assert.True(assert.NoError(err)) + require.NoError(t, err) assert.Equal(expCid, cid) // Create with anonymous volumes. @@ -340,20 +334,20 @@ func Test_ContainerExec_containerCreate(t *testing.T) { containerActionMount: {}, } d.EXPECT(). - ImageEnsure(ctx, types.ImageOptions{Name: act.Image}). + ImageEnsure(ctx, types.ImageOptions{Name: run.Container.Image}). Return(&types.ImageStatusResponse{Status: types.ImageExists}, nil) d.EXPECT(). ContainerCreate(ctx, gomock.Eq(eqCfg)). Return(expCid, nil) cid, err = r.containerCreate(ctx, a, runCfg) - assert.True(assert.NoError(err)) + require.NoError(t, err) assert.Equal(expCid, cid) // Image ensure fail. errImg := fmt.Errorf("error on image ensure") d.EXPECT(). - ImageEnsure(ctx, types.ImageOptions{Name: act.Image}). + ImageEnsure(ctx, types.ImageOptions{Name: run.Container.Image}). Return(nil, errImg) cid, err = r.containerCreate(ctx, a, runCfg) @@ -363,7 +357,7 @@ func Test_ContainerExec_containerCreate(t *testing.T) { // Container create fail. expErr := fmt.Errorf("driver container create error") d.EXPECT(). - ImageEnsure(ctx, types.ImageOptions{Name: act.Image}). + ImageEnsure(ctx, types.ImageOptions{Name: run.Container.Image}). Return(&types.ImageStatusResponse{Status: types.ImageExists}, nil) d.EXPECT(). ContainerCreate(ctx, gomock.Any()). @@ -493,8 +487,8 @@ func Test_ContainerExec_containerAttach(t *testing.T) { Return(cio, nil) acio, errCh, err := r.attachContainer(ctx, streams, cid, opts) assert.Equal(acio, cio) - assert.True(assert.NoError(err)) - assert.True(assert.NoError(<-errCh)) + require.NoError(t, err) + require.NoError(t, <-errCh) _ = acio.Close() expErr := errors.New("fail to attach") @@ -520,8 +514,7 @@ func Test_ContainerExec(t *testing.T) { cid := "cid" act := testContainerAction(nil) - assert.True(t, assert.NoError(t, act.EnsureLoaded())) - actConf := act.ActionDef() + runConf := act.RuntimeDef().Container imgBuild := &types.ImageStatusResponse{Status: types.ImageExists} cio := testContainerIO() nprv := ContainerNameProvider{Prefix: containerNamePrefix} @@ -535,13 +528,13 @@ func Test_ContainerExec(t *testing.T) { opts := types.ContainerCreateOptions{ ContainerName: nprv.Get(act.ID), - Cmd: actConf.Command, - Image: actConf.Image, + Cmd: runConf.Command, + Image: runConf.Image, NetworkMode: types.NetworkModeHost, - ExtraHosts: actConf.ExtraHosts, + ExtraHosts: runConf.ExtraHosts, Binds: []string{ - absPath(act.WorkDir()) + ":" + containerHostMount, - absPath(act.Dir()) + ":" + containerActionMount, + launchr.MustAbs(act.WorkDir()) + ":" + containerHostMount, + launchr.MustAbs(act.Dir()) + ":" + containerActionMount, }, WorkingDir: containerHostMount, AutoRemove: true, @@ -551,7 +544,7 @@ func Test_ContainerExec(t *testing.T) { AttachStdout: true, AttachStderr: true, Tty: false, - Env: actConf.Env, + Env: runConf.Env, User: getCurrentUser(), } attOpts := types.ContainerAttachOptions{ @@ -572,7 +565,7 @@ func Test_ContainerExec(t *testing.T) { { "ImageEnsure", 1, 1, - []any{eqImageOpts{types.ImageOptions{Name: actConf.Image}}}, + []any{eqImageOpts{types.ImageOptions{Name: runConf.Image}}}, []any{imgBuild, nil}, }, { @@ -706,8 +699,10 @@ func Test_ContainerExec(t *testing.T) { resCh, errCh := make(chan types.ContainerWaitResponse, 1), make(chan error, 1) assert, ctrl, d, r := prepareContainerTestSuite(t) a := act.Clone() - err := a.SetInput(Input{IO: launchr.NoopStreams()}) - assert.True(assert.NoError(err)) + input := NewInput(a, nil, nil, launchr.NoopStreams()) + input.SetValidated(true) + err := a.SetInput(input) + require.NoError(t, err) defer ctrl.Finish() defer r.Close() var prev *gomock.Call @@ -724,7 +719,7 @@ func Test_ContainerExec(t *testing.T) { ctx := context.Background() err = r.Execute(ctx, a) if tt.expErr != errAny { - assert.Equal(tt.expErr, err) + assert.ErrorIs(err, tt.expErr) } else { assert.True(assert.Error(err)) } diff --git a/pkg/action/runtime.fn.go b/pkg/action/runtime.fn.go new file mode 100644 index 0000000..5c530d5 --- /dev/null +++ b/pkg/action/runtime.fn.go @@ -0,0 +1,36 @@ +package action + +import ( + "context" + + "github.com/launchrctl/launchr/internal/launchr" +) + +// FnRuntime is a function type implementing [Runtime]. +type FnRuntime func(ctx context.Context, a *Action) error + +// NewFnRuntime creates runtime as a go function. +func NewFnRuntime(fn FnRuntime) Runtime { + return fn +} + +// Clone implements [Runtime] interface. +func (fn FnRuntime) Clone() Runtime { + return fn +} + +// Init implements [Runtime] interface. +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) +} + +// Close implements [Runtime] interface. +func (fn FnRuntime) Close() error { + return nil +} diff --git a/pkg/action/runtime.go b/pkg/action/runtime.go new file mode 100644 index 0000000..9f4cda7 --- /dev/null +++ b/pkg/action/runtime.go @@ -0,0 +1,40 @@ +package action + +import ( + "context" +) + +// 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(ctx context.Context, a *Action) error + // Close does wrap up operations. + Close() error + // Clone creates the same runtime, but in initial state. + Clone() Runtime +} + +// 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 + // ValidateInput validates input arguments in action definition. + ValidateInput(a *Action, input *Input) error +} + +// ContainerRuntime is an interface for container runtime. +type ContainerRuntime interface { + Runtime + // SetContainerNameProvider sets container name provider. + SetContainerNameProvider(ContainerNameProvider) + // AddImageBuildResolver adds an image build resolver to a chain. + AddImageBuildResolver(ImageBuildResolver) + // SetImageBuildCacheResolver sets an image build cache resolver + // to check when image must be rebuilt. + SetImageBuildCacheResolver(*ImageBuildCacheResolver) +} diff --git a/pkg/action/utils.go b/pkg/action/utils.go index 59533a4..20badcb 100644 --- a/pkg/action/utils.go +++ b/pkg/action/utils.go @@ -2,13 +2,10 @@ package action import ( "fmt" - "reflect" "strings" "sync" "gopkg.in/yaml.v3" - - "github.com/launchrctl/launchr/pkg/jsonschema" ) func yamlTypeError(s string) *yaml.TypeError { @@ -48,6 +45,7 @@ func yamlNodeLineCol(n *yaml.Node, k string) (int, int) { return n.Line, n.Column } +// dupSet is a unique set of strings, checks is string is already added to the set. type dupSet map[string]struct{} var replDashes = strings.NewReplacer("-", "_") @@ -62,11 +60,15 @@ func (d dupSet) isUnique(s string) bool { return true } +// yamlParseDefNodes contains the set of yaml nodes for parsing context. +// Used to identify unique names of action arguments and options. type yamlParseDefNodes struct { nodes map[*yaml.Node]struct{} dups dupSet } +// yamlGlobalParseMeta has a yaml node tree defined per action [Definition]. +// Used to have a context about during the parsing stage. type yamlGlobalParseMeta struct { tree map[*Definition]yamlParseDefNodes mx sync.RWMutex @@ -112,6 +114,7 @@ func (m *yamlGlobalParseMeta) dupsByNode(n *yaml.Node) dupSet { return nil } +// collectAllNodes traverses all yaml tree and returns all nodes as a slice. func collectAllNodes(n *yaml.Node) []*yaml.Node { res := make([]*yaml.Node, 0, len(n.Content)+1) res = append(res, n) @@ -121,48 +124,6 @@ func collectAllNodes(n *yaml.Node) []*yaml.Node { return res } -func reflectValRef(v any, n string) any { - return reflect.ValueOf(v).Elem().FieldByName(n).Addr().Interface() -} - -func getDefaultByType(o *Option) any { - // @todo rethink default if it's not actually defined, do not set anything. - switch o.Type { - case jsonschema.String: - return defaultVal(o.Default, "") - case jsonschema.Integer: - return defaultVal(o.Default, 0) - case jsonschema.Number: - return defaultVal(o.Default, .0) - case jsonschema.Boolean: - return defaultVal(o.Default, false) - case jsonschema.Array: - return defaultVal(o.Default, []string{}) - default: - return fmt.Errorf("value for json schema type %q is not implemented", o.Type) - } -} - -func defaultVal[T any](val any, d T) T { - if val == nil { - return d - } - - switch v := val.(type) { - case T: - return v - case []any: - if _, ok := (any)(d).([]string); ok { - strSlice := make([]string, len(v)) - for i, item := range v { - strSlice[i] = fmt.Sprintf("%v", item) - } - return (any)(strSlice).(T) - } - } - return d -} - 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 { diff --git a/pkg/action/yaml.def.go b/pkg/action/yaml.def.go index cdfc551..b023c8a 100644 --- a/pkg/action/yaml.def.go +++ b/pkg/action/yaml.def.go @@ -4,7 +4,6 @@ import ( "bytes" "errors" "fmt" - "io" "regexp" "gopkg.in/yaml.v3" @@ -14,19 +13,23 @@ import ( ) const ( - sErrFieldMustBeArr = "field must be an array" - sErrArrElMustBeObj = "array element must be an object" - sErrArrEl = "element must be an array of strings" - sErrArrOrStrEl = "element must be an array of strings or a string" - sErrArrOrMapEl = "element must be an array of strings or a key-value object" - sErrEmptyActionImg = "image field cannot be empty" - sErrEmptyActionCmd = "command field cannot be empty" - sErrEmptyActionArgName = "action argument name is required" - sErrEmptyActionOptName = "action option name is required" - sErrInvalidActionArgName = "argument name %q is not valid" - sErrInvalidActionOptName = "option name %q is not valid" - sErrDupActionVarName = "argument or option name %q is already defined, a variable name must be unique in the action definition" - sErrActionDefMissing = "action definition is missing in the declaration" + sErrFieldMustBeArr = "field must be an array" + sErrArrElMustBeObj = "array element must be an object" + sErrArrEl = "element must be an array of strings" + sErrArrOrStrEl = "element must be an array of strings or a string" + sErrArrOrMapEl = "element must be an array of strings or a key-value object" + + sErrEmptyRuntimeImg = "image field cannot be empty" + sErrEmptyRuntimeCmd = "command field cannot be empty" + sErrEmptyActionParamName = "parameter name is required" + sErrInvalidActionParamName = "parameter name %q is not valid" + sErrDupActionParamName = "parameter name %q is already defined, a variable name must be unique in the action definition" + sErrActionDefMissing = "action definition is missing in the declaration" + sErrEmptyProcessorID = "invalid configuration, processor ID is required" + + // Runtime types. + runtimeTypePlugin DefRuntimeType = "plugin" + runtimeTypeContainer DefRuntimeType = "container" ) type errUnsupportedActionVersion struct { @@ -51,10 +54,11 @@ var ( rgxVarName = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_\\-]*$`) ) -// CreateFromYaml creates an action file definition from yaml configuration. +// NewDefFromYaml creates an action file definition from yaml configuration. // It returns pointer to [Definition] or nil on error. -func CreateFromYaml(r io.Reader) (*Definition, error) { +func NewDefFromYaml(b []byte) (*Definition, error) { d := Definition{} + r := bytes.NewReader(b) decoder := yaml.NewDecoder(r) err := decoder.Decode(&d) if err != nil { @@ -73,22 +77,22 @@ func CreateFromYaml(r io.Reader) (*Definition, error) { return &d, nil } -// CreateFromYamlTpl creates an action file definition from yaml configuration -// as [CreateFromYaml] but considers that it has unescaped template values. -func CreateFromYamlTpl(b []byte) (*Definition, error) { +// NewDefFromYamlTpl creates an action file definition from yaml configuration +// as [NewDefFromYaml] but considers that it has unescaped template values. +func NewDefFromYamlTpl(b []byte) (*Definition, error) { // Find unescaped occurrences of template elements. bufRaw := rgxUnescTplRow.ReplaceAllFunc(b, func(match []byte) []byte { return rgxTplRow.ReplaceAll(match, []byte(`"$1"`)) }) - r := bytes.NewReader(bufRaw) - return CreateFromYaml(r) + return NewDefFromYaml(bufRaw) } // Definition is a representation of an action file. type Definition struct { - Version string `yaml:"version"` - WD string `yaml:"working_directory"` - Action *DefAction `yaml:"action"` + Version string `yaml:"version"` + WD string `yaml:"working_directory"` + Action *DefAction `yaml:"action"` + Runtime *DefRuntime `yaml:"runtime"` } // Content implements [Loader] interface. @@ -124,6 +128,34 @@ func (d *Definition) UnmarshalYAML(node *yaml.Node) (err error) { if d.Version == "" { d.Version = "1" } + if d.Runtime == nil { + err = setOldDefRuntime(d) + if err != nil { + return yamlTypeErrorLine("missing runtime configuration", node.Line, node.Column) + } + } + return nil +} + +// Deprecated: remove when all actions are migrated to the new schema. +func setOldDefRuntime(d *Definition) error { + if d.Action.Image == "" { + return yamlTypeErrorLine(sErrEmptyRuntimeImg, 0, 0) + } + if len((*d).Action.Command) == 0 { + return yamlTypeErrorLine(sErrEmptyRuntimeCmd, 0, 0) + } + d.Runtime = &DefRuntime{ + Type: runtimeTypeContainer, + Container: &DefRuntimeContainer{ + Command: d.Action.Command, + Image: d.Action.Image, + Build: d.Action.Build, + ExtraHosts: d.Action.ExtraHosts, + Env: d.Action.Env, + User: d.Action.User, + }, + } return nil } @@ -137,17 +169,19 @@ func validateV1(d *Definition) error { // DefAction holds action configuration. type DefAction struct { - Title string `yaml:"title"` - Description string `yaml:"description"` - Aliases []string `yaml:"alias"` - Arguments ArgumentsList `yaml:"arguments"` - Options OptionsList `yaml:"options"` - Command StrSliceOrStr `yaml:"command"` - Image string `yaml:"image"` - Build *types.BuildDefinition `yaml:"build"` - ExtraHosts StrSlice `yaml:"extra_hosts"` - Env EnvSlice `yaml:"env"` - User string `yaml:"user"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Aliases []string `yaml:"alias"` + Arguments ParametersList `yaml:"arguments"` + Options ParametersList `yaml:"options"` + + // @todo remove deprecated + Command StrSliceOrStr `yaml:"command"` // Deprecated: use [Definition.Runtime] + Image string `yaml:"image"` // Deprecated: use [Definition.Runtime] + Build *types.BuildDefinition `yaml:"build"` // Deprecated: use [Definition.Runtime] + ExtraHosts StrSlice `yaml:"extra_hosts"` // Deprecated: use [Definition.Runtime] + Env EnvSlice `yaml:"env"` // Deprecated: use [Definition.Runtime] + User string `yaml:"user"` // Deprecated: use [Definition.Runtime] } // UnmarshalYAML implements [yaml.Unmarshaler] to parse action definition. @@ -158,16 +192,98 @@ func (a *DefAction) UnmarshalYAML(n *yaml.Node) (err error) { return err } *a = DefAction(y) + return nil +} - if a.Image == "" { +// DefRuntimeType is a runtime type. +type DefRuntimeType string + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime type. +func (r *DefRuntimeType) UnmarshalYAML(n *yaml.Node) (err error) { + var s string + if err = n.Decode(&s); err != nil { + return err + } + *r = DefRuntimeType(s) + switch *r { + case runtimeTypePlugin, runtimeTypeContainer: + return nil + case "": + return yamlTypeErrorLine("empty runtime type", n.Line, n.Column) + default: + return yamlTypeErrorLine(fmt.Sprintf("unknown runtime type %q", *r), n.Line, n.Column) + } +} + +// DefRuntimeContainer has container-specific runtime configuration. +type DefRuntimeContainer struct { + Command StrSliceOrStr `yaml:"command"` + Image string `yaml:"image"` + Build *types.BuildDefinition `yaml:"build"` + ExtraHosts StrSlice `yaml:"extra_hosts"` + Env EnvSlice `yaml:"env"` + User string `yaml:"user"` +} + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime container definition. +func (r *DefRuntimeContainer) UnmarshalYAML(n *yaml.Node) (err error) { + type yamlT DefRuntimeContainer + var y yamlT + if err = n.Decode(&y); err != nil { + return err + } + *r = DefRuntimeContainer(y) + if r.Image == "" { l, c := yamlNodeLineCol(n, "image") - return yamlTypeErrorLine(sErrEmptyActionImg, l, c) + return yamlTypeErrorLine(sErrEmptyRuntimeImg, l, c) } - if len(a.Command) == 0 { + if len(r.Command) == 0 { l, c := yamlNodeLineCol(n, "command") - return yamlTypeErrorLine(sErrEmptyActionCmd, l, c) + return yamlTypeErrorLine(sErrEmptyRuntimeCmd, l, c) + } + return err +} + +// DefRuntime contains action runtime configuration. +type DefRuntime struct { + Type DefRuntimeType `yaml:"type"` + Container *DefRuntimeContainer +} + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime definition. +func (r *DefRuntime) UnmarshalYAML(n *yaml.Node) (err error) { + // If node was defined as a string, example "plugin" + var rtype DefRuntimeType + if n.Kind == yaml.ScalarNode { + err = n.Decode(&rtype) + r.Type = rtype + if r.Type != runtimeTypePlugin { + return yamlTypeErrorLine("missing runtime configuration", n.Line, n.Column) + } + return err + } + + // Preparse type to proceed parsing. + ntype := yamlFindNodeByKey(n, "type") + if ntype == nil { + return yamlTypeErrorLine("missing runtime type definition", n.Line, n.Column) + } + if err = ntype.Decode(&rtype); err != nil { + return err + } + + // Parse runtime configuration. + r.Type = rtype + switch r.Type { + case runtimeTypePlugin: + return nil + case runtimeTypeContainer: + err = n.Decode(&r.Container) + return err + default: + // Error is already returned on runtime type parsing. + panic(fmt.Sprintf("runtime type not implemented: %s", r.Type)) } - return nil } // StrSlice is an array of strings for command execution. @@ -208,10 +324,10 @@ func (l *StrSliceOrStr) UnmarshalYAML(n *yaml.Node) (err error) { return err } -// EnvSlice is an array of env vars or key-value. +// EnvSlice is an array of runtime vars or key-value. type EnvSlice []string -// UnmarshalYAML implements [yaml.Unmarshaler] to parse env []string or map[string]string. +// UnmarshalYAML implements [yaml.Unmarshaler] to parse runtime []string or map[string]string. func (l *EnvSlice) UnmarshalYAML(n *yaml.Node) (err error) { if n.Kind == yaml.MappingNode { var m map[string]string @@ -241,146 +357,125 @@ func (l *EnvSlice) UnmarshalYAML(n *yaml.Node) (err error) { return yamlTypeErrorLine(sErrArrOrMapEl, n.Line, n.Column) } -// ArgumentsList is used for custom yaml parsing of arguments list. -type ArgumentsList []*Argument +// ParametersList is used for custom yaml parsing of arguments list. +type ParametersList []*DefParameter -// UnmarshalYAML implements [yaml.Unmarshaler] to parse for [ArgumentsList]. -func (l *ArgumentsList) UnmarshalYAML(nodeList *yaml.Node) (err error) { - *l, err = unmarshalListYaml[*Argument](nodeList) +// UnmarshalYAML implements [yaml.Unmarshaler] to parse for [ParametersList]. +func (l *ParametersList) UnmarshalYAML(nodeList *yaml.Node) (err error) { + *l, err = unmarshalParamListYaml(nodeList) return err } -// Argument stores command arguments declaration. -type Argument struct { - Name string `yaml:"name"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Type jsonschema.Type `yaml:"type"` - Process []ValueProcessDef `yaml:"process"` - RawMap map[string]any +// DefParameter stores command argument or option declaration. +type DefParameter struct { + Title string `yaml:"title"` + Description string `yaml:"description"` + Type jsonschema.Type `yaml:"type"` + Default any `yaml:"default"` + Enum []any `yaml:"enum"` + Items *DefArrayItems `yaml:"items"` + + // Action specific behavior for parameters. + // Name is an action unique parameter name used. + Name string `yaml:"name"` + // Shorthand is a short name 1 syllable name used in Console. + // @todo test definition, validate, catch panic if overlay, add to readme. + Shorthand string `yaml:"shorthand"` + // Required indicates if the parameter is mandatory. + // It's not correct json schema, and it's processed to a correct place later. + Required bool `yaml:"required"` + // Process is an array of [ValueProcessor] to a value. + Process []DefValueProcessor `yaml:"process"` + // raw is a raw parameter declaration to support all JSON Schema features. + raw map[string]any } -// UnmarshalYAML implements [yaml.Unmarshaler] to parse [Argument]. -func (a *Argument) UnmarshalYAML(node *yaml.Node) (err error) { - type yamlT Argument +// UnmarshalYAML implements [yaml.Unmarshaler] to parse [DefParameter]. +func (p *DefParameter) UnmarshalYAML(n *yaml.Node) (err error) { + type yamlT DefParameter var y yamlT - errStr := []string{sErrEmptyActionArgName, sErrInvalidActionArgName, sErrDupActionVarName} - if err = unmarshalVarYaml(node, &y, errStr); err != nil { - return err - } - *a = Argument(y) - return nil -} - -// OptionsList is used for custom yaml parsing of options list. -type OptionsList []*Option - -// UnmarshalYAML implements [yaml.Unmarshaler] to parse [OptionsList]. -func (l *OptionsList) UnmarshalYAML(nodeList *yaml.Node) (err error) { - *l, err = unmarshalListYaml[*Option](nodeList) - return err -} - -// Option stores command options declaration. -type Option struct { - Name string `yaml:"name"` - Shorthand string `yaml:"shorthand"` // @todo test definition, validate, catch panic if overlay, add to readme. - Title string `yaml:"title"` - Description string `yaml:"description"` - Type jsonschema.Type `yaml:"type"` - Default any `yaml:"default"` - Required bool `yaml:"required"` // @todo that conflicts with json schema object definition - Process []ValueProcessDef `yaml:"process"` - RawMap map[string]any -} + errStr := []string{sErrEmptyActionParamName, sErrInvalidActionParamName, sErrDupActionParamName} -// ValueProcessDef stores information about processor and options that should be applied to processor. -type ValueProcessDef struct { - Processor string `yaml:"processor"` - Options map[string]any `yaml:"options"` -} - -// UnmarshalYAML implements [yaml.Unmarshaler] to parse [Option]. -func (o *Option) UnmarshalYAML(node *yaml.Node) (err error) { - type yamlT Option - var y yamlT - errStr := []string{sErrEmptyActionOptName, sErrInvalidActionOptName, sErrDupActionVarName} - if err = unmarshalVarYaml(node, &y, errStr); err != nil { - return err - } - *o = Option(y) - dval := getDefaultByType(o) - if errDef, ok := dval.(error); ok { - return yamlTypeErrorLine(errDef.Error(), node.Line, node.Column) - } - o.Default = dval - o.RawMap["default"] = o.Default - return nil -} - -func unmarshalVarYaml(n *yaml.Node, v any, errStr []string) (err error) { - if err = n.Decode(v); err != nil { + if err = n.Decode(&y); err != nil { return err } - vname := reflectValRef(v, "Name").(*string) - vtype := reflectValRef(v, "Type").(*jsonschema.Type) - vtitle := reflectValRef(v, "Title").(*string) - vraw := reflectValRef(v, "RawMap").(*map[string]any) - if *vname == "" { + *p = DefParameter(y) + if p.Name == "" { return yamlTypeErrorLine(errStr[0], n.Line, n.Column) } - if !rgxVarName.MatchString(*vname) { + if !rgxVarName.MatchString(p.Name) { l, c := yamlNodeLineCol(n, "name") - return yamlTypeErrorLine(fmt.Sprintf(errStr[1], *vname), l, c) + return yamlTypeErrorLine(fmt.Sprintf(errStr[1], p.Name), l, c) } dups := yamlTree.dupsByNode(n) - if !dups.isUnique(*vname) { + if !dups.isUnique(p.Name) { l, c := yamlNodeLineCol(n, "name") - return yamlTypeErrorLine(fmt.Sprintf(errStr[2], *vname), l, c) + return yamlTypeErrorLine(fmt.Sprintf(errStr[2], p.Name), l, c) } - if err = n.Decode(vraw); err != nil { + if err = n.Decode(&p.raw); err != nil { return err } - if *vtype == "" { - *vtype = jsonschema.String + if p.Type == "" { + p.Type = jsonschema.String } - if *vtitle == "" { - *vtitle = *vname + if p.Title == "" { + p.Title = p.Name } - (*vraw)["type"] = *vtype - // @todo review hardcoded array elements types when array is properly implemented. - if *vtype == jsonschema.Array { - items, ok := (*vraw)["items"].(map[string]any) - if !ok { - items = map[string]any{} + // Cast enum any to expected type, make sure enum is correctly filled. + for i := 0; i < len(p.Enum); i++ { + v, err := jsonschema.EnsureType(p.Type, p.Enum[i]) + if err != nil { + enumNode := yamlFindNodeByKey(n, "enum") + return yamlTypeErrorLine(err.Error(), enumNode.Line, enumNode.Column) } - - items["type"] = jsonschema.String - - // Override if enum is specified - if enum, ok := items["enum"]; ok { - items["enum"] = enum + p.Enum[i] = v + } + p.raw["type"] = p.Type + if p.Type == jsonschema.Array { + // Force default array's "items" type declaration if not specified. + if p.Items == nil { + p.Items = &DefArrayItems{Type: jsonschema.String} + p.raw["items"] = map[string]any{ + "type": jsonschema.String, + } } + } - (*vraw)["items"] = items + // Set default values. + _, okDef := p.raw["default"] + if okDef { + // Ensure default value respects the type. + dval, errDef := jsonschema.EnsureType(p.Type, p.Default) + if errDef != nil { + l, c := yamlNodeLineCol(n, "default") + return yamlTypeErrorLine(errDef.Error(), l, c) + } + p.Default = dval + p.raw["default"] = p.Default } + // Not JSONSchema properties. + delete(p.raw, "name") + delete(p.raw, "shorthand") + delete(p.raw, "required") + delete(p.raw, "process") + return nil } -func unmarshalListYaml[T any](nl *yaml.Node) ([]T, error) { +func unmarshalParamListYaml(nl *yaml.Node) ([]*DefParameter, error) { if nl.Kind != yaml.SequenceNode { return nil, yamlTypeErrorLine(sErrFieldMustBeArr, nl.Line, nl.Column) } - l := make([]T, 0, len(nl.Content)) + l := make([]*DefParameter, 0, len(nl.Content)) var errs *yaml.TypeError for _, node := range nl.Content { if node.Kind != yaml.MappingNode { errs = yamlMergeErrors(errs, yamlTypeErrorLine(sErrArrElMustBeObj, node.Line, node.Column)) continue } - var v T + var v *DefParameter if err := node.Decode(&v); err != nil { if errType, ok := err.(*yaml.TypeError); ok { errs = yamlMergeErrors(errs, errType) @@ -396,3 +491,28 @@ func unmarshalListYaml[T any](nl *yaml.Node) ([]T, error) { return l, nil } + +// DefArrayItems stores array type related information. +type DefArrayItems struct { + Type jsonschema.Type `yaml:"type"` +} + +// 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"` +} + +// UnmarshalYAML implements [yaml.Unmarshaler] to parse [DefValueProcessor]. +func (p *DefValueProcessor) UnmarshalYAML(n *yaml.Node) (err error) { + type yamlT DefValueProcessor + var y yamlT + if err = n.Decode(&y); err != nil { + return err + } + *p = DefValueProcessor(y) + if p.ID == "" { + return yamlTypeErrorLine(sErrEmptyProcessorID, n.Line, n.Column) + } + return nil +} diff --git a/pkg/action/yaml.discovery.go b/pkg/action/yaml.discovery.go index ab97b75..552e602 100644 --- a/pkg/action/yaml.discovery.go +++ b/pkg/action/yaml.discovery.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "io" - "io/fs" "regexp" "sync" ) @@ -28,43 +27,32 @@ func (y YamlDiscoveryStrategy) IsValid(name string) bool { // Loader implements [DiscoveryStrategy]. func (y YamlDiscoveryStrategy) Loader(l FileLoadFn, p ...LoadProcessor) Loader { - return &yamlFileLoader{ - open: l, - processor: NewPipeProcessor( - append([]LoadProcessor{escapeYamlTplCommentsProcessor{}}, p...)..., - ), + return &YamlFileLoader{ + YamlLoader: YamlLoader{ + Processor: NewPipeProcessor( + append([]LoadProcessor{escapeYamlTplCommentsProcessor{}}, p...)..., + ), + }, + FileOpen: l, } } -type yamlFileLoader struct { - processor LoadProcessor - raw *Definition - cached []byte - open func() (fs.File, error) - mx sync.Mutex +// YamlLoader loads action yaml from a string. +type YamlLoader struct { + Bytes []byte // Bytes represents yaml content bytes. + Processor LoadProcessor // Processor processes variables inside the file. + + raw *Definition // raw holds unprocessed definition. + mx sync.Mutex // mx is a mutex for loading and processing only once. } -func (l *yamlFileLoader) Content() ([]byte, error) { - l.mx.Lock() - defer l.mx.Unlock() - // @todo unload unused, maybe manager must do it. - var err error - if l.cached != nil { - return l.cached, nil - } - f, err := l.open() - if err != nil { - return nil, err - } - defer f.Close() - l.cached, err = io.ReadAll(f) - if err != nil { - return nil, err - } - return l.cached, nil +// Content implements [Loader] interface. +func (l *YamlLoader) Content() ([]byte, error) { + return l.Bytes, nil } -func (l *yamlFileLoader) LoadRaw() (*Definition, error) { +// LoadRaw implements [Loader] interface. +func (l *YamlLoader) LoadRaw() (*Definition, error) { var err error buf, err := l.Content() if err != nil { @@ -73,7 +61,7 @@ func (l *yamlFileLoader) LoadRaw() (*Definition, error) { l.mx.Lock() defer l.mx.Unlock() if l.raw == nil { - l.raw, err = CreateFromYamlTpl(buf) + l.raw, err = NewDefFromYamlTpl(buf) if err != nil { return nil, err } @@ -81,7 +69,8 @@ func (l *yamlFileLoader) LoadRaw() (*Definition, error) { return l.raw, err } -func (l *yamlFileLoader) Load(ctx LoadContext) (res *Definition, err error) { +// Load implements [Loader] interface. +func (l *YamlLoader) Load(ctx LoadContext) (res *Definition, err error) { // Open a file and cache content for future reads. c, err := l.Content() if err != nil { @@ -89,16 +78,63 @@ func (l *yamlFileLoader) Load(ctx LoadContext) (res *Definition, err error) { } buf := make([]byte, len(c)) copy(buf, c) - buf, err = l.processor.Process(ctx, buf) + if l.Processor != nil { + buf, err = l.Processor.Process(ctx, buf) + if err != nil { + return nil, err + } + } + res, err = NewDefFromYaml(buf) if err != nil { return nil, err } - r := bytes.NewReader(buf) - res, err = CreateFromYaml(r) + return res, err +} + +// YamlFileLoader loads action yaml from a file. +type YamlFileLoader struct { + YamlLoader + FileOpen FileLoadFn // FileOpen lazy loads the content of the file. +} + +// LoadRaw implements [Loader] interface. +func (l *YamlFileLoader) LoadRaw() (*Definition, error) { + _, err := l.Content() if err != nil { return nil, err } - return res, err + return l.YamlLoader.LoadRaw() +} + +// Load implements [Loader] interface. +func (l *YamlFileLoader) Load(ctx LoadContext) (res *Definition, err error) { + // Open a file and cache content for future reads. + _, err = l.Content() + if err != nil { + return nil, err + } + return l.YamlLoader.Load(ctx) +} + +// Content implements [Loader] interface. +func (l *YamlFileLoader) Content() ([]byte, error) { + l.mx.Lock() + defer l.mx.Unlock() + // @todo unload unused, maybe manager must do it. + var err error + if l.Bytes != nil { + return l.Bytes, nil + } + f, err := l.FileOpen() + if err != nil { + return nil, err + } + defer f.Close() + l.Bytes, err = io.ReadAll(f) + if err != nil { + return nil, err + } + return l.Bytes, nil } type escapeYamlTplCommentsProcessor struct{} diff --git a/pkg/action/yaml_const_test.go b/pkg/action/yaml_const_test.go index 2e1a25d..a1c18cc 100644 --- a/pkg/action/yaml_const_test.go +++ b/pkg/action/yaml_const_test.go @@ -1,11 +1,10 @@ package action const validEmptyVersionYaml = ` +runtime: plugin action: title: Title description: Description - image: python:3.7-slim - command: python3 {{ .Arg0 }} ` const validFullYaml = ` @@ -27,33 +26,36 @@ action: - name: arg_12 title: Argument 1 description: Argument 1 description + enum: [arg_12_enum1, arg_12_enum2] - name: arg2 title: Argument 2 description: Argument 2 description options: - name: opt1 - title: Option 1 + title: Option 1 String description: Option 1 description - name: opt-1 - title: Option 1 + title: Option 1 String with dash description: Option 1 description - name: opt2 - title: Option 2 + title: Option 2 Boolean description: Option 2 description type: boolean required: true - name: opt3 - title: Option 3 + title: Option 3 Integer description: Option 3 description type: integer - name: opt4 - title: Option 4 + title: Option 4 Number description: Option 4 description type: number - name: optarr - title: Option 5 + title: Option 5 Array description: Option 5 description type: array +runtime: + type: container image: my/image:v1 build: context: ./ @@ -83,6 +85,8 @@ action: const validCmdArrYaml = ` action: title: Title +runtime: + type: container image: python:3.7-slim command: - /bin/sh @@ -93,6 +97,8 @@ action: const invalidCmdObjYaml = ` action: title: Title +runtime: + type: container image: python:3.7-slim command: line1: /bin/sh @@ -103,6 +109,8 @@ action: const invalidCmdArrVarYaml = ` action: title: Title +runtime: + type: container image: python:3.7-slim command: - /bin/sh @@ -112,10 +120,9 @@ action: const unsupportedVersionYaml = ` version: "2" +runtime: plugin action: title: Title - image: python:3.7-slim - command: python3 ` const invalidEmptyImgYaml = ` @@ -123,12 +130,16 @@ version: action: title: Title command: python3 +runtime: + type: container ` const invalidEmptyStrImgYaml = ` version: action: title: Title +runtime: + type: container command: python3 image: "" ` @@ -137,6 +148,8 @@ const invalidEmptyCmdYaml = ` version: "1" action: title: Title +runtime: + type: container image: python:3.7-slim ` @@ -144,6 +157,8 @@ const invalidEmptyArrCmdYaml = ` version: "1" action: title: Title +runtime: + type: container image: python:3.7-slim command: [] ` @@ -151,115 +166,115 @@ action: // Arguments definition. const invalidArgsStringYaml = ` version: "1" +runtime: plugin action: title: Title arguments: "invalid" - image: python:3.7-slim - command: ls ` const invalidArgsStringArrYaml = ` version: "1" +runtime: plugin action: title: Title arguments: ["invalid"] - image: python:3.7-slim - command: ls ` const invalidArgsObjYaml = ` version: "1" +runtime: plugin action: title: Title arguments: objKey: "invalid" - image: python:3.7-slim - command: ls ` const invalidArgsEmptyNameYaml = ` version: "1" +runtime: plugin action: title: Title arguments: - title: arg1 - image: python:3.7-slim - command: ls ` const invalidArgsNameYaml = ` version: "1" +runtime: plugin action: title: Title arguments: - name: 0arg - image: python:3.7-slim - command: ls +` + +const invalidArgsDefaultMismatch = ` +version: "1" +runtime: plugin +action: + title: Title + arguments: + - name: arg + default: 1 ` // Options definition. const invalidOptsStrYaml = ` version: "1" +runtime: plugin action: title: Title options: "invalid" - image: python:3.7-slim - command: ls ` const invalidOptsStrArrYaml = ` version: "1" +runtime: plugin action: title: Title options: ["invalid"] - image: python:3.7-slim - command: ls ` const invalidOptsObjYaml = ` version: "1" +runtime: plugin action: title: Verb options: objKey: "invalid" - image: python:3.7-slim - command: ls ` const invalidOptsEmptyNameYaml = ` version: "1" +runtime: plugin action: title: Title options: - title: opt - image: python:3.7-slim - command: ls ` const invalidOptsNameYaml = ` version: "1" +runtime: plugin action: title: Title options: - name: opt+name - image: python:3.7-slim - command: ls ` const invalidDupArgsOptsNameYaml = ` version: "1" +runtime: plugin action: title: Title arguments: - name: dupName options: - name: dupName - image: python:3.7-slim - command: ls ` const invalidMultipleErrYaml = ` version: "1" +runtime: plugin action: title: Title arguments: @@ -267,36 +282,24 @@ action: options: - name: dupName - title: otherTitle - image: python:3.7-slim - command: ls ` const invalidJSONSchemaTypeYaml = ` version: "1" +runtime: plugin action: title: Title arguments: - name: dupName type: unsup - image: python:3.7-slim - command: ls -` - -const invalidJSONSchemaDefaultYaml = ` -version: "1" -action: - title: Title - options: - - name: dupName - type: object - default: - image: python:3.7-slim - command: ls ` // Build image key. const validBuildImgShortYaml = ` action: + title: Title +runtime: + type: container image: python:3.7-slim build: ./ command: ls @@ -304,6 +307,9 @@ action: const validBuildImgLongYaml = ` action: + title: Title +runtime: + type: container image: python:3.7-slim build: context: ./ @@ -320,6 +326,9 @@ action: // Extra hosts key. const validExtraHostsYaml = ` action: + title: Title +runtime: + type: container image: python:3.7-slim extra_hosts: - "host.docker.internal:host-gateway" @@ -329,6 +338,9 @@ action: const invalidExtraHostsYaml = ` action: + title: Title +runtime: + type: container image: python:3.7-slim extra_hosts: "host.docker.internal:host-gateway" command: ls @@ -337,6 +349,9 @@ action: // Environmental variables. const validEnvArr = ` action: + title: Title +runtime: + type: container image: my/image:v1 command: ls env: @@ -346,6 +361,9 @@ action: const validEnvObj = ` action: + title: Title +runtime: + type: container image: my/image:v1 command: ls env: @@ -355,6 +373,9 @@ action: const invalidEnv = ` action: + title: Title +runtime: + type: container image: my/image:v1 command: ls env: @@ -364,6 +385,9 @@ action: const invalidEnvStr = ` action: + title: Title +runtime: + type: container image: my/image:v1 command: ls env: MY_ENV_1=test1 @@ -371,6 +395,9 @@ action: const invalidEnvObj = ` action: + title: Title +runtime: + type: container image: my/image:v1 command: ls env: @@ -380,6 +407,9 @@ action: // Unescaped template strings. const validUnescTplStr = ` action: + title: Title +runtime: + type: container image: {{ .A1 }} command: {{ .A1 }} env: @@ -389,12 +419,172 @@ action: const invalidUnescUnsupKeyTplStr = ` action: + title: Title +runtime: + type: container image: {{ .A1 }}:latest {{ .A1 }}: ls ` const invalidUnescUnsupArrTplStr = ` action: + title: Title +runtime: + type: container image: {{ .A1 }} command: [{{ .A1 }}, {{ .A1 }}] ` + +const validArgString = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_string + required: true +` + +const validArgStringOptional = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_string + required: false +` + +const validArgStringEnum = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_enum + enum: [enum1, enum2] + required: true +` + +const validArgBoolean = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_boolean + type: boolean + required: true +` + +const validArgDefault = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_default + type: string + default: "default_string" + required: true +` + +const validOptBoolean = ` +runtime: plugin +action: + title: Title + options: + - name: opt_boolean + type: boolean + required: true +` + +const validOptArrayImplicitString = ` +runtime: plugin +action: + title: Title + options: + - name: opt_array_str + type: array + required: true +` + +const validOptArrayStringEnum = ` +runtime: plugin +action: + title: Title + options: + - name: opt_array_enum + type: array + items: + type: string + enum: [enum_arr1, enum_arr2] + required: true +` + +const validOptArrayInt = ` +runtime: plugin +action: + title: Title + options: + - name: opt_array_int + type: array + items: + type: integer + required: true +` + +const validOptArrayIntDefault = ` +runtime: plugin +action: + title: Title + options: + - name: opt_array_int + type: array + items: + type: integer + default: [1, 2, 3] +` + +const validMultipleArgsAndOpts = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_int + type: integer + required: true + - name: arg_str + type: string + required: true + - name: arg_str2 + type: string + required: true + - name: arg_bool + type: boolean + required: true + - name: arg_default + default: "my_default_string" + options: + - name: opt_str + type: string + - name: opt_int + type: integer + default: 42 + - name: opt_str_default + type: string + default: "optdefault" + - name: opt_str_required + type: string + required: true +` + +const validPatternFormat = ` +runtime: plugin +action: + title: Title + arguments: + - name: arg_email + type: string + required: true + format: email + - name: arg_pattern + type: string + required: true + pattern: "^[A-Z]+$" +` diff --git a/pkg/action/yaml_test.go b/pkg/action/yaml_test.go index 862edde..89e98d9 100644 --- a/pkg/action/yaml_test.go +++ b/pkg/action/yaml_test.go @@ -1,7 +1,6 @@ package action import ( - "bytes" "errors" "fmt" "testing" @@ -29,41 +28,41 @@ func Test_CreateFromYaml(t *testing.T) { {"unsupported version >=1", unsupportedVersionYaml, errUnsupportedActionVersion{"2"}}, // Image field in not provided v1. - {"empty image field v1", invalidEmptyImgYaml, yamlTypeErrorLine(sErrEmptyActionImg, 4, 3)}, - {"empty string image field v1", invalidEmptyStrImgYaml, yamlTypeErrorLine(sErrEmptyActionImg, 6, 10)}, + {"empty image field v1", invalidEmptyImgYaml, yamlTypeErrorLine(sErrEmptyRuntimeImg, 7, 3)}, + {"empty string image field v1", invalidEmptyStrImgYaml, yamlTypeErrorLine(sErrEmptyRuntimeImg, 8, 10)}, // Command field in not provided v1. - {"empty command field v1", invalidEmptyCmdYaml, yamlTypeErrorLine(sErrEmptyActionCmd, 4, 3)}, - {"empty array command field v1", invalidEmptyArrCmdYaml, yamlTypeErrorLine(sErrEmptyActionCmd, 6, 12)}, + {"empty command field v1", invalidEmptyCmdYaml, yamlTypeErrorLine(sErrEmptyRuntimeCmd, 6, 3)}, + {"empty array command field v1", invalidEmptyArrCmdYaml, yamlTypeErrorLine(sErrEmptyRuntimeCmd, 8, 12)}, // Arguments are incorrectly provided v1 - string, not an array of objects. - {"invalid arguments field - string v1", invalidArgsStringYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 5, 14)}, + {"invalid arguments field - string v1", invalidArgsStringYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 6, 14)}, // Arguments are incorrectly provided v1 - array of strings, not an array of objects. - {"invalid arguments field - strings array", invalidArgsStringArrYaml, yamlTypeErrorLine(sErrArrElMustBeObj, 5, 15)}, + {"invalid arguments field - strings array", invalidArgsStringArrYaml, yamlTypeErrorLine(sErrArrElMustBeObj, 6, 15)}, // Arguments are incorrectly provided v1 - object, not an array of objects. - {"invalid arguments field - object", invalidArgsObjYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 6, 5)}, - {"invalid argument empty name", invalidArgsEmptyNameYaml, yamlTypeErrorLine(sErrEmptyActionArgName, 6, 7)}, - {"invalid argument name", invalidArgsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrInvalidActionArgName, "0arg"), 6, 13)}, + {"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)}, // Options are incorrectly provided v1 - string, not an array of objects. - {"invalid options field - string", invalidOptsStrYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 5, 12)}, + {"invalid options field - string", invalidOptsStrYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 6, 12)}, // Options are incorrectly provided v1 - array of strings, not an array of objects. - {"invalid options field - string array", invalidOptsStrArrYaml, yamlTypeErrorLine(sErrArrElMustBeObj, 5, 13)}, + {"invalid options field - string array", invalidOptsStrArrYaml, yamlTypeErrorLine(sErrArrElMustBeObj, 6, 13)}, // Options are incorrectly provided v1 - object, not an array of objects. - {"invalid options field - object", invalidOptsObjYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 6, 5)}, - {"invalid option empty name", invalidOptsEmptyNameYaml, yamlTypeErrorLine(sErrEmptyActionOptName, 6, 7)}, - {"invalid option name", invalidOptsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrInvalidActionOptName, "opt+name"), 6, 13)}, - {"invalid duplicate argument/option name", invalidDupArgsOptsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrDupActionVarName, "dupName"), 8, 13)}, + {"invalid options field - object", invalidOptsObjYaml, yamlTypeErrorLine(sErrFieldMustBeArr, 7, 5)}, + {"invalid option empty name", invalidOptsEmptyNameYaml, yamlTypeErrorLine(sErrEmptyActionParamName, 7, 7)}, + {"invalid option name", invalidOptsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrInvalidActionParamName, "opt+name"), 7, 13)}, + {"invalid duplicate argument/option name", invalidDupArgsOptsNameYaml, yamlTypeErrorLine(fmt.Sprintf(sErrDupActionParamName, "dupName"), 9, 13)}, {"invalid multiple errors", invalidMultipleErrYaml, yamlMergeErrors( - yamlTypeErrorLine(fmt.Sprintf(sErrDupActionVarName, "dupName"), 8, 13), - yamlTypeErrorLine(sErrEmptyActionOptName, 9, 7), + yamlTypeErrorLine(fmt.Sprintf(sErrDupActionParamName, "dupName"), 9, 13), + yamlTypeErrorLine(sErrEmptyActionParamName, 10, 7), )}, - {"invalid json schema type", invalidJSONSchemaTypeYaml, yamlTypeErrorLine(fmt.Sprintf("json schema type %q is unsupported", "unsup"), 7, 13)}, - {"invalid json schema default", invalidJSONSchemaDefaultYaml, yamlTypeErrorLine(fmt.Sprintf("value for json schema type %q is not implemented", "object"), 6, 7)}, + {"invalid json schema type", invalidJSONSchemaTypeYaml, yamlTypeErrorLine(fmt.Sprintf("json schema type %q is unsupported", "unsup"), 8, 13)}, // Command declaration as array of strings. {"valid command - strings array", validCmdArrYaml, nil}, - {"invalid command - object", invalidCmdObjYaml, yamlTypeErrorLine(sErrArrOrStrEl, 6, 5)}, - {"invalid command - various array", invalidCmdArrVarYaml, yamlTypeErrorLine(sErrArrOrStrEl, 6, 5)}, + {"invalid command - object", invalidCmdObjYaml, yamlTypeErrorLine(sErrArrOrStrEl, 8, 5)}, + {"invalid command - various array", invalidCmdArrVarYaml, yamlTypeErrorLine(sErrArrOrStrEl, 8, 5)}, // Build image. {"build image - short", validBuildImgShortYaml, nil}, @@ -71,14 +70,14 @@ func Test_CreateFromYaml(t *testing.T) { // Extra hosts. {"extra hosts", validExtraHostsYaml, nil}, - {"extra hosts invalid", invalidExtraHostsYaml, yamlTypeErrorLine(sErrArrEl, 4, 16)}, + {"extra hosts invalid", invalidExtraHostsYaml, yamlTypeErrorLine(sErrArrEl, 7, 16)}, // Env variables replacement. {"env variables string array", validEnvArr, nil}, {"env variables map", validEnvObj, nil}, {"invalid env variables", invalidEnv, errAny}, - {"invalid env declaration - string", invalidEnvStr, yamlTypeErrorLine(sErrArrOrMapEl, 5, 8)}, - {"invalid env declaration - object", invalidEnvObj, yamlTypeErrorLine(sErrArrOrMapEl, 6, 5)}, + {"invalid env declaration - string", invalidEnvStr, yamlTypeErrorLine(sErrArrOrMapEl, 8, 8)}, + {"invalid env declaration - object", invalidEnvObj, yamlTypeErrorLine(sErrArrOrMapEl, 9, 5)}, // Templating. {"unescaped template val", validUnescTplStr, errAny}, @@ -87,13 +86,13 @@ func Test_CreateFromYaml(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := CreateFromYaml(bytes.NewReader([]byte(tt.input))) + _, err := NewDefFromYaml([]byte(tt.input)) if tt.expErr == errAny { assert.True(t, assert.Error(t, err)) } else if assert.IsType(t, tt.expErr, err) { assert.Equal(t, tt.expErr, err) } else { - assert.ErrorIs(t, tt.expErr, err) + assert.ErrorIs(t, err, tt.expErr) } }) } @@ -120,7 +119,7 @@ func Test_CreateFromYamlTpl(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - _, err := CreateFromYamlTpl([]byte(tt.input)) + _, err := NewDefFromYamlTpl([]byte(tt.input)) if tt.expErr == errAny { assert.True(t, assert.Error(t, err)) } else { diff --git a/pkg/cli/out.go b/pkg/cli/out.go deleted file mode 100644 index e293aef..0000000 --- a/pkg/cli/out.go +++ /dev/null @@ -1,18 +0,0 @@ -// Package cli implements printing functionality for CLI output. -package cli - -import ( - "github.com/launchrctl/launchr/internal/launchr" -) - -// Print formats a string. -// Deprecated: use `launchr.Term().Printf()` or `launchr.Term().Info().Printf()` -func Print(format string, a ...any) { - launchr.Term().Printf(format, a...) -} - -// Println formats a string and adds a new line. -// Deprecated: use `launchr.Term().Printfln()` or `launchr.Term().Info().Printfln()` -func Println(format string, a ...any) { - launchr.Term().Printfln(format, a...) -} diff --git a/pkg/driver/hijack.go b/pkg/driver/hijack.go index 35b7af9..e09b89e 100644 --- a/pkg/driver/hijack.go +++ b/pkg/driver/hijack.go @@ -19,6 +19,9 @@ import ( // The default escape key sequence: ctrl-p, ctrl-q var defaultEscapeKeys = []byte{16, 17} +// EscapeError is an error thrown when escape sequence is input. +type EscapeError = term.EscapeError + // ContainerInOut stores container driver in/out streams. type ContainerInOut struct { In io.WriteCloser @@ -132,7 +135,7 @@ func (h *hijackedIOStreamer) setupInput() (restore func(), err error) { } if err := setRawTerminal(h.streams); err != nil { - return nil, fmt.Errorf("unable to set IO streams as raw terminal: %s", err) + return nil, fmt.Errorf("unable to set io streams as raw terminal: %s", err) } // Use sync.Once so we may call restore multiple times but ensure we @@ -207,7 +210,7 @@ func (h *hijackedIOStreamer) beginInputStream(restoreInput func()) (doneC <-chan restoreInput() launchr.Log().Debug("[hijack] End of stdin") - if _, ok := err.(term.EscapeError); ok { + if _, ok := err.(EscapeError); ok { detached <- err return } diff --git a/pkg/action/signals.go b/pkg/driver/signals.go similarity index 73% rename from pkg/action/signals.go rename to pkg/driver/signals.go index 3621d4c..3354402 100644 --- a/pkg/action/signals.go +++ b/pkg/driver/signals.go @@ -1,4 +1,4 @@ -package action +package driver import ( "context" @@ -8,13 +8,12 @@ import ( "github.com/moby/sys/signal" "github.com/launchrctl/launchr/internal/launchr" - "github.com/launchrctl/launchr/pkg/driver" ) // ForwardAllSignals forwards signals to the container // // The channel you pass in must already be setup to receive any signals you want to forward. -func ForwardAllSignals(ctx context.Context, cli driver.ContainerRunner, cid string, sigc <-chan os.Signal) { +func ForwardAllSignals(ctx context.Context, cli ContainerRunner, cid string, sigc <-chan os.Signal) { var ( s os.Signal ok bool @@ -55,8 +54,15 @@ func ForwardAllSignals(ctx context.Context, cli driver.ContainerRunner, cid stri } } -func notifyAllSignals() chan os.Signal { +// NotifyAllSignals starts watching interrupt signals. +func NotifyAllSignals() chan os.Signal { sigc := make(chan os.Signal, 128) gosignal.Notify(sigc) return sigc } + +// StopCatchSignals stops catching the signals and closes the specified channel. +func StopCatchSignals(sigc chan os.Signal) { + gosignal.Stop(sigc) + close(sigc) +} diff --git a/pkg/action/signals_unix.go b/pkg/driver/signals_unix.go similarity index 76% rename from pkg/action/signals_unix.go rename to pkg/driver/signals_unix.go index 14d9111..015a37a 100644 --- a/pkg/action/signals_unix.go +++ b/pkg/driver/signals_unix.go @@ -1,6 +1,6 @@ -//go:build !windows +//go:build unix -package action +package driver import ( "os" diff --git a/pkg/action/signals_windows.go b/pkg/driver/signals_windows.go similarity index 85% rename from pkg/action/signals_windows.go rename to pkg/driver/signals_windows.go index 00d4ab7..99eb62c 100644 --- a/pkg/action/signals_windows.go +++ b/pkg/driver/signals_windows.go @@ -1,6 +1,6 @@ //go:build windows -package action +package driver import "os" diff --git a/pkg/driver/utils.go b/pkg/driver/utils.go new file mode 100644 index 0000000..d7a90b1 --- /dev/null +++ b/pkg/driver/utils.go @@ -0,0 +1,30 @@ +package driver + +import ( + "io" + + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/namesgenerator" + + "github.com/launchrctl/launchr/internal/launchr" +) + +// GetRandomName generates a random human-friendly name. +func GetRandomName(retry int) string { + return namesgenerator.GetRandomName(retry) +} + +// DockerDisplayJSONMessages prints docker json output to streams. +func DockerDisplayJSONMessages(in io.Reader, streams launchr.Streams) error { + err := jsonmessage.DisplayJSONMessagesToStream(in, streams.Out(), nil) + if err != nil { + if jerr, ok := err.(*jsonmessage.JSONError); ok { + // If no error code is set, default to 1 + if jerr.Code == 0 { + jerr.Code = 1 + } + return jerr + } + } + return err +} diff --git a/pkg/jsonschema/error.go b/pkg/jsonschema/error.go new file mode 100644 index 0000000..7e8fec0 --- /dev/null +++ b/pkg/jsonschema/error.go @@ -0,0 +1,72 @@ +package jsonschema + +import ( + "fmt" + "sort" + "strings" + + "github.com/santhosh-tekuri/jsonschema/v6" + + "github.com/launchrctl/launchr/internal/launchr" +) + +// ErrSchemaValidationArray is an array of validation errors. +type ErrSchemaValidationArray []ErrSchemaValidation + +// ErrSchemaValidation is a validation error. +type ErrSchemaValidation struct { + // Path is a key path to the property. + Path []string + // Msg is an error message. + Msg string + + // key is a sortable key. + key string +} + +// Error implements error interface. +func (err ErrSchemaValidationArray) Error() string { + msgs := make([]string, len(err)) + for i := 0; i < len(err); i++ { + msgs[i] = err[i].Error() + } + return fmt.Sprintf("validation errors:\n - %s", strings.Join(msgs, "\n - ")) +} + +// NewErrSchemaValidation creates a new error. +func NewErrSchemaValidation(path []string, msg string) ErrSchemaValidation { + return ErrSchemaValidation{ + Path: path, + Msg: msg, + key: strings.Join(path, "/"), + } +} + +// Error implements error interface. +func (err ErrSchemaValidation) Error() string { + return fmt.Sprintf("%s: %s", err.Path, err.Msg) +} + +// newSchemaValidationErrors creates our error from jsonschema lib. +func newSchemaValidationErrors(err *jsonschema.ValidationError) ErrSchemaValidationArray { + sl := collectNestedValidationErrors(err) + // Sort errors by property name. + sort.Slice(sl, func(i, j int) bool { + return sl[i].key < sl[j].key + }) + return sl +} + +// collectNestedValidationErrors creates a plain slice of nested validation errors. +func collectNestedValidationErrors(err *jsonschema.ValidationError) []ErrSchemaValidation { + if err.Causes == nil { + return []ErrSchemaValidation{ + NewErrSchemaValidation(err.InstanceLocation, err.ErrorKind.LocalizedString(launchr.DefaultTextPrinter)), + } + } + res := make([]ErrSchemaValidation, 0, len(err.Causes)) + for i := 0; i < len(err.Causes); i++ { + res = append(res, collectNestedValidationErrors(err.Causes[i])...) + } + return res +} diff --git a/pkg/jsonschema/type.go b/pkg/jsonschema/type.go index 9e08565..6809b30 100644 --- a/pkg/jsonschema/type.go +++ b/pkg/jsonschema/type.go @@ -1,6 +1,15 @@ // Package jsonschema has functionality related to json schema support. package jsonschema +import ( + "bytes" + "encoding/json" + "fmt" + "strconv" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + // Type is a json schema type. type Type string @@ -29,6 +38,63 @@ func TypeFromString(t string) Type { } } +// EnsureType checks if the given value v respects json schema type t. +// Returns a type default value if v is nil. +// Error is returned on type mismatch or type not implemented. +func EnsureType(t Type, v any) (any, error) { + switch t { + case String: + return useValueOrDefault(v, "") + case Integer: + return useValueOrDefault(v, 0) + case Number: + return useValueOrDefault(v, .0) + case Boolean: + return useValueOrDefault(v, false) + case Array: + return useValueOrDefault(v, []any{}) + case Object: + return useValueOrDefault(v, map[string]any{}) + case Null: + return useValueOrDefault[any](v, nil) + default: + return nil, fmt.Errorf("json schema type %q is not implemented", t) + } +} + +func useValueOrDefault[T any](val any, d T) (T, error) { + // User default value is not defined, use type default. + if val == nil { + return d, nil + } + + // User value type is of expected type (same as default type). + switch v := val.(type) { + case T: + return v, nil + } + + return d, fmt.Errorf("value type and expected type mismatch") +} + +// ConvertStringToType converts a string value to jsonschema type. +func ConvertStringToType(s string, t Type) (any, error) { + switch t { + case String: + return s, nil + case Integer: + return strconv.Atoi(s) + case Number: + return strconv.ParseFloat(s, 64) + case Boolean: + return strconv.ParseBool(s) + case Null: + return nil, nil + default: + return nil, fmt.Errorf("convert to json schema type %q is not implemented", t) + } +} + // Schema is a json schema definition. // It doesn't implement all and may not comply fully. // See https://json-schema.org/specification.html @@ -42,3 +108,36 @@ type Schema struct { // @todo make a recursive type of properties. Properties map[string]any `json:"properties"` } + +// Validate checks if input complies with given schema. +func Validate(s Schema, input map[string]any) error { + // @todo cache jsonschema and resources. + b, err := json.Marshal(s) + if err != nil { + return err + } + + schema, err := jsonschema.UnmarshalJSON(bytes.NewBuffer(b)) + if err != nil { + return err + } + c := jsonschema.NewCompiler() + if err = c.AddResource(s.ID, schema); err != nil { + return err + } + c.AssertFormat() + sch, err := c.Compile(s.ID) + if err != nil { + return err + } + + err = sch.Validate(input) + if err == nil { + return nil + } + // Return our error type. + if errv, okType := err.(*jsonschema.ValidationError); okType { + return newSchemaValidationErrors(errv) + } + return err +} diff --git a/pkg/log/logger.go b/pkg/log/logger.go deleted file mode 100644 index ccc92fa..0000000 --- a/pkg/log/logger.go +++ /dev/null @@ -1,64 +0,0 @@ -// Package log is meant to provide a global logger for the application -// and provide logging functionality interface. -package log - -import ( - "fmt" - "strconv" - - "github.com/launchrctl/launchr/internal/launchr" -) - -func convertToSlogArgs(args ...any) []any { - if args == nil { - return nil - } - res := make([]any, 2*len(args)) - for i, v := range args { - res[2*i] = "arg" + strconv.Itoa(i) - res[2*i+1] = v - } - return res -} - -// Debug runs Logger.Debug with a global logger. -// Deprecated: use the new structured logger - `launchr.Log().Debug(msg, argName1, argVal1, argName2, ...)` -func Debug(format string, v ...any) { - launchr.Log().Debug(format, convertToSlogArgs(v)...) -} - -// Info runs Logger.Info with a global logger. -// Deprecated: use the new structured logger - `launchr.Log().Info(msg, argName1, argVal1, argName2, ...)` -func Info(format string, v ...any) { - launchr.Log().Info(format, convertToSlogArgs(v)...) -} - -// Warn runs Logger.Warn with a global logger. -// Deprecated: use the new structured logger - `launchr.Log().Warn(msg, argName1, argVal1, argName2, ...)` -func Warn(format string, v ...any) { - launchr.Log().Warn(format, convertToSlogArgs(v)...) -} - -// Err runs Logger.Err with a global logger. -// Deprecated: use the new structured logger - `launchr.Log().Error(msg, argName1, argVal1, argName2, ...)` -func Err(format string, v ...any) { - Error(format, v...) -} - -// Error runs Logger.Err with a global logger. -// Deprecated: use new structured logger - `launchr.Log().Error(msg, argName1, argVal1, argName2, ...)` -func Error(format string, v ...any) { - launchr.Log().Error(format, convertToSlogArgs(v)...) -} - -// Panic runs Logger.Panic with a global logger. -// Deprecated: no longer supported with no direct replacement. -func Panic(format string, v ...any) { //nolint:revive - panic(fmt.Sprint("DEPRECATED log.Panic call.", format)) -} - -// Fatal runs Logger.Fatal with a global logger. -// Deprecated: no longer supported with no direct replacement. -func Fatal(format string, v ...any) { //nolint:revive - panic(fmt.Sprint("DEPRECATED log.Fatal call.", format)) -} diff --git a/pkg/action/cobra.go b/plugins/actionscobra/cobra.go similarity index 63% rename from pkg/action/cobra.go rename to plugins/actionscobra/cobra.go index bf56e8b..9af2105 100644 --- a/pkg/action/cobra.go +++ b/plugins/actionscobra/cobra.go @@ -1,4 +1,4 @@ -package action +package actionscobra import ( "fmt" @@ -8,60 +8,50 @@ import ( "github.com/spf13/pflag" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" "github.com/launchrctl/launchr/pkg/jsonschema" ) // CobraImpl returns cobra command implementation for an action command. -func CobraImpl(a *Action, streams launchr.Streams) (*launchr.Command, error) { - def, err := a.Raw() - if err != nil { - return nil, err - } - actConf := def.Action - argsDef := actConf.Arguments +func CobraImpl(a *action.Action, streams launchr.Streams) (*launchr.Command, error) { + def := a.ActionDef() + argsDef := def.Arguments use := a.ID for _, p := range argsDef { use += " " + p.Name } - options := make(TypeOpts) - runOpts := make(TypeOpts) + options := make(action.InputParams) + runOpts := make(action.InputParams) cmd := &launchr.Command{ Use: use, - // Using custom args validation in [action.ValidateInput]. // @todo: maybe we need a long template for arguments description // @todo: have aliases documented in help - Short: getDesc(actConf.Title, actConf.Description), - Aliases: actConf.Aliases, - RunE: func(cmd *launchr.Command, args []string) error { - err = a.EnsureLoaded() + Short: getDesc(def.Title, def.Description), + Aliases: def.Aliases, + RunE: func(cmd *launchr.Command, args []string) (err error) { + // Don't show usage help on a runtime error. + cmd.SilenceUsage = true + + // Set action input. + argsNamed, err := action.ArgsPosToNamed(a, args) if err != nil { return err } - // Pass to the run environment its flags. - if env, ok := a.env.(RunEnvironmentFlags); ok { - runOpts = filterFlags(cmd, runOpts) - err = env.UseFlags(derefOpts(runOpts)) + 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 { + runOpts = derefOpts(filterChangedFlags(cmd, runOpts)) + err = r.UseFlags(runOpts) if err != nil { return err } - } - - // Set action input. - input := Input{ - Args: argsToMap(args, argsDef), - ArgsRaw: args, - Opts: derefOpts(options), - OptsRaw: derefOpts(filterFlags(cmd, options)), - IO: streams, - } - if runEnv, ok := a.env.(RunEnvironmentFlags); ok { - if err = runEnv.ValidateInput(a, input.Args); err != nil { + if err = r.ValidateInput(a, input); err != nil { return err } } - cmd.SilenceUsage = true // Don't show usage help on a runtime error. - + // Set and validate input. if err = a.SetInput(input); err != nil { return err } @@ -72,14 +62,14 @@ func CobraImpl(a *Action, streams launchr.Streams) (*launchr.Command, error) { } // Collect action flags. - err = setCommandOptions(cmd, actConf.Options, options) + err := setCommandOptions(cmd, def.Options, options) if err != nil { return nil, err } - // Collect run environment flags. + // Collect runtime flags. globalFlags := []string{"help"} - if env, ok := a.env.(RunEnvironmentFlags); ok { + if env, ok := a.Runtime().(action.RuntimeFlags); ok { err = setCommandOptions(cmd, env.FlagsDefinition(), runOpts) if err != nil { return nil, err @@ -162,8 +152,8 @@ Use "{{.CommandPath}} [command] --help" for more information about a command.{{e ` } -func filterFlags(cmd *launchr.Command, opts TypeOpts) TypeOpts { - filtered := make(TypeOpts) +func filterChangedFlags(cmd *launchr.Command, opts action.InputParams) action.InputParams { + filtered := make(action.InputParams) for name, flag := range opts { // Filter options not set. if opts[name] != nil && cmd.Flags().Changed(name) { @@ -173,7 +163,7 @@ func filterFlags(cmd *launchr.Command, opts TypeOpts) TypeOpts { return filtered } -func setCommandOptions(cmd *launchr.Command, defs OptionsList, opts TypeOpts) error { +func setCommandOptions(cmd *launchr.Command, defs action.ParametersList, opts action.InputParams) error { for _, opt := range defs { v, err := setFlag(cmd, opt) if err != nil { @@ -184,16 +174,6 @@ func setCommandOptions(cmd *launchr.Command, defs OptionsList, opts TypeOpts) er return nil } -func argsToMap(args []string, argsDef ArgumentsList) TypeArgs { - mapped := make(TypeArgs, len(args)) - for i, a := range args { - if i < len(argsDef) { - mapped[argsDef[i].Name] = a - } - } - return mapped -} - func getDesc(title string, desc string) string { parts := make([]string, 0, 2) if title != "" { @@ -205,21 +185,38 @@ func getDesc(title string, desc string) string { return strings.Join(parts, ": ") } -func setFlag(cmd *launchr.Command, opt *Option) (any, error) { +func setFlag(cmd *launchr.Command, opt *action.DefParameter) (any, error) { var val any desc := getDesc(opt.Title, opt.Description) + // Get default value if it's not set. + dval, err := jsonschema.EnsureType(opt.Type, opt.Default) + if err != nil { + return nil, err + } switch opt.Type { case jsonschema.String: - val = cmd.Flags().StringP(opt.Name, opt.Shorthand, opt.Default.(string), desc) + val = cmd.Flags().StringP(opt.Name, opt.Shorthand, dval.(string), desc) case jsonschema.Integer: - val = cmd.Flags().IntP(opt.Name, opt.Shorthand, opt.Default.(int), desc) + val = cmd.Flags().IntP(opt.Name, opt.Shorthand, dval.(int), desc) case jsonschema.Number: - val = cmd.Flags().Float64P(opt.Name, opt.Shorthand, opt.Default.(float64), desc) + val = cmd.Flags().Float64P(opt.Name, opt.Shorthand, dval.(float64), desc) case jsonschema.Boolean: - val = cmd.Flags().BoolP(opt.Name, opt.Shorthand, opt.Default.(bool), desc) + val = cmd.Flags().BoolP(opt.Name, opt.Shorthand, dval.(bool), desc) case jsonschema.Array: - // @todo use Var and define a custom value, jsonschema accepts interface{} - val = cmd.Flags().StringSliceP(opt.Name, opt.Shorthand, opt.Default.([]string), desc) + dslice := dval.([]any) + switch opt.Items.Type { + case jsonschema.String: + val = cmd.Flags().StringSliceP(opt.Name, opt.Shorthand, action.CastSliceAnyToTyped[string](dslice), desc) + case jsonschema.Integer: + val = cmd.Flags().IntSliceP(opt.Name, opt.Shorthand, action.CastSliceAnyToTyped[int](dslice), desc) + case jsonschema.Number: + val = cmd.Flags().Float64SliceP(opt.Name, opt.Shorthand, action.CastSliceAnyToTyped[float64](dslice), desc) + case jsonschema.Boolean: + val = cmd.Flags().BoolSliceP(opt.Name, opt.Shorthand, action.CastSliceAnyToTyped[bool](dslice), desc) + default: + // @todo use cmd.Flags().Var() and define a custom value, jsonschema accepts "any". + return nil, fmt.Errorf("json schema array type %q is not implemented", opt.Items.Type) + } default: return nil, fmt.Errorf("json schema type %q is not implemented", opt.Type) } @@ -229,8 +226,8 @@ func setFlag(cmd *launchr.Command, opt *Option) (any, error) { return val, nil } -func derefOpts(opts TypeOpts) TypeOpts { - der := make(TypeOpts, len(opts)) +func derefOpts(opts action.InputParams) action.InputParams { + der := make(action.InputParams, len(opts)) for k, v := range opts { der[k] = derefOpt(v) } @@ -247,15 +244,15 @@ func derefOpt(v any) any { return *v case *float64: return *v + case *[]any: + return *v case *[]string: - // Cast to a slice of interface because jsonschema validator supports only such arrays. - toAny := make([]any, len(*v)) - for i := 0; i < len(*v); i++ { - toAny[i] = (*v)[i] - } - return toAny + return *v + case *[]int: + return *v + case *[]bool: + return *v default: - // @todo recheck if reflect.ValueOf(v).Kind() == reflect.Ptr { panic(fmt.Sprintf("error on a value dereferencing: unsupported %T", v)) } diff --git a/plugins/actionscobra/plugin.go b/plugins/actionscobra/plugin.go new file mode 100644 index 0000000..9c127f0 --- /dev/null +++ b/plugins/actionscobra/plugin.go @@ -0,0 +1,135 @@ +// Package actionscobra is a launchr plugin providing cobra interface to actions. +package actionscobra + +import ( + "context" + "errors" + "math" + "time" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" +) + +var ( + errDiscoveryTimeout = "action discovery timeout exceeded" +) + +// ActionsGroup is a command group definition. +var ActionsGroup = &launchr.CommandGroup{ + ID: "actions", + Title: "Actions:", +} + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +// Plugin is [launchr.Plugin] to add command line interface to actions. +type Plugin struct { + app launchr.AppInternal + am action.Manager + pm launchr.PluginManager +} + +// PluginInfo implements [launchr.Plugin] interface. +func (p *Plugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{ + // Set to max to run discovery after all. + Weight: math.MaxInt, + } +} + +// OnAppInit implements [launchr.Plugin] interface. +func (p *Plugin) OnAppInit(app launchr.App) error { + p.app = app.(launchr.AppInternal) + app.GetService(&p.am) + app.GetService(&p.pm) + return p.discoverActions() +} + +func (p *Plugin) discoverActions() (err error) { + app := p.app + early := app.CmdEarlyParsed() + // Skip actions discovering. + if early.IsVersion || early.IsGen { + return err + } + var discovered []*action.Action + // @todo configure timeout from flags + // Define timeout for cases when we may traverse the whole FS, e.g. in / or home. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + for _, p := range launchr.GetPluginByType[action.DiscoveryPlugin](p.pm) { + actions, errDis := p.V.DiscoverActions(ctx) + if errDis != nil { + return errDis + } + discovered = append(discovered, actions...) + } + // Failed to discover actions in reasonable time. + if errCtx := ctx.Err(); errCtx != nil { + return errors.New(errDiscoveryTimeout) + } + + // Add discovered actions. + for _, a := range discovered { + err = p.am.Add(a) + 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) + continue + } + } + + // Alter all registered actions. + for _, p := range launchr.GetPluginByType[action.AlterActionsPlugin](p.pm) { + err = p.V.AlterActions() + if err != nil { + return err + } + } + // @todo maybe cache discovery result for performance. + return err +} + +// CobraAddCommands implements [launchr.CobraPlugin] interface to add actions in command line. +func (p *Plugin) CobraAddCommands(rootCmd *launchr.Command) error { + app := p.app + early := app.CmdEarlyParsed() + // Convert actions to cobra commands. + // Check the requested command to see what actions we must actually load. + var actions map[string]*action.Action + if early.Command != "" { + // Check if an alias was provided to find the real action. + early.Command = p.am.GetIDFromAlias(early.Command) + a, ok := p.am.Get(early.Command) + if ok { + // Use only the requested action. + actions = map[string]*action.Action{a.ID: a} + } else { + // Action was not requested, no need to load them. + return nil + } + } else { + // Load all. + actions = p.am.All() + } + + // @todo consider cobra completion and caching between runs. + if len(actions) > 0 { + rootCmd.AddGroup(ActionsGroup) + } + streams := p.app.Streams() + for _, a := range actions { + cmd, err := CobraImpl(a, streams) + 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) + continue + } + cmd.GroupID = ActionsGroup.ID + rootCmd.AddCommand(cmd) + } + return nil +} diff --git a/plugins/builder/action.yaml b/plugins/builder/action.yaml new file mode 100644 index 0000000..c08bb56 --- /dev/null +++ b/plugins/builder/action.yaml @@ -0,0 +1,57 @@ +runtime: plugin +action: + title: Build + description: >- + Builds application with specified configuration + options: + - name: name + shorthand: n + title: Name + description: Result application name + type: string + default: DEFAULT_NAME_PLACEHOLDER + - name: output + shorthand: o + title: Output + description: Build output file, by default application name is used + type: string + default: "" + - name: build-version + title: Build version + description: Arbitrary version of application + type: string + default: "" + - name: timeout + shorthand: t + title: Timeout + description: "Build timeout duration, example: 0, 100ms, 1h23m" + type: string + default: 120s + - name: tag + title: Tags + description: Go build tags + type: array + default: [] + - name: plugin + shorthand: p + title: Plugins + description: Include PLUGIN into the build with an optional version + type: array + default: [] + - name: replace + shorthand: r + title: Replace + description: Replace go dependency, see "go mod edit -replace" + type: array + default: [] + - name: debug + shorthand: d + title: Debug + description: Include debug flags into the build to support go debugging with "delve". If not specified, debugging info is trimmed + type: boolean + default: false + - name: no-cache + title: No cache + description: Disable the usage of cache, e.g., when using 'go get' for dependencies. + type: boolean + default: false \ No newline at end of file diff --git a/plugins/builder/plugin.go b/plugins/builder/plugin.go index 6fdb2fc..0f29f61 100644 --- a/plugins/builder/plugin.go +++ b/plugins/builder/plugin.go @@ -2,7 +2,9 @@ package builder import ( + "bytes" "context" + _ "embed" "fmt" "math" "path/filepath" @@ -10,8 +12,12 @@ import ( "time" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/action" ) +//go:embed action.yaml +var actionYaml []byte + func init() { launchr.RegisterPlugin(&Plugin{}) } @@ -43,35 +49,30 @@ type builderInput struct { // OnAppInit implements [launchr.OnAppInitPlugin] interface. func (p *Plugin) OnAppInit(app launchr.App) error { p.app = app + actionYaml = bytes.Replace(actionYaml, []byte("DEFAULT_NAME_PLACEHOLDER"), []byte(p.app.Name()), 1) return nil } -// CobraAddCommands implements [launchr.CobraPlugin] interface to provide build functionality. -func (p *Plugin) CobraAddCommands(rootCmd *launchr.Command) error { - // Flag options. - flags := builderInput{} - - buildCmd := &launchr.Command{ - Use: "build", - Short: "Builds application with specified configuration", - RunE: func(cmd *launchr.Command, _ []string) error { - // Don't show usage help on a runtime error. - cmd.SilenceUsage = true - return Execute(cmd.Context(), p.app.Streams(), &flags) - }, - } - // Command flags. - buildCmd.Flags().StringVarP(&flags.name, "name", "n", p.app.Name(), `Result application name`) - buildCmd.Flags().StringVarP(&flags.out, "output", "o", "", `Build output file, by default application name is used`) - buildCmd.Flags().StringVar(&flags.version, "build-version", "", `Arbitrary version of application`) - buildCmd.Flags().StringVarP(&flags.timeout, "timeout", "t", "120s", `Build timeout duration, example: 0, 100ms, 1h23m`) - buildCmd.Flags().StringSliceVarP(&flags.tags, "tag", "", nil, `Go build tags`) - buildCmd.Flags().StringSliceVarP(&flags.plugins, "plugin", "p", nil, `Include PLUGIN into the build with an optional version`) - buildCmd.Flags().StringSliceVarP(&flags.replace, "replace", "r", nil, `Replace go dependency, see "go mod edit -replace"`) - buildCmd.Flags().BoolVarP(&flags.debug, "debug", "d", false, `Include debug flags into the build to support go debugging with "delve". If not specified, debugging info is trimmed`) - buildCmd.Flags().BoolVarP(&flags.nocache, "no-cache", "", false, `Disable the usage of cache, e.g., when using 'go get' for dependencies.`) - rootCmd.AddCommand(buildCmd) - return nil +// DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface. +func (p *Plugin) DiscoverActions(_ context.Context) ([]*action.Action, error) { + a := action.NewFromYAML("build", actionYaml) + a.SetRuntime(action.NewFnRuntime(func(ctx context.Context, a *action.Action) error { + input := a.Input() + flags := builderInput{ + name: input.Opt("name").(string), + out: input.Opt("output").(string), + version: input.Opt("build-version").(string), + timeout: input.Opt("timeout").(string), + tags: action.InputOptSlice[string](input, "tag"), + plugins: action.InputOptSlice[string](input, "plugin"), + replace: action.InputOptSlice[string](input, "replace"), + debug: input.Opt("debug").(bool), + nocache: input.Opt("no-cache").(bool), + } + + return Execute(ctx, p.app.Streams(), &flags) + })) + return []*action.Action{a}, nil } // Generate implements [launchr.GeneratePlugin] interface. diff --git a/plugins/builtinprocessors/plugin.go b/plugins/builtinprocessors/plugin.go index 528f99c..5ad33cb 100644 --- a/plugins/builtinprocessors/plugin.go +++ b/plugins/builtinprocessors/plugin.go @@ -17,15 +17,15 @@ func init() { launchr.RegisterPlugin(Plugin{}) } -// Plugin is launchr plugin to provide action processors. +// Plugin is [launchr.Plugin] to provide action processors. type Plugin struct{} -// PluginInfo implements launchr.Plugin interface. +// PluginInfo implements [launchr.Plugin] interface. func (p Plugin) PluginInfo() launchr.PluginInfo { return launchr.PluginInfo{} } -// OnAppInit implements launchr.Plugin interface. +// OnAppInit implements [launchr.OnAppInitPlugin] interface. func (p Plugin) OnAppInit(app launchr.App) error { // Get services. var cfg launchr.Config @@ -54,12 +54,6 @@ func addValueProcessors(m action.Manager, cfg launchr.Config) { } func getByKeyProcessor(value any, options map[string]any, cfg launchr.Config) (any, error) { - if value != nil { - launchr.Term().Warning().Printfln("Skipping processor %q, value is not empty. Value will remain unchanged", getConfigValue) - launchr.Log().Warn("skipping processor, value is not empty", "processor", getConfigValue) - return value, nil - } - path, ok := options["path"].(string) if !ok { return value, fmt.Errorf(`option "path" is required for %q processor`, getConfigValue) diff --git a/plugins/default.go b/plugins/default.go index 101c147..a09ca1b 100644 --- a/plugins/default.go +++ b/plugins/default.go @@ -4,6 +4,7 @@ package plugins import ( // Default launchr plugins to include for launchr functionality. _ "github.com/launchrctl/launchr/plugins/actionnaming" + _ "github.com/launchrctl/launchr/plugins/actionscobra" _ "github.com/launchrctl/launchr/plugins/builder" _ "github.com/launchrctl/launchr/plugins/builtinprocessors" _ "github.com/launchrctl/launchr/plugins/verbosity" diff --git a/plugins/verbosity/plugin.go b/plugins/verbosity/plugin.go index a1694c0..f9ff672 100644 --- a/plugins/verbosity/plugin.go +++ b/plugins/verbosity/plugin.go @@ -26,8 +26,9 @@ func (p Plugin) PluginInfo() launchr.PluginInfo { type LogFormat string const ( - LogFormatPlain LogFormat = "plain" // LogFormatPlain is a default logger output format. - LogFormatJSON LogFormat = "json" // LogFormatJSON is a json logger output format. + LogFormatPretty LogFormat = "pretty" // LogFormatPretty is a default logger output format. + LogFormatPlain LogFormat = "plain" // LogFormatPlain is a plain logger output format. + LogFormatJSON LogFormat = "json" // LogFormatJSON is a json logger output format. ) // Set implements [fmt.Stringer] interface. @@ -35,18 +36,19 @@ func (e *LogFormat) String() string { return string(*e) } -// Set implements [cobra.Value] interface. +// Set implements [github.com/spf13/pflag.Value] interface. func (e *LogFormat) Set(v string) error { - switch v { - case string(LogFormatPlain), string(LogFormatJSON): - *e = LogFormat(v) + lf := LogFormat(v) + switch lf { + case LogFormatPlain, LogFormatJSON, LogFormatPretty: + *e = lf return nil default: return errors.New(`must be one of "plain" or "json"`) } } -// Type implements [cobra.Value] interface. +// Type implements [github.com/spf13/pflag.Value] interface. func (e *LogFormat) Type() string { return "LogFormat" } @@ -63,17 +65,17 @@ func (p Plugin) OnAppInit(app launchr.App) error { return nil } // Define verbosity flags. - cmd := appInternal.GetRootCmd() + cmd := appInternal.RootCmd() pflags := cmd.PersistentFlags() // Make sure not to fail on unknown flags because we are parsing early. unkFlagsBkp := pflags.ParseErrorsWhitelist.UnknownFlags pflags.ParseErrorsWhitelist.UnknownFlags = true pflags.CountVarP(&verbosity, "verbose", "v", "log verbosity level, use -vvvv DEBUG, -vvv INFO, -vv WARN, -v ERROR") - pflags.VarP(&logFormat, "log-format", "", "log format, may be plain or json") + pflags.VarP(&logFormat, "log-format", "", "log format, may be pretty, plain or json (default pretty)") pflags.BoolVarP(&quiet, "quiet", "q", false, "disable output to the console") // Parse available flags. - err := pflags.Parse(appInternal.EarlyParsedFlags()) + err := pflags.Parse(appInternal.CmdEarlyParsed().Args) if launchr.IsCommandErrHelp(err) { return nil } diff --git a/plugins/yamldiscovery/plugin.go b/plugins/yamldiscovery/plugin.go index 9e4562d..19268d0 100644 --- a/plugins/yamldiscovery/plugin.go +++ b/plugins/yamldiscovery/plugin.go @@ -11,30 +11,44 @@ import ( ) func init() { - launchr.RegisterPlugin(Plugin{}) + launchr.RegisterPlugin(&Plugin{}) } // Plugin is a [launchr.Plugin] to discover actions defined in yaml. -type Plugin struct{} +type Plugin struct { + am action.Manager + app launchr.App +} // PluginInfo implements [launchr.Plugin] interface. -func (p Plugin) PluginInfo() launchr.PluginInfo { +func (p *Plugin) PluginInfo() launchr.PluginInfo { return launchr.PluginInfo{ Weight: math.MinInt, } } // OnAppInit implements [launchr.Plugin] interface to provide discovered actions. -func (p Plugin) OnAppInit(_ launchr.App) error { +func (p *Plugin) OnAppInit(app launchr.App) error { + app.GetService(&p.am) + p.app = app return nil } // DiscoverActions implements [action.DiscoveryPlugin] interface. -func (p Plugin) DiscoverActions(ctx context.Context, fs launchr.ManagedFS, idp action.IDProvider) ([]*action.Action, error) { - if fs, ok := fs.(action.DiscoveryFS); ok { - d := action.NewYamlDiscovery(fs) - d.SetActionIDProvider(idp) - return d.Discover(ctx) +func (p *Plugin) DiscoverActions(ctx context.Context) ([]*action.Action, error) { + var res []*action.Action + idp := p.am.GetActionIDProvider() + for _, fs := range p.app.GetRegisteredFS() { + if fs, ok := fs.(action.DiscoveryFS); ok { + d := action.NewYamlDiscovery(fs) + d.SetActionIDProvider(idp) + discovered, err := d.Discover(ctx) + if err != nil { + return nil, err + } + res = append(res, discovered...) + } } - return nil, nil + + return res, nil }