diff --git a/Makefile b/Makefile index 130041a..ca63ed6 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ -.PHONY: build deps lint test docker integration-test docs docs-host plugins install-plugins publish-aptly-repo +.PHONY: build deps lint test docker integration-test plugins install-plugins publish-aptly-repo install BINARY_NAME=sitectl DOCS_PORT ?= 3000 +INSTALL_DIR ?= /usr/local/bin deps: go get . @@ -10,16 +11,10 @@ deps: build: deps go build -o $(BINARY_NAME) . -docs: - docker run --rm -it \ - -p $(DOCS_PORT):$(DOCS_PORT) \ - -v "$(CURDIR):/work" \ - -w /work \ - node:22-bookworm \ - sh -lc "npx mint dev --port $(DOCS_PORT) --host 0.0.0.0" - -docs-host: - npx mint dev +install: build + sudo cp $(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + @if [ -d ../sitectl-isle ]; then $(MAKE) -C ../sitectl-isle install; fi + @if [ -d ../sitectl-drupal ]; then $(MAKE) -C ../sitectl-drupal install; fi lint: go fmt ./... diff --git a/cmd/component.go b/cmd/component.go new file mode 100644 index 0000000..8958257 --- /dev/null +++ b/cmd/component.go @@ -0,0 +1,219 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" +) + +var ( + componentDescribeName string + componentDescribePath string + componentDescribeDrupalRoot string + componentDescribeVerbose bool + componentDescribeFormat string + componentReconcileName string + componentReconcilePath string + componentReconcileDrupalRoot string + componentReconcileReport bool + componentReconcileVerbose bool + componentReconcileFormat string + componentSetPath string + componentSetDrupalRoot string + componentSetState string + componentSetDisposition string + componentSetTLSMode string + componentSetYolo bool + invokePluginCommand = func(pluginName string, args []string) error { + installed, ok := plugin.FindInstalled(pluginName) + if !ok { + return fmt.Errorf("plugin %q is not installed", pluginName) + } + _, err := pluginSDK.InvokePluginCommand(installed.Name, args, plugin.CommandExecOptions{ + Stdin: RootCmd.InOrStdin(), + Stdout: RootCmd.OutOrStdout(), + Stderr: RootCmd.ErrOrStderr(), + }) + return err + } +) + +var componentCmd = &cobra.Command{ + Use: "component", + Short: "Describe and reconcile stack components for the active context", +} + +var componentDescribeCmd = &cobra.Command{ + Use: "describe", + Aliases: []string{"status"}, + Short: "Describe the current component state", + RunE: func(cmd *cobra.Command, args []string) error { + owner, name, err := resolveComponentOwner(cmd, componentDescribeName) + if err != nil { + return err + } + + invocation := []string{"__component", "describe"} + if name != "" { + invocation = append(invocation, "--component", name) + } + if strings.TrimSpace(componentDescribePath) != "" { + invocation = append(invocation, "--path", componentDescribePath) + } + if strings.TrimSpace(componentDescribeDrupalRoot) != "" { + invocation = append(invocation, "--drupal-rootfs", componentDescribeDrupalRoot) + } + if componentDescribeVerbose { + invocation = append(invocation, "--verbose") + } + if strings.TrimSpace(componentDescribeFormat) != "" { + invocation = append(invocation, "--format", componentDescribeFormat) + } + + return invokePluginCommand(owner, invocation) + }, +} + +var componentReconcileCmd = &cobra.Command{ + Use: "reconcile", + Aliases: []string{"review", "align"}, + Short: "Review and reconcile component state", + RunE: func(cmd *cobra.Command, args []string) error { + owner, name, err := resolveComponentOwner(cmd, componentReconcileName) + if err != nil { + return err + } + + invocation := []string{"__component", "reconcile"} + if name != "" { + invocation = append(invocation, "--component", name) + } + if strings.TrimSpace(componentReconcilePath) != "" { + invocation = append(invocation, "--path", componentReconcilePath) + } + if strings.TrimSpace(componentReconcileDrupalRoot) != "" { + invocation = append(invocation, "--drupal-rootfs", componentReconcileDrupalRoot) + } + if componentReconcileReport { + invocation = append(invocation, "--report") + } + if componentReconcileVerbose { + invocation = append(invocation, "--verbose") + } + if strings.TrimSpace(componentReconcileFormat) != "" { + invocation = append(invocation, "--format", componentReconcileFormat) + } + + return invokePluginCommand(owner, invocation) + }, +} + +var componentSetCmd = &cobra.Command{ + Use: "set [disposition]", + Short: "Set a component disposition", + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + owner, name, err := resolveComponentOwner(cmd, args[0]) + if err != nil { + return err + } + + invocation := []string{"__component", "set", name} + if len(args) > 1 { + invocation = append(invocation, args[1]) + } + if strings.TrimSpace(componentSetPath) != "" { + invocation = append(invocation, "--path", componentSetPath) + } + if strings.TrimSpace(componentSetDrupalRoot) != "" { + invocation = append(invocation, "--drupal-rootfs", componentSetDrupalRoot) + } + if strings.TrimSpace(componentSetState) != "" { + invocation = append(invocation, "--state", componentSetState) + } + if strings.TrimSpace(componentSetDisposition) != "" { + invocation = append(invocation, "--disposition", componentSetDisposition) + } + if strings.TrimSpace(componentSetTLSMode) != "" { + invocation = append(invocation, "--tls-mode", componentSetTLSMode) + } + if componentSetYolo { + invocation = append(invocation, "--yolo") + } + + return invokePluginCommand(owner, invocation) + }, +} + +func init() { + pluginSDK = plugin.NewSDK(plugin.Metadata{Name: "sitectl"}) + + componentDescribeCmd.Flags().StringVarP(&componentDescribeName, "component", "c", "", "Namespaced component to describe, for example isle/fcrepo") + componentDescribeCmd.Flags().StringVar(&componentDescribePath, "path", "", "Project path override") + componentDescribeCmd.Flags().StringVar(&componentDescribeDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") + componentDescribeCmd.Flags().BoolVar(&componentDescribeVerbose, "verbose", false, "Include verbose component details") + componentDescribeCmd.Flags().StringVar(&componentDescribeFormat, "format", "", "Output format override") + + componentReconcileCmd.Flags().StringVarP(&componentReconcileName, "component", "c", "", "Namespaced component to reconcile, for example isle/fcrepo") + componentReconcileCmd.Flags().StringVar(&componentReconcilePath, "path", "", "Project path override") + componentReconcileCmd.Flags().StringVar(&componentReconcileDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") + componentReconcileCmd.Flags().BoolVar(&componentReconcileReport, "report", false, "Render a report instead of applying changes") + componentReconcileCmd.Flags().BoolVar(&componentReconcileVerbose, "verbose", false, "Include verbose component details") + componentReconcileCmd.Flags().StringVar(&componentReconcileFormat, "format", "", "Output format override") + + componentSetCmd.Flags().StringVar(&componentSetPath, "path", "", "Project path override") + componentSetCmd.Flags().StringVar(&componentSetDrupalRoot, "drupal-rootfs", "", "Drupal rootfs path override") + componentSetCmd.Flags().StringVar(&componentSetState, "state", "", "Explicit state override") + componentSetCmd.Flags().StringVar(&componentSetDisposition, "disposition", "", "Explicit disposition override") + componentSetCmd.Flags().StringVar(&componentSetTLSMode, "tls-mode", "", "TLS mode override") + componentSetCmd.Flags().BoolVar(&componentSetYolo, "yolo", false, "Apply without confirmation") + + componentCmd.AddCommand(componentDescribeCmd) + componentCmd.AddCommand(componentReconcileCmd) + componentCmd.AddCommand(componentSetCmd) + RootCmd.AddCommand(componentCmd) +} + +var pluginSDK *plugin.SDK + +func resolveComponentOwner(cmd *cobra.Command, raw string) (string, string, error) { + contextName, err := config.ResolveCurrentContextName(cmd.Flags()) + if err != nil { + return "", "", err + } + + ctx, err := config.GetContext(contextName) + if err != nil { + return "", "", err + } + + owner := ctx.Plugin + name := strings.TrimSpace(raw) + if pluginName, componentName, ok := splitNamespacedComponent(name); ok { + owner = pluginName + name = componentName + } + if strings.TrimSpace(owner) == "" { + return "", "", fmt.Errorf("context %q does not define a plugin owner", ctx.Name) + } + if owner == "core" { + return "", "", fmt.Errorf("context %q uses plugin %q; component commands require a stack plugin such as isle", ctx.Name, owner) + } + return owner, name, nil +} + +func splitNamespacedComponent(raw string) (string, string, bool) { + pluginName, componentName, ok := strings.Cut(strings.TrimSpace(raw), "/") + if !ok { + return "", "", false + } + pluginName = strings.TrimSpace(pluginName) + componentName = strings.TrimSpace(componentName) + if pluginName == "" || componentName == "" { + return "", "", false + } + return pluginName, componentName, true +} diff --git a/cmd/component_test.go b/cmd/component_test.go new file mode 100644 index 0000000..3840090 --- /dev/null +++ b/cmd/component_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "testing" + + "github.com/libops/sitectl/pkg/config" + "github.com/spf13/cobra" +) + +func TestSplitNamespacedComponent(t *testing.T) { + pluginName, componentName, ok := splitNamespacedComponent("isle/fcrepo") + if !ok { + t.Fatal("expected namespaced component to parse") + } + if pluginName != "isle" || componentName != "fcrepo" { + t.Fatalf("unexpected parse result: %q %q", pluginName, componentName) + } +} + +func TestResolveComponentOwnerUsesNamespace(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + if err := config.SaveContext(&config.Context{ + Name: "museum", + Site: "museum", + Plugin: "isle", + DockerHostType: config.ContextLocal, + DockerSocket: "/var/run/docker.sock", + ProjectDir: tempHome, + }, true); err != nil { + t.Fatalf("SaveContext() error = %v", err) + } + + cmd := &cobra.Command{Use: "describe"} + cmd.Flags().String("context", "", "") + if err := cmd.Flags().Set("context", "museum"); err != nil { + t.Fatalf("Set(context) error = %v", err) + } + + owner, name, err := resolveComponentOwner(cmd, "drupal/modules") + if err != nil { + t.Fatalf("resolveComponentOwner() error = %v", err) + } + if owner != "drupal" || name != "modules" { + t.Fatalf("unexpected owner/name: %q %q", owner, name) + } +} + +func TestResolveComponentOwnerFallsBackToContextPlugin(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + if err := config.SaveContext(&config.Context{ + Name: "museum", + Site: "museum", + Plugin: "isle", + DockerHostType: config.ContextLocal, + DockerSocket: "/var/run/docker.sock", + ProjectDir: tempHome, + }, true); err != nil { + t.Fatalf("SaveContext() error = %v", err) + } + + cmd := &cobra.Command{Use: "describe"} + cmd.Flags().String("context", "", "") + if err := cmd.Flags().Set("context", "museum"); err != nil { + t.Fatalf("Set(context) error = %v", err) + } + + owner, name, err := resolveComponentOwner(cmd, "fcrepo") + if err != nil { + t.Fatalf("resolveComponentOwner() error = %v", err) + } + if owner != "isle" || name != "fcrepo" { + t.Fatalf("unexpected owner/name: %q %q", owner, name) + } +} diff --git a/cmd/debug.go b/cmd/debug.go new file mode 100644 index 0000000..9d01157 --- /dev/null +++ b/cmd/debug.go @@ -0,0 +1,665 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "charm.land/lipgloss/v2" + dockercontainer "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + dockerimage "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" + "github.com/kballard/go-shellquote" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/docker" + "github.com/libops/sitectl/pkg/plugin" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var debugOutputPath string +var debugVerbose bool + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +const ( + imageSizeWarningThreshold = int64(20 * 1024 * 1024 * 1024) + dockerPruneDocsURL = "https://docs.docker.com/engine/manage-resources/pruning/" +) + +var ( + debugPanelStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#112235")). + Padding(1, 2) + debugTitleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#98C1D9")) + debugMutedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9FB3C8")) + debugSectionDividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#29425E")) + debugStatusOKStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7BD389")) + debugStatusWarningStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F4C95D")) + debugStatusFailedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F28482")) + debugRowStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#112235")) +) + +var debugCmd = &cobra.Command{ + Use: "debug", + Short: "Collect a text support bundle for the active 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 + } + + var body strings.Builder + body.WriteString(renderCoreDebug(ctx)) + + if pluginName := strings.TrimSpace(ctx.Plugin); pluginName != "" && pluginName != "core" { + pluginArgs := []string{"__debug"} + if debugVerbose { + pluginArgs = append(pluginArgs, "--verbose") + } + output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Capture: true}) + if err != nil { + return err + } + if trimmed := strings.TrimSpace(output); trimmed != "" { + body.WriteString("\n\n") + body.WriteString(trimmed) + } + } + + if strings.TrimSpace(debugOutputPath) != "" { + report := renderPlainDebugReport(body.String()) + if err := os.WriteFile(debugOutputPath, []byte(report+"\n"), 0o644); err != nil { + return err + } + _, err = fmt.Fprintf(cmd.OutOrStdout(), "wrote debug bundle to %s\n", debugOutputPath) + return err + } + + _, err = fmt.Fprintln(cmd.OutOrStdout(), body.String()) + return err + }, +} + +func init() { + debugCmd.Flags().StringVarP(&debugOutputPath, "output", "o", "", "Write the debug report to a file instead of stdout") + debugCmd.Flags().BoolVarP(&debugVerbose, "verbose", "v", false, "Include verbose diagnostic details") + RootCmd.AddCommand(debugCmd) +} + +func renderCoreDebug(ctx config.Context) string { + meta := []debugRow{ + {Label: "Generated", Value: time.Now().UTC().Format(time.RFC3339)}, + {Label: "Context", Value: ctx.Name}, + {Label: "Plugin owner", Value: firstNonEmpty(ctx.Plugin, "core")}, + {Label: "Docker host type", Value: string(ctx.DockerHostType)}, + {Label: "Project dir", Value: ctx.ProjectDir}, + } + if strings.TrimSpace(ctx.ProjectName) != "" { + meta = append(meta, debugRow{Label: "Project name", Value: ctx.ProjectName}) + } + if strings.TrimSpace(ctx.ComposeProjectName) != "" { + meta = append(meta, debugRow{Label: "Compose project", Value: ctx.ComposeProjectName}) + } + if strings.TrimSpace(ctx.DockerSocket) != "" { + meta = append(meta, debugRow{Label: "Docker socket", Value: ctx.DockerSocket}) + } + + coreBody := []string{ + debugMutedStyle.Render("General Docker configuration and host-level diagnostics for this context."), + "", + debugDivider(), + "", + debugTitleStyle.Render("General"), + "", + formatDebugRows(meta), + } + if diagnostics, err := collectLogDiagnostics(&ctx); err == nil { + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(diagnostics))) + if debugVerbose { + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Details"), "", renderLogDetailsBody(diagnostics)) + } + } else { + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows([]debugRow{ + {Label: "Log status", Value: renderStatus("warning")}, + {Label: "Log diagnostics", Value: err.Error()}, + })) + } + if diagnostics, err := collectImageDiagnostics(&ctx); err == nil { + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows(imageSummaryRows(diagnostics))) + } else { + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows([]debugRow{ + {Label: "Image status", Value: renderStatus("warning")}, + {Label: "Image diagnostics", Value: err.Error()}, + })) + } + return renderDebugPanel("sitectl", strings.Join(coreBody, "\n")) +} + +type logDiagnostics struct { + TotalBytes int64 + KnownSize bool + Containers []containerLogDiagnostics + UnboundedCount int + ExternalDriverCount int +} + +type containerLogDiagnostics struct { + Service string + Container string + Driver string + LogPath string + SizeBytes int64 + HasSize bool + Rotated bool + External bool + RotationHint string +} + +type imageDiagnostics struct { + TotalBytes int64 + ImageCount int +} + +func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { + cli, err := docker.GetDockerCli(ctxCfg) + if err != nil { + return logDiagnostics{}, err + } + defer cli.Close() + + filterArgs := filters.NewArgs() + filterArgs.Add("label", "com.docker.compose.project="+ctxCfg.EffectiveComposeProjectName()) + containers, err := cli.CLI.ContainerList(context.Background(), dockercontainer.ListOptions{ + All: true, + Filters: filterArgs, + }) + if err != nil { + return logDiagnostics{}, err + } + + diagnostics := logDiagnostics{ + KnownSize: true, + Containers: make([]containerLogDiagnostics, 0, len(containers)), + } + remotePaths := make([]string, 0, len(containers)) + + for _, summary := range containers { + name := trimContainerName(summary.Names) + service := firstNonEmpty(summary.Labels["com.docker.compose.service"], name) + inspect, err := cli.CLI.ContainerInspect(context.Background(), name) + if err != nil { + return logDiagnostics{}, err + } + + item := describeContainerLogs(service, name, inspect) + if item.External { + diagnostics.ExternalDriverCount++ + } + if !item.Rotated && !item.External { + diagnostics.UnboundedCount++ + } + if item.LogPath != "" && ctxCfg.DockerHostType != config.ContextLocal { + remotePaths = append(remotePaths, item.LogPath) + } + diagnostics.Containers = append(diagnostics.Containers, item) + } + + if ctxCfg.DockerHostType == config.ContextLocal { + for i := range diagnostics.Containers { + item := &diagnostics.Containers[i] + if item.LogPath == "" { + diagnostics.KnownSize = false + continue + } + size, hasSize, err := logFileSizeLocal(item.LogPath) + if err != nil { + item.RotationHint = appendHint(item.RotationHint, fmt.Sprintf("unable to stat log file: %v", err)) + diagnostics.KnownSize = false + continue + } + item.SizeBytes = size + item.HasSize = hasSize + if hasSize { + diagnostics.TotalBytes += size + } else { + diagnostics.KnownSize = false + } + } + } else if len(remotePaths) > 0 { + sizes, err := logFileSizesRemote(ctxCfg, remotePaths) + if err != nil { + diagnostics.KnownSize = false + for i := range diagnostics.Containers { + if diagnostics.Containers[i].LogPath == "" { + continue + } + diagnostics.Containers[i].RotationHint = appendHint(diagnostics.Containers[i].RotationHint, fmt.Sprintf("unable to stat log file: %v", err)) + } + } else { + for i := range diagnostics.Containers { + item := &diagnostics.Containers[i] + if item.LogPath != "" { + size, ok := sizes[item.LogPath] + if ok { + item.SizeBytes = size + item.HasSize = true + diagnostics.TotalBytes += size + continue + } + } + diagnostics.KnownSize = false + } + } + } + + sort.Slice(diagnostics.Containers, func(i, j int) bool { + return diagnostics.Containers[i].Service < diagnostics.Containers[j].Service + }) + + return diagnostics, nil +} + +func collectImageDiagnostics(ctxCfg *config.Context) (imageDiagnostics, error) { + cli, err := docker.GetDockerCli(ctxCfg) + if err != nil { + return imageDiagnostics{}, err + } + defer cli.Close() + + apiClient, ok := cli.CLI.(*client.Client) + if !ok { + return imageDiagnostics{}, fmt.Errorf("docker client does not support image listing") + } + + images, err := apiClient.ImageList(context.Background(), dockerimage.ListOptions{All: true}) + if err != nil { + return imageDiagnostics{}, err + } + + diagnostics := imageDiagnostics{ImageCount: len(images)} + for _, image := range images { + if image.Size > 0 { + diagnostics.TotalBytes += image.Size + } + } + + return diagnostics, nil +} + +func imageSummaryRows(diagnostics imageDiagnostics) []debugRow { + state := "ok" + rows := []debugRow{ + {Label: "Image status", Value: renderStatus(state)}, + {Label: "Total images", Value: humanBytes(diagnostics.TotalBytes)}, + {Label: "Image count", Value: strconv.Itoa(diagnostics.ImageCount)}, + } + if diagnostics.TotalBytes >= imageSizeWarningThreshold { + state = "warning" + rows[0].Value = renderStatus(state) + rows = append(rows, + debugRow{Label: "Recommendation", Value: "run docker system prune -af periodically on development hosts"}, + debugRow{Label: "Docs", Value: dockerPruneDocsURL}, + ) + } + return rows +} + +func describeContainerLogs(service, containerName string, inspect dockercontainer.InspectResponse) containerLogDiagnostics { + item := containerLogDiagnostics{ + Service: service, + Container: containerName, + LogPath: strings.TrimSpace(inspect.LogPath), + } + if inspect.HostConfig != nil { + item.Driver = strings.TrimSpace(inspect.HostConfig.LogConfig.Type) + item.Rotated, item.External, item.RotationHint = evaluateLogConfig(inspect.HostConfig.LogConfig.Type, inspect.HostConfig.LogConfig.Config) + } + if item.Driver == "" { + item.Driver = "default" + } + return item +} + +func evaluateLogConfig(driver string, options map[string]string) (rotated bool, external bool, hint string) { + switch strings.TrimSpace(driver) { + case "", "json-file", "local": + maxSize := strings.TrimSpace(options["max-size"]) + maxFile := strings.TrimSpace(options["max-file"]) + if maxSize != "" && maxFile != "" { + return true, false, fmt.Sprintf("rotation configured: max-size=%s max-file=%s", maxSize, maxFile) + } + if maxSize != "" { + return true, false, fmt.Sprintf("rotation configured: max-size=%s", maxSize) + } + return false, false, "file-backed logs are not capped; set max-size and max-file" + case "syslog", "journald", "gelf", "fluentd", "awslogs", "splunk", "gcplogs": + return true, true, "logs ship to an external logging driver" + default: + if len(options) == 0 { + return false, false, "custom log driver has no explicit rotation settings" + } + return true, true, "custom log driver configured" + } +} + +func logFileSizeLocal(path string) (int64, bool, error) { + if strings.TrimSpace(path) == "" { + return 0, false, nil + } + slog.Debug("logFileSizeLocal", "path", path) + + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return 0, false, nil + } + if errors.Is(err, os.ErrPermission) { + size, sudoErr := logFileSizeLocalSudo(path) + if sudoErr == nil { + return size, true, nil + } + return 0, false, fmt.Errorf("%w; sudo stat failed: %v", err, sudoErr) + } + return 0, false, err + } + return info.Size(), true, nil +} + +func logFileSizeLocalSudo(path string) (int64, error) { + cmd := exec.Command("sudo", "-n", "sh", "-lc", fmt.Sprintf("test -f %s && wc -c < %s || true", shellquote.Join(path), shellquote.Join(path))) + slog.Debug(cmd.String()) + output, err := cmd.CombinedOutput() + if err != nil { + return 0, err + } + text := strings.TrimSpace(string(output)) + if text == "" { + return 0, nil + } + return strconv.ParseInt(text, 10, 64) +} + +func logFileSizesRemote(ctxCfg *config.Context, paths []string) (map[string]int64, error) { + uniquePaths := make([]string, 0, len(paths)) + seen := map[string]bool{} + for _, path := range paths { + path = strings.TrimSpace(path) + if path == "" || seen[path] { + continue + } + seen[path] = true + uniquePaths = append(uniquePaths, path) + } + if len(uniquePaths) == 0 { + return map[string]int64{}, nil + } + + client, err := ctxCfg.DialSSH() + if err != nil { + return nil, err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + + parts := make([]string, 0, len(uniquePaths)) + for _, path := range uniquePaths { + quoted := shellquote.Join(path) + parts = append(parts, fmt.Sprintf("if test -f %s; then printf '%%s\\t' %s; stat -c %%s %s; fi", quoted, quoted, quoted)) + } + cmd := strings.Join(parts, "; ") + if ctxCfg.RunSudo { + cmd = "sudo -n sh -lc " + shellquote.Join(cmd) + } + output, err := session.CombinedOutput(cmd) + if err != nil { + return nil, err + } + + sizes := map[string]int64{} + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + path, rawSize, ok := strings.Cut(line, "\t") + if !ok { + continue + } + size, err := strconv.ParseInt(strings.TrimSpace(rawSize), 10, 64) + if err != nil { + return nil, err + } + sizes[strings.TrimSpace(path)] = size + } + return sizes, nil +} + +func logSummaryRows(diagnostics logDiagnostics) []debugRow { + totalLine := "unknown" + if diagnostics.KnownSize { + totalLine = humanBytes(diagnostics.TotalBytes) + } + totalState := "ok" + if !diagnostics.KnownSize { + totalState = "warning" + } else if diagnostics.TotalBytes >= 1<<30 { + totalState = "warning" + } + + logHandling := "file-backed container logs appear capped" + if diagnostics.UnboundedCount == 0 { + if diagnostics.ExternalDriverCount > 0 { + logHandling = "logs are capped or shipped to an external driver" + } + } else { + totalState = "failed" + if diagnostics.UnboundedCount <= 2 { + totalState = "warning" + } + logHandling = fmt.Sprintf("%d container(s) are using unbounded file-backed logs", diagnostics.UnboundedCount) + } + + rows := []debugRow{ + {Label: "Log status", Value: renderStatus(totalState)}, + {Label: "Total logs", Value: totalLine}, + {Label: "Log handling", Value: logHandling}, + } + if !diagnostics.KnownSize { + rows = append(rows, debugRow{Label: "Note", Value: "unable to determine one or more container log file sizes"}) + } else if diagnostics.TotalBytes >= 1<<30 { + rows = append(rows, debugRow{Label: "Note", Value: "aggregate container logs exceed 1 GiB"}) + } + if diagnostics.UnboundedCount > 0 { + rows = append(rows, debugRow{ + Label: "Recommendation", + Value: `configure Docker log rotation with max-size and max-file, or ship logs to syslog, journald, or another central driver + +https://docs.docker.com/engine/logging/configure/`}) + } + return rows +} + +func renderLogDetailsBody(diagnostics logDiagnostics) string { + lines := []string{"Log details:"} + for _, item := range diagnostics.Containers { + line := fmt.Sprintf(" %s: driver=%s", item.Service, item.Driver) + if item.HasSize { + line += fmt.Sprintf(", size=%s", humanBytes(item.SizeBytes)) + } + if item.External { + line += ", external" + } else if item.Rotated { + line += ", rotated" + } else { + line += ", not rotated" + } + if item.RotationHint != "" { + line += fmt.Sprintf(" (%s)", item.RotationHint) + } + lines = append(lines, line) + } + return strings.Join(lines, "\n") +} + +type debugRow struct { + Label string + Value string +} + +func renderDebugPanel(title, body string) string { + header := debugTitleStyle.Render(strings.TrimSpace(title)) + content := header + if strings.TrimSpace(body) != "" { + content += "\n\n" + body + } + return debugPanelStyle.Width(debugPanelWidth()).Render(content) +} + +func formatDebugRows(rows []debugRow) string { + labelWidth := 0 + for _, row := range rows { + if len(strings.TrimSpace(row.Label)) > labelWidth { + labelWidth = len(strings.TrimSpace(row.Label)) + } + } + + lines := make([]string, 0, len(rows)) + rowWidth := debugContentWidth() + for _, row := range rows { + label := strings.TrimSpace(row.Label) + value := strings.TrimSpace(row.Value) + if label == "" { + lines = append(lines, renderDebugRow(rowWidth, "", value)) + continue + } + lines = append(lines, renderDebugRow(rowWidth, fmt.Sprintf("%-*s", labelWidth, label), value)) + } + return strings.Join(lines, "\n") +} + +func renderDebugRow(width int, label, value string) string { + valueWidth := max(0, width-lipgloss.Width(label)-2) + row := label + if strings.TrimSpace(label) != "" { + row += " " + } + row += lipgloss.NewStyle(). + Width(valueWidth). + Background(lipgloss.Color("#112235")). + Render(value) + return debugRowStyle.Width(width).Render(row) +} + +func debugPanelWidth() int { + if columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && columns > 0 { + return max(40, columns) + } + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return max(40, width) + } + return 100 +} + +func debugContentWidth() int { + return max(20, debugPanelWidth()-4) +} + +func debugDivider() string { + return debugSectionDividerStyle.Width(debugContentWidth()).Render(strings.Repeat("─", debugContentWidth())) +} + +func renderStatus(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "ok": + return debugStatusOKStyle.Render("OK") + case "warning": + return debugStatusWarningStyle.Render("WARNING") + case "failed": + return debugStatusFailedStyle.Render("FAILED") + default: + return debugMutedStyle.Render(strings.ToUpper(strings.TrimSpace(state))) + } +} + +func humanBytes(size int64) string { + const unit = 1024 + if size < unit { + return fmt.Sprintf("%dB", size) + } + div, exp := int64(unit), 0 + for n := size / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f%ciB", float64(size)/float64(div), "KMGTPE"[exp]) +} + +func trimContainerName(names []string) string { + for _, name := range names { + trimmed := strings.TrimPrefix(strings.TrimSpace(name), "/") + if trimmed != "" { + return trimmed + } + } + return "" +} + +func renderPlainDebugReport(value string) string { + lines := strings.Split(ansiPattern.ReplaceAllString(value, ""), "\n") + for i := range lines { + lines[i] = strings.TrimRight(lines[i], " \t") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func appendHint(current, next string) string { + current = strings.TrimSpace(current) + next = strings.TrimSpace(next) + switch { + case current == "": + return next + case next == "": + return current + default: + return current + "; " + next + } +} diff --git a/cmd/debug_test.go b/cmd/debug_test.go new file mode 100644 index 0000000..9822f69 --- /dev/null +++ b/cmd/debug_test.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestEvaluateLogConfigDetectsUnboundedJSONFileLogs(t *testing.T) { + rotated, external, hint := evaluateLogConfig("json-file", map[string]string{}) + if rotated { + t.Fatal("expected json-file without limits not to be rotated") + } + if external { + t.Fatal("expected json-file without limits not to be external") + } + if !strings.Contains(hint, "max-size") { + t.Fatalf("expected rotation hint, got %q", hint) + } +} + +func TestLogSummaryRowsIncludeRecommendationWhenLogsNeedAttention(t *testing.T) { + rows := logSummaryRows(logDiagnostics{ + KnownSize: true, + TotalBytes: 25 * 1024 * 1024, + UnboundedCount: 1, + Containers: []containerLogDiagnostics{ + {Service: "drupal", Driver: "json-file", SizeBytes: 25 * 1024 * 1024, HasSize: true, Rotated: false, RotationHint: "file-backed logs are not capped; set max-size and max-file"}, + }, + }) + + rendered := formatDebugRows(rows) + if !strings.Contains(rendered, "Total logs") || !strings.Contains(rendered, "25.0MiB") { + t.Fatalf("expected total log size, got:\n%s", rendered) + } + if !strings.Contains(rendered, "Recommendation") { + t.Fatalf("expected recommendation guidance, got:\n%s", rendered) + } +} + +func TestRenderLogDetailsBodyIncludesPerContainerRows(t *testing.T) { + rendered := renderLogDetailsBody(logDiagnostics{ + KnownSize: true, + TotalBytes: 25 * 1024 * 1024, + UnboundedCount: 1, + Containers: []containerLogDiagnostics{ + {Service: "drupal", Driver: "json-file", SizeBytes: 25 * 1024 * 1024, HasSize: true, Rotated: false, RotationHint: "file-backed logs are not capped; set max-size and max-file"}, + }, + }) + + if !strings.Contains(rendered, "drupal: driver=json-file, size=25.0MiB, not rotated") { + t.Fatalf("expected per-container detail, got:\n%s", rendered) + } +} + +func TestLogSummaryRowsStayCompact(t *testing.T) { + rows := logSummaryRows(logDiagnostics{ + KnownSize: true, + TotalBytes: 25 * 1024 * 1024, + UnboundedCount: 1, + Containers: []containerLogDiagnostics{ + {Service: "drupal", Driver: "json-file", SizeBytes: 25 * 1024 * 1024, HasSize: true, Rotated: false}, + }, + }) + + rendered := formatDebugRows(rows) + if strings.Contains(rendered, "drupal: driver=") { + t.Fatalf("expected compact output without per-container detail, got:\n%s", rendered) + } + if !strings.Contains(rendered, "Log handling") || !strings.Contains(rendered, "1 container(s) are using unbounded file-backed logs") { + t.Fatalf("expected compact log handling line, got:\n%s", rendered) + } +} + +func TestImageSummaryRowsWarnWhenThresholdExceeded(t *testing.T) { + rendered := formatDebugRows(imageSummaryRows(imageDiagnostics{ + TotalBytes: imageSizeWarningThreshold + 1, + ImageCount: 42, + })) + + if !strings.Contains(rendered, "docker system prune -af") { + t.Fatalf("expected prune recommendation, got:\n%s", rendered) + } + if !strings.Contains(rendered, dockerPruneDocsURL) { + t.Fatalf("expected prune docs link, got:\n%s", rendered) + } +} diff --git a/docs.json b/docs.json deleted file mode 100644 index ef3608e..0000000 --- a/docs.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "https://mintlify.com/docs.json", - "theme": "mint", - "name": "sitectl", - "colors": { - "primary": "#16A34A", - "light": "#07C983", - "dark": "#15803D" - }, - "favicon": "/favicon.ico", - "navigation": { - "tabs": [ - { - "tab": "Home", - "groups": [ - { - "group": "Getting started", - "pages": [ - "index", - "docs/install", - "docs/quickstart" - ] - }, - { - "group": "Concepts", - "pages": [ - "docs/tui", - "docs/context", - "docs/plugins", - "docs/components" - ] - }, - { - "group": "Usage", - "pages": [ - "docs/commands/compose" - ] - }, - { - "group": "Plugins", - "pages": [ - "docs/plugins/drupal", - "docs/plugins/isle" - ] - }, - { - "group": "Contributors", - "pages": [ - "docs/contributors", - "docs/contributors/docs" - ] - } - ] - }, - { - "tab": "Drupal plugin", - "groups": [ - { - "group": "Commands", - "pages": [ - "docs/plugins/drupal/drush" - ] - } - ] - }, - { - "tab": "ISLE plugin", - "groups": [ - { - "group": "Commands", - "pages": [ - "docs/plugins/isle/index" - ] - }, - { - "group": "Components", - "pages": [ - "docs/plugins/isle/fcrepo", - "docs/plugins/isle/blazegraph" - ] - } - ] - } - ] - }, - "logo": { - "light": "/docs/logo/libops-light.png", - "dark": "/docs/logo/libops-dark.png" - }, - "contextual": { - "options": [ - "copy" - ] - }, - "footer": { - "socials": { - "github": "https://github.com/libops/sitectl" - } - } -} diff --git a/docs/commands/compose.mdx b/docs/commands/compose.mdx deleted file mode 100644 index e275719..0000000 --- a/docs/commands/compose.mdx +++ /dev/null @@ -1,20 +0,0 @@ ---- -title: Compose command -description: Run Docker Compose commands through the active sitectl context. ---- - -import { Compose } from "/docs/snippets/compose-tooltip.mdx"; - -# command - -The `compose` command runs commands against the active `sitectl` context. - -Use it when you want the same operation to respect the site and environment wiring already stored in the context. - -Examples: - -```bash -sitectl compose ps -sitectl compose logs -sitectl compose exec app bash -``` diff --git a/docs/components.mdx b/docs/components.mdx deleted file mode 100644 index 0618b76..0000000 --- a/docs/components.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Components -description: Understand how sitectl uses components to express reviewed stack defaults and operator tradeoffs. ---- - -# Components - -Components describe optional parts of a stack in a structured way. - -They are how `sitectl` marries infrastructure settings and app-specific settings into one reviewed configuration. - -Instead of treating a stack as one large blob, `sitectl` lets each component carry its own defaults, follow-up questions, and operator guidance. - -Common component states: - -- `enabled` -- `disabled` -- `superseded` -- `distributed` diff --git a/docs/concepts/contributing.mdx b/docs/concepts/contributing.mdx new file mode 100644 index 0000000..c092201 --- /dev/null +++ b/docs/concepts/contributing.mdx @@ -0,0 +1,64 @@ +--- +title: Contributing +description: Local development workflow for sitectl core and plugins. +--- + +## Plugin Development + +`sitectl` has a core binary and optional plugin binaries named `sitectl-`. + +The current local development plugin chain in this workspace is: + +- `sitectl` +- `sitectl-isle` +- `sitectl-drupal` + +Core `sitectl` owns the top-level operator-facing command shape. Plugins extend that behavior through hidden extension commands that are dispatched based on the active context plugin. + +Examples: + +- `sitectl debug` +- `sitectl component describe` +- `sitectl component set isle/fcrepo` + +When a context belongs to `isle`, core `sitectl` routes the request to `sitectl-isle`. The ISLE plugin can then extend the result further by invoking included plugins such as `sitectl-drupal`. + +## Local Install Workflow + +For local development, install the binaries into `/usr/local/bin` so the core binary can discover and invoke the plugin binaries through `PATH`. + +Run this from the core repo: + +```bash +make install +``` + +That target will: + +1. Build and install `sitectl` +2. Change into `../sitectl-isle` and run `make install` +3. Change into `../sitectl-drupal` and run `make install` + +The plugin `install` targets run `make work` before building so they use the local core `sitectl` checkout during development. + +If you are only working on a plugin, you can also install it directly: + +```bash +cd ../sitectl-isle +make install +``` + +```bash +cd ../sitectl-drupal +make install +``` + +## Why Use `make install` + +This matters for plugin chaining. + +If you only rebuild `sitectl` locally but do not install the plugin binaries into a directory on `PATH`, core command dispatch will not see the current local plugin builds. Installing all three binaries keeps the whole stack aligned while you work on: + +- core command routing in `sitectl` +- stack logic in `sitectl-isle` +- Drupal-specific extensions in `sitectl-drupal` diff --git a/docs/context.mdx b/docs/context.mdx deleted file mode 100644 index e4099a2..0000000 --- a/docs/context.mdx +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Context -description: How sitectl models sites, environments, and the saved context that connects to each one. ---- - -import { Compose } from "/docs/snippets/compose-tooltip.mdx"; - -`sitectl` organizes around a **site** and its **environments** into what it calls a context. - -- A **site** is the project itself. -- An **environment** is where that site runs: `local`, `staging`, `prod`, and so on. -- A **context** is the saved connection information for the given site environment. - -Examples: - -- `museum-local` -- `museum-prod` - -Contexts tell `sitectl` where lives and how to reach it. diff --git a/docs/contributors/docs.mdx b/docs/contributors/docs.mdx deleted file mode 100644 index c42d09b..0000000 --- a/docs/contributors/docs.mdx +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Documentation -description: How to edit, preview, and organize the Mintlify documentation in the sitectl repository. ---- - -import { TUI } from "/docs/snippets/tui-tooltip.mdx"; - -The docs site is driven by Mintlify from this repository. - -Mintlify's docs are available at https://www.mintlify.com/docs - -Mintlify reads: - -- [docs.json](https://github.com/libops/sitectl/blob/main/docs.json) for navigation and site settings -- [index.mdx](https://github.com/libops/sitectl/blob/main/index.mdx) for the homepage -- files under [`docs/`](https://github.com/libops/sitectl/tree/main/docs) for the rest of the documentation - -## Local development - -Mintlify’s local dev startup command is `mint dev`, but Mintlify does not support Node 25+. This repo avoids that issue by running Mintlify in a Node 22 Docker container: - -```bash -make docs -``` - -The docs server binds to port `3000` by default. You can override it with: - -```bash -make docs DOCS_PORT=3333 -``` - -If you already have a supported LTS Node version installed locally, you can also run the host-native command: - -```bash -make docs-host -``` - -## Making docs changes - -When you update the docs: - -- keep pages in `docs/` unless the page is the root homepage, which lives at `index.mdx` -- preserve the existing nav structure in `docs.json` and only add to it when needed -- use MDX for new pages -- keep contributor and operator guidance in the docs site instead of reviving `CONTRIBUTING.md` - -## content - -The tour reads: - -- `internal/tuitour/content/index.md` -- `internal/tuitour/content/tui.md` -- `internal/tuitour/content/context.md` -- `internal/tuitour/content/plugins.md` -- `internal/tuitour/content/components.md` - -That content is intentionally separate from the Mintlify docs so the in-app tour can stay plain markdown and keep its own presentation. - -## Deployment - -Docs deploy through the Mintlify GitHub App connected to this repository. There is no GitHub Pages workflow to maintain here. diff --git a/docs/contributors/index.mdx b/docs/contributors/index.mdx deleted file mode 100644 index 06266d3..0000000 --- a/docs/contributors/index.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: Contributing -description: Contributor guidance for sitectl, including UI architecture rules and release publishing expectations. ---- - -import { TUI } from "/docs/snippets/tui-tooltip.mdx"; - -# Contributing - -## UI Architecture - -`sitectl` supports two interaction modes: - -- one-off command execution such as `sitectl compose ps` -- an embedded dashboard launched by running `sitectl` with no additional arguments - -Because both modes need to share behavior, interactive command UIs must be designed as composable Bubble Tea models instead of bespoke terminal flows. - -## Rule - -When a command needs interactive UI: - -- keep business logic separate from UI state and rendering -- make the UI self-contained inside the command or shared UI package -- ensure the same UI can run standalone or be embedded inside the dashboard - -In practice, command implementations should follow this split: - -- service layer: pure command logic and side effects -- UI layer: Bubble Tea model and Bubbles-based components -- Cobra layer: chooses between non-interactive execution and launching the UI - -## Required Libraries - -Interactive `sitectl` UIs should build on the shared stack already in use: - -- `bubbletea` for state, events, and screen management -- `bubbles` for list, help, input, viewport, progress, and similar primitives -- `lipgloss` for styling and layout -- `bubblezone` for click targets and mouse hit detection where needed -- `harmonica` for motion and transitions where appropriate -- `ntcharts` for terminal charts where appropriate - -## What Not To Do - -Do not implement custom terminal widgets when the library stack already provides them. - -Examples: - -- do not hand-roll a select menu when `bubbles/list` fits -- do not hand-roll a text input when `bubbles/textinput` or `textarea` fits -- do not hand-roll help footers when `bubbles/help` fits -- do not hand-roll scroll containers when `bubbles/viewport` fits - -`lipgloss` should be used for presentation and composition, not as a replacement for Bubble Tea or Bubbles interaction primitives. - -## Shared Components - -Reusable interaction primitives should live in shared UI packages so commands and the dashboard can both consume them. - -Current direction: - -- shared prompt, select, and input components belong in `pkg/ui` -- command-specific interactive screens can live near the command, but should still be Bubble Tea models -- older bespoke prompt implementations should be migrated to shared Bubble Tea and Bubbles components over time - -## Design Goal - -A command that has an interactive flow should be embeddable in the dashboard without rewriting its UI logic. - -That means a command UI should be structured so it can be: - -- launched directly from Cobra -- pushed or mounted inside the dashboard - -If a proposed command UI cannot be reused that way, it should be redesigned before being added. - -## Release Publishing - -GoReleaser builds the release artifacts, including Linux packages via `nfpms`. - -- GitHub release publishing runs from [goreleaser.yaml](https://github.com/libops/sitectl/blob/main/.github/workflows/goreleaser.yaml) - -### Linux package publishing - -This repo also publishes Debian and RPM repositories through the shared libops packaging infrastructure. - -GitHub Actions workflow: - -- [publish-apt-repo.yaml](https://github.com/libops/sitectl/blob/main/.github/workflows/publish-apt-repo.yaml) -- shared publishing script: [publish-package-repo.sh](https://github.com/libops/terraform-linux-packages/blob/main/scripts/publish-package-repo.sh) - -Required GitHub secrets: - -- `HOMEBREW_REPO`: token used by GoReleaser for release publishing - -Required GitHub variables: - -- `LIBOPS_PACKAGES_GCLOUD_OIDC_POOL`: Workload Identity provider resource name -- `LIBOPS_PACKAGES_GCLOUD_PROJECT`: Google Cloud project ID that holds the package infrastructure -- `LIBOPS_PACKAGES_GSA`: Google service account email used by GitHub Actions -- `LIBOPS_PACKAGES_GCS_BUCKET`: bucket name that hosts the published package repository -- `LIBOPS_PACKAGES_APTLY_GPG_KEY_ID`: GPG key ID or fingerprint to use for signing -- `LIBOPS_PACKAGES_APTLY_GPG_PRIVATE_KEY_SECRET`: Secret Manager secret ID that stores the armored private key -- `LIBOPS_PACKAGES_APTLY_GPG_PASSPHRASE_SECRET`: Secret Manager secret ID that stores the signing key passphrase - -Optional GitHub variables: - -- `GCS_BUCKET_PREFIX` default: empty -- `APTLY_DISTRIBUTIONS` default: `bookworm` -- `APTLY_COMPONENT` default: `main` -- `APTLY_ARCHITECTURES` default: `amd64,arm64` -- `APTLY_PUBLISH_PREFIX` default: `.` -- `APTLY_ORIGIN` default: `libops` -- `APTLY_LABEL` default: `sitectl` -- `APTLY_PUBLIC_KEY_NAME` default: `sitectl-archive-keyring` -- `RPM_REPOSITORY_PATH` default: `rpm` - -The workflow rebuilds Debian and RPM repository metadata from the current release artifacts only. That is enough for fresh installs and upgrades, but it does not preserve older package versions for pinning or rollback. diff --git a/docs/install.mdx b/docs/install.mdx deleted file mode 100644 index d47d5ca..0000000 --- a/docs/install.mdx +++ /dev/null @@ -1,294 +0,0 @@ ---- -title: Install and Upgrade -description: Install or upgrade sitectl with Homebrew, native Linux packages, or direct binaries. ---- - -Select a tab below for the core `sitectl` command or one of its plugins to see install and upgrade instructions. -Installing a `sitectl` plugin automatically will install `sitectl` on your machine if using homebrew or linux packages to install. - - - - -## Homebrew - -You can install `sitectl` using Homebrew: - -```bash -brew tap libops/homebrew https://github.com/libops/homebrew -brew install libops/homebrew/sitectl -``` - -To upgrade later: - -```bash -brew update -brew upgrade libops/homebrew/sitectl -``` - -## Linux Packages - -Releases publish native Linux packages through the libops package repository. - - - -```bash Debian / Ubuntu -curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg -echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" | sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null -sudo apt update -sudo apt install sitectl -``` - -```bash Fedora / Rocky / RHEL -sudo tee /etc/yum.repos.d/sitectl.repo >/dev/null <<'EOF' -[sitectl] -name=sitectl -baseurl=https://packages.libops.io/sitectl/rpm -enabled=1 -gpgcheck=0 -repo_gpgcheck=1 -gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc -EOF - -sudo dnf makecache -sudo dnf install sitectl -``` - - - -To upgrade later: - - - -```bash Debian / Ubuntu -sudo apt update -sudo apt install --only-upgrade sitectl -``` - -```bash Fedora / Rocky / RHEL -sudo dnf upgrade sitectl -``` - - - -## Binary Install - -You can install `sitectl` by either downloading or building the `sitectl` binary. - - - - -You can download a binary for your system from [the latest release of sitectl](https://github.com/libops/sitectl/releases/latest). - - - - -Requires `go` and `make` - -```bash -git clone https://github.com/libops/sitectl -cd sitectl -make build -./sitectl --help -``` - - - -To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. - -Once `sitectl` is on your system, put the binary in a directory that is in your `$PATH`. - - - - -## Homebrew - -You can install `sitectl-drupal` using Homebrew: - -```bash -brew tap libops/homebrew https://github.com/libops/homebrew -brew install libops/homebrew/sitectl-drupal -``` - -Homebrew will also install `sitectl` as a dependency. - -To upgrade later: - -```bash -brew update -brew upgrade libops/homebrew/sitectl-drupal -``` - -## Linux Packages - -Releases publish native Linux packages through the libops package repository. - - - -```bash Debian / Ubuntu -curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg -echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" | sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null -sudo apt update -sudo apt install sitectl-drupal -``` - -```bash Fedora / Rocky / RHEL -sudo tee /etc/yum.repos.d/sitectl.repo >/dev/null <<'EOF' -[sitectl] -name=sitectl -baseurl=https://packages.libops.io/sitectl/rpm -enabled=1 -gpgcheck=0 -repo_gpgcheck=1 -gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc -EOF - -sudo dnf makecache -sudo dnf install sitectl-drupal -``` - - - -The package manager will also install `sitectl` as a dependency. - -To upgrade later: - - - -```bash Debian / Ubuntu -sudo apt update -sudo apt install --only-upgrade sitectl-drupal -``` - -```bash Fedora / Rocky / RHEL -sudo dnf upgrade sitectl-drupal -``` - - - -## Binary Install - -You can install `sitectl-drupal` by either downloading or building the `sitectl-drupal` binary. - -You also need the base `sitectl` binary on your system. - - - - -You can download binaries for your system from [the latest release of sitectl](https://github.com/libops/sitectl/releases/latest) and [the latest release of sitectl-drupal](https://github.com/libops/sitectl-drupal/releases/latest). - - - - -Requires `go` and `make` - -```bash -git clone https://github.com/libops/sitectl-drupal -cd sitectl-drupal -make build -./sitectl-drupal --help -``` - - - -To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. - -Once `sitectl-drupal` is on your system, put the binary in a directory that is in your `$PATH`. - - - - -## Homebrew - -You can install `sitectl-isle` using Homebrew: - -```bash -brew tap libops/homebrew https://github.com/libops/homebrew -brew install libops/homebrew/sitectl-isle -``` - -Homebrew will also install `sitectl` and `sitectl-drupal` as dependencies. - -To upgrade later: - -```bash -brew update -brew upgrade libops/homebrew/sitectl-isle -``` - -## Linux Packages - -Releases publish native Linux packages through the libops package repository. - - - -```bash Debian / Ubuntu -curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg -echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" | sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null -sudo apt update -sudo apt install sitectl-isle -``` - -```bash Fedora / Rocky / RHEL -sudo tee /etc/yum.repos.d/sitectl.repo >/dev/null <<'EOF' -[sitectl] -name=sitectl -baseurl=https://packages.libops.io/sitectl/rpm -enabled=1 -gpgcheck=0 -repo_gpgcheck=1 -gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc -EOF - -sudo dnf makecache -sudo dnf install sitectl-isle -``` - - - -The package manager will also install `sitectl` and `sitectl-drupal` as dependencies. - -To upgrade later: - - - -```bash Debian / Ubuntu -sudo apt update -sudo apt install --only-upgrade sitectl-isle -``` - -```bash Fedora / Rocky / RHEL -sudo dnf upgrade sitectl-isle -``` - - - -## Binary Install - -You can install `sitectl-isle` by either downloading or building the `sitectl-isle` binary. - -You also need the base `sitectl` binary on your system. - - - - -You can download binaries for your system from [the latest release of sitectl](https://github.com/libops/sitectl/releases/latest) and [the latest release of sitectl-isle](https://github.com/libops/sitectl-isle/releases/latest). - - - - -Requires `go` and `make` - -```bash -git clone https://github.com/libops/sitectl-isle -cd sitectl-isle -make build -./sitectl-isle --help -``` - - - -To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. - -Once `sitectl-isle` is on your system, put the binary in a directory that is in your `$PATH`. - - diff --git a/docs/logo/dark.svg b/docs/logo/dark.svg deleted file mode 100644 index 8b343cd..0000000 --- a/docs/logo/dark.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/logo/libops-dark.png b/docs/logo/libops-dark.png deleted file mode 100644 index e3fbb46..0000000 Binary files a/docs/logo/libops-dark.png and /dev/null differ diff --git a/docs/logo/libops-light.png b/docs/logo/libops-light.png deleted file mode 100644 index 1a2b14f..0000000 Binary files a/docs/logo/libops-light.png and /dev/null differ diff --git a/docs/logo/libops-vertical.png b/docs/logo/libops-vertical.png deleted file mode 100644 index 2dbed42..0000000 Binary files a/docs/logo/libops-vertical.png and /dev/null differ diff --git a/docs/logo/light.svg b/docs/logo/light.svg deleted file mode 100644 index 03e62bf..0000000 --- a/docs/logo/light.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/plugins.mdx b/docs/plugins.mdx deleted file mode 100644 index 7cbaa6f..0000000 --- a/docs/plugins.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Plugins -description: How sitectl plugins extend the core CLI with stack-specific workflows. ---- - -Plugins extend `sitectl` with stack-specific commands and create flows. - -Examples: - -- `drupal` adds Drupal-oriented utilities -- `isle` adds Islandora and ISLE workflows - -The core binary discovers installed plugins and exposes them as `sitectl ...`. diff --git a/docs/plugins/drupal.mdx b/docs/plugins/drupal.mdx deleted file mode 100644 index a8bfdc4..0000000 --- a/docs/plugins/drupal.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Drupal plugin -description: Drupal-oriented sitectl workflows and command surface. ---- - -# Drupal plugin - -The Drupal plugin extends `sitectl` with Drupal-oriented workflows and commands. - -Use it when you want Drupal-specific operations layered on top of the core context and compose model. - -See also: - -- [Drush command](/docs/plugins/drupal/drush) diff --git a/docs/plugins/drupal/drush.mdx b/docs/plugins/drupal/drush.mdx deleted file mode 100644 index 8cb4ae4..0000000 --- a/docs/plugins/drupal/drush.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Drush -description: Drupal plugin command reference entry point for Drush-oriented workflows. ---- - -# Drush - -The Drupal plugin exposes Drush-oriented workflows through `sitectl`. - -This page is the landing page for Drupal command documentation and can be expanded as the plugin surface grows. diff --git a/docs/plugins/isle.mdx b/docs/plugins/isle.mdx deleted file mode 100644 index 49c2082..0000000 --- a/docs/plugins/isle.mdx +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: ISLE plugin -description: Islandora and ISLE-oriented sitectl workflows and component guides. ---- - -# ISLE plugin - -The ISLE plugin extends `sitectl` with Islandora and ISLE-specific workflows. - -See also: - -- [ISLE overview](/docs/plugins/isle/index) -- [Fcrepo component](/docs/plugins/isle/fcrepo) -- [Blazegraph component](/docs/plugins/isle/blazegraph) diff --git a/docs/plugins/isle/blazegraph.mdx b/docs/plugins/isle/blazegraph.mdx deleted file mode 100644 index 2dc08fd..0000000 --- a/docs/plugins/isle/blazegraph.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Blazegraph -description: Reference anchor for the Blazegraph component in ISLE-oriented sitectl stacks. ---- - -# Blazegraph - -Blazegraph is another core ISLE service concept. - -This page exists as the anchor for ISLE component documentation and can be expanded with deployment and operator guidance. diff --git a/docs/plugins/isle/fcrepo.mdx b/docs/plugins/isle/fcrepo.mdx deleted file mode 100644 index c62faf6..0000000 --- a/docs/plugins/isle/fcrepo.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: Fcrepo -description: Reference anchor for the Fcrepo component in ISLE-oriented sitectl stacks. ---- - -# Fcrepo - -Fcrepo is one of the core service concepts that appears in ISLE-oriented stacks. - -This page exists as the anchor for ISLE component documentation and can be expanded with stack-specific operator guidance. diff --git a/docs/plugins/isle/index.mdx b/docs/plugins/isle/index.mdx deleted file mode 100644 index fc9c6b1..0000000 --- a/docs/plugins/isle/index.mdx +++ /dev/null @@ -1,10 +0,0 @@ ---- -title: ISLE plugin overview -description: Overview of the sitectl ISLE plugin and where it fits in Islandora-oriented stacks. ---- - -# ISLE plugin overview - -The ISLE plugin adds Islandora-specific behavior on top of the core `sitectl` workflow. - -It is intended for sites and stacks that need Islandora-aware create flows and component guidance. diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx deleted file mode 100644 index 73f1036..0000000 --- a/docs/quickstart.mdx +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: Quickstart -description: Learn the sitectl command shape and the core areas to explore after installation. ---- - -import { Compose } from "/docs/snippets/compose-tooltip.mdx"; -import { TUI } from "/docs/snippets/tui-tooltip.mdx"; - -## Setup - -After [installing `sitectl`](/docs/install), start by running: - -```bash -sitectl -``` - -Running `sitectl` with no arguments opens the , where you can: - - - - Connect sitectl to a project you already run locally or remotely. - - - Use the guided flow to start a new site with the available stack-specific tooling. - - - -## Monitoring - -After you setup or install a site, you can use sitectl to monitor the health of the docker compose project. - -Information like general container health, host stats, and logs will all be available in the sitectl dashboard. - -## Operations - -After you've configured your site(s) and environment(s) as sitectl contexts, you can begin using sitectl to help with site operations. - -You can also run individual commands directly for one-off tasks when you do not need the full flow. - -The main areas to learn from there are: - - - - Describe a site and its environments so sitectl knows how to connect and operate. - - - Run commands through sitectl against local and remote contexts. - - - Add stack-specific behavior for the technologies your sites use. - - - Work with reviewed operator defaults for optional parts of a stack. - - - -## Basic command shape - -```bash -sitectl [subcommand] [flags] -``` - -Common top-level commands: - - - - Manage your sitectl contexts. - - - Run `docker compose` commands on any `sitectl` context - - - Use SSH port forwarding for one of your remote services to your local machine. - - - For macOS open a connection to a mariadb/mysql database using SequelAce - - diff --git a/docs/snippets/compose-tooltip.mdx b/docs/snippets/compose-tooltip.mdx deleted file mode 100644 index e4d4dc9..0000000 --- a/docs/snippets/compose-tooltip.mdx +++ /dev/null @@ -1,17 +0,0 @@ -export const Compose = () => ( - - Docker Compose is Docker's tool for defining and running multi-container applications.{" "} - https://docs.docker.com/compose/. - - } - > - <> - - {" "} - Compose - - -); diff --git a/docs/snippets/tui-tooltip.mdx b/docs/snippets/tui-tooltip.mdx deleted file mode 100644 index 1ff85b8..0000000 --- a/docs/snippets/tui-tooltip.mdx +++ /dev/null @@ -1,8 +0,0 @@ -export const TUI = () => ( - - TUI - -); diff --git a/docs/tui.mdx b/docs/tui.mdx deleted file mode 100644 index f394ad8..0000000 --- a/docs/tui.mdx +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Terminal UI -description: The interactive terminal interface for moving between sites, environments, and operator actions. ---- - -import { TUI } from "/docs/snippets/tui-tooltip.mdx"; - -The `sitectl` is the main interface for managing a site and its environments. - -It gives you one place to: - -- inspect environments -- run commands against a specific environment -- install new sites -- move between local and remote environments for the same site diff --git a/index.mdx b/index.mdx index 4992d5d..0dc52d2 100644 --- a/index.mdx +++ b/index.mdx @@ -38,6 +38,10 @@ By making the operation of the Docker containers needed to run an application we +## Development + +See the [contributing guide](/docs/concepts/contributing.mdx) for the local core/plugin development workflow, including the chained `make install` target used during plugin development. + ## Why not just use Docker Contexts? While [Docker's native context feature](https://docs.docker.com/engine/manage-resources/contexts/) handles basic docker daemon connections, `sitectl` is purpose-built for projects and adds: diff --git a/pkg/plugin/debugui/debugui.go b/pkg/plugin/debugui/debugui.go new file mode 100644 index 0000000..4f7cc36 --- /dev/null +++ b/pkg/plugin/debugui/debugui.go @@ -0,0 +1,130 @@ +package debugui + +import ( + "fmt" + "os" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "golang.org/x/term" +) + +type Row struct { + Label string + Value string +} + +var ( + panelStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#112235")). + Padding(1, 2) + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#98C1D9")) + mutedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#9FB3C8")) + sectionDividerStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#29425E")) + statusOKStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7BD389")) + statusWarningStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F4C95D")) + statusFailedStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#F28482")) + rowStyle = lipgloss.NewStyle(). + Background(lipgloss.Color("#112235")) +) + +func RenderPanel(title, body string) string { + header := Title(title) + content := header + if strings.TrimSpace(body) != "" { + content += "\n\n" + body + } + return panelStyle.Width(PanelWidth()).Render(content) +} + +func FormatRows(rows []Row) string { + labelWidth := 0 + for _, row := range rows { + if width := len(strings.TrimSpace(row.Label)); width > labelWidth { + labelWidth = width + } + } + + lines := make([]string, 0, len(rows)) + rowWidth := ContentWidth() + for _, row := range rows { + label := strings.TrimSpace(row.Label) + value := strings.TrimSpace(row.Value) + if label == "" { + lines = append(lines, renderRow(rowWidth, "", value)) + continue + } + lines = append(lines, renderRow(rowWidth, fmt.Sprintf("%-*s", labelWidth, label), value)) + } + return strings.Join(lines, "\n") +} + +func Title(text string) string { + return titleStyle.Render(strings.TrimSpace(text)) +} + +func Muted(text string) string { + return mutedStyle.Render(text) +} + +func Divider() string { + return sectionDividerStyle.Width(ContentWidth()).Render(strings.Repeat("─", ContentWidth())) +} + +func Status(state string) string { + switch strings.ToLower(strings.TrimSpace(state)) { + case "ok": + return statusOKStyle.Render("OK") + case "warning": + return statusWarningStyle.Render("WARNING") + case "failed": + return statusFailedStyle.Render("FAILED") + default: + return mutedStyle.Render(strings.ToUpper(strings.TrimSpace(state))) + } +} + +func PanelWidth() int { + if columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && columns > 0 { + return max(40, columns) + } + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return max(40, width) + } + return 100 +} + +func ContentWidth() int { + return max(20, PanelWidth()-4) +} + +func renderRow(width int, label, value string) string { + valueWidth := max(0, width-lipgloss.Width(label)-2) + row := label + if strings.TrimSpace(label) != "" { + row += " " + } + row += lipgloss.NewStyle(). + Width(valueWidth). + Background(lipgloss.Color("#112235")). + Render(value) + return rowStyle.Width(width).Render(row) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/plugin/discovery.go b/pkg/plugin/discovery.go index 46abdb1..4a3907c 100644 --- a/pkg/plugin/discovery.go +++ b/pkg/plugin/discovery.go @@ -18,6 +18,7 @@ type InstalledPlugin struct { Author string TemplateRepo string CanCreate bool + Includes []string } var builtinTemplateRepos = map[string]string{ @@ -61,6 +62,15 @@ func DiscoverInstalledFromPath(pathEnv string) []InstalledPlugin { return discovered } +func FindInstalled(name string) (InstalledPlugin, bool) { + for _, discovered := range DiscoverInstalled() { + if discovered.Name == name { + return discovered, true + } + } + return InstalledPlugin{}, false +} + func inspectInstalledPlugin(pluginName, binaryName, pluginPath string) InstalledPlugin { info := InstalledPlugin{ Name: pluginName, @@ -139,6 +149,17 @@ func ParsePluginInfoOutput(output string) InstalledPlugin { info.Author = value case "template-repo": info.TemplateRepo = value + case "includes": + if value == "" { + continue + } + for _, include := range strings.Split(value, ",") { + include = strings.TrimSpace(include) + if include == "" { + continue + } + info.Includes = append(info.Includes, include) + } } } diff --git a/pkg/plugin/discovery_test.go b/pkg/plugin/discovery_test.go index b604e91..4e9b2d6 100644 --- a/pkg/plugin/discovery_test.go +++ b/pkg/plugin/discovery_test.go @@ -11,6 +11,7 @@ Version: 1.2.3 Description: Islandora support Author: LibOps Template-Repo: https://github.com/islandora-devops/isle-site-template +Includes: drupal,libops ` info := ParsePluginInfoOutput(output) @@ -23,6 +24,9 @@ Template-Repo: https://github.com/islandora-devops/isle-site-template if info.Description != "Islandora support" { t.Fatalf("expected description to be parsed, got %q", info.Description) } + if len(info.Includes) != 2 || info.Includes[0] != "drupal" || info.Includes[1] != "libops" { + t.Fatalf("expected includes to be parsed, got %v", info.Includes) + } } func TestDiscoverInstalledFromPathFallsBackToBuiltinTemplateRepo(t *testing.T) { diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index ca05aac..108f4b1 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -1,10 +1,14 @@ package plugin import ( + "bytes" "context" "fmt" + "io" "log/slog" "os" + "os/exec" + "strconv" "strings" "charm.land/fang/v2" @@ -14,6 +18,7 @@ import ( "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/validate" "github.com/spf13/cobra" + "golang.org/x/term" ) // Metadata contains information about a plugin @@ -23,6 +28,7 @@ type Metadata struct { Description string Author string TemplateRepo string + Includes []string } var builtinPluginIncludes = map[string][]string{ @@ -158,6 +164,9 @@ func (s *SDK) GetMetadataCommand() *cobra.Command { if s.Metadata.TemplateRepo != "" { fmt.Printf("Template-Repo: %s\n", s.Metadata.TemplateRepo) } + if len(s.Metadata.Includes) > 0 { + fmt.Printf("Includes: %s\n", strings.Join(s.Metadata.Includes, ",")) + } }, } } @@ -220,13 +229,13 @@ func validateContextPlugin(contextPlugin, requestedPlugin string) error { if requestedPlugin == "" { return fmt.Errorf("requested plugin is empty") } - if contextPluginSupports(contextPlugin, requestedPlugin) { + if ContextPluginSupports(contextPlugin, requestedPlugin) { return nil } return fmt.Errorf("context plugin %q does not include %q", contextPlugin, requestedPlugin) } -func contextPluginSupports(contextPlugin, requestedPlugin string) bool { +func ContextPluginSupports(contextPlugin, requestedPlugin string) bool { if contextPlugin == requestedPlugin { return true } @@ -238,7 +247,7 @@ func contextPluginSupports(contextPlugin, requestedPlugin string) bool { return false } visited[plugin] = true - for _, included := range builtinPluginIncludes[plugin] { + for _, included := range pluginIncludes(plugin) { if included == requestedPlugin || walk(included) { return true } @@ -249,6 +258,31 @@ func contextPluginSupports(contextPlugin, requestedPlugin string) bool { return walk(contextPlugin) } +func pluginIncludes(plugin string) []string { + seen := map[string]bool{} + includes := make([]string, 0, len(builtinPluginIncludes[plugin])) + + for _, include := range builtinPluginIncludes[plugin] { + if include == "" || seen[include] { + continue + } + seen[include] = true + includes = append(includes, include) + } + + if installed, ok := FindInstalled(plugin); ok { + for _, include := range installed.Includes { + if include == "" || seen[include] { + continue + } + seen[include] = true + includes = append(includes, include) + } + } + + return includes +} + // GetComponentManager creates a component manager bound to the active sitectl context. func (s *SDK) GetComponentManager() (*component.Manager, error) { ctx, err := s.GetContext() @@ -301,3 +335,105 @@ func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string return cli.ExecInteractive(ctx, containerID, cmd) } + +type CommandExecOptions struct { + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Capture bool +} + +func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts CommandExecOptions) (string, error) { + installed, ok := FindInstalled(pluginName) + if !ok { + return "", fmt.Errorf("plugin %q is not installed", pluginName) + } + + invocation := make([]string, 0, len(args)+4) + if strings.TrimSpace(s.Config.Context) != "" { + invocation = append(invocation, "--context", s.Config.Context) + } + if strings.TrimSpace(s.Config.LogLevel) != "" { + invocation = append(invocation, "--log-level", s.Config.LogLevel) + } + invocation = append(invocation, args...) + + cmd := exec.Command(installed.Path, invocation...) + cmd.Env = os.Environ() + if width, ok := terminalColumns(); ok { + cmd.Env = append(cmd.Env, fmt.Sprintf("COLUMNS=%d", width)) + } + cmd.Stdin = opts.Stdin + cmd.Stderr = opts.Stderr + + if opts.Capture { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = strings.TrimSpace(stdout.String()) + } + if detail != "" { + return "", fmt.Errorf("run plugin %q: %w: %s", pluginName, err, detail) + } + return "", fmt.Errorf("run plugin %q: %w", pluginName, err) + } + return stdout.String(), nil + } + + if opts.Stdout != nil { + cmd.Stdout = opts.Stdout + } + if cmd.Stdout == nil { + cmd.Stdout = os.Stdout + } + if cmd.Stderr == nil { + cmd.Stderr = os.Stderr + } + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("run plugin %q: %w", pluginName, err) + } + return "", nil +} + +func (s *SDK) InvokeIncludedPluginCommand(pluginName string, args []string, opts CommandExecOptions) (string, error) { + allowed := false + for _, include := range s.Metadata.Includes { + if include == pluginName { + allowed = true + break + } + } + if !allowed { + return "", fmt.Errorf("plugin %q is not included by %q", pluginName, s.Metadata.Name) + } + + return s.InvokePluginCommand(pluginName, args, opts) +} + +func (s *SDK) InvokeIncludedPlugins(args []string) ([]string, error) { + outputs := make([]string, 0, len(s.Metadata.Includes)) + for _, include := range s.Metadata.Includes { + output, err := s.InvokeIncludedPluginCommand(include, args, CommandExecOptions{Capture: true}) + if err != nil { + return nil, err + } + if trimmed := strings.TrimSpace(output); trimmed != "" { + outputs = append(outputs, trimmed) + } + } + return outputs, nil +} + +func terminalColumns() (int, bool) { + if columns, err := strconv.Atoi(strings.TrimSpace(os.Getenv("COLUMNS"))); err == nil && columns > 0 { + return columns, true + } + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + return width, true + } + return 0, false +} diff --git a/pkg/plugin/sdk_test.go b/pkg/plugin/sdk_test.go index f2cb4c3..e43225a 100644 --- a/pkg/plugin/sdk_test.go +++ b/pkg/plugin/sdk_test.go @@ -61,3 +61,12 @@ func TestGetContextRejectsUnsupportedPlugin(t *testing.T) { t.Fatal("expected plugin compatibility error") } } + +func TestContextPluginSupportsBuiltinHierarchy(t *testing.T) { + if !ContextPluginSupports("isle", "drupal") { + t.Fatal("expected isle contexts to support drupal") + } + if ContextPluginSupports("drupal", "isle") { + t.Fatal("did not expect drupal contexts to support isle") + } +}