From f776b84fb4245479e29e1e111424fc1849652c99 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 21 Mar 2026 12:41:24 -0400 Subject: [PATCH 1/5] Pass context flag to the debug and component commands so plugins know which context to run against --- Makefile | 4 ++-- cmd/component.go | 33 +++++++++++++++++++-------------- cmd/component_test.go | 10 ++++++++-- cmd/debug.go | 2 +- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index ca63ed6..0273485 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BINARY_NAME=sitectl DOCS_PORT ?= 3000 -INSTALL_DIR ?= /usr/local/bin +INSTALL_DIR ?= $(or $(dir $(shell which $(BINARY_NAME) 2>/dev/null)),/usr/local/bin/) deps: go get . @@ -12,7 +12,7 @@ build: deps go build -o $(BINARY_NAME) . install: build - sudo cp $(BINARY_NAME) $(INSTALL_DIR)/$(BINARY_NAME) + 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 diff --git a/cmd/component.go b/cmd/component.go index 8958257..7577df2 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -27,12 +27,17 @@ var ( componentSetDisposition string componentSetTLSMode string componentSetYolo bool - invokePluginCommand = func(pluginName string, args []string) error { + invokePluginCommand = func(pluginName, contextName 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{ + invocation := make([]string, 0, len(args)+2) + if strings.TrimSpace(contextName) != "" { + invocation = append(invocation, "--context", contextName) + } + invocation = append(invocation, args...) + _, err := pluginSDK.InvokePluginCommand(installed.Name, invocation, plugin.CommandExecOptions{ Stdin: RootCmd.InOrStdin(), Stdout: RootCmd.OutOrStdout(), Stderr: RootCmd.ErrOrStderr(), @@ -51,7 +56,7 @@ var componentDescribeCmd = &cobra.Command{ Aliases: []string{"status"}, Short: "Describe the current component state", RunE: func(cmd *cobra.Command, args []string) error { - owner, name, err := resolveComponentOwner(cmd, componentDescribeName) + contextName, owner, name, err := resolveComponentOwner(cmd, componentDescribeName) if err != nil { return err } @@ -73,7 +78,7 @@ var componentDescribeCmd = &cobra.Command{ invocation = append(invocation, "--format", componentDescribeFormat) } - return invokePluginCommand(owner, invocation) + return invokePluginCommand(owner, contextName, invocation) }, } @@ -82,7 +87,7 @@ var componentReconcileCmd = &cobra.Command{ Aliases: []string{"review", "align"}, Short: "Review and reconcile component state", RunE: func(cmd *cobra.Command, args []string) error { - owner, name, err := resolveComponentOwner(cmd, componentReconcileName) + contextName, owner, name, err := resolveComponentOwner(cmd, componentReconcileName) if err != nil { return err } @@ -107,7 +112,7 @@ var componentReconcileCmd = &cobra.Command{ invocation = append(invocation, "--format", componentReconcileFormat) } - return invokePluginCommand(owner, invocation) + return invokePluginCommand(owner, contextName, invocation) }, } @@ -116,7 +121,7 @@ var componentSetCmd = &cobra.Command{ 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]) + contextName, owner, name, err := resolveComponentOwner(cmd, args[0]) if err != nil { return err } @@ -144,7 +149,7 @@ var componentSetCmd = &cobra.Command{ invocation = append(invocation, "--yolo") } - return invokePluginCommand(owner, invocation) + return invokePluginCommand(owner, contextName, invocation) }, } @@ -179,15 +184,15 @@ func init() { var pluginSDK *plugin.SDK -func resolveComponentOwner(cmd *cobra.Command, raw string) (string, string, error) { +func resolveComponentOwner(cmd *cobra.Command, raw string) (string, string, string, error) { contextName, err := config.ResolveCurrentContextName(cmd.Flags()) if err != nil { - return "", "", err + return "", "", "", err } ctx, err := config.GetContext(contextName) if err != nil { - return "", "", err + return "", "", "", err } owner := ctx.Plugin @@ -197,12 +202,12 @@ func resolveComponentOwner(cmd *cobra.Command, raw string) (string, string, erro name = componentName } if strings.TrimSpace(owner) == "" { - return "", "", fmt.Errorf("context %q does not define a plugin owner", ctx.Name) + 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 "", "", "", fmt.Errorf("context %q uses plugin %q; component commands require a stack plugin such as isle", ctx.Name, owner) } - return owner, name, nil + return contextName, owner, name, nil } func splitNamespacedComponent(raw string) (string, string, bool) { diff --git a/cmd/component_test.go b/cmd/component_test.go index 3840090..f2851e9 100644 --- a/cmd/component_test.go +++ b/cmd/component_test.go @@ -38,10 +38,13 @@ func TestResolveComponentOwnerUsesNamespace(t *testing.T) { t.Fatalf("Set(context) error = %v", err) } - owner, name, err := resolveComponentOwner(cmd, "drupal/modules") + contextName, owner, name, err := resolveComponentOwner(cmd, "drupal/modules") if err != nil { t.Fatalf("resolveComponentOwner() error = %v", err) } + if contextName != "museum" { + t.Fatalf("unexpected context name: %q", contextName) + } if owner != "drupal" || name != "modules" { t.Fatalf("unexpected owner/name: %q %q", owner, name) } @@ -68,10 +71,13 @@ func TestResolveComponentOwnerFallsBackToContextPlugin(t *testing.T) { t.Fatalf("Set(context) error = %v", err) } - owner, name, err := resolveComponentOwner(cmd, "fcrepo") + contextName, owner, name, err := resolveComponentOwner(cmd, "fcrepo") if err != nil { t.Fatalf("resolveComponentOwner() error = %v", err) } + if contextName != "museum" { + t.Fatalf("unexpected context name: %q", contextName) + } if owner != "isle" || name != "fcrepo" { t.Fatalf("unexpected owner/name: %q %q", owner, name) } diff --git a/cmd/debug.go b/cmd/debug.go index 9d01157..a23fdb4 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -77,7 +77,7 @@ var debugCmd = &cobra.Command{ body.WriteString(renderCoreDebug(ctx)) if pluginName := strings.TrimSpace(ctx.Plugin); pluginName != "" && pluginName != "core" { - pluginArgs := []string{"__debug"} + pluginArgs := []string{"--context", contextName, "__debug"} if debugVerbose { pluginArgs = append(pluginArgs, "--verbose") } From 6bb1073f2a583484d234820a7266b6f8e72f3aff Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 21 Mar 2026 12:49:46 -0400 Subject: [PATCH 2/5] expose an sdk for accessing local and remote files --- cmd/debug.go | 26 -------- pkg/plugin/files.go | 127 +++++++++++++++++++++++++++++++++++++++ pkg/plugin/files_test.go | 96 +++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 26 deletions(-) create mode 100644 pkg/plugin/files.go create mode 100644 pkg/plugin/files_test.go diff --git a/cmd/debug.go b/cmd/debug.go index a23fdb4..aa1ebac 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -2,11 +2,9 @@ package cmd import ( "context" - "errors" "fmt" "log/slog" "os" - "os/exec" "regexp" "sort" "strconv" @@ -378,32 +376,11 @@ func logFileSizeLocal(path string) (int64, bool, error) { 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{} @@ -437,9 +414,6 @@ func logFileSizesRemote(ctxCfg *config.Context, paths []string) (map[string]int6 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 diff --git a/pkg/plugin/files.go b/pkg/plugin/files.go new file mode 100644 index 0000000..0f19aeb --- /dev/null +++ b/pkg/plugin/files.go @@ -0,0 +1,127 @@ +package plugin + +import ( + "io" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/libops/sitectl/pkg/config" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +type FileAccessor struct { + ctx *config.Context + ssh *ssh.Client + sftp *sftp.Client +} + +func (s *SDK) GetFileAccessor() (*FileAccessor, error) { + ctx, err := s.GetContext() + if err != nil { + return nil, err + } + return NewFileAccessor(ctx) +} + +func NewFileAccessor(ctx *config.Context) (*FileAccessor, error) { + accessor := &FileAccessor{ctx: ctx} + if ctx == nil || ctx.DockerHostType == config.ContextLocal { + return accessor, nil + } + client, err := ctx.DialSSH() + if err != nil { + return nil, err + } + sftpClient, err := sftp.NewClient(client) + if err != nil { + client.Close() + return nil, err + } + accessor.ssh = client + accessor.sftp = sftpClient + return accessor, nil +} + +func (a *FileAccessor) Close() error { + if a == nil { + return nil + } + if a.sftp != nil { + _ = a.sftp.Close() + } + if a.ssh != nil { + return a.ssh.Close() + } + return nil +} + +func (a *FileAccessor) ReadFile(path string) ([]byte, error) { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { + return os.ReadFile(path) + } + file, err := a.sftp.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + return io.ReadAll(file) +} + +func (a *FileAccessor) ListFiles(root string) ([]string, error) { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { + files := []string{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rel)) + return nil + }) + return files, err + } + files := []string{} + walker := a.sftp.Walk(root) + for walker.Step() { + if err := walker.Err(); err != nil { + return nil, err + } + if walker.Stat().IsDir() { + continue + } + rel, err := filepath.Rel(root, walker.Path()) + if err != nil { + return nil, err + } + files = append(files, filepath.ToSlash(rel)) + } + return files, nil +} + +func (a *FileAccessor) MatchFiles(root, pattern string) ([]string, error) { + files, err := a.ListFiles(root) + if err != nil { + return nil, err + } + matches := []string{} + for _, rel := range files { + ok, err := filepath.Match(pattern, filepath.Base(rel)) + if err != nil { + return nil, err + } + if ok { + matches = append(matches, filepath.Join(root, rel)) + } + } + sort.Strings(matches) + return matches, nil +} diff --git a/pkg/plugin/files_test.go b/pkg/plugin/files_test.go new file mode 100644 index 0000000..d93efb3 --- /dev/null +++ b/pkg/plugin/files_test.go @@ -0,0 +1,96 @@ +package plugin + +import ( + "os" + "path/filepath" + "slices" + "testing" + + "github.com/libops/sitectl/pkg/config" +) + +func TestNewFileAccessorLocalReadListAndMatch(t *testing.T) { + root := t.TempDir() + + if err := os.MkdirAll(filepath.Join(root, "nested"), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "alpha.yml"), []byte("alpha"), 0o644); err != nil { + t.Fatalf("WriteFile(alpha) error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "nested", "beta.yml"), []byte("beta"), 0o644); err != nil { + t.Fatalf("WriteFile(beta) error = %v", err) + } + if err := os.WriteFile(filepath.Join(root, "nested", "gamma.txt"), []byte("gamma"), 0o644); err != nil { + t.Fatalf("WriteFile(gamma) error = %v", err) + } + + ctx := &config.Context{DockerHostType: config.ContextLocal} + accessor, err := NewFileAccessor(ctx) + if err != nil { + t.Fatalf("NewFileAccessor() error = %v", err) + } + defer accessor.Close() + + got, err := accessor.ReadFile(filepath.Join(root, "alpha.yml")) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(got) != "alpha" { + t.Fatalf("expected alpha content, got %q", string(got)) + } + + files, err := accessor.ListFiles(root) + if err != nil { + t.Fatalf("ListFiles() error = %v", err) + } + wantFiles := []string{"alpha.yml", "nested/beta.yml", "nested/gamma.txt"} + if !slices.Equal(files, wantFiles) { + t.Fatalf("unexpected files: got %v want %v", files, wantFiles) + } + + matches, err := accessor.MatchFiles(root, "*.yml") + if err != nil { + t.Fatalf("MatchFiles() error = %v", err) + } + wantMatches := []string{ + filepath.Join(root, "alpha.yml"), + filepath.Join(root, "nested", "beta.yml"), + } + if !slices.Equal(matches, wantMatches) { + t.Fatalf("unexpected matches: got %v want %v", matches, wantMatches) + } +} + +func TestSDKGetFileAccessorUsesResolvedContext(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + ctx := config.Context{ + Name: "museum", + Site: "museum", + Plugin: "isle", + DockerHostType: config.ContextLocal, + DockerSocket: "/var/run/docker.sock", + ProjectDir: tempHome, + } + if err := config.SaveContext(&ctx, true); err != nil { + t.Fatalf("SaveContext() error = %v", err) + } + + sdk := NewSDK(Metadata{Name: "drupal"}) + sdk.Config.Context = "museum" + + accessor, err := sdk.GetFileAccessor() + if err != nil { + t.Fatalf("GetFileAccessor() error = %v", err) + } + defer accessor.Close() + + if accessor.ctx == nil { + t.Fatal("expected accessor context to be set") + } + if accessor.ctx.Name != "museum" { + t.Fatalf("unexpected accessor context %q", accessor.ctx.Name) + } +} From f4c7f7de1990017c45277cfc2fe14a45411e3df4 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 21 Mar 2026 13:00:20 -0400 Subject: [PATCH 3/5] debug --- cmd/debug.go | 18 +++++ pkg/plugin/files.go | 154 +++++++++++++++++++++++++++++++++++++-- pkg/plugin/files_test.go | 11 +++ pkg/plugin/sdk.go | 21 ++++-- 4 files changed, 188 insertions(+), 16 deletions(-) diff --git a/cmd/debug.go b/cmd/debug.go index aa1ebac..4d1233a 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -79,10 +79,12 @@ var debugCmd = &cobra.Command{ if debugVerbose { pluginArgs = append(pluginArgs, "--verbose") } + slog.Debug("handing off debug to plugin", "context", contextName, "plugin", pluginName, "args", pluginArgs) output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Capture: true}) if err != nil { return err } + slog.Debug("plugin debug completed", "context", contextName, "plugin", pluginName) if trimmed := strings.TrimSpace(output); trimmed != "" { body.WriteString("\n\n") body.WriteString(trimmed) @@ -110,6 +112,7 @@ func init() { } func renderCoreDebug(ctx config.Context) string { + slog.Debug("starting core debug", "context", ctx.Name, "docker_host_type", ctx.DockerHostType) meta := []debugRow{ {Label: "Generated", Value: time.Now().UTC().Format(time.RFC3339)}, {Label: "Context", Value: ctx.Name}, @@ -136,25 +139,32 @@ func renderCoreDebug(ctx config.Context) string { "", formatDebugRows(meta), } + slog.Debug("collecting log diagnostics", "context", ctx.Name) if diagnostics, err := collectLogDiagnostics(&ctx); err == nil { + slog.Debug("collected log diagnostics", "context", ctx.Name, "containers", len(diagnostics.Containers), "known_size", diagnostics.KnownSize) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(diagnostics))) if debugVerbose { coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Details"), "", renderLogDetailsBody(diagnostics)) } } else { + slog.Debug("log diagnostics failed", "context", ctx.Name, "error", err) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows([]debugRow{ {Label: "Log status", Value: renderStatus("warning")}, {Label: "Log diagnostics", Value: err.Error()}, })) } + slog.Debug("collecting image diagnostics", "context", ctx.Name) if diagnostics, err := collectImageDiagnostics(&ctx); err == nil { + slog.Debug("collected image diagnostics", "context", ctx.Name, "images", diagnostics.ImageCount, "total_bytes", diagnostics.TotalBytes) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows(imageSummaryRows(diagnostics))) } else { + slog.Debug("image diagnostics failed", "context", ctx.Name, "error", err) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows([]debugRow{ {Label: "Image status", Value: renderStatus("warning")}, {Label: "Image diagnostics", Value: err.Error()}, })) } + slog.Debug("finished core debug", "context", ctx.Name) return renderDebugPanel("sitectl", strings.Join(coreBody, "\n")) } @@ -184,6 +194,7 @@ type imageDiagnostics struct { } func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { + slog.Debug("opening docker client for log diagnostics", "context", ctxCfg.Name) cli, err := docker.GetDockerCli(ctxCfg) if err != nil { return logDiagnostics{}, err @@ -199,6 +210,7 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { if err != nil { return logDiagnostics{}, err } + slog.Debug("listed containers for log diagnostics", "context", ctxCfg.Name, "count", len(containers)) diagnostics := logDiagnostics{ KnownSize: true, @@ -249,6 +261,7 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { } } } else if len(remotePaths) > 0 { + slog.Debug("collecting remote log file sizes", "context", ctxCfg.Name, "paths", len(remotePaths)) sizes, err := logFileSizesRemote(ctxCfg, remotePaths) if err != nil { diagnostics.KnownSize = false @@ -283,6 +296,7 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { } func collectImageDiagnostics(ctxCfg *config.Context) (imageDiagnostics, error) { + slog.Debug("opening docker client for image diagnostics", "context", ctxCfg.Name) cli, err := docker.GetDockerCli(ctxCfg) if err != nil { return imageDiagnostics{}, err @@ -298,6 +312,7 @@ func collectImageDiagnostics(ctxCfg *config.Context) (imageDiagnostics, error) { if err != nil { return imageDiagnostics{}, err } + slog.Debug("listed images", "context", ctxCfg.Name, "count", len(images)) diagnostics := imageDiagnostics{ImageCount: len(images)} for _, image := range images { @@ -396,6 +411,7 @@ func logFileSizesRemote(ctxCfg *config.Context, paths []string) (map[string]int6 return map[string]int64{}, nil } + slog.Debug("dialing ssh for remote log sizes", "context", ctxCfg.Name, "paths", len(uniquePaths)) client, err := ctxCfg.DialSSH() if err != nil { return nil, err @@ -414,10 +430,12 @@ func logFileSizesRemote(ctxCfg *config.Context, paths []string) (map[string]int6 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, "; ") + slog.Debug("running remote log size command", "context", ctxCfg.Name, "command", cmd) output, err := session.CombinedOutput(cmd) if err != nil { return nil, err } + slog.Debug("completed remote log size command", "context", ctxCfg.Name) sizes := map[string]int64{} for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { diff --git a/pkg/plugin/files.go b/pkg/plugin/files.go index 0f19aeb..9104d55 100644 --- a/pkg/plugin/files.go +++ b/pkg/plugin/files.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "sort" + "sync" "github.com/libops/sitectl/pkg/config" "github.com/pkg/sftp" @@ -13,9 +14,13 @@ import ( ) type FileAccessor struct { - ctx *config.Context - ssh *ssh.Client - sftp *sftp.Client + ctx *config.Context + ssh *ssh.Client + sftp *sftp.Client + mu sync.Mutex + readFileCache map[string][]byte + readDirCache map[string][]os.FileInfo + listFilesCache map[string][]string } func (s *SDK) GetFileAccessor() (*FileAccessor, error) { @@ -27,7 +32,12 @@ func (s *SDK) GetFileAccessor() (*FileAccessor, error) { } func NewFileAccessor(ctx *config.Context) (*FileAccessor, error) { - accessor := &FileAccessor{ctx: ctx} + accessor := &FileAccessor{ + ctx: ctx, + readFileCache: map[string][]byte{}, + readDirCache: map[string][]os.FileInfo{}, + listFilesCache: map[string][]string{}, + } if ctx == nil || ctx.DockerHostType == config.ContextLocal { return accessor, nil } @@ -59,18 +69,34 @@ func (a *FileAccessor) Close() error { } func (a *FileAccessor) ReadFile(path string) ([]byte, error) { + if data, ok := a.cachedFile(path); ok { + return data, nil + } + + var data []byte + var err error if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { - return os.ReadFile(path) + data, err = os.ReadFile(path) + } else { + file, openErr := a.sftp.Open(path) + if openErr != nil { + return nil, openErr + } + defer file.Close() + data, err = io.ReadAll(file) } - file, err := a.sftp.Open(path) if err != nil { return nil, err } - defer file.Close() - return io.ReadAll(file) + a.storeFile(path, data) + return append([]byte(nil), data...), nil } func (a *FileAccessor) ListFiles(root string) ([]string, error) { + if files, ok := a.cachedList(root); ok { + return files, nil + } + if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { files := []string{} err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { @@ -87,6 +113,9 @@ func (a *FileAccessor) ListFiles(root string) ([]string, error) { files = append(files, filepath.ToSlash(rel)) return nil }) + if err == nil { + a.storeList(root, files) + } return files, err } files := []string{} @@ -104,6 +133,7 @@ func (a *FileAccessor) ListFiles(root string) ([]string, error) { } files = append(files, filepath.ToSlash(rel)) } + a.storeList(root, files) return files, nil } @@ -125,3 +155,111 @@ func (a *FileAccessor) MatchFiles(root, pattern string) ([]string, error) { sort.Strings(matches) return matches, nil } + +func (a *FileAccessor) MatchFilesInDir(root, pattern string) ([]string, error) { + matches := []string{} + + entries, err := a.readDir(root) + if err != nil { + return nil, err + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + ok, err := filepath.Match(pattern, entry.Name()) + if err != nil { + return nil, err + } + if ok { + matches = append(matches, filepath.Join(root, entry.Name())) + } + } + sort.Strings(matches) + return matches, nil +} + +func (a *FileAccessor) readDir(root string) ([]os.FileInfo, error) { + a.mu.Lock() + if entries, ok := a.readDirCache[root]; ok { + a.mu.Unlock() + return entries, nil + } + a.mu.Unlock() + + var entries []os.FileInfo + if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { + dirEntries, err := os.ReadDir(root) + if err != nil { + return nil, err + } + fileInfos := make([]os.FileInfo, 0, len(dirEntries)) + for _, entry := range dirEntries { + info, infoErr := entry.Info() + if infoErr != nil { + return nil, infoErr + } + fileInfos = append(fileInfos, info) + } + entries = fileInfos + } else { + var err error + entries, err = a.sftp.ReadDir(root) + if err != nil { + return nil, err + } + } + + a.mu.Lock() + a.readDirCache[root] = entries + a.mu.Unlock() + return entries, nil +} + +func (a *FileAccessor) cachedFile(path string) ([]byte, bool) { + if a == nil { + return nil, false + } + a.mu.Lock() + defer a.mu.Unlock() + data, ok := a.readFileCache[path] + if !ok { + return nil, false + } + return append([]byte(nil), data...), true +} + +func (a *FileAccessor) storeFile(path string, data []byte) { + if a == nil { + return + } + a.mu.Lock() + a.readFileCache[path] = append([]byte(nil), data...) + a.mu.Unlock() +} + +func (a *FileAccessor) cachedList(root string) ([]string, bool) { + if a == nil { + return nil, false + } + a.mu.Lock() + defer a.mu.Unlock() + files, ok := a.listFilesCache[root] + if !ok { + return nil, false + } + out := make([]string, len(files)) + copy(out, files) + return out, true +} + +func (a *FileAccessor) storeList(root string, files []string) { + if a == nil { + return + } + out := make([]string, len(files)) + copy(out, files) + a.mu.Lock() + a.listFilesCache[root] = out + a.mu.Unlock() +} diff --git a/pkg/plugin/files_test.go b/pkg/plugin/files_test.go index d93efb3..e997239 100644 --- a/pkg/plugin/files_test.go +++ b/pkg/plugin/files_test.go @@ -60,6 +60,17 @@ func TestNewFileAccessorLocalReadListAndMatch(t *testing.T) { if !slices.Equal(matches, wantMatches) { t.Fatalf("unexpected matches: got %v want %v", matches, wantMatches) } + + flatMatches, err := accessor.MatchFilesInDir(root, "*.yml") + if err != nil { + t.Fatalf("MatchFilesInDir() error = %v", err) + } + wantFlatMatches := []string{ + filepath.Join(root, "alpha.yml"), + } + if !slices.Equal(flatMatches, wantFlatMatches) { + t.Fatalf("unexpected flat matches: got %v want %v", flatMatches, wantFlatMatches) + } } func TestSDKGetFileAccessorUsesResolvedContext(t *testing.T) { diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 108f4b1..5c77952 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -94,17 +94,13 @@ func (s *SDK) setupLogging(cmd *cobra.Command) error { opts := &slog.HandlerOptions{ Level: level, } - handler := slog.New(slog.NewTextHandler(os.Stdout, opts)) + handler := slog.New(slog.NewTextHandler(os.Stderr, opts)) slog.SetDefault(handler) - // Store config for plugin use + // Store config for plugin use. s.Config.LogLevel = ll if s.RootCmd.PersistentFlags().Lookup("context") != nil { - if cmd.Flags().Changed("context") { - s.Config.Context, _ = cmd.Flags().GetString("context") - } else { - s.Config.Context = "" - } + s.Config.Context, _ = cmd.Flags().GetString("context") } return nil @@ -357,6 +353,7 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command invocation = append(invocation, "--log-level", s.Config.LogLevel) } invocation = append(invocation, args...) + slog.Debug("invoking plugin command", "plugin", pluginName, "path", installed.Path, "args", invocation, "capture", opts.Capture) cmd := exec.Command(installed.Path, invocation...) cmd.Env = os.Environ() @@ -370,7 +367,13 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout - cmd.Stderr = &stderr + stderrSink := io.Writer(&stderr) + if opts.Stderr != nil { + stderrSink = io.MultiWriter(opts.Stderr, &stderr) + } else { + stderrSink = io.MultiWriter(os.Stderr, &stderr) + } + cmd.Stderr = stderrSink if err := cmd.Run(); err != nil { detail := strings.TrimSpace(stderr.String()) if detail == "" { @@ -381,6 +384,7 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command } return "", fmt.Errorf("run plugin %q: %w", pluginName, err) } + slog.Debug("plugin command completed", "plugin", pluginName, "path", installed.Path) return stdout.String(), nil } @@ -396,6 +400,7 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command if err := cmd.Run(); err != nil { return "", fmt.Errorf("run plugin %q: %w", pluginName, err) } + slog.Debug("plugin command completed", "plugin", pluginName, "path", installed.Path) return "", nil } From 1a7cd4c1a34fa4f7b8b016b48e77ea27676bca16 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 21 Mar 2026 13:47:34 -0400 Subject: [PATCH 4/5] improve transport/session layer --- cmd/debug.go | 384 ++++++++++++++++++------------------------- cmd/debug_test.go | 18 +- pkg/docker/docker.go | 29 +++- pkg/plugin/files.go | 150 ++++++++++++++++- pkg/plugin/sdk.go | 66 +++++++- 5 files changed, 396 insertions(+), 251 deletions(-) diff --git a/cmd/debug.go b/cmd/debug.go index 4d1233a..f44f1dc 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -3,12 +3,14 @@ package cmd import ( "context" "fmt" + "io" "log/slog" "os" "regexp" "sort" "strconv" "strings" + "sync" "time" "charm.land/lipgloss/v2" @@ -16,7 +18,6 @@ import ( "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" @@ -26,6 +27,7 @@ import ( var debugOutputPath string var debugVerbose bool +var debugProgressUIActive bool var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) @@ -70,39 +72,61 @@ var debugCmd = &cobra.Command{ if err != nil { return err } - - var body strings.Builder - body.WriteString(renderCoreDebug(ctx)) - - if pluginName := strings.TrimSpace(ctx.Plugin); pluginName != "" && pluginName != "core" { - pluginArgs := []string{"--context", contextName, "__debug"} - if debugVerbose { - pluginArgs = append(pluginArgs, "--verbose") - } - slog.Debug("handing off debug to plugin", "context", contextName, "plugin", pluginName, "args", pluginArgs) - output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Capture: true}) + reporter := debugProgressReporter(nil) + if stderrFile, ok := cmd.ErrOrStderr().(*os.File); ok && term.IsTerminal(int(stderrFile.Fd())) { + report, err := runDebugCollectionWithProgress(cmd, contextName, ctx) if err != nil { return err } - slog.Debug("plugin debug completed", "context", contextName, "plugin", pluginName) - if trimmed := strings.TrimSpace(output); trimmed != "" { - body.WriteString("\n\n") - body.WriteString(trimmed) - } + return writeDebugReport(cmd, report) } - 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) + report, err := collectDebugReport(contextName, ctx, reporter) + if err != nil { return err } + return writeDebugReport(cmd, report) + }, +} - _, err = fmt.Fprintln(cmd.OutOrStdout(), body.String()) +func writeDebugReport(cmd *cobra.Command, report string) error { + if strings.TrimSpace(debugOutputPath) != "" { + report = renderPlainDebugReport(report) + 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(), report) + return err +} + +func collectDebugReport(contextName string, ctx config.Context, reporter debugProgressReporter) (string, error) { + var body strings.Builder + reportProgress(reporter, "Collecting Core Diagnostics", "Inspecting Docker configuration, logs, and images") + body.WriteString(renderCoreDebug(ctx)) + + if pluginName := strings.TrimSpace(ctx.Plugin); pluginName != "" && pluginName != "core" { + pluginArgs := []string{"--context", contextName, "__debug"} + if debugVerbose { + pluginArgs = append(pluginArgs, "--verbose") + } + reportProgress(reporter, "Collecting Plugin Diagnostics", fmt.Sprintf("Running %s debug collectors", pluginName)) + slog.Debug("handing off debug to plugin", "context", contextName, "plugin", pluginName, "args", pluginArgs) + output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Capture: true, LiveStderr: !progressEnabled()}) + if err != nil { + return "", err + } + slog.Debug("plugin debug completed", "context", contextName, "plugin", pluginName) + if trimmed := strings.TrimSpace(output); trimmed != "" { + body.WriteString("\n\n") + body.WriteString(trimmed) + } + } + + return body.String(), nil } func init() { @@ -111,6 +135,24 @@ func init() { RootCmd.AddCommand(debugCmd) } +func runDebugCollectionWithProgress(cmd *cobra.Command, contextName string, ctx config.Context) (string, error) { + debugProgressUIActive = true + defer func() { debugProgressUIActive = false }() + progress := newDebugProgressLine(cmd.ErrOrStderr()) + defer progress.Close() + return collectDebugReport(contextName, ctx, progress.Report) +} + +func reportProgress(reporter debugProgressReporter, title, detail string) { + if reporter != nil { + reporter(title, detail) + } +} + +func progressEnabled() bool { + return debugProgressUIActive +} + func renderCoreDebug(ctx config.Context) string { slog.Debug("starting core debug", "context", ctx.Name, "docker_host_type", ctx.DockerHostType) meta := []debugRow{ @@ -139,29 +181,28 @@ func renderCoreDebug(ctx config.Context) string { "", formatDebugRows(meta), } - slog.Debug("collecting log diagnostics", "context", ctx.Name) - if diagnostics, err := collectLogDiagnostics(&ctx); err == nil { - slog.Debug("collected log diagnostics", "context", ctx.Name, "containers", len(diagnostics.Containers), "known_size", diagnostics.KnownSize) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(diagnostics))) + logDiagnostics, logErr, imageDiagnostics, imageErr := collectCoreDockerDiagnostics(&ctx) + if logErr == nil { + slog.Debug("collected log diagnostics", "context", ctx.Name, "containers", len(logDiagnostics.Containers)) + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(logDiagnostics))) if debugVerbose { - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Details"), "", renderLogDetailsBody(diagnostics)) + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Details"), "", renderLogDetailsBody(logDiagnostics)) } } else { - slog.Debug("log diagnostics failed", "context", ctx.Name, "error", err) + slog.Debug("log diagnostics failed", "context", ctx.Name, "error", logErr) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows([]debugRow{ {Label: "Log status", Value: renderStatus("warning")}, - {Label: "Log diagnostics", Value: err.Error()}, + {Label: "Log diagnostics", Value: logErr.Error()}, })) } - slog.Debug("collecting image diagnostics", "context", ctx.Name) - if diagnostics, err := collectImageDiagnostics(&ctx); err == nil { - slog.Debug("collected image diagnostics", "context", ctx.Name, "images", diagnostics.ImageCount, "total_bytes", diagnostics.TotalBytes) - coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows(imageSummaryRows(diagnostics))) + if imageErr == nil { + slog.Debug("collected image diagnostics", "context", ctx.Name, "images", imageDiagnostics.ImageCount, "total_bytes", imageDiagnostics.TotalBytes) + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows(imageSummaryRows(imageDiagnostics))) } else { - slog.Debug("image diagnostics failed", "context", ctx.Name, "error", err) + slog.Debug("image diagnostics failed", "context", ctx.Name, "error", imageErr) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Image Summary"), "", formatDebugRows([]debugRow{ {Label: "Image status", Value: renderStatus("warning")}, - {Label: "Image diagnostics", Value: err.Error()}, + {Label: "Image diagnostics", Value: imageErr.Error()}, })) } slog.Debug("finished core debug", "context", ctx.Name) @@ -169,8 +210,6 @@ func renderCoreDebug(ctx config.Context) string { } type logDiagnostics struct { - TotalBytes int64 - KnownSize bool Containers []containerLogDiagnostics UnboundedCount int ExternalDriverCount int @@ -180,9 +219,6 @@ type containerLogDiagnostics struct { Service string Container string Driver string - LogPath string - SizeBytes int64 - HasSize bool Rotated bool External bool RotationHint string @@ -193,14 +229,76 @@ type imageDiagnostics struct { ImageCount int } -func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { - slog.Debug("opening docker client for log diagnostics", "context", ctxCfg.Name) - cli, err := docker.GetDockerCli(ctxCfg) - if err != nil { - return logDiagnostics{}, err +type debugProgressReporter func(title, detail string) + +type debugProgressLine struct { + out *os.File + frames []string + index int + title string + detail string + mu sync.Mutex +} + +func newDebugProgressLine(w io.Writer) *debugProgressLine { + file, ok := w.(*os.File) + if !ok { + return &debugProgressLine{frames: []string{".", "o", "O", "o"}} } - defer cli.Close() + return &debugProgressLine{ + out: file, + frames: []string{"-", "\\", "|", "/"}, + title: "Preparing Debug Bundle", + detail: "Starting diagnostic collection", + } +} +func (p *debugProgressLine) Report(title, detail string) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + p.title = strings.TrimSpace(title) + p.detail = strings.TrimSpace(detail) + if p.out == nil { + return + } + frame := p.frames[p.index%len(p.frames)] + p.index++ + line := fmt.Sprintf("\r%s %s", frame, strings.TrimSpace(strings.Join([]string{p.title, p.detail}, " - "))) + fmt.Fprint(p.out, truncateDebugProgress(line)) +} + +func (p *debugProgressLine) Close() { + if p == nil || p.out == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprint(p.out, "\r\033[2K") +} + +func truncateDebugProgress(line string) string { + width := debugPanelWidth() + if width <= 0 { + return line + } + plain := ansiPattern.ReplaceAllString(line, "") + if lipgloss.Width(plain) <= width { + return line + } + runes := []rune(plain) + if len(runes) <= width { + return string(runes) + } + if width <= 1 { + return string(runes[:width]) + } + return string(runes[:width-1]) + "…" +} + +func collectLogDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerClient) (logDiagnostics, error) { filterArgs := filters.NewArgs() filterArgs.Add("label", "com.docker.compose.project="+ctxCfg.EffectiveComposeProjectName()) containers, err := cli.CLI.ContainerList(context.Background(), dockercontainer.ListOptions{ @@ -213,10 +311,8 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { slog.Debug("listed containers for log diagnostics", "context", ctxCfg.Name, "count", len(containers)) diagnostics := logDiagnostics{ - KnownSize: true, Containers: make([]containerLogDiagnostics, 0, len(containers)), } - remotePaths := make([]string, 0, len(containers)) for _, summary := range containers { name := trimContainerName(summary.Names) @@ -233,61 +329,9 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { 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 { - slog.Debug("collecting remote log file sizes", "context", ctxCfg.Name, "paths", len(remotePaths)) - 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 }) @@ -295,14 +339,7 @@ func collectLogDiagnostics(ctxCfg *config.Context) (logDiagnostics, error) { return diagnostics, nil } -func collectImageDiagnostics(ctxCfg *config.Context) (imageDiagnostics, error) { - slog.Debug("opening docker client for image diagnostics", "context", ctxCfg.Name) - cli, err := docker.GetDockerCli(ctxCfg) - if err != nil { - return imageDiagnostics{}, err - } - defer cli.Close() - +func collectImageDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerClient) (imageDiagnostics, error) { apiClient, ok := cli.CLI.(*client.Client) if !ok { return imageDiagnostics{}, fmt.Errorf("docker client does not support image listing") @@ -324,6 +361,21 @@ func collectImageDiagnostics(ctxCfg *config.Context) (imageDiagnostics, error) { return diagnostics, nil } +func collectCoreDockerDiagnostics(ctxCfg *config.Context) (logDiagnostics, error, imageDiagnostics, error) { + slog.Debug("opening shared docker client for core diagnostics", "context", ctxCfg.Name) + cli, err := docker.GetDockerCli(ctxCfg) + if err != nil { + return logDiagnostics{}, err, imageDiagnostics{}, err + } + defer cli.Close() + + slog.Debug("collecting log diagnostics", "context", ctxCfg.Name) + logs, logErr := collectLogDiagnosticsWithClient(ctxCfg, cli) + slog.Debug("collecting image diagnostics", "context", ctxCfg.Name) + images, imageErr := collectImageDiagnosticsWithClient(ctxCfg, cli) + return logs, logErr, images, imageErr +} + func imageSummaryRows(diagnostics imageDiagnostics) []debugRow { state := "ok" rows := []debugRow{ @@ -346,7 +398,6 @@ func describeContainerLogs(service, containerName string, inspect dockercontaine item := containerLogDiagnostics{ Service: service, Container: containerName, - LogPath: strings.TrimSpace(inspect.LogPath), } if inspect.HostConfig != nil { item.Driver = strings.TrimSpace(inspect.HostConfig.LogConfig.Type) @@ -380,93 +431,8 @@ func evaluateLogConfig(driver string, options map[string]string) (rotated bool, } } -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 - } - return 0, false, err - } - return info.Size(), true, nil -} - -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 - } - - slog.Debug("dialing ssh for remote log sizes", "context", ctxCfg.Name, "paths", len(uniquePaths)) - 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, "; ") - slog.Debug("running remote log size command", "context", ctxCfg.Name, "command", cmd) - output, err := session.CombinedOutput(cmd) - if err != nil { - return nil, err - } - slog.Debug("completed remote log size command", "context", ctxCfg.Name) - - 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 { @@ -483,18 +449,12 @@ func logSummaryRows(diagnostics logDiagnostics) []debugRow { 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 + Value: `for non-local environments, 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/`}) } @@ -505,9 +465,6 @@ 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 { @@ -642,16 +599,3 @@ func firstNonEmpty(values ...string) string { } 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 index 9822f69..a39c0f9 100644 --- a/cmd/debug_test.go +++ b/cmd/debug_test.go @@ -20,17 +20,15 @@ func TestEvaluateLogConfigDetectsUnboundedJSONFileLogs(t *testing.T) { 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"}, + {Service: "drupal", Driver: "json-file", 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, "Total logs") { + t.Fatalf("expected log summary without total log size, got:\n%s", rendered) } if !strings.Contains(rendered, "Recommendation") { t.Fatalf("expected recommendation guidance, got:\n%s", rendered) @@ -39,26 +37,22 @@ func TestLogSummaryRowsIncludeRecommendationWhenLogsNeedAttention(t *testing.T) 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"}, + {Service: "drupal", Driver: "json-file", 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") { + if !strings.Contains(rendered, "drupal: driver=json-file, 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}, + {Service: "drupal", Driver: "json-file", Rotated: false}, }, }) diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index a052341..f1e7df5 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -32,13 +32,16 @@ type DockerClient struct { CLI DockerAPI SshCli *ssh.Client httpClient *http.Client + ownsSSH bool } func (d *DockerClient) Close() error { var firstErr error if d.SshCli != nil { - if err := d.SshCli.Close(); err != nil && firstErr == nil { - firstErr = err + if d.ownsSSH { + if err := d.SshCli.Close(); err != nil && firstErr == nil { + firstErr = err + } } } if d.httpClient != nil { @@ -62,6 +65,23 @@ func GetDockerCli(activeCtx *config.Context) (*DockerClient, error) { if err != nil { return nil, fmt.Errorf("error establishing SSH connection: %v", err) } + return GetDockerCliWithSSH(activeCtx, sshConn, true) +} + +func GetDockerCliWithSSH(activeCtx *config.Context, sshConn *ssh.Client, ownsSSH bool) (*DockerClient, error) { + if activeCtx.DockerHostType == config.ContextLocal { + cli, err := client.NewClientWithOpts( + client.WithHost("unix://"+activeCtx.DockerSocket), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("error creating local Docker client: %v", err) + } + return &DockerClient{CLI: cli}, nil + } + if sshConn == nil { + return nil, fmt.Errorf("ssh client is required for remote docker context") + } transport := &http.Transport{ DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return sshConn.Dial("unix", activeCtx.DockerSocket) @@ -76,13 +96,16 @@ func GetDockerCli(activeCtx *config.Context) (*DockerClient, error) { client.WithAPIVersionNegotiation(), ) if err != nil { - sshConn.Close() + if ownsSSH { + sshConn.Close() + } return nil, fmt.Errorf("error creating Docker client over SSH: %v", err) } return &DockerClient{ CLI: cli, SshCli: sshConn, httpClient: httpClient, + ownsSSH: ownsSSH, }, nil } diff --git a/pkg/plugin/files.go b/pkg/plugin/files.go index 9104d55..b8e90fa 100644 --- a/pkg/plugin/files.go +++ b/pkg/plugin/files.go @@ -1,13 +1,16 @@ package plugin import ( + "fmt" "io" "io/fs" "os" "path/filepath" "sort" + "strings" "sync" + "github.com/kballard/go-shellquote" "github.com/libops/sitectl/pkg/config" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" @@ -17,6 +20,7 @@ type FileAccessor struct { ctx *config.Context ssh *ssh.Client sftp *sftp.Client + ownsSSH bool mu sync.Mutex readFileCache map[string][]byte readDirCache map[string][]os.FileInfo @@ -24,16 +28,33 @@ type FileAccessor struct { } func (s *SDK) GetFileAccessor() (*FileAccessor, error) { + if s.fileAccessor != nil { + return s.fileAccessor, nil + } ctx, err := s.GetContext() if err != nil { return nil, err } - return NewFileAccessor(ctx) + if ctx == nil || ctx.DockerHostType == config.ContextLocal { + s.fileAccessor, err = NewFileAccessor(ctx) + return s.fileAccessor, err + } + sshClient, err := s.getSSHClient() + if err != nil { + return nil, err + } + s.fileAccessor, err = NewFileAccessorWithSSH(ctx, sshClient, false) + return s.fileAccessor, err } func NewFileAccessor(ctx *config.Context) (*FileAccessor, error) { + return NewFileAccessorWithSSH(ctx, nil, true) +} + +func NewFileAccessorWithSSH(ctx *config.Context, client *ssh.Client, ownsSSH bool) (*FileAccessor, error) { accessor := &FileAccessor{ ctx: ctx, + ownsSSH: ownsSSH, readFileCache: map[string][]byte{}, readDirCache: map[string][]os.FileInfo{}, listFilesCache: map[string][]string{}, @@ -41,13 +62,18 @@ func NewFileAccessor(ctx *config.Context) (*FileAccessor, error) { if ctx == nil || ctx.DockerHostType == config.ContextLocal { return accessor, nil } - client, err := ctx.DialSSH() - if err != nil { - return nil, err + if client == nil { + var err error + client, err = ctx.DialSSH() + if err != nil { + return nil, err + } } sftpClient, err := sftp.NewClient(client) if err != nil { - client.Close() + if ownsSSH { + client.Close() + } return nil, err } accessor.ssh = client @@ -62,7 +88,7 @@ func (a *FileAccessor) Close() error { if a.sftp != nil { _ = a.sftp.Close() } - if a.ssh != nil { + if a.ssh != nil && a.ownsSSH { return a.ssh.Close() } return nil @@ -92,6 +118,78 @@ func (a *FileAccessor) ReadFile(path string) ([]byte, error) { return append([]byte(nil), data...), nil } +func (a *FileAccessor) ReadFiles(paths []string) (map[string][]byte, error) { + results := make(map[string][]byte, len(paths)) + missing := 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 + if data, ok := a.cachedFile(path); ok { + results[path] = data + continue + } + missing = append(missing, path) + } + + if len(missing) == 0 { + return results, nil + } + + if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { + for _, path := range missing { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + a.storeFile(path, data) + results[path] = append([]byte(nil), data...) + } + return results, nil + } + + session, err := a.ssh.NewSession() + if err != nil { + return nil, err + } + defer session.Close() + + var builder strings.Builder + for _, path := range missing { + quoted := shellquote.Join(path) + builder.WriteString("printf '__SITECTL_START__%s\\n' ") + builder.WriteString(quoted) + builder.WriteString("; cat ") + builder.WriteString(quoted) + builder.WriteString("; printf '\\n__SITECTL_END__%s\\n' ") + builder.WriteString(quoted) + builder.WriteString("; ") + } + + output, err := session.CombinedOutput(builder.String()) + if err != nil { + return nil, err + } + + parsed, err := parseBatchedFileOutput(string(output)) + if err != nil { + return nil, err + } + for _, path := range missing { + data, ok := parsed[path] + if !ok { + return nil, fmt.Errorf("missing batched file content for %s", path) + } + a.storeFile(path, data) + results[path] = append([]byte(nil), data...) + } + return results, nil +} + func (a *FileAccessor) ListFiles(root string) ([]string, error) { if files, ok := a.cachedList(root); ok { return files, nil @@ -180,6 +278,9 @@ func (a *FileAccessor) MatchFilesInDir(root, pattern string) ([]string, error) { } func (a *FileAccessor) readDir(root string) ([]os.FileInfo, error) { + if a == nil { + return nil, os.ErrInvalid + } a.mu.Lock() if entries, ok := a.readDirCache[root]; ok { a.mu.Unlock() @@ -188,7 +289,7 @@ func (a *FileAccessor) readDir(root string) ([]os.FileInfo, error) { a.mu.Unlock() var entries []os.FileInfo - if a == nil || a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { + if a.ctx == nil || a.ctx.DockerHostType == config.ContextLocal { dirEntries, err := os.ReadDir(root) if err != nil { return nil, err @@ -263,3 +364,38 @@ func (a *FileAccessor) storeList(root string, files []string) { a.listFilesCache[root] = out a.mu.Unlock() } + +func parseBatchedFileOutput(output string) (map[string][]byte, error) { + const startPrefix = "__SITECTL_START__" + const endPrefix = "__SITECTL_END__" + + results := map[string][]byte{} + var currentPath string + var current strings.Builder + + for _, line := range strings.Split(output, "\n") { + switch { + case strings.HasPrefix(line, startPrefix): + currentPath = strings.TrimPrefix(line, startPrefix) + current.Reset() + case strings.HasPrefix(line, endPrefix): + endPath := strings.TrimPrefix(line, endPrefix) + if currentPath == "" || endPath != currentPath { + return nil, fmt.Errorf("batched file output markers out of sync") + } + results[currentPath] = []byte(strings.TrimSuffix(current.String(), "\n")) + currentPath = "" + default: + if currentPath == "" { + continue + } + current.WriteString(line) + current.WriteString("\n") + } + } + + if currentPath != "" { + return nil, fmt.Errorf("unterminated batched file output for %s", currentPath) + } + return results, nil +} diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 5c77952..03284b4 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -18,6 +18,7 @@ import ( "github.com/libops/sitectl/pkg/helpers" "github.com/libops/sitectl/pkg/validate" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh" "golang.org/x/term" ) @@ -49,6 +50,10 @@ type SDK struct { Config Config RootCmd *cobra.Command contextValidators []validate.Validator + contextCache *config.Context + sshClient *ssh.Client + dockerClient *docker.DockerClient + fileAccessor *FileAccessor } // NewSDK creates a new plugin SDK instance @@ -171,18 +176,39 @@ func (s *SDK) GetMetadataCommand() *cobra.Command { // This is a helper for plugins that need to interact with Docker // Returns the existing DockerClient which handles both local and remote contexts func (s *SDK) GetDockerClient() (*docker.DockerClient, error) { + if s.dockerClient != nil { + return s.dockerClient, nil + } ctx, err := s.GetContext() if err != nil { return nil, fmt.Errorf("failed to get context: %w", err) } + if ctx.DockerHostType == config.ContextLocal { + s.dockerClient, err = docker.GetDockerCli(ctx) + return s.dockerClient, err + } + sshClient, err := s.getSSHClient() + if err != nil { + return nil, err + } + s.dockerClient, err = docker.GetDockerCliWithSSH(ctx, sshClient, false) + if err != nil { + return nil, err + } + return s.dockerClient, nil +} - return docker.GetDockerCli(ctx) +func (s *SDK) GetSSHClient() (*ssh.Client, error) { + return s.getSSHClient() } // GetContext loads the sitectl context configuration // This is useful for plugins that need to access context-specific settings // If no context is specified, returns the current context from config func (s *SDK) GetContext() (*config.Context, error) { + if s.contextCache != nil { + return s.contextCache, nil + } // Load the config cfg, err := config.Load() if err != nil { @@ -208,13 +234,32 @@ func (s *SDK) GetContext() (*config.Context, error) { if err := validateContextPlugin(ctx.Plugin, s.Metadata.Name); err != nil { return nil, fmt.Errorf("context %q is not supported by plugin %q: %w", ctx.Name, s.Metadata.Name, err) } - return &ctx, nil + s.contextCache = &ctx + return s.contextCache, nil } } return nil, fmt.Errorf("context %q not found", contextName) } +func (s *SDK) getSSHClient() (*ssh.Client, error) { + if s.sshClient != nil { + return s.sshClient, nil + } + ctx, err := s.GetContext() + if err != nil { + return nil, err + } + if ctx == nil || ctx.DockerHostType == config.ContextLocal { + return nil, nil + } + s.sshClient, err = ctx.DialSSH() + if err != nil { + return nil, err + } + return s.sshClient, nil +} + func validateContextPlugin(contextPlugin, requestedPlugin string) error { contextPlugin = strings.TrimSpace(contextPlugin) requestedPlugin = strings.TrimSpace(requestedPlugin) @@ -333,10 +378,11 @@ func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string } type CommandExecOptions struct { - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer - Capture bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Capture bool + LiveStderr bool } func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts CommandExecOptions) (string, error) { @@ -367,11 +413,13 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command var stdout bytes.Buffer var stderr bytes.Buffer cmd.Stdout = &stdout - stderrSink := io.Writer(&stderr) - if opts.Stderr != nil { + var stderrSink io.Writer + if opts.Stderr != nil && opts.LiveStderr { stderrSink = io.MultiWriter(opts.Stderr, &stderr) - } else { + } else if opts.LiveStderr { stderrSink = io.MultiWriter(os.Stderr, &stderr) + } else { + stderrSink = &stderr } cmd.Stderr = stderrSink if err := cmd.Run(); err != nil { From 42eb4fb75babdfb11479a4821b10c1edcddb8555 Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Sat, 21 Mar 2026 15:02:54 -0400 Subject: [PATCH 5/5] security review --- cmd/component.go | 7 +- cmd/compose.go | 2 +- cmd/config.go | 47 ----- cmd/config_create_test.go | 8 - cmd/debug.go | 88 +++++--- cmd/make.go | 34 ---- cmd/port-forward.go | 10 +- cmd/root.go | 5 +- index.mdx | 68 ------- pkg/config/cmd.go | 98 +++++---- pkg/config/cmd_test.go | 16 ++ pkg/config/context.go | 110 ++++------ pkg/config/context_test.go | 21 +- pkg/config/discovery.go | 4 +- pkg/config/file_accessor.go | 337 +++++++++++++++++++++++++++++++ pkg/config/files.go | 123 ++--------- pkg/config/utils.go | 1 - pkg/config/utils_test.go | 3 - pkg/config/validation_helpers.go | 14 +- pkg/docker/docker.go | 12 +- pkg/docker/docker_test.go | 6 +- pkg/docker/summary.go | 86 +------- pkg/plugin/files.go | 182 ++++++++++------- pkg/plugin/sdk.go | 83 +++++--- pkg/plugin/sdk_test.go | 191 ++++++++++++++++++ pkg/tui/dashboard.go | 51 +---- 26 files changed, 945 insertions(+), 662 deletions(-) delete mode 100644 cmd/make.go delete mode 100644 index.mdx create mode 100644 pkg/config/file_accessor.go diff --git a/cmd/component.go b/cmd/component.go index 7577df2..b8d8c89 100644 --- a/cmd/component.go +++ b/cmd/component.go @@ -38,9 +38,10 @@ var ( } invocation = append(invocation, args...) _, err := pluginSDK.InvokePluginCommand(installed.Name, invocation, plugin.CommandExecOptions{ - Stdin: RootCmd.InOrStdin(), - Stdout: RootCmd.OutOrStdout(), - Stderr: RootCmd.ErrOrStderr(), + Context: RootCmd.Context(), + Stdin: RootCmd.InOrStdin(), + Stdout: RootCmd.OutOrStdout(), + Stderr: RootCmd.ErrOrStderr(), }) return err } diff --git a/cmd/compose.go b/cmd/compose.go index 512265e..561f5c2 100644 --- a/cmd/compose.go +++ b/cmd/compose.go @@ -124,7 +124,7 @@ Examples: cmdArgs = append(cmdArgs, filteredArgs...) c := exec.Command("docker", cmdArgs...) c.Dir = context.ProjectDir - _, err = context.RunCommand(c) + _, err = context.RunCommandContext(cmd.Context(), c) if err != nil { return err } diff --git a/cmd/config.go b/cmd/config.go index 690bd82..ca88036 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -774,8 +774,6 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* localCtx.ComposeNetwork, localCtx.EffectiveComposeNetwork(), ) - runSudo := remoteContextBool(previousRemote, func(ctx *config.Context) bool { return ctx.RunSudo }, localCtx.RunSudo) - for { hostname, err = promptRequiredValueWithDefault("Remote hostname/domain (e.g. stage.example.com)", hostname) if err != nil { @@ -808,7 +806,6 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* SSHUser: sshUser, SSHPort: sshPort, SSHKeyPath: sshKey, - RunSudo: localCtx.RunSudo, DockerSocket: dockerSocket, ComposeFile: append([]string{}, localCtx.ComposeFile...), EnvFile: append([]string{}, localCtx.EnvFile...), @@ -820,7 +817,6 @@ func promptRemoteEnvironmentContext(localCtx, previousRemote *config.Context) (* remoteCtx.ProjectName = projectName remoteCtx.ComposeProjectName = composeProjectName remoteCtx.ComposeNetwork = composeNetwork - remoteCtx.RunSudo = runSudo if detected := config.DetectContextComposeNetwork(remoteCtx); detected != "" { remoteCtx.ComposeNetwork = detected } @@ -911,37 +907,6 @@ func promptUintWithDefault(label string, defaultValue uint) (uint, error) { return uint(parsed), nil } -func promptBooleanChoice(label string, defaultValue bool) (bool, error) { - defaultChoice := "no" - if defaultValue { - defaultChoice = "yes" - } - value, err := createConfigPromptChoice( - strings.ToLower(strings.ReplaceAll(label, " ", "-")), - []corecomponent.Choice{ - { - Value: "yes", - Label: "yes", - Help: label, - Aliases: []string{"y", "1"}, - }, - { - Value: "no", - Label: "no", - Help: "Do not use sudo.", - Aliases: []string{"n", "2"}, - }, - }, - defaultChoice, - createConfigInput, - strings.Split(corecomponent.RenderSection(label, label+"?"), "\n")..., - ) - if err != nil { - return false, err - } - return strings.TrimSpace(value) == "yes", nil -} - func validateRemoteDockerAccess(ctx *config.Context) error { if ctx == nil || ctx.DockerHostType != config.ContextRemote { return nil @@ -988,16 +953,11 @@ func validateRemoteDockerAccess(ctx *config.Context) error { if promptErr != nil { return promptErr } - runSudo, promptErr := promptBooleanChoice("Run Docker commands with sudo", ctx.RunSudo) - if promptErr != nil { - return promptErr - } ctx.ProjectDir = projectDir ctx.ProjectName = projectName ctx.ComposeProjectName = firstNonEmptyString(ctx.ComposeProjectName, projectName) ctx.ComposeNetwork = firstNonEmptyString(config.DetectContextComposeNetwork(ctx), ctx.ComposeNetwork, ctx.EffectiveComposeNetwork()) ctx.DockerSocket = dockerSocket - ctx.RunSudo = runSudo continue } return nil @@ -1034,13 +994,6 @@ func remoteContextUint(ctx *config.Context, getter func(*config.Context) uint, f return fallback } -func remoteContextBool(ctx *config.Context, getter func(*config.Context) bool, fallback bool) bool { - if ctx == nil || getter == nil { - return fallback - } - return getter(ctx) -} - func suggestedEnvironmentContextName(localCtx *config.Context, environment string) string { base := strings.TrimSpace(environment) if localCtx == nil { diff --git a/cmd/config_create_test.go b/cmd/config_create_test.go index 71b8ece..3bc22db 100644 --- a/cmd/config_create_test.go +++ b/cmd/config_create_test.go @@ -740,9 +740,6 @@ func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing. if ctx.DockerSocket != "/run/user/1000/docker.sock" { t.Fatalf("expected updated docker socket, got %q", ctx.DockerSocket) } - if !ctx.RunSudo { - t.Fatal("expected updated run sudo true") - } if ctx.ProjectName != "museum-prod" { t.Fatalf("expected updated project name museum-prod, got %q", ctx.ProjectName) } @@ -762,8 +759,6 @@ func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing. return "no", nil case "update-environment-context": return "update", nil - case "run-docker-commands-with-sudo": - return "yes", nil default: t.Fatalf("unexpected choice prompt: %s", name) return "", nil @@ -811,9 +806,6 @@ func TestRunCreateConfigRepromptsDockerSettingsAfterComposePSFailure(t *testing. if remoteCtx.Plugin != "core" { t.Fatalf("expected saved plugin core, got %q", remoteCtx.Plugin) } - if !remoteCtx.RunSudo { - t.Fatal("expected saved run sudo true") - } } func TestInheritNewContextDefaultsFromActive(t *testing.T) { diff --git a/cmd/debug.go b/cmd/debug.go index f44f1dc..f0981fe 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -81,7 +81,7 @@ var debugCmd = &cobra.Command{ return writeDebugReport(cmd, report) } - report, err := collectDebugReport(contextName, ctx, reporter) + report, err := collectDebugReport(cmd.Context(), contextName, ctx, reporter) if err != nil { return err } @@ -103,19 +103,25 @@ func writeDebugReport(cmd *cobra.Command, report string) error { return err } -func collectDebugReport(contextName string, ctx config.Context, reporter debugProgressReporter) (string, error) { +func collectDebugReport(runCtx context.Context, contextName string, ctx config.Context, reporter debugProgressReporter) (string, error) { + if err := runCtx.Err(); err != nil { + return "", err + } var body strings.Builder reportProgress(reporter, "Collecting Core Diagnostics", "Inspecting Docker configuration, logs, and images") - body.WriteString(renderCoreDebug(ctx)) + body.WriteString(renderCoreDebug(runCtx, ctx)) if pluginName := strings.TrimSpace(ctx.Plugin); pluginName != "" && pluginName != "core" { + if err := runCtx.Err(); err != nil { + return "", err + } pluginArgs := []string{"--context", contextName, "__debug"} if debugVerbose { pluginArgs = append(pluginArgs, "--verbose") } reportProgress(reporter, "Collecting Plugin Diagnostics", fmt.Sprintf("Running %s debug collectors", pluginName)) slog.Debug("handing off debug to plugin", "context", contextName, "plugin", pluginName, "args", pluginArgs) - output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Capture: true, LiveStderr: !progressEnabled()}) + output, err := pluginSDK.InvokePluginCommand(pluginName, pluginArgs, plugin.CommandExecOptions{Context: runCtx, Capture: true, LiveStderr: !progressEnabled()}) if err != nil { return "", err } @@ -140,7 +146,7 @@ func runDebugCollectionWithProgress(cmd *cobra.Command, contextName string, ctx defer func() { debugProgressUIActive = false }() progress := newDebugProgressLine(cmd.ErrOrStderr()) defer progress.Close() - return collectDebugReport(contextName, ctx, progress.Report) + return collectDebugReport(cmd.Context(), contextName, ctx, progress.Report) } func reportProgress(reporter debugProgressReporter, title, detail string) { @@ -153,7 +159,7 @@ func progressEnabled() bool { return debugProgressUIActive } -func renderCoreDebug(ctx config.Context) string { +func renderCoreDebug(runCtx context.Context, ctx config.Context) string { slog.Debug("starting core debug", "context", ctx.Name, "docker_host_type", ctx.DockerHostType) meta := []debugRow{ {Label: "Generated", Value: time.Now().UTC().Format(time.RFC3339)}, @@ -181,7 +187,7 @@ func renderCoreDebug(ctx config.Context) string { "", formatDebugRows(meta), } - logDiagnostics, logErr, imageDiagnostics, imageErr := collectCoreDockerDiagnostics(&ctx) + logDiagnostics, logErr, imageDiagnostics, imageErr := collectCoreDockerDiagnostics(runCtx, &ctx) if logErr == nil { slog.Debug("collected log diagnostics", "context", ctx.Name, "containers", len(logDiagnostics.Containers)) coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Log Summary"), "", formatDebugRows(logSummaryRows(logDiagnostics))) @@ -238,6 +244,8 @@ type debugProgressLine struct { title string detail string mu sync.Mutex + done chan struct{} + once sync.Once } func newDebugProgressLine(w io.Writer) *debugProgressLine { @@ -245,12 +253,15 @@ func newDebugProgressLine(w io.Writer) *debugProgressLine { if !ok { return &debugProgressLine{frames: []string{".", "o", "O", "o"}} } - return &debugProgressLine{ + progress := &debugProgressLine{ out: file, frames: []string{"-", "\\", "|", "/"}, title: "Preparing Debug Bundle", detail: "Starting diagnostic collection", + done: make(chan struct{}), } + go progress.animate(120 * time.Millisecond) + return progress } func (p *debugProgressLine) Report(title, detail string) { @@ -258,9 +269,40 @@ func (p *debugProgressLine) Report(title, detail string) { return } p.mu.Lock() - defer p.mu.Unlock() p.title = strings.TrimSpace(title) p.detail = strings.TrimSpace(detail) + p.renderLocked() + p.mu.Unlock() +} + +func (p *debugProgressLine) Close() { + if p == nil || p.out == nil { + return + } + p.once.Do(func() { + close(p.done) + p.mu.Lock() + defer p.mu.Unlock() + fmt.Fprint(p.out, "\r\033[2K") + }) +} + +func (p *debugProgressLine) animate(interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.mu.Lock() + p.renderLocked() + p.mu.Unlock() + case <-p.done: + return + } + } +} + +func (p *debugProgressLine) renderLocked() { if p.out == nil { return } @@ -270,15 +312,6 @@ func (p *debugProgressLine) Report(title, detail string) { fmt.Fprint(p.out, truncateDebugProgress(line)) } -func (p *debugProgressLine) Close() { - if p == nil || p.out == nil { - return - } - p.mu.Lock() - defer p.mu.Unlock() - fmt.Fprint(p.out, "\r\033[2K") -} - func truncateDebugProgress(line string) string { width := debugPanelWidth() if width <= 0 { @@ -298,10 +331,10 @@ func truncateDebugProgress(line string) string { return string(runes[:width-1]) + "…" } -func collectLogDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerClient) (logDiagnostics, error) { +func collectLogDiagnosticsWithClient(runCtx context.Context, ctxCfg *config.Context, cli *docker.DockerClient) (logDiagnostics, error) { filterArgs := filters.NewArgs() filterArgs.Add("label", "com.docker.compose.project="+ctxCfg.EffectiveComposeProjectName()) - containers, err := cli.CLI.ContainerList(context.Background(), dockercontainer.ListOptions{ + containers, err := cli.CLI.ContainerList(runCtx, dockercontainer.ListOptions{ All: true, Filters: filterArgs, }) @@ -315,9 +348,12 @@ func collectLogDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerC } for _, summary := range containers { + if err := runCtx.Err(); err != nil { + return logDiagnostics{}, err + } name := trimContainerName(summary.Names) service := firstNonEmpty(summary.Labels["com.docker.compose.service"], name) - inspect, err := cli.CLI.ContainerInspect(context.Background(), name) + inspect, err := cli.CLI.ContainerInspect(runCtx, name) if err != nil { return logDiagnostics{}, err } @@ -339,13 +375,13 @@ func collectLogDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerC return diagnostics, nil } -func collectImageDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.DockerClient) (imageDiagnostics, error) { +func collectImageDiagnosticsWithClient(runCtx context.Context, ctxCfg *config.Context, cli *docker.DockerClient) (imageDiagnostics, error) { 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}) + images, err := apiClient.ImageList(runCtx, dockerimage.ListOptions{All: true}) if err != nil { return imageDiagnostics{}, err } @@ -361,7 +397,7 @@ func collectImageDiagnosticsWithClient(ctxCfg *config.Context, cli *docker.Docke return diagnostics, nil } -func collectCoreDockerDiagnostics(ctxCfg *config.Context) (logDiagnostics, error, imageDiagnostics, error) { +func collectCoreDockerDiagnostics(runCtx context.Context, ctxCfg *config.Context) (logDiagnostics, error, imageDiagnostics, error) { slog.Debug("opening shared docker client for core diagnostics", "context", ctxCfg.Name) cli, err := docker.GetDockerCli(ctxCfg) if err != nil { @@ -370,9 +406,9 @@ func collectCoreDockerDiagnostics(ctxCfg *config.Context) (logDiagnostics, error defer cli.Close() slog.Debug("collecting log diagnostics", "context", ctxCfg.Name) - logs, logErr := collectLogDiagnosticsWithClient(ctxCfg, cli) + logs, logErr := collectLogDiagnosticsWithClient(runCtx, ctxCfg, cli) slog.Debug("collecting image diagnostics", "context", ctxCfg.Name) - images, imageErr := collectImageDiagnosticsWithClient(ctxCfg, cli) + images, imageErr := collectImageDiagnosticsWithClient(runCtx, ctxCfg, cli) return logs, logErr, images, imageErr } diff --git a/cmd/make.go b/cmd/make.go deleted file mode 100644 index d258bf1..0000000 --- a/cmd/make.go +++ /dev/null @@ -1,34 +0,0 @@ -package cmd - -import ( - "os/exec" - - "github.com/libops/sitectl/pkg/config" - "github.com/libops/sitectl/pkg/helpers" - "github.com/spf13/cobra" -) - -// makeCmd support deprecated custom make commands -var makeCmd = &cobra.Command{ - Use: "make", - Short: "Run custom make commands", - Args: cobra.ArbitraryArgs, - Run: func(cmd *cobra.Command, args []string) { - f := cmd.Flags() - context, err := config.CurrentContext(f) - if err != nil { - helpers.ExitOnError(err) - } - - c := exec.Command("make", args...) - c.Dir = context.ProjectDir - _, err = context.RunCommand(c) - if err != nil { - helpers.ExitOnError(err) - } - }, -} - -func init() { - RootCmd.AddCommand(makeCmd) -} diff --git a/cmd/port-forward.go b/cmd/port-forward.go index 8514fac..7113082 100644 --- a/cmd/port-forward.go +++ b/cmd/port-forward.go @@ -63,6 +63,7 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection listeners := make([]net.Listener, 0, len(args)) done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGHUP, syscall.SIGTERM) + defer signal.Stop(done) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -77,10 +78,16 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection if err != nil { return fmt.Errorf("invalid local port '%s': must be an integer", localPortStr) } + if localPort < 1 || localPort > 65535 { + return fmt.Errorf("invalid local port '%s': must be between 1 and 65535", localPortStr) + } remotePort, err := strconv.Atoi(remotePortStr) if err != nil { return fmt.Errorf("invalid remote port '%s': must be an integer", remotePortStr) } + if remotePort < 1 || remotePort > 65535 { + return fmt.Errorf("invalid remote port '%s': must be between 1 and 65535", remotePortStr) + } addr := fmt.Sprintf("localhost:%d", localPort) listener, err := net.Listen("tcp", addr) @@ -89,7 +96,7 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection } listeners = append(listeners, listener) - containerName, err := cli.GetContainerName(c, service) + containerName, err := cli.GetContainerNameContext(ctx, c, service) if err != nil { return err } @@ -100,7 +107,6 @@ Be sure to run Ctrl+c in your terminal when you are done to close the connection remoteEndpoint := fmt.Sprintf("%s:%d", serviceIp, remotePort) go func(listener net.Listener, lp, remoteAddr string) { - defer listener.Close() fmt.Printf("Forwarding localhost:%s -> %s via SSH\n", lp, remoteAddr) for { localConn, err := listener.Accept() diff --git a/cmd/root.go b/cmd/root.go index 2c10fc3..5ecc591 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "os/signal" "strings" "syscall" @@ -48,8 +49,10 @@ var RootCmd = &cobra.Command{ } func Execute() { + runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() err := fang.Execute( - context.Background(), + runCtx, RootCmd, fang.WithVersion(RootCmd.Version), ) diff --git a/index.mdx b/index.mdx deleted file mode 100644 index 0dc52d2..0000000 --- a/index.mdx +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: sitectl -description: Command line utility to interact with your local and remote Docker Compose sites. ---- - -import { Compose } from "/docs/snippets/compose-tooltip.mdx"; -import { TUI } from "/docs/snippets/tui-tooltip.mdx"; - -## Overview - -`sitectl` was made with LAC-GLAM institutions at top of mind. `sitectl` is a command line utility to operate your local and remote sites. - - -## Scaling Human Operators - -The philosophy behind `sitectl` is not to help scale operations in the traditional technological sense of the word, but rather to scale *human operators*. -As more institutions run their own instances of an OSS project, the resulting increase in contributors triggers a -virtuous cycle of growth. - -By making the operation of the Docker containers needed to run an application well-defined through common, repeatable patterns using the spec, `sitectl`'s value prop is: -* **Empower institutions:** Giving organizations the capability and confidence to reliably host the software they depend on without relying soley on a dedicated DevOps team. -* **Empower individual contributors:** Providing teams with solid, standardized tooling that eliminates environmental toil and lets them focus on the work that matters. - -## `sitectl` Features - - - - Use the for routine site setup, monitoring, and operator workflows. - - - Track local and remote environments so sitectl can understand where a site lives and how to reach it. - - - Add stack-specific behavior for common technologies without abandoning the core workflow. - - - Model reviewed stack defaults and operator choices in a more structured way than ad hoc notes. - - - -## 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: - - - - SFTP file operations, sudo support, and clearer SSH error handling. - - - General helpers to do things like resolve service names to containers, extract secrets and env vars for `exec` commands, and inspect container network details. - - -first design} icon="code"> - Automatically set the equivalent of `DOCKER_HOST`, `COMPOSE_PROJECT_NAME`, `COMPOSE_FILE`, and `COMPOSE_ENV_FILES` from the active sitectl context. - - - -## Why not make kube operators? - -Though isn't designed for massive-scale orchestration, the applications hosted by most LAC-GLAM institutions rarely require more than modest scaling. -The real advantage of is the developer experience. Because the exact same orchestration runs in both development and production - with only minor environmental tweaks - you can reliably mirror production on your local machine. -This provides built-in deployment safety long before your CI pipeline runs a single test. - -We could have spent our resources building Kubernetes operators for various LAC-GLAM stacks instead of creating sitectl. -But sitectl was a deliberate choice: it empowers institutions to adopt open-source projects without the hurdle of hiring a k8s admin or absorbing the heavy operational overhead of a Kubernetes cluster. diff --git a/pkg/config/cmd.go b/pkg/config/cmd.go index d6cfc81..79b52a3 100644 --- a/pkg/config/cmd.go +++ b/pkg/config/cmd.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "strings" + "sync" "github.com/kballard/go-shellquote" "golang.org/x/crypto/ssh" @@ -16,19 +17,27 @@ import ( ) func (c *Context) RunCommand(cmd *exec.Cmd) (string, error) { - return c.runCommand(cmd, true) + return c.runCommandContext(context.Background(), cmd, true) } func (c *Context) RunQuietCommand(cmd *exec.Cmd) (string, error) { - return c.runCommand(cmd, false) + return c.runCommandContext(context.Background(), cmd, false) } -func (c *Context) runCommand(cmd *exec.Cmd, printOutput bool) (string, error) { - var output string +func (c *Context) RunCommandContext(ctx context.Context, cmd *exec.Cmd) (string, error) { + return c.runCommandContext(ctx, cmd, true) +} + +func (c *Context) RunQuietCommandContext(ctx context.Context, cmd *exec.Cmd) (string, error) { + return c.runCommandContext(ctx, cmd, false) +} + +func (c *Context) runCommandContext(ctx context.Context, cmd *exec.Cmd, printOutput bool) (string, error) { + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + var output strings.Builder if c.DockerHostType == ContextLocal { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...) + cmd = exec.CommandContext(runCtx, cmd.Path, cmd.Args[1:]...) cmd.Env = os.Environ() if printOutput { cmd.Stdin = os.Stdin @@ -52,7 +61,8 @@ func (c *Context) runCommand(cmd *exec.Cmd, printOutput bool) (string, error) { if printOutput { fmt.Println(line) } - output = strings.TrimSpace(line) + output.WriteString(line) + output.WriteString("\n") } if err := scanner.Err(); err != nil { slog.Error("Error reading stdout", "err", err) @@ -60,58 +70,64 @@ func (c *Context) runCommand(cmd *exec.Cmd, printOutput bool) (string, error) { if err := cmd.Wait(); err != nil { return "", fmt.Errorf("error waiting for command %s: %v", cmd.String(), err) } - return output, nil + return strings.TrimRight(output.String(), "\n"), nil } sshClient, err := c.DialSSH() if err != nil { return "", fmt.Errorf("error establishing SSH connection: %v", err) } - defer sshClient.Close() remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(c.ProjectDir)) - if c.RunSudo { - remoteCmd += "sudo " - } remoteCmd += shellquote.Join(cmd.Args...) slog.Info("Running remote command", "host", c.SSHHostname, "cmd", remoteCmd) session, err := sshClient.NewSession() if err != nil { + _ = sshClient.Close() return "", fmt.Errorf("error creating SSH session: %v", err) } - defer session.Close() - modes := ssh.TerminalModes{ - ssh.ECHO: 0, - ssh.TTY_OP_ISPEED: 14400, - ssh.TTY_OP_OSPEED: 14400, - } - width, height, err := term.GetSize(int(os.Stdin.Fd())) - if err != nil { - width = 80 - height = 40 - } - if err := session.RequestPty("xterm", width, height, modes); err != nil { - return "", fmt.Errorf("error requesting pseudo terminal: %w", err) + // closeOnce ensures session and client are closed exactly once, + // whether by the watchdog goroutine (context cancellation) or by deferred cleanup. + var closeOnce sync.Once + closeResources := func() { + _ = session.Close() + _ = sshClient.Close() } + defer closeOnce.Do(closeResources) + go func() { + <-runCtx.Done() + closeOnce.Do(closeResources) + }() - // set terminal to raw for easier stdin/out/err handling - // between the os and ssh session - if printOutput && term.IsTerminal(int(os.Stdin.Fd())) { - oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if printOutput { + modes := ssh.TerminalModes{ + ssh.ECHO: 0, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, + } + width, height, err := term.GetSize(int(os.Stdin.Fd())) if err != nil { - return "", fmt.Errorf("failed to set terminal to raw mode: %v", err) + width = 80 + height = 40 } - defer func() { - if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil { - slog.Error("Unable to return terminal to original state.", "err", err) + if err := session.RequestPty("xterm", width, height, modes); err != nil { + return "", fmt.Errorf("error requesting pseudo terminal: %w", err) + } + + if term.IsTerminal(int(os.Stdin.Fd())) { + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return "", fmt.Errorf("failed to set terminal to raw mode: %v", err) } - }() - } + defer func() { + if err := term.Restore(int(os.Stdin.Fd()), oldState); err != nil { + slog.Error("Unable to return terminal to original state.", "err", err) + } + }() + } - // setup some stdout/err pipes so we can capture output - if printOutput { session.Stdin = os.Stdin } stdoutPipe, err := session.StdoutPipe() @@ -138,7 +154,7 @@ func (c *Context) runCommand(cmd *exec.Cmd, printOutput bool) (string, error) { if printOutput { fmt.Print(chunk) } - output = chunk + output.WriteString(chunk) } if err != nil { if err == io.EOF { @@ -152,10 +168,10 @@ func (c *Context) runCommand(cmd *exec.Cmd, printOutput bool) (string, error) { if err = session.Wait(); err != nil { // do not mark error on sigint if exitErr, ok := err.(*ssh.ExitError); ok && exitErr.ExitStatus() == 130 { - return output, nil + return output.String(), nil } return "", fmt.Errorf("error waiting for remote command %q: %v", remoteCmd, err) } - return output, nil + return output.String(), nil } diff --git a/pkg/config/cmd_test.go b/pkg/config/cmd_test.go index 09a8752..57c0a32 100644 --- a/pkg/config/cmd_test.go +++ b/pkg/config/cmd_test.go @@ -19,3 +19,19 @@ func TestRunCommandLocal(t *testing.T) { t.Fatalf("expected output to contain 'hello', got %v", output) } } + +func TestRunCommandRemoteSudoUnsupported(t *testing.T) { + ctx := &Context{ + DockerHostType: ContextRemote, + SSHUser: "deploy", + SSHHostname: "example.org", + } + + _, err := ctx.RunCommand(exec.Command("docker", "ps")) + if err == nil { + t.Fatal("expected remote ssh error") + } + if !strings.Contains(err.Error(), "error establishing SSH connection") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/config/context.go b/pkg/config/context.go index d943a47..3382319 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -3,8 +3,6 @@ package config import ( "errors" "fmt" - "io" - "log" "log/slog" "net/url" "os" @@ -14,7 +12,6 @@ import ( "strings" "time" - "github.com/pkg/sftp" "github.com/spf13/pflag" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/knownhosts" @@ -46,7 +43,6 @@ type Context struct { SSHKeyPath string `yaml:"ssh-key,omitempty"` EnvFile []string `yaml:"env-file"` ComposeFile []string `yaml:"compose-file,omitempty"` - RunSudo bool `yaml:"sudo"` // Database connection configuration DatabaseService string `yaml:"database-service,omitempty"` @@ -54,7 +50,7 @@ type Context struct { DatabasePasswordSecret string `yaml:"database-password-secret,omitempty"` DatabaseName string `yaml:"database-name,omitempty"` - ReadSmallFileFunc func(filename string) string `yaml:"-"` + ReadSmallFileFunc func(filename string) (string, error) `yaml:"-"` // Extra holds plugin-specific configuration. // Each plugin uses its own key (e.g., "drupal", "isle", "wordpress"). @@ -193,7 +189,7 @@ func ResolveCurrentContextName(f *pflag.FlagSet) (string, error) { return c, nil } -func (c *Context) ReadSmallFile(filename string) string { +func (c *Context) ReadSmallFile(filename string) (string, error) { if c.ReadSmallFileFunc != nil { return c.ReadSmallFileFunc(filename) } @@ -201,41 +197,22 @@ func (c *Context) ReadSmallFile(filename string) string { if c.DockerHostType == ContextLocal { data, err := os.ReadFile(filename) if err != nil { - slog.Error("Error reading file", "file", filename, "err", err) - return "" + return "", fmt.Errorf("read file %q: %w", filename, err) } - - return string(data) - } - client, err := c.DialSSH() - if err != nil { - slog.Error("Error establishing SSH connection", "err", err) - return "" + return string(data), nil } - defer client.Close() - sftpClient, err := sftp.NewClient(client) + accessor, err := c.NewFileAccessor() if err != nil { - slog.Error("Error creating SFTP client", "err", err) - return "" + return "", fmt.Errorf("create file accessor: %w", err) } - defer sftpClient.Close() + defer accessor.Close() - // Use SFTP to read the file securely - remoteFile, err := sftpClient.Open(filename) + data, err := accessor.ReadFile(filename) if err != nil { - slog.Error("Error opening remote file", "file", filename, "err", err) - return "" - } - defer remoteFile.Close() - - data, err := io.ReadAll(remoteFile) - if err != nil { - slog.Error("Error reading remote file", "file", filename, "err", err) - return "" + return "", fmt.Errorf("read remote file %q: %w", filename, err) } - - return string(data) + return string(data), nil } func (c Context) EffectiveComposeProjectName() string { @@ -258,6 +235,9 @@ func (c *Context) DialSSH() (*ssh.Client, error) { // Check if the error is due to encryption (passphrase required) var ppErr *ssh.PassphraseMissingError if errors.As(err, &ppErr) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil, fmt.Errorf("ssh key %s requires a passphrase, but no interactive terminal is available", c.SSHKeyPath) + } // Key is encrypted, prompt for passphrase fmt.Printf("Enter passphrase for SSH key %s: ", c.SSHKeyPath) passphrase, err := term.ReadPassword(int(os.Stdin.Fd())) @@ -276,7 +256,10 @@ func (c *Context) DialSSH() (*ssh.Client, error) { } } - knownHostsPath := filepath.Join(filepath.Dir(c.SSHKeyPath), "known_hosts") + knownHostsPath, err := defaultKnownHostsPath() + if err != nil { + return nil, fmt.Errorf("error resolving known_hosts path: %w", err) + } slog.Debug("Setting known_hosts", "known_hosts", knownHostsPath) hostKeyCallback, err := knownhosts.New(knownHostsPath) if err != nil { @@ -316,6 +299,21 @@ func (c *Context) DialSSH() (*ssh.Client, error) { return client, nil } +func defaultKnownHostsPath() (string, error) { + home := os.Getenv("HOME") + if strings.TrimSpace(home) == "" { + u, err := user.Current() + if err != nil { + return "", err + } + home = u.HomeDir + } + if strings.TrimSpace(home) == "" { + return "", fmt.Errorf("unable to determine user home directory") + } + return filepath.Join(home, ".ssh", "known_hosts"), nil +} + func (c *Context) ProjectDirExists() (bool, error) { if c.DockerHostType == ContextLocal { _, err := os.Stat(c.ProjectDir) @@ -329,21 +327,13 @@ func (c *Context) ProjectDirExists() (bool, error) { return !os.IsNotExist(err), nil } - client, err := c.DialSSH() + accessor, err := c.NewFileAccessor() if err != nil { slog.Error("Error establishing SSH connection", "err", err) return false, err } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) - if err != nil { - slog.Error("Error creating SFTP client", "err", err) - return false, err - } - defer sftpClient.Close() - - _, err = sftpClient.Stat(c.ProjectDir) + defer accessor.Close() + _, err = accessor.Stat(c.ProjectDir) if err != nil { return false, nil } @@ -459,37 +449,13 @@ func (cc *Context) VerifyRemoteInput(existingSite bool) error { } func (c *Context) UploadFile(source, destination string) error { - client, err := c.DialSSH() + accessor, err := c.NewFileAccessor() if err != nil { slog.Error("Error establishing SSH connection", "err", err) return err } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) - if err != nil { - log.Fatal(err) - } - defer sftpClient.Close() - - localFile, err := os.Open(source) - if err != nil { - log.Fatal(err) - } - defer localFile.Close() - - remoteFile, err := sftpClient.Create(destination) - if err != nil { - return err - } - defer remoteFile.Close() - - _, err = remoteFile.ReadFrom(localFile) - if err != nil { - return err - } - - return nil + defer accessor.Close() + return accessor.UploadFile(source, destination) } // GetSshUri returns an SSH connection URI diff --git a/pkg/config/context_test.go b/pkg/config/context_test.go index dbf97a4..cc037b3 100644 --- a/pkg/config/context_test.go +++ b/pkg/config/context_test.go @@ -111,7 +111,6 @@ func contextsEqual(a, b Context) bool { a.SSHKeyPath == b.SSHKeyPath && len(a.EnvFile) == len(b.EnvFile) && len(a.ComposeFile) == len(b.ComposeFile) && - a.RunSudo == b.RunSudo && a.DatabaseService == b.DatabaseService && a.DatabaseUser == b.DatabaseUser && a.DatabasePasswordSecret == b.DatabasePasswordSecret && @@ -291,7 +290,10 @@ func TestReadSmallFileLocal(t *testing.T) { ctx := &Context{ DockerHostType: ContextLocal, } - readContent := ctx.ReadSmallFile(filePath) + readContent, err := ctx.ReadSmallFile(filePath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } if readContent != content { t.Fatalf("expected %q, got %q", content, readContent) } @@ -317,6 +319,21 @@ func TestDialSSHError(t *testing.T) { } } +func TestDefaultKnownHostsPathUsesHomeSSHDirectory(t *testing.T) { + tempHome := t.TempDir() + t.Setenv("HOME", tempHome) + + path, err := defaultKnownHostsPath() + if err != nil { + t.Fatalf("defaultKnownHostsPath() error = %v", err) + } + + want := filepath.Join(tempHome, ".ssh", "known_hosts") + if path != want { + t.Fatalf("defaultKnownHostsPath() = %q, want %q", path, want) + } +} + func TestProjectDirExistsLocal(t *testing.T) { tempHome := t.TempDir() t.Setenv("HOME", tempHome) diff --git a/pkg/config/discovery.go b/pkg/config/discovery.go index fabf1d3..2ecb9d7 100644 --- a/pkg/config/discovery.go +++ b/pkg/config/discovery.go @@ -137,8 +137,8 @@ func readComposeDiscoveryDocForContext(ctx *Context) (composeDiscoveryDoc, bool) if err != nil || !exists { continue } - data := ctx.ReadSmallFile(path) - if strings.TrimSpace(data) == "" { + data, err := ctx.ReadSmallFile(path) + if err != nil || strings.TrimSpace(data) == "" { continue } var doc composeDiscoveryDoc diff --git a/pkg/config/file_accessor.go b/pkg/config/file_accessor.go new file mode 100644 index 0000000..49988d0 --- /dev/null +++ b/pkg/config/file_accessor.go @@ -0,0 +1,337 @@ +package config + +import ( + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" +) + +const maxRemoteReadBytes int64 = 4 << 20 +const remoteReadConcurrency = 8 + +type FileAccessor struct { + ctx *Context + ssh *ssh.Client + sftp *sftp.Client + ownsSSH bool +} + +func (c *Context) NewFileAccessor() (*FileAccessor, error) { + return NewFileAccessor(c) +} + +func NewFileAccessor(ctx *Context) (*FileAccessor, error) { + return NewFileAccessorWithSSH(ctx, nil, true) +} + +func NewFileAccessorWithSSH(ctx *Context, sshClient *ssh.Client, ownsSSH bool) (*FileAccessor, error) { + accessor := &FileAccessor{ctx: ctx, ownsSSH: ownsSSH} + if ctx == nil || ctx.DockerHostType == ContextLocal { + return accessor, nil + } + if sshClient == nil { + var err error + sshClient, err = ctx.DialSSH() + if err != nil { + return nil, err + } + } + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + if ownsSSH { + sshClient.Close() + } + return nil, err + } + accessor.ssh = sshClient + accessor.sftp = sftpClient + return accessor, nil +} + +func (a *FileAccessor) Close() error { + if a == nil { + return nil + } + if a.sftp != nil { + _ = a.sftp.Close() + } + if a.ssh != nil && a.ownsSSH { + return a.ssh.Close() + } + return nil +} + +func (a *FileAccessor) ReadFile(filename string) ([]byte, error) { + return a.ReadFileContext(context.Background(), filename) +} + +func (a *FileAccessor) ReadFileContext(ctx context.Context, filename string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + return os.ReadFile(filename) + } + remoteFile, err := a.sftp.Open(filename) + if err != nil { + return nil, err + } + defer remoteFile.Close() + return readAllLimited(remoteFile, maxRemoteReadBytes) +} + +func (a *FileAccessor) ReadFiles(paths []string) (map[string][]byte, error) { + return a.ReadFilesContext(context.Background(), paths) +} + +func (a *FileAccessor) ReadFilesContext(ctx context.Context, paths []string) (map[string][]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + results := make(map[string][]byte, len(paths)) + missing := make([]string, 0, len(paths)) + + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + missing = append(missing, path) + } + if len(missing) == 0 { + return results, nil + } + + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + for _, path := range missing { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + results[path] = data + } + return results, nil + } + + type readResult struct { + path string + data []byte + err error + } + + workers := remoteReadConcurrency + if len(missing) < workers { + workers = len(missing) + } + if workers < 1 { + workers = 1 + } + + jobs := make(chan string, len(missing)) + out := make(chan readResult, len(missing)) + var wg sync.WaitGroup + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case path, ok := <-jobs: + if !ok { + return + } + if err := ctx.Err(); err != nil { + out <- readResult{path: path, err: err} + return + } + remoteFile, err := a.sftp.Open(path) + if err != nil { + out <- readResult{path: path, err: err} + cancel() + return + } + data, err := readAllLimited(remoteFile, maxRemoteReadBytes) + remoteFile.Close() + out <- readResult{path: path, data: data, err: err} + if err != nil { + cancel() + return + } + } + } + }() + } + +enqueue: + for _, path := range missing { + if err := ctx.Err(); err != nil { + break + } + select { + case <-ctx.Done(): + break enqueue + case jobs <- path: + } + } + close(jobs) + + go func() { + wg.Wait() + close(out) + }() + + var firstErr error + for result := range out { + if result.err != nil && firstErr == nil { + firstErr = result.err + cancel() + continue + } + if result.err != nil { + continue + } + results[result.path] = result.data + } + if firstErr != nil { + return nil, firstErr + } + return results, nil +} + +func (a *FileAccessor) WriteFile(filename string, data []byte) error { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { + return err + } + return os.WriteFile(filename, data, 0o644) + } + if err := mkdirAllRemote(a.sftp, filepath.Dir(filename)); err != nil { + return err + } + remoteFile, err := a.sftp.Create(filename) + if err != nil { + return err + } + defer remoteFile.Close() + _, err = remoteFile.Write(data) + return err +} + +func (a *FileAccessor) RemoveFile(filename string) error { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + if err := os.Remove(filename); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + if err := a.sftp.Remove(filename); err != nil && !isSFTPNotExist(err) { + return err + } + return nil +} + +func (a *FileAccessor) ListFiles(root string) ([]string, error) { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + return listLocalFiles(root) + } + walker := a.sftp.Walk(root) + files := []string{} + for walker.Step() { + if err := walker.Err(); err != nil { + return nil, err + } + if walker.Stat().IsDir() { + continue + } + rel, err := filepath.Rel(root, walker.Path()) + if err != nil { + return nil, err + } + files = append(files, filepath.ToSlash(rel)) + } + return files, nil +} + +func (a *FileAccessor) FileExists(path string) (bool, error) { + if a == nil || a.ctx == nil { + return false, nil + } + if strings.TrimSpace(path) == "" { + return false, nil + } + if a.ctx.DockerHostType == ContextLocal { + _, err := os.Stat(path) + if os.IsNotExist(err) { + return false, nil + } + return err == nil, err + } + _, err := a.sftp.Stat(path) + if err != nil { + return false, nil + } + return true, nil +} + +func (a *FileAccessor) Stat(path string) (fs.FileInfo, error) { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + return os.Stat(path) + } + return a.sftp.Stat(path) +} + +func (a *FileAccessor) UploadFile(source, destination string) error { + localFile, err := os.Open(source) + if err != nil { + return err + } + defer localFile.Close() + + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + remoteFile, err := os.Create(destination) + if err != nil { + return err + } + defer remoteFile.Close() + _, err = io.Copy(remoteFile, localFile) + return err + } + + if err := mkdirAllRemote(a.sftp, filepath.Dir(destination)); err != nil { + return err + } + remoteFile, err := a.sftp.Create(destination) + if err != nil { + return err + } + defer remoteFile.Close() + _, err = io.Copy(remoteFile, localFile) + return err +} + +func readAllLimited(r io.Reader, limit int64) ([]byte, error) { + limited := io.LimitReader(r, limit+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, fmt.Errorf("remote file exceeds %d bytes", limit) + } + return data, nil +} diff --git a/pkg/config/files.go b/pkg/config/files.go index 078f98a..5510cec 100644 --- a/pkg/config/files.go +++ b/pkg/config/files.go @@ -2,9 +2,7 @@ package config import ( "fmt" - "io" "io/fs" - "os" "path/filepath" "strings" @@ -13,136 +11,55 @@ import ( // ReadFile reads a file from the context, supporting local and remote paths. func (c *Context) ReadFile(filename string) ([]byte, error) { - if c.DockerHostType == ContextLocal { - return os.ReadFile(filename) - } - - client, err := c.DialSSH() - if err != nil { - return nil, fmt.Errorf("dial ssh: %w", err) - } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) - if err != nil { - return nil, fmt.Errorf("create sftp client: %w", err) - } - defer sftpClient.Close() - - remoteFile, err := sftpClient.Open(filename) + accessor, err := c.NewFileAccessor() if err != nil { - return nil, fmt.Errorf("open remote file %q: %w", filename, err) + return nil, fmt.Errorf("create file accessor: %w", err) } - defer remoteFile.Close() - - data, err := io.ReadAll(remoteFile) + defer accessor.Close() + data, err := accessor.ReadFile(filename) if err != nil { - return nil, fmt.Errorf("read remote file %q: %w", filename, err) + return nil, fmt.Errorf("read file %q: %w", filename, err) } - return data, nil } // WriteFile writes a file to the context, creating parent directories as needed. func (c *Context) WriteFile(filename string, data []byte) error { - if c.DockerHostType == ContextLocal { - if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { - return fmt.Errorf("create parent directories for %q: %w", filename, err) - } - return os.WriteFile(filename, data, 0o644) - } - - client, err := c.DialSSH() - if err != nil { - return fmt.Errorf("dial ssh: %w", err) - } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) + accessor, err := c.NewFileAccessor() if err != nil { - return fmt.Errorf("create sftp client: %w", err) + return fmt.Errorf("create file accessor: %w", err) } - defer sftpClient.Close() - - if err := mkdirAllRemote(sftpClient, filepath.Dir(filename)); err != nil { - return err + defer accessor.Close() + if err := accessor.WriteFile(filename, data); err != nil { + return fmt.Errorf("write file %q: %w", filename, err) } - - remoteFile, err := sftpClient.Create(filename) - if err != nil { - return fmt.Errorf("create remote file %q: %w", filename, err) - } - defer remoteFile.Close() - - if _, err := remoteFile.Write(data); err != nil { - return fmt.Errorf("write remote file %q: %w", filename, err) - } - return nil } // RemoveFile removes a file from the context. func (c *Context) RemoveFile(filename string) error { - if c.DockerHostType == ContextLocal { - if err := os.Remove(filename); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - - client, err := c.DialSSH() - if err != nil { - return fmt.Errorf("dial ssh: %w", err) - } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) + accessor, err := c.NewFileAccessor() if err != nil { - return fmt.Errorf("create sftp client: %w", err) + return fmt.Errorf("create file accessor: %w", err) } - defer sftpClient.Close() - - if err := sftpClient.Remove(filename); err != nil && !isSFTPNotExist(err) { - return fmt.Errorf("remove remote file %q: %w", filename, err) + defer accessor.Close() + if err := accessor.RemoveFile(filename); err != nil { + return fmt.Errorf("remove file %q: %w", filename, err) } - return nil } // ListFiles lists files under a directory relative to the directory root. func (c *Context) ListFiles(root string) ([]string, error) { - if c.DockerHostType == ContextLocal { - return listLocalFiles(root) - } - - client, err := c.DialSSH() + accessor, err := c.NewFileAccessor() if err != nil { - return nil, fmt.Errorf("dial ssh: %w", err) + return nil, fmt.Errorf("create file accessor: %w", err) } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) + defer accessor.Close() + files, err := accessor.ListFiles(root) if err != nil { - return nil, fmt.Errorf("create sftp client: %w", err) + return nil, fmt.Errorf("list files under %q: %w", root, err) } - defer sftpClient.Close() - - walker := sftpClient.Walk(root) - files := []string{} - for walker.Step() { - if err := walker.Err(); err != nil { - return nil, fmt.Errorf("walk remote path %q: %w", walker.Path(), err) - } - if walker.Stat().IsDir() { - continue - } - rel, err := filepath.Rel(root, walker.Path()) - if err != nil { - return nil, fmt.Errorf("get relative path for %q: %w", walker.Path(), err) - } - files = append(files, filepath.ToSlash(rel)) - } - return files, nil } diff --git a/pkg/config/utils.go b/pkg/config/utils.go index cd54ebc..ddb9675 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -136,7 +136,6 @@ func contextHasStoredValues(context Context) bool { context.SSHKeyPath != "" || len(context.EnvFile) > 0 || len(context.ComposeFile) > 0 || - context.RunSudo || context.DatabaseService != "" || context.DatabaseUser != "" || context.DatabasePasswordSecret != "" || diff --git a/pkg/config/utils_test.go b/pkg/config/utils_test.go index ac18f04..4a70ca8 100644 --- a/pkg/config/utils_test.go +++ b/pkg/config/utils_test.go @@ -106,9 +106,6 @@ func TestLoadFromFlags(t *testing.T) { if ctx.Environment != "staging" { t.Errorf("Expected environment 'staging', got %q", ctx.Environment) } - if ctx.RunSudo != true { - t.Errorf("Expected site 'true', got %t", ctx.RunSudo) - } expectedSlice := []string{".env", "/tmp/.env"} if !reflect.DeepEqual(ctx.EnvFile, expectedSlice) { t.Errorf("expected env-file slice %v but got %v", expectedSlice, ctx.EnvFile) diff --git a/pkg/config/validation_helpers.go b/pkg/config/validation_helpers.go index dfcde8c..178c916 100644 --- a/pkg/config/validation_helpers.go +++ b/pkg/config/validation_helpers.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/kballard/go-shellquote" - "github.com/pkg/sftp" ) func IsDockerSocketAlive(socket string) bool { @@ -31,19 +30,12 @@ func (c *Context) FileExists(path string) (bool, error) { return err == nil, err } - client, err := c.DialSSH() + accessor, err := c.NewFileAccessor() if err != nil { return false, err } - defer client.Close() - - sftpClient, err := sftp.NewClient(client) - if err != nil { - return false, err - } - defer sftpClient.Close() - - _, err = sftpClient.Stat(path) + defer accessor.Close() + _, err = accessor.Stat(path) if err != nil { return false, nil } diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index f1e7df5..00be6dc 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -118,7 +118,11 @@ func GetSecret(ctx context.Context, cli DockerAPI, c *config.Context, containerN for _, mount := range containerJSON.Mounts { if mount.Destination == expectedTarget { secretFilePath := filepath.Join(c.ProjectDir, "secrets", secretName) - return c.ReadSmallFile(secretFilePath), nil + secret, err := c.ReadSmallFile(secretFilePath) + if err != nil { + return "", fmt.Errorf("read secret %q: %w", secretName, err) + } + return secret, nil } } return GetConfigEnv(ctx, cli, containerName, secretName) @@ -161,8 +165,10 @@ func (d *DockerClient) GetServiceIp(ctx context.Context, c *config.Context, cont } func (d *DockerClient) GetContainerName(c *config.Context, service string) (string, error) { - ctx := context.Background() + return d.GetContainerNameContext(context.Background(), c, service) +} +func (d *DockerClient) GetContainerNameContext(ctx context.Context, c *config.Context, service string) (string, error) { // Define the filters based on the Docker Compose labels. filterArgs := filters.NewArgs() filterArgs.Add("label", "com.docker.compose.project="+c.EffectiveComposeProjectName()) @@ -338,7 +344,7 @@ func getDatabaseURIsWithClient(ctx context.Context, dockerCli *DockerClient, c * dbHost := "127.0.0.1" // Get the database container name - containerName, err := dockerCli.GetContainerName(c, c.DatabaseService) + containerName, err := dockerCli.GetContainerNameContext(ctx, c, c.DatabaseService) if err != nil { return "", "", fmt.Errorf("failed to get %s container: %w", c.DatabaseService, err) } diff --git a/pkg/docker/docker_test.go b/pkg/docker/docker_test.go index e66580f..20caba5 100644 --- a/pkg/docker/docker_test.go +++ b/pkg/docker/docker_test.go @@ -127,11 +127,11 @@ func TestGetSecret_MountedSecret(t *testing.T) { fakeConfig := &config.Context{ ProjectDir: "/tmp/project", ProjectName: "test", - ReadSmallFileFunc: func(path string) string { + ReadSmallFileFunc: func(path string) (string, error) { if strings.HasSuffix(path, filepath.Join("secrets", "secretName")) { - return "fileSecret" + return "fileSecret", nil } - return "" + return "", nil }, } secret, err := GetSecret(context.Background(), fake, fakeConfig, "dummyContainer", "secretName") diff --git a/pkg/docker/summary.go b/pkg/docker/summary.go index c47d634..68bcc6b 100644 --- a/pkg/docker/summary.go +++ b/pkg/docker/summary.go @@ -12,9 +12,7 @@ import ( dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" - "github.com/kballard/go-shellquote" "github.com/libops/sitectl/pkg/config" - "golang.org/x/crypto/ssh" ) type ServiceSummary struct { @@ -136,33 +134,7 @@ func runComposePS(ctxCfg *config.Context) (string, error) { output, err := cmd.CombinedOutput() return string(output), err } - - remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) - if ctxCfg.RunSudo { - remoteCmd += "sudo " - } - remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) - - client, err := ctxCfg.DialSSH() - if err != nil { - return "", err - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - output, err := session.CombinedOutput(remoteCmd) - if err != nil { - if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { - return string(output), nil - } - return string(output), err - } - return string(output), nil + return ctxCfg.RunQuietCommand(exec.Command("docker", args...)) } func runDockerStats(ctxCfg *config.Context) (string, error) { @@ -173,33 +145,7 @@ func runDockerStats(ctxCfg *config.Context) (string, error) { output, err := cmd.CombinedOutput() return string(output), err } - - remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) - if ctxCfg.RunSudo { - remoteCmd += "sudo " - } - remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) - - client, err := ctxCfg.DialSSH() - if err != nil { - return "", err - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - output, err := session.CombinedOutput(remoteCmd) - if err != nil { - if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { - return string(output), nil - } - return string(output), err - } - return string(output), nil + return ctxCfg.RunQuietCommand(exec.Command("docker", args...)) } func runHostMetrics(ctxCfg *config.Context) (string, error) { @@ -259,33 +205,7 @@ printf '{"load1":"%s","cpu_count":"%s","disk_total_kb":"%s","disk_avail_kb":"%s" output, err := cmd.CombinedOutput() return string(output), err } - - client, err := ctxCfg.DialSSH() - if err != nil { - return "", err - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) - if ctxCfg.RunSudo { - remoteCmd += "sudo " - } - remoteCmd += shellquote.Join("sh", "-lc", script) - - output, err := session.CombinedOutput(remoteCmd) - if err != nil { - if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { - return string(output), nil - } - return string(output), err - } - return string(output), nil + return ctxCfg.RunQuietCommand(exec.Command("sh", "-lc", script)) } func composePSArgs(ctxCfg config.Context) []string { diff --git a/pkg/plugin/files.go b/pkg/plugin/files.go index b8e90fa..df360b0 100644 --- a/pkg/plugin/files.go +++ b/pkg/plugin/files.go @@ -1,21 +1,23 @@ package plugin import ( + "context" "fmt" "io" "io/fs" "os" "path/filepath" "sort" - "strings" "sync" - "github.com/kballard/go-shellquote" "github.com/libops/sitectl/pkg/config" "github.com/pkg/sftp" "golang.org/x/crypto/ssh" ) +const maxRemoteReadBytes int64 = 4 << 20 +const remoteReadConcurrency = 8 + type FileAccessor struct { ctx *config.Context ssh *ssh.Client @@ -28,23 +30,18 @@ type FileAccessor struct { } func (s *SDK) GetFileAccessor() (*FileAccessor, error) { - if s.fileAccessor != nil { - return s.fileAccessor, nil - } ctx, err := s.GetContext() if err != nil { return nil, err } if ctx == nil || ctx.DockerHostType == config.ContextLocal { - s.fileAccessor, err = NewFileAccessor(ctx) - return s.fileAccessor, err + return NewFileAccessor(ctx) } sshClient, err := s.getSSHClient() if err != nil { return nil, err } - s.fileAccessor, err = NewFileAccessorWithSSH(ctx, sshClient, false) - return s.fileAccessor, err + return NewFileAccessorWithSSH(ctx, sshClient, false) } func NewFileAccessor(ctx *config.Context) (*FileAccessor, error) { @@ -95,6 +92,13 @@ func (a *FileAccessor) Close() error { } func (a *FileAccessor) ReadFile(path string) ([]byte, error) { + return a.ReadFileContext(context.Background(), path) +} + +func (a *FileAccessor) ReadFileContext(ctx context.Context, path string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if data, ok := a.cachedFile(path); ok { return data, nil } @@ -109,7 +113,7 @@ func (a *FileAccessor) ReadFile(path string) ([]byte, error) { return nil, openErr } defer file.Close() - data, err = io.ReadAll(file) + data, err = readAllLimited(file, maxRemoteReadBytes) } if err != nil { return nil, err @@ -119,16 +123,22 @@ func (a *FileAccessor) ReadFile(path string) ([]byte, error) { } func (a *FileAccessor) ReadFiles(paths []string) (map[string][]byte, error) { + return a.ReadFilesContext(context.Background(), paths) +} + +func (a *FileAccessor) ReadFilesContext(ctx context.Context, paths []string) (map[string][]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + ctx, cancel := context.WithCancel(ctx) + defer cancel() results := make(map[string][]byte, len(paths)) missing := make([]string, 0, len(paths)) - seen := map[string]bool{} for _, path := range paths { - path = strings.TrimSpace(path) - if path == "" || seen[path] { + if path == "" { continue } - seen[path] = true if data, ok := a.cachedFile(path); ok { results[path] = data continue @@ -152,40 +162,91 @@ func (a *FileAccessor) ReadFiles(paths []string) (map[string][]byte, error) { return results, nil } - session, err := a.ssh.NewSession() - if err != nil { - return nil, err + type readResult struct { + path string + data []byte + err error + } + + workers := remoteReadConcurrency + if len(missing) < workers { + workers = len(missing) + } + if workers < 1 { + workers = 1 + } + + jobs := make(chan string, len(missing)) + out := make(chan readResult, len(missing)) + var wg sync.WaitGroup + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case path, ok := <-jobs: + if !ok { + return + } + if err := ctx.Err(); err != nil { + out <- readResult{path: path, err: err} + return + } + file, err := a.sftp.Open(path) + if err != nil { + out <- readResult{path: path, err: err} + cancel() + return + } + data, err := readAllLimited(file, maxRemoteReadBytes) + file.Close() + out <- readResult{path: path, data: data, err: err} + if err != nil { + cancel() + return + } + } + } + }() } - defer session.Close() - var builder strings.Builder +enqueue: for _, path := range missing { - quoted := shellquote.Join(path) - builder.WriteString("printf '__SITECTL_START__%s\\n' ") - builder.WriteString(quoted) - builder.WriteString("; cat ") - builder.WriteString(quoted) - builder.WriteString("; printf '\\n__SITECTL_END__%s\\n' ") - builder.WriteString(quoted) - builder.WriteString("; ") + if err := ctx.Err(); err != nil { + break + } + select { + case <-ctx.Done(): + break enqueue + case jobs <- path: + } } + close(jobs) - output, err := session.CombinedOutput(builder.String()) - if err != nil { - return nil, err - } + go func() { + wg.Wait() + close(out) + }() - parsed, err := parseBatchedFileOutput(string(output)) - if err != nil { - return nil, err - } - for _, path := range missing { - data, ok := parsed[path] - if !ok { - return nil, fmt.Errorf("missing batched file content for %s", path) + var firstErr error + for result := range out { + if result.err != nil && firstErr == nil { + firstErr = result.err + cancel() + continue + } + if result.err != nil { + continue } - a.storeFile(path, data) - results[path] = append([]byte(nil), data...) + a.storeFile(result.path, result.data) + results[result.path] = append([]byte(nil), result.data...) + } + if firstErr != nil { + return nil, firstErr } return results, nil } @@ -365,37 +426,14 @@ func (a *FileAccessor) storeList(root string, files []string) { a.mu.Unlock() } -func parseBatchedFileOutput(output string) (map[string][]byte, error) { - const startPrefix = "__SITECTL_START__" - const endPrefix = "__SITECTL_END__" - - results := map[string][]byte{} - var currentPath string - var current strings.Builder - - for _, line := range strings.Split(output, "\n") { - switch { - case strings.HasPrefix(line, startPrefix): - currentPath = strings.TrimPrefix(line, startPrefix) - current.Reset() - case strings.HasPrefix(line, endPrefix): - endPath := strings.TrimPrefix(line, endPrefix) - if currentPath == "" || endPath != currentPath { - return nil, fmt.Errorf("batched file output markers out of sync") - } - results[currentPath] = []byte(strings.TrimSuffix(current.String(), "\n")) - currentPath = "" - default: - if currentPath == "" { - continue - } - current.WriteString(line) - current.WriteString("\n") - } +func readAllLimited(r io.Reader, limit int64) ([]byte, error) { + limited := io.LimitReader(r, limit+1) + data, err := io.ReadAll(limited) + if err != nil { + return nil, err } - - if currentPath != "" { - return nil, fmt.Errorf("unterminated batched file output for %s", currentPath) + if int64(len(data)) > limit { + return nil, fmt.Errorf("remote file exceeds %d bytes", limit) } - return results, nil + return data, nil } diff --git a/pkg/plugin/sdk.go b/pkg/plugin/sdk.go index 03284b4..901799e 100644 --- a/pkg/plugin/sdk.go +++ b/pkg/plugin/sdk.go @@ -8,8 +8,10 @@ import ( "log/slog" "os" "os/exec" + "os/signal" "strconv" "strings" + "syscall" "charm.land/fang/v2" "github.com/libops/sitectl/pkg/component" @@ -52,8 +54,6 @@ type SDK struct { contextValidators []validate.Validator contextCache *config.Context sshClient *ssh.Client - dockerClient *docker.DockerClient - fileAccessor *FileAccessor } // NewSDK creates a new plugin SDK instance @@ -70,6 +70,9 @@ func NewSDK(metadata Metadata) *SDK { PersistentPreRunE: func(cmd *cobra.Command, args []string) error { return sdk.setupLogging(cmd) }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + sdk.Close() + }, Annotations: map[string]string{ cobra.CommandDisplayNameAnnotation: fmt.Sprintf("sitectl %s", metadata.Name), }, @@ -133,8 +136,14 @@ func (s *SDK) AddCommand(cmd *cobra.Command) { // Execute runs the plugin func (s *SDK) Execute() { + runCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + go func() { + <-runCtx.Done() + _ = s.Close() + }() if err := fang.Execute( - context.Background(), + runCtx, s.RootCmd, fang.WithVersion(s.RootCmd.Version), ); err != nil { @@ -176,26 +185,18 @@ func (s *SDK) GetMetadataCommand() *cobra.Command { // This is a helper for plugins that need to interact with Docker // Returns the existing DockerClient which handles both local and remote contexts func (s *SDK) GetDockerClient() (*docker.DockerClient, error) { - if s.dockerClient != nil { - return s.dockerClient, nil - } ctx, err := s.GetContext() if err != nil { return nil, fmt.Errorf("failed to get context: %w", err) } if ctx.DockerHostType == config.ContextLocal { - s.dockerClient, err = docker.GetDockerCli(ctx) - return s.dockerClient, err + return docker.GetDockerCli(ctx) } sshClient, err := s.getSSHClient() if err != nil { return nil, err } - s.dockerClient, err = docker.GetDockerCliWithSSH(ctx, sshClient, false) - if err != nil { - return nil, err - } - return s.dockerClient, nil + return docker.GetDockerCliWithSSH(ctx, sshClient, false) } func (s *SDK) GetSSHClient() (*ssh.Client, error) { @@ -260,6 +261,18 @@ func (s *SDK) getSSHClient() (*ssh.Client, error) { return s.sshClient, nil } +func (s *SDK) Close() error { + if s == nil { + return nil + } + if s.sshClient != nil { + err := s.sshClient.Close() + s.sshClient = nil + return err + } + return nil +} + func validateContextPlugin(contextPlugin, requestedPlugin string) error { contextPlugin = strings.TrimSpace(contextPlugin) requestedPlugin = strings.TrimSpace(requestedPlugin) @@ -353,31 +366,43 @@ func (s *SDK) PromptAndSaveLocalContext(opts config.LocalContextCreateOptions) ( return config.PromptAndSaveLocalContext(opts) } -// ExecInContainer executes a command in a Docker container -// This is a convenience wrapper for plugins -func (s *SDK) ExecInContainer(ctx context.Context, containerID string, cmd []string) (int, error) { +// ExecContainer executes a command in a Docker container using the shared SDK Docker path. +func (s *SDK) ExecContainer(ctx context.Context, opts docker.ExecOptions) (int, error) { cli, err := s.GetDockerClient() if err != nil { return -1, fmt.Errorf("failed to create Docker client: %w", err) } defer cli.Close() - return cli.ExecSimple(ctx, containerID, cmd) + return cli.Exec(ctx, opts) } -// ExecInContainerInteractive executes an interactive command in a Docker container with TTY -// This is a convenience wrapper for plugins -func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string, cmd []string) (int, error) { - cli, err := s.GetDockerClient() - if err != nil { - return -1, fmt.Errorf("failed to create Docker client: %w", err) - } - defer cli.Close() +// ExecInContainer executes a command in a Docker container. +// This is a convenience wrapper for plugins. +func (s *SDK) ExecInContainer(ctx context.Context, containerID string, cmd []string) (int, error) { + return s.ExecContainer(ctx, docker.ExecOptions{ + Container: containerID, + Cmd: cmd, + AttachStdout: true, + AttachStderr: true, + }) +} - return cli.ExecInteractive(ctx, containerID, cmd) +// ExecInContainerInteractive executes an interactive command in a Docker container with TTY. +// This is a convenience wrapper for plugins. +func (s *SDK) ExecInContainerInteractive(ctx context.Context, containerID string, cmd []string) (int, error) { + return s.ExecContainer(ctx, docker.ExecOptions{ + Container: containerID, + Cmd: cmd, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + }) } type CommandExecOptions struct { + Context context.Context Stdin io.Reader Stdout io.Writer Stderr io.Writer @@ -401,7 +426,11 @@ func (s *SDK) InvokePluginCommand(pluginName string, args []string, opts Command invocation = append(invocation, args...) slog.Debug("invoking plugin command", "plugin", pluginName, "path", installed.Path, "args", invocation, "capture", opts.Capture) - cmd := exec.Command(installed.Path, invocation...) + execCtx := opts.Context + if execCtx == nil { + execCtx = context.Background() + } + cmd := exec.CommandContext(execCtx, installed.Path, invocation...) cmd.Env = os.Environ() if width, ok := terminalColumns(); ok { cmd.Env = append(cmd.Env, fmt.Sprintf("COLUMNS=%d", width)) diff --git a/pkg/plugin/sdk_test.go b/pkg/plugin/sdk_test.go index e43225a..b7099e7 100644 --- a/pkg/plugin/sdk_test.go +++ b/pkg/plugin/sdk_test.go @@ -1,9 +1,15 @@ package plugin import ( + "bytes" + "os" + "path/filepath" + "reflect" + "strings" "testing" "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/validate" ) func TestGetContextAllowsIncludedPlugin(t *testing.T) { @@ -70,3 +76,188 @@ func TestContextPluginSupportsBuiltinHierarchy(t *testing.T) { t.Fatal("did not expect drupal contexts to support isle") } } + +func TestPluginIncludesMergesBuiltinAndInstalledWithoutDuplicates(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + script := `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: isle" + echo "Includes: drupal,libops" + exit 0 +fi +if [ "$1" = "create" ] && [ "$2" = "--help" ]; then + exit 0 +fi +exit 1 +` + writePluginScript(t, dir, "sitectl-isle", script) + + got := pluginIncludes("isle") + want := []string{"drupal", "libops"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("pluginIncludes() = %v, want %v", got, want) + } +} + +func TestInvokePluginCommandCapturePassesContextAndLogLevel(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + t.Setenv("COLUMNS", "123") + + script := `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: child" + exit 0 +fi +if [ "$1" = "create" ] && [ "$2" = "--help" ]; then + exit 1 +fi +printf 'ARGS=%s\n' "$*" +printf 'COLUMNS=%s\n' "$COLUMNS" +` + writePluginScript(t, dir, "sitectl-child", script) + + sdk := NewSDK(Metadata{Name: "isle"}) + sdk.Config.Context = "demo" + sdk.Config.LogLevel = "DEBUG" + + out, err := sdk.InvokePluginCommand("child", []string{"__debug", "--verbose"}, CommandExecOptions{Capture: true}) + if err != nil { + t.Fatalf("InvokePluginCommand() error = %v", err) + } + if !strings.Contains(out, "ARGS=--context demo --log-level DEBUG __debug --verbose") { + t.Fatalf("expected context/log-level args in output, got %q", out) + } + if !strings.Contains(out, "COLUMNS=123") { + t.Fatalf("expected COLUMNS env in output, got %q", out) + } +} + +func TestInvokePluginCommandCaptureReturnsStderrDetail(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + script := `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: broken" + exit 0 +fi +echo "something went wrong" >&2 +exit 2 +` + writePluginScript(t, dir, "sitectl-broken", script) + + sdk := NewSDK(Metadata{Name: "isle"}) + _, err := sdk.InvokePluginCommand("broken", []string{"__debug"}, CommandExecOptions{Capture: true}) + if err == nil { + t.Fatal("expected InvokePluginCommand() error") + } + if !strings.Contains(err.Error(), "something went wrong") { + t.Fatalf("expected stderr detail in error, got %v", err) + } +} + +func TestInvokePluginCommandCaptureCanMirrorLiveStderr(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + script := `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: noisy" + exit 0 +fi +echo "visible stderr" >&2 +echo "stdout payload" +` + writePluginScript(t, dir, "sitectl-noisy", script) + + sdk := NewSDK(Metadata{Name: "isle"}) + var stderr bytes.Buffer + out, err := sdk.InvokePluginCommand("noisy", []string{"__debug"}, CommandExecOptions{ + Capture: true, + LiveStderr: true, + Stderr: &stderr, + }) + if err != nil { + t.Fatalf("InvokePluginCommand() error = %v", err) + } + if !strings.Contains(stderr.String(), "visible stderr") { + t.Fatalf("expected mirrored stderr, got %q", stderr.String()) + } + if !strings.Contains(out, "stdout payload") { + t.Fatalf("expected stdout payload, got %q", out) + } +} + +func TestInvokeIncludedPluginCommandRejectsUnincludedPlugin(t *testing.T) { + sdk := NewSDK(Metadata{Name: "isle", Includes: []string{"drupal"}}) + + _, err := sdk.InvokeIncludedPluginCommand("libops", []string{"__debug"}, CommandExecOptions{Capture: true}) + if err == nil { + t.Fatal("expected included plugin validation error") + } + if !strings.Contains(err.Error(), `is not included by "isle"`) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestInvokeIncludedPluginsCollectsTrimmedOutputs(t *testing.T) { + dir := t.TempDir() + t.Setenv("PATH", dir) + + writePluginScript(t, dir, "sitectl-drupal", `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: drupal" + exit 0 +fi +echo " drupal output " +`) + writePluginScript(t, dir, "sitectl-libops", `#!/bin/sh +if [ "$1" = "plugin-info" ]; then + echo "Name: libops" + exit 0 +fi +echo "" +`) + + sdk := NewSDK(Metadata{Name: "isle", Includes: []string{"drupal", "libops"}}) + outputs, err := sdk.InvokeIncludedPlugins([]string{"__debug"}) + if err != nil { + t.Fatalf("InvokeIncludedPlugins() error = %v", err) + } + want := []string{"drupal output"} + if !reflect.DeepEqual(outputs, want) { + t.Fatalf("InvokeIncludedPlugins() = %v, want %v", outputs, want) + } +} + +func TestContextValidatorsReturnsCopy(t *testing.T) { + sdk := NewSDK(Metadata{Name: "isle"}) + first := validate.Validator(func(*config.Context) ([]validate.Result, error) { return nil, nil }) + second := validate.Validator(func(*config.Context) ([]validate.Result, error) { return nil, nil }) + + sdk.RegisterContextValidator(first) + sdk.RegisterContextValidator(nil) + sdk.RegisterContextValidator(second) + + got := sdk.ContextValidators() + if len(got) != 2 { + t.Fatalf("expected 2 validators, got %d", len(got)) + } + + got[0] = nil + again := sdk.ContextValidators() + if again[0] == nil { + t.Fatal("expected ContextValidators() to return a copy") + } +} + +func writePluginScript(t *testing.T, dir, name, script string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile(%s) error = %v", name, err) + } +} diff --git a/pkg/tui/dashboard.go b/pkg/tui/dashboard.go index 5ae0ef3..1895ab6 100644 --- a/pkg/tui/dashboard.go +++ b/pkg/tui/dashboard.go @@ -26,7 +26,6 @@ import ( "github.com/libops/sitectl/pkg/docker" "github.com/libops/sitectl/pkg/plugin" zone "github.com/lrstanley/bubblezone/v2" - "golang.org/x/crypto/ssh" ) type siteGroup struct { @@ -1286,33 +1285,7 @@ func fetchComposeLogs(ctx config.Context) (string, error) { output, err := cmd.CombinedOutput() return string(output), err } - - remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctx.ProjectDir)) - if ctx.RunSudo { - remoteCmd += "sudo " - } - remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) - - client, err := ctx.DialSSH() - if err != nil { - return "", err - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - output, err := session.CombinedOutput(remoteCmd) - if err != nil { - if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { - return string(output), nil - } - return string(output), err - } - return string(output), nil + return ctx.RunQuietCommand(exec.Command("docker", args...)) } func composeArgs(ctx config.Context, subcommand ...string) []string { @@ -2083,25 +2056,5 @@ func fetchContainerLogs(ctx config.Context, containerName string) (string, error output, err := cmd.CombinedOutput() return string(output), err } - - remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctx.ProjectDir)) - if ctx.RunSudo { - remoteCmd += "sudo " - } - remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) - - client, err := ctx.DialSSH() - if err != nil { - return "", err - } - defer client.Close() - - session, err := client.NewSession() - if err != nil { - return "", err - } - defer session.Close() - - output, err := session.CombinedOutput(remoteCmd) - return string(output), err + return ctx.RunQuietCommand(exec.Command("docker", args...)) }