diff --git a/config/config/command.go b/config/config/command.go index 620e0d20..0998e24d 100644 --- a/config/config/command.go +++ b/config/config/command.go @@ -58,6 +58,7 @@ type Command struct { Ref string // can be specified only with ref RefArgs []string + Plugins map[string]CommandPlugin } // NewCommand creates new command struct. @@ -66,6 +67,7 @@ func NewCommand(name string) Command { Name: name, Env: make(map[string]string), SkipDocopts: false, + Plugins: make(map[string]CommandPlugin), } } diff --git a/config/config/config.go b/config/config/config.go index 812f9f54..d48d1528 100644 --- a/config/config/config.go +++ b/config/config/config.go @@ -6,6 +6,7 @@ var ( // COMMANDS is a top-level directive. Includes all commands to run. COMMANDS = "commands" SHELL = "shell" + PLUGINS = "plugins" ENV = "env" EvalEnv = "eval_env" MIXINS = "mixins" @@ -15,7 +16,7 @@ var ( var ( ValidConfigDirectives = set.NewSet( - COMMANDS, SHELL, ENV, EvalEnv, MIXINS, VERSION, BEFORE, + COMMANDS, SHELL, ENV, EvalEnv, MIXINS, VERSION, BEFORE, PLUGINS, ) ValidMixinConfigDirectives = set.NewSet( COMMANDS, ENV, EvalEnv, BEFORE, @@ -36,6 +37,7 @@ type Config struct { isMixin bool // if true, we consider config as mixin and apply different parsing and validation // absolute path to .lets DotLetsDir string + Plugins map[string]ConfigPlugin } func NewConfig(workDir string, configAbsPath string, dotLetsDir string) *Config { @@ -45,6 +47,7 @@ func NewConfig(workDir string, configAbsPath string, dotLetsDir string) *Config WorkDir: workDir, FilePath: configAbsPath, DotLetsDir: dotLetsDir, + Plugins: make(map[string]ConfigPlugin), } } diff --git a/config/config/plugin.go b/config/config/plugin.go new file mode 100644 index 00000000..56a94a05 --- /dev/null +++ b/config/config/plugin.go @@ -0,0 +1,82 @@ +package config + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" +) + +type ConfigPlugin struct { + Name string + // if Repo not specified, then it is lets own plugins, lets-cli/lets-plugin- + // if Repo in format , then we append Name to Repo, / # TODO or maybe /lets-plugin- + Repo string + Version string + Url string + Bin string +} + +type pluginResult struct { + ResultType string `json:"type"` + Result string `json:"result"` +} + +func (p ConfigPlugin) Exec(commandPlugin CommandPlugin) error { + config, err := commandPlugin.SerializeConfig() + if err != nil { + return err + } + + cmd := exec.Command( + p.Bin, + string(config), // TODo how to encode + ) // #nosec G204 + + var out bytes.Buffer + // TODO how to show plugin logs ? + cmd.Stdout = &out + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return err + //return &RunErr{err: fmt.Errorf("failed to run child command '%s' from 'depends': %w", r.cmd.Name, err)} + } + + // TODO check exit code + output := out.String() + fmt.Printf("output %s", output) + + result := pluginResult{} + if err := json.Unmarshal([]byte(output), &result); err != nil { + return err + } + fmt.Printf("result %v", result) + + if result.ResultType == "json" { + pluginResponse := map[string]interface{}{} + + if err := json.Unmarshal([]byte(result.Result), &pluginResponse); err != nil { + return err + } + + fmt.Printf("pluginResponse %v", pluginResponse) + } + return nil +} + +// TODO maybe plugin must have lifecycle +type CommandPlugin struct { + Name string + Config map[string]interface{} +} + +func (p CommandPlugin) Run(cmd *Command, cfg *Config) error { + plugin := cfg.Plugins[p.Name] + return plugin.Exec(p) +} + +func (p CommandPlugin) SerializeConfig() ([]byte, error) { + return json.Marshal(p.Config) +} diff --git a/config/parser/command.go b/config/parser/command.go index b0fc8a5f..b7b8fc44 100644 --- a/config/parser/command.go +++ b/config/parser/command.go @@ -13,6 +13,7 @@ var ( DESCRIPTION = "description" WORKDIR = "work_dir" SHELL = "shell" + PLUGINS = "plugins" ENV = "env" EvalEnv = "eval_env" OPTIONS = "options" @@ -29,6 +30,7 @@ var directives = set.NewSet[string]( DESCRIPTION, WORKDIR, SHELL, + PLUGINS, ENV, EvalEnv, OPTIONS, @@ -76,6 +78,16 @@ func parseCommand(newCmd *config.Command, rawCommand map[string]interface{}, cfg } } + if rawPlugins, ok := rawCommand[PLUGINS]; ok { + plugins, ok := rawPlugins.(map[string]interface{}) + if !ok { + return fmt.Errorf("plugins must be a mapping") + } + if err := parsePlugins(plugins, newCmd); err != nil { + return err + } + } + rawEnv := make(map[string]interface{}) if env, ok := rawCommand[ENV]; ok { diff --git a/config/parser/parser.go b/config/parser/parser.go index a30e31eb..e00f3657 100644 --- a/config/parser/parser.go +++ b/config/parser/parser.go @@ -164,6 +164,16 @@ func parseConfig(rawKeyValue map[string]interface{}, cfg *config.Config) error { return fmt.Errorf("'shell' field is required") } + if rawPlugins, ok := rawKeyValue[config.PLUGINS]; ok { + plugins, ok := rawPlugins.(map[string]interface{}) + if !ok { + return fmt.Errorf("plugins must be a mapping") + } + if err := parseConfigPlugins(plugins, cfg); err != nil { + return err + } + } + if mixins, ok := rawKeyValue[config.MIXINS]; ok { mixins, ok := mixins.([]interface{}) if !ok { @@ -279,6 +289,42 @@ func parseBefore(before string, cfg *config.Config) error { return nil } +func parseConfigPlugins(rawPlugins map[string]interface{}, cfg *config.Config) error { + plugins := make(map[string]config.ConfigPlugin) + + for key, value := range rawPlugins { + pluginConfig, ok := value.(map[string]interface{}) + if !ok { + // TODO maybe print plugin configuration schema + return fmt.Errorf("plugin %s configuration must be a mapping", key) + } + + plugin := config.ConfigPlugin{Name: key} + + for configKey, configVal := range pluginConfig { + switch configVal := configVal.(type) { + case string: + switch configKey { + case "version": + plugin.Version = configVal + case "url": + plugin.Url = configVal + case "bin": + plugin.Bin = configVal + case "repo": + plugin.Repo = configVal + } + } + } + + plugins[key] = plugin + } + + cfg.Plugins = plugins + + return nil +} + func parseCommands(cmds map[string]interface{}, cfg *config.Config) ([]config.Command, error) { var commands []config.Command for rawName, rawValue := range cmds { diff --git a/config/parser/plugin.go b/config/parser/plugin.go new file mode 100644 index 00000000..0296aa59 --- /dev/null +++ b/config/parser/plugin.go @@ -0,0 +1,26 @@ +package parser + +import ( + "fmt" + + "github.com/lets-cli/lets/config/config" +) + +func parsePlugins(rawPlugins map[string]interface{}, newCmd *config.Command) error { + plugins := make(map[string]config.CommandPlugin) + + for key, value := range rawPlugins { + // TODO validate if plugin declared here is declared in config at the top + pluginConfig, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("plugin %s configuration must be a mapping", key) + } + + plugin := config.CommandPlugin{Name: key, Config: pluginConfig} + plugins[key] = plugin + } + + newCmd.Plugins = plugins + + return nil +} diff --git a/lets.yaml b/lets.yaml index e1908d8f..f0a1b1ff 100644 --- a/lets.yaml +++ b/lets.yaml @@ -4,6 +4,17 @@ mixins: - build.yaml - -lets.my.yaml +plugins: + docker-push-pull: + repo: lets-cli/lets-plugin-docker-push-pull + # TODO version is hashed for cache + version: v0.0.45 + #version: latest + # TODO url is hashed for cache + url: "https://github.com/lets-cli/lets/releases/download/{{.Version}}/lets_{{.Os}}_{{.Arch}}.tar.gz" + # TODO if bin specified, url is ignored + bin: ../lets-plugin-docker-push-pull/docker-push-pull + env: NAME: "max" AGE: 27 @@ -11,11 +22,22 @@ env: sh: echo "`id -u`:`id -g`" NGINX_DEV: checksum: [go.mod, go.sum] - -eval_env: - CURRENT_UID: echo "`id -u`:`id -g`" + LINT_TAG: + checksum: [docker/lint.Dockerfile] commands: + build-image: + plugins: + docker-push-pull: + registry_url: "https://registry.evo.dev" + build_context: "/home/max/code/lets-workspace/lets" + dockerfile: docker/lint.Dockerfile +# image: registry.evo.dev/core-team/company-stats/backend + image: lets-lint + tag: "123" # TODO has to resolve env +# tag: ${LINT_TAG} # TODO has to resolve env + target: dev + release: description: Create tag and push options: | diff --git a/main.go b/main.go index b7336a40..cec62a71 100644 --- a/main.go +++ b/main.go @@ -10,6 +10,7 @@ import ( "github.com/lets-cli/lets/config" "github.com/lets-cli/lets/env" "github.com/lets-cli/lets/logging" + "github.com/lets-cli/lets/plugins" "github.com/lets-cli/lets/runner" "github.com/lets-cli/lets/workdir" log "github.com/sirupsen/logrus" @@ -44,6 +45,13 @@ func main() { cmd.ConfigErrorCheck(rootCmd, readConfigErr) } + if len(cfg.Plugins) > 0 { + if err := plugins.Load(cfg); err != nil { + log.Error(err) + os.Exit(1) + } + } + if err := rootCmd.ExecuteContext(ctx); err != nil { log.Error(err.Error()) diff --git a/plugins/load.go b/plugins/load.go new file mode 100644 index 00000000..5d428329 --- /dev/null +++ b/plugins/load.go @@ -0,0 +1,63 @@ +package plugins + +import ( + "fmt" + "strings" + + "github.com/lets-cli/lets/config/config" +) + +func Load(cfg *config.Config) error { + // 1. check if plugin exist on .lets + // 2. check if version is downloaded in .lets + // 3. downloading progress bar + for _, plugin := range cfg.Plugins { + if plugin.Bin != "" { + // if bin specified, skip downloading new version + // TODO do we need to copypaste binary to .lets ? + continue + } + + // TODO validate repo and url + if plugin.Url == "" { + plugin.Url = getDefaultDownloadUrl(plugin) + } else { + plugin.Url = expandUrl(plugin, cfg) + } + + // TODO download from url + + } + return nil +} + +func getDefaultDownloadUrl(plugin config.ConfigPlugin) string { + repo := plugin.Repo + if repo == "" { + repo = fmt.Sprintf("lets-cli/lets-plugin-%s", plugin.Name) + } else if !strings.Contains(repo, "/") { + repo = fmt.Sprintf("%s/lets-plugin-%s", repo, plugin.Name) + } + + //https://github.com/lets-cli/lets/releases/download/{{.Version}}/lets_{{.Os}}_{{.Arch}}.tar.gz + os := "linux" + arch := "amd64" + bin := fmt.Sprintf("lets_plugin_%s_%s_%s", plugin.Name, os, arch) + // TODO require bin or tar.gz ? + version := plugin.Version // TODO what if latest ? + return fmt.Sprintf( + "https://github.com/%s/releases/download/%s/%s", + repo, version, bin, + ) +} + +func expandUrl(plugin config.ConfigPlugin, cfg *config.Config) string { + url := plugin.Url + + if strings.Contains(url, "{{.Version}}") { + // TODO well we must use go templates here )) + url = strings.Replace(url, "{{.Version}}", plugin.Version, 1) + } + + return url +} diff --git a/runner/run.go b/runner/run.go index 01afe174..16318249 100644 --- a/runner/run.go +++ b/runner/run.go @@ -105,6 +105,10 @@ func (r *Runner) run(ctx context.Context) error { return err } + if err := r.runPlugins(); err != nil { + return err + } + if err := r.runDepends(ctx); err != nil { return err } @@ -167,6 +171,22 @@ func (r *Runner) runAfterScript() { } } +func (r *Runner) runPlugin(plugin config.CommandPlugin) error { + debugf("executing plugin %s:\ncommand: %s", plugin.Name, r.cmd.Name) + return plugin.Run(r.cmd, r.cfg) +} + +func (r *Runner) runPlugins() error { + // TODO how to store plugin response ??? + for _, plugin := range r.cmd.Plugins { + debugf("executing plugin %s:\ncommand: %s", plugin.Name, r.cmd.Name) + if err := plugin.Run(r.cmd, r.cfg); err != nil { + return err + } + } + return nil +} + type RunOptions struct { Config *config.Config RawArgs []string