diff --git a/cmd/create.go b/cmd/create.go new file mode 100644 index 0000000..afc9a87 --- /dev/null +++ b/cmd/create.go @@ -0,0 +1,260 @@ +package cmd + +import ( + "fmt" + "sort" + "strings" + "text/tabwriter" + + corecomponent "github.com/libops/sitectl/pkg/component" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var ( + createDiscoverPlugins = plugin.DiscoverInstalled + createPromptChoice = corecomponent.PromptChoice + createPromptInput = config.GetInput +) + +var createCmd = &cobra.Command{ + Use: "create [plugin[/definition]] [args...]", + Short: "Create a new stack from an installed plugin definition", + Long: "Create a new stack using a first-class create definition registered by an installed sitectl plugin.", + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 && args[0] == "list" { + return runCreateList(cmd) + } + return runCreate(cmd, args) + }, +} + +func init() { + RootCmd.AddCommand(createCmd) +} + +func runCreateList(cmd *cobra.Command) error { + plugins := availableCreatePlugins() + if len(plugins) == 0 { + _, err := fmt.Fprintln(cmd.OutOrStdout(), "No create definitions found. Install a sitectl-* plugin that registers one.") + return err + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "PLUGIN\tNAME\tDESCRIPTION\tMIN\tREPO") + for _, installed := range plugins { + for _, spec := range installed.CreateDefinitions { + minimums := formatCreateMinimums(spec) + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", installed.Name, spec.Name, spec.Description, minimums, helpersFirstCreateRepo(installed, spec)) + } + } + return w.Flush() +} + +func runCreate(cmd *cobra.Command, args []string) error { + plugins := availableCreatePlugins() + if len(plugins) == 0 { + return fmt.Errorf("no create definitions found; install a sitectl-* plugin that registers one") + } + + owner, spec, remaining, err := resolveCreateInvocation(plugins, args) + if err != nil { + return err + } + + _, err = pluginSDK.InvokePluginCommand(owner, append([]string{"__create", spec.Name}, remaining...), plugin.CommandExecOptions{ + Context: RootCmd.Context(), + Stdin: cmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + if err != nil { + return cleanPluginCommandError(err) + } + return nil +} + +func availableCreatePlugins() []plugin.InstalledPlugin { + plugins := createDiscoverPlugins() + filtered := make([]plugin.InstalledPlugin, 0, len(plugins)) + for _, installed := range plugins { + if len(installed.CreateDefinitions) == 0 { + continue + } + filtered = append(filtered, installed) + } + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].Name < filtered[j].Name + }) + return filtered +} + +func resolveCreateInvocation(plugins []plugin.InstalledPlugin, args []string) (string, plugin.CreateSpec, []string, error) { + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + installed, err := promptForCreatePlugin(plugins) + if err != nil { + return "", plugin.CreateSpec{}, nil, err + } + spec, err := selectCreateDefinition(installed) + return installed.Name, spec, args, err + } + + target := strings.TrimSpace(args[0]) + remaining := args[1:] + if pluginName, createName, ok := strings.Cut(target, "/"); ok { + installed, found := findCreatePlugin(plugins, pluginName) + if !found { + return "", plugin.CreateSpec{}, nil, fmt.Errorf("plugin %q is not installed or does not define any create flows", pluginName) + } + spec, found := findCreateDefinition(installed.CreateDefinitions, createName) + if !found { + return "", plugin.CreateSpec{}, nil, fmt.Errorf("create definition %q not found for plugin %q", createName, installed.Name) + } + return installed.Name, spec, remaining, nil + } + + if installed, found := findCreatePlugin(plugins, target); found { + spec, err := selectCreateDefinition(installed) + return installed.Name, spec, remaining, err + } + + owner, spec, err := resolveCreateDefinitionByName(plugins, target) + return owner, spec, remaining, err +} + +func promptForCreatePlugin(plugins []plugin.InstalledPlugin) (plugin.InstalledPlugin, error) { + if len(plugins) == 1 { + return plugins[0], nil + } + choices := make([]corecomponent.Choice, 0, len(plugins)) + for _, installed := range plugins { + label := installed.Name + help := strings.TrimSpace(installed.Description) + if help == "" { + help = fmt.Sprintf("Create with the %s plugin", installed.Name) + } + choices = append(choices, corecomponent.Choice{Value: installed.Name, Label: label, Help: help}) + } + value, err := createPromptChoice("plugin", choices, plugins[0].Name, createPromptInput, + strings.Split(corecomponent.RenderSection("Create plugin", "Choose which installed plugin should provision this new stack."), "\n")..., + ) + if err != nil { + return plugin.InstalledPlugin{}, err + } + installed, _ := findCreatePlugin(plugins, value) + return installed, nil +} + +func selectCreateDefinition(installed plugin.InstalledPlugin) (plugin.CreateSpec, error) { + if spec, ok := defaultCreateDefinition(installed.CreateDefinitions); ok { + return spec, nil + } + if len(installed.CreateDefinitions) == 1 { + return installed.CreateDefinitions[0], nil + } + choices := make([]corecomponent.Choice, 0, len(installed.CreateDefinitions)) + for _, spec := range installed.CreateDefinitions { + choices = append(choices, corecomponent.Choice{ + Value: spec.Name, + Label: spec.Name, + Help: strings.TrimSpace(spec.Description), + }) + } + value, err := createPromptChoice("create definition", choices, installed.CreateDefinitions[0].Name, createPromptInput, + strings.Split(corecomponent.RenderSection("Create definition", fmt.Sprintf("The %s plugin exposes multiple create flows. Choose one.", installed.Name)), "\n")..., + ) + if err != nil { + return plugin.CreateSpec{}, err + } + spec, _ := findCreateDefinition(installed.CreateDefinitions, value) + return spec, nil +} + +func defaultCreateDefinition(specs []plugin.CreateSpec) (plugin.CreateSpec, bool) { + if len(specs) == 0 { + return plugin.CreateSpec{}, false + } + for _, spec := range specs { + if spec.Default { + return spec, true + } + } + if len(specs) == 1 { + return specs[0], true + } + return plugin.CreateSpec{}, false +} + +func resolveCreateDefinitionByName(plugins []plugin.InstalledPlugin, name string) (string, plugin.CreateSpec, error) { + var matches []struct { + owner string + spec plugin.CreateSpec + } + for _, installed := range plugins { + if spec, found := findCreateDefinition(installed.CreateDefinitions, name); found { + matches = append(matches, struct { + owner string + spec plugin.CreateSpec + }{owner: installed.Name, spec: spec}) + } + } + if len(matches) == 0 { + return "", plugin.CreateSpec{}, fmt.Errorf("create target %q not found; use `sitectl create list` to see available definitions", name) + } + if len(matches) > 1 { + owners := make([]string, 0, len(matches)) + for _, match := range matches { + owners = append(owners, match.owner) + } + sort.Strings(owners) + return "", plugin.CreateSpec{}, fmt.Errorf("create target %q is ambiguous; qualify it as plugin/name (%s)", name, strings.Join(owners, ", ")) + } + return matches[0].owner, matches[0].spec, nil +} + +func findCreatePlugin(plugins []plugin.InstalledPlugin, name string) (plugin.InstalledPlugin, bool) { + for _, installed := range plugins { + if strings.EqualFold(installed.Name, strings.TrimSpace(name)) { + return installed, true + } + } + return plugin.InstalledPlugin{}, false +} + +func findCreateDefinition(specs []plugin.CreateSpec, name string) (plugin.CreateSpec, bool) { + for _, spec := range specs { + if strings.EqualFold(spec.Name, strings.TrimSpace(name)) { + return spec, true + } + } + return plugin.CreateSpec{}, false +} + +func formatCreateMinimums(spec plugin.CreateSpec) string { + parts := []string{} + if spec.MinCPUCores > 0 { + parts = append(parts, fmt.Sprintf("%.0f CPU", spec.MinCPUCores)) + } + if strings.TrimSpace(spec.MinMemory) != "" { + parts = append(parts, spec.MinMemory+" RAM") + } + if strings.TrimSpace(spec.MinDiskSpace) != "" { + parts = append(parts, spec.MinDiskSpace+" disk") + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ", ") +} + +func helpersFirstCreateRepo(installed plugin.InstalledPlugin, spec plugin.CreateSpec) string { + if strings.TrimSpace(spec.DockerComposeRepo) != "" { + return spec.DockerComposeRepo + } + if strings.TrimSpace(installed.TemplateRepo) != "" { + return installed.TemplateRepo + } + return "-" +} diff --git a/pkg/plugin/creates.go b/pkg/plugin/creates.go new file mode 100644 index 0000000..efc0cc2 --- /dev/null +++ b/pkg/plugin/creates.go @@ -0,0 +1,134 @@ +package plugin + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v3" +) + +type CreateSpec struct { + Name string `yaml:"name"` + Plugin string `yaml:"plugin,omitempty"` + Description string `yaml:"description,omitempty"` + Default bool `yaml:"default,omitempty"` + MinCPUCores float64 `yaml:"min_cpu_cores,omitempty"` + MinMemory string `yaml:"min_memory,omitempty"` + MinDiskSpace string `yaml:"min_disk_space,omitempty"` + DockerComposeRepo string `yaml:"docker_compose_repo,omitempty"` + DockerComposeBranch string `yaml:"docker_compose_branch,omitempty"` + DockerComposeInit []string `yaml:"docker_compose_init,omitempty"` + DockerComposeUp []string `yaml:"docker_compose_up,omitempty"` + DockerComposeDown []string `yaml:"docker_compose_down,omitempty"` +} + +type RegisteredCreate struct { + Spec CreateSpec + Command *cobra.Command +} + +type CreateRunner interface { + BindFlags(cmd *cobra.Command) + Run(cmd *cobra.Command) error +} + +func (s *SDK) RegisterCreate(spec CreateSpec, cmd *cobra.Command) { + if s == nil || cmd == nil { + return + } + root := s.ensureCreateRoot() + spec = normalizeCreateSpec(spec) + if strings.TrimSpace(spec.Name) == "" { + spec.Name = strings.TrimSpace(cmd.Use) + } + if strings.TrimSpace(spec.Plugin) == "" { + spec.Plugin = s.Metadata.Name + } + if strings.TrimSpace(spec.Name) == "" { + return + } + cmd.Use = spec.Name + cmd.Hidden = true + if cmd.Short == "" { + cmd.Short = spec.Description + } + root.AddCommand(cmd) + s.creates = append(s.creates, RegisteredCreate{Spec: spec, Command: cmd}) +} + +func (s *SDK) RegisterCreateRunner(spec CreateSpec, runner CreateRunner) { + if s == nil || runner == nil { + return + } + spec = normalizeCreateSpec(spec) + cmd := &cobra.Command{ + Use: strings.TrimSpace(spec.Name), + Short: spec.Description, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runner.Run(cmd) + }, + } + runner.BindFlags(cmd) + s.RegisterCreate(spec, cmd) +} + +func (s *SDK) CreateDefinitions() []CreateSpec { + if s == nil { + return nil + } + out := make([]CreateSpec, 0, len(s.creates)) + for _, registered := range s.creates { + out = append(out, registered.Spec) + } + return out +} + +func (s *SDK) ensureCreateRoot() *cobra.Command { + if s.createRootCmd != nil { + return s.createRootCmd + } + root := &cobra.Command{ + Use: "__create", + Hidden: true, + SilenceUsage: true, + } + listCmd := &cobra.Command{ + Use: "list", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + specs := s.CreateDefinitions() + data, err := yaml.Marshal(specs) + if err != nil { + return fmt.Errorf("marshal creates: %w", err) + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } + root.AddCommand(listCmd) + s.createRootCmd = root + s.RootCmd.AddCommand(root) + return root +} + +func normalizeCreateSpec(spec CreateSpec) CreateSpec { + spec.Name = strings.TrimSpace(spec.Name) + spec.Plugin = strings.TrimSpace(spec.Plugin) + spec.Description = strings.TrimSpace(spec.Description) + spec.MinMemory = strings.TrimSpace(spec.MinMemory) + spec.MinDiskSpace = strings.TrimSpace(spec.MinDiskSpace) + spec.DockerComposeRepo = strings.TrimSpace(spec.DockerComposeRepo) + spec.DockerComposeBranch = strings.TrimSpace(spec.DockerComposeBranch) + if spec.DockerComposeBranch == "" && spec.DockerComposeRepo != "" { + spec.DockerComposeBranch = "main" + } + if len(spec.DockerComposeUp) == 0 && spec.DockerComposeRepo != "" { + spec.DockerComposeUp = []string{"docker compose up --remove-orphans"} + } + if len(spec.DockerComposeDown) == 0 && spec.DockerComposeRepo != "" { + spec.DockerComposeDown = []string{"docker compose down"} + } + return spec +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index b2310ce..0106184 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -7,18 +7,21 @@ import ( "os/exec" "path/filepath" "strings" + + yaml "gopkg.in/yaml.v3" ) type InstalledPlugin struct { - Name string - BinaryName string - Path string - Version string - Description string - Author string - TemplateRepo string - CanCreate bool - Includes []string + Name string + BinaryName string + Path string + Version string + Description string + Author string + TemplateRepo string + CanCreate bool + Includes []string + CreateDefinitions []CreateSpec } var builtinTemplateRepos = map[string]string{ @@ -72,16 +75,21 @@ func FindInstalled(name string) (InstalledPlugin, bool) { } func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) InstalledPlugin { + createDefinitions := pluginCreateDefinitions(pluginPath) info := InstalledPlugin{ - Name: pluginName, - BinaryName: binaryName, - Path: pluginPath, - Description: fmt.Sprintf("the %s plugin", pluginName), - CanCreate: pluginSupportsCreate(pluginPath), + Name: pluginName, + BinaryName: binaryName, + Path: pluginPath, + Description: fmt.Sprintf("the %s plugin", pluginName), + CanCreate: len(createDefinitions) > 0, + CreateDefinitions: createDefinitions, } if repo := builtinTemplateRepos[pluginName]; repo != "" { info.TemplateRepo = repo } + if spec, ok := defaultCreateDefinition(createDefinitions); ok && strings.TrimSpace(spec.DockerComposeRepo) != "" { + info.TemplateRepo = spec.DockerComposeRepo + } cmd := exec.Command(pluginPath, "plugin-info") output, err := cmd.Output() @@ -108,13 +116,39 @@ func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) Installed if !parsed.CanCreate { parsed.CanCreate = info.CanCreate } + if len(parsed.CreateDefinitions) == 0 { + parsed.CreateDefinitions = info.CreateDefinitions + } return parsed } -func pluginSupportsCreate(pluginPath string) bool { - _, err := exec.Command(pluginPath, "create", "--help").Output() - return err == nil +func pluginCreateDefinitions(pluginPath string) []CreateSpec { + cmd := exec.Command(pluginPath, "__create", "list") + output, err := cmd.Output() + if err != nil { + return nil + } + var specs []CreateSpec + if err := yaml.Unmarshal(output, &specs); err != nil { + return nil + } + for i := range specs { + specs[i] = normalizeCreateSpec(specs[i]) + } + return specs +} + +func defaultCreateDefinition(specs []CreateSpec) (CreateSpec, bool) { + if len(specs) == 0 { + return CreateSpec{}, false + } + for _, spec := range specs { + if spec.Default { + return spec, true + } + } + return specs[0], true } func ParsePluginInfoOutput(output string) InstalledPlugin { diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go index 4e9b2d6..58730cb 100644 --- a/pkg/plugin/discovery_test.go +++ b/pkg/plugin/discovery_test.go @@ -2,6 +2,7 @@ package plugin import ( "os" + "strings" "testing" ) @@ -49,13 +50,18 @@ func TestDiscoverInstalledFromPathFallsBackToBuiltinTemplateRepo(t *testing.T) { } } -func TestDiscoverInstalledFromPathDetectsCreateCommand(t *testing.T) { +func TestDiscoverInstalledFromPathDetectsCreateDefinitions(t *testing.T) { dir := t.TempDir() pathEnv := dir script := `#!/bin/sh -if [ "$1" = "create" ] && [ "$2" = "--help" ]; then - echo "create help" +if [ "$1" = "__create" ] && [ "$2" = "list" ]; then + cat <<'YAML' +- name: default + description: Demo stack + default: true + docker_compose_repo: https://github.com/example/demo +YAML exit 0 fi if [ "$1" = "plugin-info" ]; then @@ -73,6 +79,15 @@ exit 1 t.Fatalf("expected one plugin, got %d", len(plugins)) } if !plugins[0].CanCreate { - t.Fatalf("expected plugin create command to be detected") + t.Fatalf("expected plugin create definitions to be detected") + } + if len(plugins[0].CreateDefinitions) != 1 { + t.Fatalf("expected one create definition, got %d", len(plugins[0].CreateDefinitions)) + } + if plugins[0].CreateDefinitions[0].Name != "default" { + t.Fatalf("expected default create definition, got %+v", plugins[0].CreateDefinitions[0]) + } + if !strings.Contains(plugins[0].TemplateRepo, "github.com/example/demo") { + t.Fatalf("expected template repo from create definition, got %q", plugins[0].TemplateRepo) } } diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 03b21a3..088b3d1 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -55,6 +55,8 @@ type SDK struct { sshClient *ssh.Client jobs []RegisteredJob jobRootCmd *cobra.Command + creates []RegisteredCreate + createRootCmd *cobra.Command } // NewSDK creates a new plugin SDK instance diff --git a/pkg/plugin/sdk_test.go b/pkg/plugin/sdk_test.go index b7099e7..2e5e22f 100644 --- a/pkg/plugin/sdk_test.go +++ b/pkg/plugin/sdk_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/validate" ) @@ -261,3 +263,30 @@ func writePluginScript(t *testing.T, dir, name, script string) { t.Fatalf("WriteFile(%s) error = %v", name, err) } } + +func TestRegisterCreateRunnerExposesDefinitions(t *testing.T) { + sdk := NewSDK(Metadata{Name: "isle"}) + sdk.RegisterCreateRunner(CreateSpec{ + Name: "default", + Description: "Create an ISLE stack", + Default: true, + DockerComposeRepo: "https://github.com/example/isle", + }, createRunnerStub{}) + + defs := sdk.CreateDefinitions() + if len(defs) != 1 { + t.Fatalf("expected 1 create definition, got %d", len(defs)) + } + if defs[0].Name != "default" { + t.Fatalf("expected default create definition, got %+v", defs[0]) + } + if sdk.createRootCmd == nil { + t.Fatal("expected hidden create root command to be registered") + } +} + +type createRunnerStub struct{} + +func (createRunnerStub) BindFlags(cmd *cobra.Command) {} + +func (createRunnerStub) Run(cmd *cobra.Command) error { return nil }