Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
260 changes: 260 additions & 0 deletions cmd/create.go
Original file line number Diff line number Diff line change
@@ -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 "-"
}
134 changes: 134 additions & 0 deletions pkg/plugin/creates.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading