diff --git a/Makefile b/Makefile index 5cab6bf..9cb0f1c 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install docs-snippets +.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install BINARY_NAME=sitectl DOCS_PORT ?= 3000 @@ -33,5 +33,3 @@ test: build publish-aptly-repo: bash ./scripts/publish-aptly-repo.sh -docs-snippets: - go run ./scripts/gen-docs-snippets/ diff --git a/cmd/component.go b/cmd/component.go index 880deab..bb9c81e 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -199,6 +199,7 @@ func init() { componentCmd.AddCommand(componentDescribeCmd) componentCmd.AddCommand(componentReconcileCmd) componentCmd.AddCommand(componentSetCmd) + componentCmd.GroupID = "advanced" RootCmd.AddCommand(componentCmd) } diff --git a/cmd/compose.go b/cmd/compose.go index 8599e6c..508c00b 100644 --- a/cmd/compose.go +++ b/cmd/compose.go @@ -134,5 +134,6 @@ Examples: } func init() { + composeCmd.GroupID = "ops" RootCmd.AddCommand(composeCmd) } diff --git a/cmd/config.go b/cmd/config.go index 259a21d..8c6f020 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -489,5 +489,6 @@ func init() { configCmd.AddCommand(setContextCmd) configCmd.AddCommand(useContextCmd) configCmd.AddCommand(deleteContextCmd) + configCmd.GroupID = "setup" RootCmd.AddCommand(configCmd) } diff --git a/cmd/converge.go b/cmd/converge.go new file mode 100644 index 0000000..eeb6f7d --- /dev/null +++ b/cmd/converge.go @@ -0,0 +1,66 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var convergeCmd = &cobra.Command{ + Use: "converge [flags]", + Short: "Detect and repair component configuration drift", + Long: `Inspect each component registered by the active context's plugin and apply +any changes needed to bring the project back into alignment. + +By default the command is interactive and asks before applying changes. Pass +--report to preview what would change without applying it. + +This command dispatches to the plugin associated with the active context. +All flags and arguments are forwarded to the plugin's converge handler. + +Examples: + sitectl converge + sitectl converge --report + sitectl converge --component fcrepo`, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + filteredArgs, contextName, err := helpers.GetContextFromArgs(cmd, args) + if err != nil { + return err + } + + ctx, err := config.GetContext(contextName) + if err != nil { + return err + } + + pluginName := strings.TrimSpace(ctx.Plugin) + if pluginName == "" || pluginName == "core" { + return fmt.Errorf("context %q does not define a plugin that supports converge", ctx.Name) + } + if !pluginHasConverge(pluginName) { + return fmt.Errorf("plugin %q does not support converge", pluginName) + } + + invocation := append([]string{"--context", contextName, "__converge"}, filteredArgs...) + _, err = pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{ + Context: RootCmd.Context(), + Stdin: RootCmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + if err != nil { + return cleanPluginCommandError(err) + } + return nil + }, +} + +func init() { + convergeCmd.GroupID = "workflow" + RootCmd.AddCommand(convergeCmd) +} diff --git a/cmd/create.go b/cmd/create.go index a48c511..6f207b5 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -32,6 +32,7 @@ var createCmd = &cobra.Command{ } func init() { + createCmd.GroupID = "setup" RootCmd.AddCommand(createCmd) } diff --git a/cmd/cron.go b/cmd/cron.go index 3e31a8d..d6da32d 100644 --- a/cmd/cron.go +++ b/cmd/cron.go @@ -478,5 +478,6 @@ func init() { cronCmd.AddCommand(cronRunCmd) cronCmd.AddCommand(cronInstalledCmd) cronCmd.AddCommand(cronRenderSystemdCmd) + cronCmd.GroupID = "ops" RootCmd.AddCommand(cronCmd) } diff --git a/cmd/debug.go b/cmd/debug.go index b72005f..fcd5395 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -123,6 +123,7 @@ func collectDebugReport(runCtx context.Context, contextName string, ctx config.C func init() { debugCmd.Flags().StringVarP(&debugOutputPath, "output", "o", "", "Write the bundle to a file instead of stdout.") debugCmd.Flags().BoolVarP(&debugVerbose, "verbose", "v", false, "Include additional diagnostic details.") + debugCmd.GroupID = "troubleshoot" RootCmd.AddCommand(debugCmd) } diff --git a/cmd/deploy.go b/cmd/deploy.go new file mode 100644 index 0000000..f8ce3ac --- /dev/null +++ b/cmd/deploy.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "fmt" + "log/slog" + "os/exec" + "slices" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var ( + deployBranch string + deployNoPull bool + deploySkipGit bool +) + +var deployCmd = &cobra.Command{ + Use: "deploy", + Short: "Deploy the active context: pull updates and restart services", + Long: `Deploy the active context by orchestrating a full update cycle. + +The deploy sequence runs: + 1. Plugin pre-down hooks (if the context plugin registers a deploy runner) + 2. docker compose down + 3. git fetch and git checkout (unless --skip-git is set) + 4. docker compose pull (unless --no-pull is set) + 5. docker compose up -d --remove-orphans + 6. Plugin post-up hooks (if the context plugin registers a deploy runner) + +The --branch flag overrides which branch is checked out during the git step. +If omitted, the repository's current branch is updated via fetch without switching. + +Examples: + sitectl deploy # Deploy current branch on active context + sitectl deploy --branch main # Switch to main and deploy + sitectl deploy --skip-git # Restart services without pulling git changes + sitectl deploy --context prod # Deploy on a specific context`, + RunE: func(cmd *cobra.Command, args []string) error { + contextName, err := config.ResolveCurrentContextName(cmd.Flags()) + if err != nil { + return err + } + ctx, err := config.GetContext(contextName) + if err != nil { + return err + } + + pluginName := strings.TrimSpace(ctx.Plugin) + hasDeployHooks := pluginHasDeployHooks(cmd, contextName, pluginName) + + // 1. Pre-down hooks + if hasDeployHooks { + slog.Debug("running pre-down hooks", "context", contextName, "plugin", pluginName) + if err := invokeDeployHook(cmd, contextName, pluginName, "pre-down"); err != nil { + return fmt.Errorf("pre-down hook failed: %w", err) + } + } + + // 2. Compose down + slog.Debug("running compose down", "context", contextName) + if err := runContextCompose(cmd, ctx, []string{"down"}); err != nil { + return fmt.Errorf("compose down failed: %w", err) + } + + // 3. Git update + if !deploySkipGit { + slog.Debug("running git update", "context", contextName, "branch", deployBranch) + if err := runGitUpdate(cmd, ctx, deployBranch); err != nil { + return fmt.Errorf("git update failed: %w", err) + } + } + + // 4. Compose pull + if !deployNoPull { + slog.Debug("running compose pull", "context", contextName) + if err := runContextCompose(cmd, ctx, []string{"pull"}); err != nil { + return fmt.Errorf("compose pull failed: %w", err) + } + } + + // 5. Compose up + slog.Debug("running compose up", "context", contextName) + if err := runContextCompose(cmd, ctx, []string{"up", "-d", "--remove-orphans"}); err != nil { + return fmt.Errorf("compose up failed: %w", err) + } + + // 6. Post-up hooks + if hasDeployHooks { + slog.Debug("running post-up hooks", "context", contextName, "plugin", pluginName) + if err := invokeDeployHook(cmd, contextName, pluginName, "post-up"); err != nil { + return fmt.Errorf("post-up hook failed: %w", err) + } + } + + return nil + }, +} + +func init() { + deployCmd.Flags().StringVar(&deployBranch, "branch", "", "Git branch to check out during the deploy (default: update current branch)") + deployCmd.Flags().BoolVar(&deployNoPull, "no-pull", false, "Skip docker compose pull before bringing services up") + deployCmd.Flags().BoolVar(&deploySkipGit, "skip-git", false, "Skip the git fetch/checkout step") + deployCmd.GroupID = "workflow" + RootCmd.AddCommand(deployCmd) +} + +// pluginHasDeployHooks checks whether the context plugin has registered deploy hooks +// using the lightweight plugin discovery metadata. +func pluginHasDeployHooks(_ *cobra.Command, _ string, pluginName string) bool { + if pluginName == "" || pluginName == "core" { + return false + } + installed, ok := plugin.FindInstalled(pluginName) + if !ok { + return false + } + return installed.CanDeploy +} + +// invokeDeployHook calls __deploy on the context plugin. +func invokeDeployHook(cmd *cobra.Command, contextName, pluginName, hook string) error { + invocation := []string{"--context", contextName, "__deploy", hook} + _, err := pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{ + Context: cmd.Context(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + return err +} + +// runContextCompose runs a docker compose subcommand via the context's RunCommandContext, +// mirroring the compose.go injection of -f and --env-file flags. +func runContextCompose(cmd *cobra.Command, ctx config.Context, args []string) error { + if ctx.DockerHostType == config.ContextLocal { + hasProject, err := ctx.HasComposeProject() + if err != nil { + return fmt.Errorf("inspect compose project in %s: %w", ctx.ProjectDir, err) + } + if !hasProject { + return fmt.Errorf("no compose project file found in %s", ctx.ProjectDir) + } + if err := ctx.EnsureTrackedComposeOverrideSymlink(); err != nil { + return err + } + } + + cmdArgs := []string{"compose"} + for _, f := range ctx.ComposeFile { + cmdArgs = append(cmdArgs, "-f", f) + } + for _, e := range ctx.EnvFile { + cmdArgs = append(cmdArgs, "--env-file", e) + } + // Auto-add -d --remove-orphans for up if not already present. + if len(args) > 0 && args[0] == "up" { + if !slices.Contains(args, "-d") && !slices.Contains(args, "--detach") { + args = append(args, "-d", "--remove-orphans") + } + } + cmdArgs = append(cmdArgs, args...) + + c := exec.Command("docker", cmdArgs...) + c.Dir = ctx.ProjectDir + _, err := ctx.RunCommandContext(cmd.Context(), c) + return err +} + +// runGitUpdate runs git fetch and optionally git checkout in the project dir. +func runGitUpdate(cmd *cobra.Command, ctx config.Context, branch string) error { + fetchCmd := exec.Command("git", "fetch") + fetchCmd.Dir = ctx.ProjectDir + if _, err := ctx.RunCommandContext(cmd.Context(), fetchCmd); err != nil { + return fmt.Errorf("git fetch: %w", err) + } + + if strings.TrimSpace(branch) == "" { + // No branch specified: pull the current branch. + pullCmd := exec.Command("git", "pull") + pullCmd.Dir = ctx.ProjectDir + if _, err := ctx.RunCommandContext(cmd.Context(), pullCmd); err != nil { + return fmt.Errorf("git pull: %w", err) + } + return nil + } + + checkoutCmd := exec.Command("git", "checkout", strings.TrimSpace(branch)) + checkoutCmd.Dir = ctx.ProjectDir + if _, err := ctx.RunCommandContext(cmd.Context(), checkoutCmd); err != nil { + return fmt.Errorf("git checkout %s: %w", branch, err) + } + + pullCmd := exec.Command("git", "pull") + pullCmd.Dir = ctx.ProjectDir + if _, err := ctx.RunCommandContext(cmd.Context(), pullCmd); err != nil { + return fmt.Errorf("git pull: %w", err) + } + return nil +} diff --git a/cmd/job.go b/cmd/job.go index 50b9c87..f73acdc 100644 --- a/cmd/job.go +++ b/cmd/job.go @@ -161,6 +161,7 @@ func resolveJobOwner(ctx config.Context, raw string) (string, string, error) { func init() { jobCmd.AddCommand(jobListCmd) jobCmd.AddCommand(jobExecCmd) + jobCmd.GroupID = "ops" RootCmd.AddCommand(jobCmd) } diff --git a/cmd/plugins.go b/cmd/plugins.go new file mode 100644 index 0000000..114c96f --- /dev/null +++ b/cmd/plugins.go @@ -0,0 +1,30 @@ +package cmd + +import "github.com/libops/sitectl/pkg/plugin" + +// pluginHasConverge checks whether the named plugin has registered a converge runner. +func pluginHasConverge(pluginName string) bool { + installed, ok := plugin.FindInstalled(pluginName) + if !ok { + return false + } + return installed.CanConverge +} + +// pluginHasSet checks whether the named plugin has registered a set runner. +func pluginHasSet(pluginName string) bool { + installed, ok := plugin.FindInstalled(pluginName) + if !ok { + return false + } + return installed.CanSet +} + +// pluginHasValidate checks whether the named plugin has registered a validate runner. +func pluginHasValidate(pluginName string) bool { + installed, ok := plugin.FindInstalled(pluginName) + if !ok { + return false + } + return installed.CanValidate +} diff --git a/cmd/port-forward.go b/cmd/port-forward.go index 60c8110..2599480 100644 --- a/cmd/port-forward.go +++ b/cmd/port-forward.go @@ -151,5 +151,6 @@ func forward(client *ssh.Client, localConn net.Conn, remoteAddr string, errw io. } func init() { + portForwardCmd.GroupID = "troubleshoot" RootCmd.AddCommand(portForwardCmd) } diff --git a/cmd/root.go b/cmd/root.go index bf297d6..ca8b6ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -83,6 +83,16 @@ func init() { RootCmd.PersistentFlags().String("context", c, "The sitectl context to use. See sitectl config --help for more info") RootCmd.PersistentFlags().String("log-level", ll, "The logging level for the command") + + RootCmd.AddGroup( + &cobra.Group{ID: "setup", Title: "Setup:"}, + &cobra.Group{ID: "workflow", Title: "Workflow:"}, + &cobra.Group{ID: "ops", Title: "Operations:"}, + &cobra.Group{ID: "troubleshoot", Title: "Troubleshooting:"}, + &cobra.Group{ID: "advanced", Title: "Advanced:"}, + &cobra.Group{ID: "plugins", Title: "Plugin Commands:"}, + ) + discoverAndRegisterPlugins() } @@ -107,6 +117,7 @@ func discoverAndRegisterPlugins() { return nil }, DisableFlagParsing: true, + GroupID: "plugins", } RootCmd.AddCommand(pluginCmd) } diff --git a/cmd/sequelace.go b/cmd/sequelace.go index 52a056e..1e91be4 100644 --- a/cmd/sequelace.go +++ b/cmd/sequelace.go @@ -56,6 +56,7 @@ database port is never exposed on the host. This command is macOS only.`, } func init() { + sequelAceCmd.GroupID = "troubleshoot" RootCmd.AddCommand(sequelAceCmd) sequelAceCmd.Flags().String("sequel-ace-path", "/Applications/Sequel Ace.app/Contents/MacOS/Sequel Ace", "Path to the Sequel Ace binary.") diff --git a/cmd/set.go b/cmd/set.go new file mode 100644 index 0000000..22093c4 --- /dev/null +++ b/cmd/set.go @@ -0,0 +1,75 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var setCmd = &cobra.Command{ + Use: "set [disposition] [flags]", + Short: "Enable, disable, or reconfigure a component", + Long: `Set the state or disposition of a named component for the active context. + +The component name may be prefixed with the plugin namespace: + + sitectl set isle/fcrepo off + sitectl set blazegraph disabled + +This command dispatches to the plugin associated with the active context. +All flags and arguments are forwarded to the plugin's set handler. + +Examples: + sitectl set fcrepo off + sitectl set isle/fcrepo disabled + sitectl set isle-tls on --tls-mode letsencrypt`, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + filteredArgs, contextName, err := helpers.GetContextFromArgs(cmd, args) + if err != nil { + return err + } + if len(filteredArgs) == 0 { + return fmt.Errorf("component name is required") + } + + ctx, err := config.GetContext(contextName) + if err != nil { + return err + } + + pluginName := strings.TrimSpace(ctx.Plugin) + componentArg := filteredArgs[0] + // Allow plugin/component namespacing: resolve the owning plugin. + if pluginPart, _, ok := splitNamespacedComponent(componentArg); ok { + pluginName = pluginPart + } + if pluginName == "" || pluginName == "core" { + return fmt.Errorf("context %q does not define a plugin that supports set", ctx.Name) + } + if !pluginHasSet(pluginName) { + return fmt.Errorf("plugin %q does not support set", pluginName) + } + + invocation := append([]string{"--context", contextName, "__set"}, filteredArgs...) + _, err = pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{ + Context: RootCmd.Context(), + Stdin: RootCmd.InOrStdin(), + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + }) + if err != nil { + return cleanPluginCommandError(err) + } + return nil + }, +} + +func init() { + setCmd.GroupID = "workflow" + RootCmd.AddCommand(setCmd) +} diff --git a/cmd/validate.go b/cmd/validate.go new file mode 100644 index 0000000..1fef257 --- /dev/null +++ b/cmd/validate.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/helpers" + "github.com/libops/sitectl/pkg/plugin" + sitevalidate "github.com/libops/sitectl/pkg/validate" + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v3" +) + +var validateCmd = &cobra.Command{ + Use: "validate [flags]", + Short: "Validate the context configuration and project layout", + Long: `Validate the active context's configuration and project layout. + +Core checks include: required context fields, compose project presence, +context file accessibility, override symlink, and Docker socket access. + +If the active context's plugin registers a validate handler, plugin-specific +checks (e.g. Drupal rootfs path, component state consistency) are also run +and merged into the report. + +All flags not consumed by sitectl itself are forwarded to the plugin's +validate handler, allowing plugin-specific flags such as --drupal-rootfs. + +Exits non-zero if any check fails. + +Examples: + sitectl validate + sitectl validate --format table + sitectl validate --drupal-rootfs drupal/rootfs`, + DisableFlagParsing: true, + RunE: func(cmd *cobra.Command, args []string) error { + filteredArgs, contextName, err := helpers.GetContextFromArgs(cmd, args) + if err != nil { + return err + } + + // Extract --format from remaining args before forwarding to plugin. + validateFormat, pluginArgs := extractFlag(filteredArgs, "--format") + + ctxVal, err := config.GetContext(contextName) + if err != nil { + return err + } + ctx := &ctxVal + + cfg, err := config.Load() + if err != nil { + return err + } + + // Run core validators. + results, err := sitevalidate.Run(ctx, sitevalidate.CoreValidators(cfg)...) + if err != nil { + return err + } + + // Run plugin validators if the plugin supports __validate. + pluginName := strings.TrimSpace(ctx.Plugin) + if pluginName != "" && pluginName != "core" && pluginHasValidate(pluginName) { + invocation := append([]string{"--context", contextName, "__validate"}, pluginArgs...) + output, invokeErr := pluginSDK.InvokePluginCommand(pluginName, invocation, plugin.CommandExecOptions{ + Context: RootCmd.Context(), + Capture: true, + }) + if invokeErr != nil { + return fmt.Errorf("plugin validate failed: %w", invokeErr) + } + if trimmed := strings.TrimSpace(output); trimmed != "" { + var pluginResults []sitevalidate.Result + if err := yaml.Unmarshal([]byte(trimmed), &pluginResults); err != nil { + return fmt.Errorf("parse plugin validate results: %w", err) + } + results = append(results, pluginResults...) + } + } + + sitevalidate.SortResults(results) + report := sitevalidate.NewReport(ctx, results) + if err := sitevalidate.WriteReports(cmd.OutOrStdout(), []sitevalidate.Report{report}, validateFormat); err != nil { + return err + } + if !report.Valid { + return fmt.Errorf("validation failed") + } + return nil + }, +} + +// extractFlag removes --flag and its value from args and returns (value, remaining). +// Handles both "--flag value" and "--flag=value" forms. +func extractFlag(args []string, flag string) (string, []string) { + value := "" + remaining := make([]string, 0, len(args)) + skipNext := false + for _, arg := range args { + if skipNext { + value = arg + skipNext = false + continue + } + if arg == flag { + skipNext = true + continue + } + if strings.HasPrefix(arg, flag+"=") { + value = strings.TrimPrefix(arg, flag+"=") + continue + } + remaining = append(remaining, arg) + } + return value, remaining +} + +func init() { + validateCmd.GroupID = "workflow" + RootCmd.AddCommand(validateCmd) +} diff --git a/pkg/plugin/converge.go b/pkg/plugin/converge.go new file mode 100644 index 0000000..89133a3 --- /dev/null +++ b/pkg/plugin/converge.go @@ -0,0 +1,37 @@ +package plugin + +import ( + "github.com/libops/sitectl/pkg/config" + "github.com/spf13/cobra" +) + +// ConvergeRunner implements plugin-specific component convergence. +// Run detects and repairs configuration drift for the active context. +type ConvergeRunner interface { + BindFlags(cmd *cobra.Command) + Run(cmd *cobra.Command, ctx *config.Context) error +} + +// RegisterConvergeRunner registers a converge runner for the plugin. The SDK +// creates the __converge hidden command that is invoked by sitectl converge. +func (s *SDK) RegisterConvergeRunner(runner ConvergeRunner) { + if s == nil || runner == nil { + return + } + cmd := &cobra.Command{ + Use: "__converge", + Short: "Internal converge hook", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + return runner.Run(cmd, ctx) + }, + } + runner.BindFlags(cmd) + s.RootCmd.AddCommand(cmd) + s.hasConverge = true +} diff --git a/pkg/plugin/debug.go b/pkg/plugin/debug.go new file mode 100644 index 0000000..80b4222 --- /dev/null +++ b/pkg/plugin/debug.go @@ -0,0 +1,71 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin/debugui" + "github.com/spf13/cobra" +) + +// DebugRunner renders plugin-specific debug diagnostics. +// Render returns the body content (without panel wrapper) for the plugin's debug section. +// The SDK wraps the body in a named panel and delegates to included plugins. +type DebugRunner interface { + BindFlags(cmd *cobra.Command) + Render(cmd *cobra.Command, ctx *config.Context) (string, error) +} + +// RegisterDebugHandler registers a debug runner for the plugin. +// The SDK creates the __debug hidden command, wraps the runner's output in a +// named panel, and delegates to any included plugins. +func (s *SDK) RegisterDebugHandler(runner DebugRunner) { + if s == nil || runner == nil { + return + } + cmd := &cobra.Command{ + Use: "__debug", + Short: "Internal debug extension command", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + body, err := runner.Render(cmd, ctx) + if err != nil { + return err + } + rendered := debugui.RenderPanel(s.Metadata.Name, body) + + // Delegate to included plugins. + for _, include := range s.Metadata.Includes { + slog.Debug("running included plugin debug", "plugin", s.Metadata.Name, "include", include) + output, invokeErr := s.InvokeIncludedPluginCommand(include, []string{"__debug"}, CommandExecOptions{ + Context: cmd.Context(), + Capture: true, + }) + if invokeErr != nil { + rendered += "\n\n" + debugui.RenderPanel(include, debugui.FormatRows([]debugui.Row{ + {Label: "Status", Value: debugui.Status("warning")}, + {Label: "Detail", Value: invokeErr.Error()}, + })) + continue + } + if trimmed := strings.TrimSpace(output); trimmed != "" { + slog.Debug("included plugin debug completed", "plugin", s.Metadata.Name, "include", include) + rendered += "\n\n" + trimmed + } else { + slog.Debug("included plugin returned empty debug output", "plugin", s.Metadata.Name, "include", include) + } + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), rendered) + return err + }, + } + runner.BindFlags(cmd) + s.RootCmd.AddCommand(cmd) +} diff --git a/pkg/plugin/deploy.go b/pkg/plugin/deploy.go new file mode 100644 index 0000000..0edba6f --- /dev/null +++ b/pkg/plugin/deploy.go @@ -0,0 +1,129 @@ +package plugin + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v3" +) + +// DeploySpec describes a plugin's deploy capability. +type DeploySpec struct { + Name string `yaml:"name"` + Plugin string `yaml:"plugin,omitempty"` + Description string `yaml:"description,omitempty"` + Default bool `yaml:"default,omitempty"` +} + +// RegisteredDeploy holds a registered deploy spec. +type RegisteredDeploy struct { + Spec DeploySpec +} + +// DeployRunner implements plugin-specific lifecycle hooks for the deploy flow. +// PreDown runs before compose down; PostUp runs after compose up. +type DeployRunner interface { + BindFlags(cmd *cobra.Command) + PreDown(cmd *cobra.Command, ctx *config.Context) error + PostUp(cmd *cobra.Command, ctx *config.Context) error +} + +// RegisterDeployRunner registers a deploy runner for the plugin. The SDK creates +// the __deploy hidden command with pre-down and post-up subcommands that are +// invoked by sitectl deploy around the compose down/up cycle. +func (s *SDK) RegisterDeployRunner(spec DeploySpec, runner DeployRunner) { + if s == nil || runner == nil { + return + } + spec = normalizeDeploySpec(spec) + if strings.TrimSpace(spec.Name) == "" { + return + } + if strings.TrimSpace(spec.Plugin) == "" { + spec.Plugin = s.Metadata.Name + } + + root := s.ensureDeployRoot() + + preDownCmd := &cobra.Command{ + Use: "pre-down", + Short: "Run pre-down lifecycle hooks before compose down", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + return runner.PreDown(cmd, ctx) + }, + } + postUpCmd := &cobra.Command{ + Use: "post-up", + Short: "Run post-up lifecycle hooks after compose up", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + return runner.PostUp(cmd, ctx) + }, + } + runner.BindFlags(preDownCmd) + runner.BindFlags(postUpCmd) + + root.AddCommand(preDownCmd) + root.AddCommand(postUpCmd) + s.deploys = append(s.deploys, RegisteredDeploy{Spec: spec}) +} + +// DeployDefinitions returns the deploy specs registered with this SDK instance. +func (s *SDK) DeployDefinitions() []DeploySpec { + if s == nil { + return nil + } + out := make([]DeploySpec, 0, len(s.deploys)) + for _, registered := range s.deploys { + out = append(out, registered.Spec) + } + return out +} + +func (s *SDK) ensureDeployRoot() *cobra.Command { + if s.deployRootCmd != nil { + return s.deployRootCmd + } + root := &cobra.Command{ + Use: "__deploy", + Hidden: true, + SilenceUsage: true, + } + listCmd := &cobra.Command{ + Use: "list", + Hidden: true, + RunE: func(cmd *cobra.Command, args []string) error { + specs := s.DeployDefinitions() + data, err := yaml.Marshal(specs) + if err != nil { + return fmt.Errorf("marshal deploys: %w", err) + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } + root.AddCommand(listCmd) + s.deployRootCmd = root + s.RootCmd.AddCommand(root) + return root +} + +func normalizeDeploySpec(spec DeploySpec) DeploySpec { + spec.Name = strings.TrimSpace(spec.Name) + spec.Plugin = strings.TrimSpace(spec.Plugin) + spec.Description = strings.TrimSpace(spec.Description) + return spec +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 1ae5f81..de59894 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -21,8 +21,13 @@ type InstalledPlugin struct { Author string TemplateRepo string CanCreate bool + CanDeploy bool + CanConverge bool + CanSet bool + CanValidate bool Includes []string CreateDefinitions []CreateSpec + DeployDefinitions []DeploySpec } var builtinTemplateRepos = map[string]string{ @@ -171,7 +176,10 @@ func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) Installed if !parsed.CanCreate { parsed.CanCreate = len(parsed.CreateDefinitions) > 0 } - slog.Debug("inspected plugin metadata", "plugin", pluginName, "path", pluginPath, "can_create", parsed.CanCreate, "includes", len(parsed.Includes), "create_definitions", len(parsed.CreateDefinitions), "duration", time.Since(started)) + if !parsed.CanDeploy { + parsed.CanDeploy = len(parsed.DeployDefinitions) > 0 + } + slog.Debug("inspected plugin metadata", "plugin", pluginName, "path", pluginPath, "can_create", parsed.CanCreate, "can_deploy", parsed.CanDeploy, "can_converge", parsed.CanConverge, "can_set", parsed.CanSet, "can_validate", parsed.CanValidate, "includes", len(parsed.Includes), "create_definitions", len(parsed.CreateDefinitions), "deploy_definitions", len(parsed.DeployDefinitions), "duration", time.Since(started)) return parsed } diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 23ad7ff..e7f230d 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -59,6 +59,11 @@ type SDK struct { creates []RegisteredCreate createRootCmd *cobra.Command componentDefs []component.Definition + deploys []RegisteredDeploy + deployRootCmd *cobra.Command + hasConverge bool + hasSet bool + hasValidate bool } // NewSDK creates a new plugin SDK instance @@ -178,8 +183,13 @@ func (s *SDK) GetDiscoveryMetadataCommand() *cobra.Command { TemplateRepo: strings.TrimSpace(s.Metadata.TemplateRepo), Includes: append([]string{}, s.Metadata.Includes...), CreateDefinitions: s.CreateDefinitions(), + DeployDefinitions: s.DeployDefinitions(), + CanConverge: s.hasConverge, + CanSet: s.hasSet, + CanValidate: s.hasValidate, } info.CanCreate = len(info.CreateDefinitions) > 0 + info.CanDeploy = len(info.DeployDefinitions) > 0 if info.TemplateRepo == "" { if spec, ok := defaultCreateDefinition(info.CreateDefinitions); ok { info.TemplateRepo = strings.TrimSpace(spec.DockerComposeRepo) diff --git a/pkg/plugin/set.go b/pkg/plugin/set.go new file mode 100644 index 0000000..750370d --- /dev/null +++ b/pkg/plugin/set.go @@ -0,0 +1,38 @@ +package plugin + +import ( + "github.com/libops/sitectl/pkg/config" + "github.com/spf13/cobra" +) + +// SetRunner implements plugin-specific component state management. +// Run applies the requested state or disposition to the named component. +type SetRunner interface { + BindFlags(cmd *cobra.Command) + Run(cmd *cobra.Command, args []string, ctx *config.Context) error +} + +// RegisterSetRunner registers a set runner for the plugin. The SDK creates the +// __set hidden command that is invoked by sitectl set. +func (s *SDK) RegisterSetRunner(runner SetRunner) { + if s == nil || runner == nil { + return + } + cmd := &cobra.Command{ + Use: "__set", + Short: "Internal set hook", + Hidden: true, + SilenceUsage: true, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + return runner.Run(cmd, args, ctx) + }, + } + runner.BindFlags(cmd) + s.RootCmd.AddCommand(cmd) + s.hasSet = true +} diff --git a/pkg/plugin/validate.go b/pkg/plugin/validate.go new file mode 100644 index 0000000..ce7f425 --- /dev/null +++ b/pkg/plugin/validate.go @@ -0,0 +1,50 @@ +package plugin + +import ( + "github.com/libops/sitectl/pkg/config" + sitevalidate "github.com/libops/sitectl/pkg/validate" + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v3" +) + +// ValidateRunner implements plugin-specific context validation. +// Run returns a list of validation results for the active context. +type ValidateRunner interface { + BindFlags(cmd *cobra.Command) + Run(cmd *cobra.Command, ctx *config.Context) ([]sitevalidate.Result, error) +} + +// RegisterValidateRunner registers a validate runner for the plugin. The SDK +// creates the __validate hidden command that is invoked by sitectl validate. +// The command outputs YAML-encoded []validate.Result that sitectl merges with +// core validation results before writing the final report. +func (s *SDK) RegisterValidateRunner(runner ValidateRunner) { + if s == nil || runner == nil { + return + } + cmd := &cobra.Command{ + Use: "__validate", + Short: "Internal validate hook", + Hidden: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + ctx, err := s.GetContext() + if err != nil { + return err + } + results, err := runner.Run(cmd, ctx) + if err != nil { + return err + } + data, err := yaml.Marshal(results) + if err != nil { + return err + } + _, err = cmd.OutOrStdout().Write(data) + return err + }, + } + runner.BindFlags(cmd) + s.RootCmd.AddCommand(cmd) + s.hasValidate = true +} diff --git a/scripts/gen-docs-snippets/main.go b/scripts/gen-docs-snippets/main.go deleted file mode 100644 index f1d728e..0000000 --- a/scripts/gen-docs-snippets/main.go +++ /dev/null @@ -1,166 +0,0 @@ -// gen-docs-snippets generates MDX snippet files for each sitectl command. -// Run via: make docs-snippets -// Output goes to ../sitectl-docs/snippets/commands/ -package main - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - sitectlcmd "github.com/libops/sitectl/cmd" - "github.com/spf13/cobra" - "github.com/spf13/pflag" -) - -const ( - displayPrefix = "sitectl" - outputDir = "../sitectl-docs/snippets/commands" - autoGenHeader = "{/* Auto-generated from source. Run `make docs-snippets` to update. */}\n\n" -) - -func main() { - root := sitectlcmd.RootCmd - root.DisableAutoGenTag = true - - if err := os.MkdirAll(outputDir, 0o755); err != nil { - fmt.Fprintf(os.Stderr, "create output dir: %v\n", err) - os.Exit(1) - } - - var count int - walkCommands(root, func(cmd *cobra.Command) { - slug := commandSlug(cmd) - path := filepath.Join(outputDir, slug+".mdx") - if err := os.WriteFile(path, []byte(renderSnippet(cmd)), 0o644); err != nil { - fmt.Fprintf(os.Stderr, "write %s: %v\n", path, err) - os.Exit(1) - } - fmt.Println(path) - count++ - }) - fmt.Printf("generated %d snippets\n", count) -} - -func walkCommands(cmd *cobra.Command, fn func(*cobra.Command)) { - for _, sub := range cmd.Commands() { - if skipCommand(sub) { - continue - } - fn(sub) - walkCommands(sub, fn) - } -} - -func skipCommand(cmd *cobra.Command) bool { - if cmd.Hidden { - return true - } - name := cmd.Name() - if name == "help" || name == "completion" { - return true - } - // Skip thin plugin-passthrough commands (no Long, DisableFlagParsing, no subcommands) - if cmd.DisableFlagParsing && strings.TrimSpace(cmd.Long) == "" && !cmd.HasAvailableSubCommands() { - return true - } - return false -} - -func commandSlug(cmd *cobra.Command) string { - path := cmd.CommandPath() - prefix := strings.ReplaceAll(displayPrefix, " ", "-") - if strings.HasPrefix(path, displayPrefix+" ") { - rel := path[len(displayPrefix)+1:] - return strings.ToLower(prefix + "-" + strings.ReplaceAll(rel, " ", "-")) - } - return strings.ToLower(prefix) -} - -func buildUseLine(cmd *cobra.Command) string { - // cmd.CommandPath() already includes the correct display name (e.g. "sitectl compose") - // for plugins it uses the CommandDisplayNameAnnotation ("sitectl isle create") - path := cmd.CommandPath() - - var fullPath string - if path == displayPrefix || strings.HasPrefix(path, displayPrefix+" ") { - fullPath = path - } else { - fullPath = displayPrefix + " " + path - } - - // Append args from Use (everything after the command name) - useParts := strings.Fields(cmd.Use) - if len(useParts) > 1 { - fullPath += " " + strings.Join(useParts[1:], " ") - } - - // For group commands (no RunE), append - if !cmd.Runnable() && cmd.HasAvailableSubCommands() { - fullPath += " " - } - - return fullPath -} - -func collectLocalFlags(cmd *cobra.Command) []*pflag.Flag { - var flags []*pflag.Flag - cmd.Flags().VisitAll(func(f *pflag.Flag) { - if !f.Hidden { - flags = append(flags, f) - } - }) - return flags -} - -func renderSnippet(cmd *cobra.Command) string { - var b strings.Builder - b.WriteString(autoGenHeader) - - // Long description, falling back to Short - desc := strings.TrimSpace(cmd.Long) - if desc == "" { - desc = strings.TrimSpace(cmd.Short) - } - if desc != "" { - b.WriteString(desc) - b.WriteString("\n\n") - } - - // Usage code block - b.WriteString("```bash\n") - b.WriteString(buildUseLine(cmd)) - b.WriteString("\n```\n") - - // Aliases - if len(cmd.Aliases) > 0 { - b.WriteString("\n**Aliases:** `") - b.WriteString(strings.Join(cmd.Aliases, "`, `")) - b.WriteString("`\n") - } - - // Flags table (skip for DisableFlagParsing commands — they accept arbitrary args) - if !cmd.DisableFlagParsing { - flags := collectLocalFlags(cmd) - if len(flags) > 0 { - b.WriteString("\n| Flag | Default | Description |\n") - b.WriteString("|------|---------|-------------|\n") - for _, f := range flags { - flagStr := "--" + f.Name - if f.Shorthand != "" { - flagStr = "-" + f.Shorthand + ", " + flagStr - } - defVal := f.DefValue - if defVal == "" { - defVal = " " - } else { - defVal = "`" + defVal + "`" - } - fmt.Fprintf(&b, "| `%s` | %s | %s |\n", flagStr, defVal, f.Usage) - } - } - } - - return b.String() -}