diff --git a/cmd/debug.go b/cmd/debug.go index f0981fe..b3271e5 100644 --- a/cmd/debug.go +++ b/cmd/debug.go @@ -18,6 +18,7 @@ import ( "github.com/docker/docker/api/types/filters" dockerimage "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" + "github.com/libops/sitectl/internal/debugreport" "github.com/libops/sitectl/pkg/config" "github.com/libops/sitectl/pkg/docker" "github.com/libops/sitectl/pkg/plugin" @@ -187,7 +188,40 @@ func renderCoreDebug(runCtx context.Context, ctx config.Context) string { "", formatDebugRows(meta), } - logDiagnostics, logErr, imageDiagnostics, imageErr := collectCoreDockerDiagnostics(runCtx, &ctx) + var sharedSession *debugreport.Session + if ctx.DockerHostType == config.ContextRemote { + if session, err := debugreport.NewSession(&ctx); err != nil { + slog.Debug("shared debug session setup failed", "context", ctx.Name, "error", err) + } else { + sharedSession = session + defer sharedSession.Close() + } + } + + var hostDiagnostics debugreport.HostDiagnostics + var composeDiagnostics debugreport.ComposeDiagnostics + var logDiagnostics logDiagnostics + var imageDiagnostics imageDiagnostics + var logErr error + var imageErr error + + if sharedSession != nil { + hostDiagnostics = debugreport.CollectHostDiagnosticsWithSession(runCtx, &ctx, sharedSession) + composeDiagnostics = debugreport.CollectComposeDiagnosticsWithSession(runCtx, &ctx, sharedSession) + if cli, err := sharedSession.DockerClient(); err != nil { + logErr = err + imageErr = err + } else { + logDiagnostics, logErr = collectLogDiagnosticsWithClient(runCtx, &ctx, cli) + imageDiagnostics, imageErr = collectImageDiagnosticsWithClient(runCtx, &ctx, cli) + } + } else { + hostDiagnostics = debugreport.CollectHostDiagnostics(runCtx, &ctx) + composeDiagnostics = debugreport.CollectComposeDiagnostics(runCtx, &ctx) + logDiagnostics, logErr, imageDiagnostics, imageErr = collectCoreDockerDiagnostics(runCtx, &ctx) + } + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Host Resources"), "", formatDebugRows(hostSummaryRows(hostDiagnostics, ctx.ProjectDir))) + coreBody = append(coreBody, "", debugDivider(), "", debugTitleStyle.Render("Compose Services"), "", formatDebugRows(composeSummaryRows(composeDiagnostics))) 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))) @@ -430,6 +464,91 @@ func imageSummaryRows(diagnostics imageDiagnostics) []debugRow { return rows } +func hostSummaryRows(diagnostics debugreport.HostDiagnostics, projectDir string) []debugRow { + status := "ok" + if len(diagnostics.Issues) > 0 { + status = "warning" + } + + rows := []debugRow{ + {Label: "Host status", Value: renderStatus(status)}, + {Label: "CPUs", Value: renderDebugValue(intValueOrUnknown(diagnostics.CPUCount))}, + {Label: "Memory", Value: renderDebugValue(bytesValueOrUnknown(diagnostics.MemoryBytes))}, + {Label: "Swap", Value: renderDebugValue(bytesValueOrUnknown(diagnostics.SwapBytes))}, + {Label: "Available disk", Value: renderDebugValue(diskValueOrUnknown(diagnostics.DiskAvailableBytes, projectDir))}, + {Label: "OS version", Value: renderDebugValue(diagnostics.OSVersion)}, + } + if len(diagnostics.Issues) > 0 { + rows = append(rows, debugRow{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) + } + return rows +} + +func composeSummaryRows(diagnostics debugreport.ComposeDiagnostics) []debugRow { + status := "ok" + if len(diagnostics.Issues) > 0 { + status = "warning" + } + + rows := []debugRow{ + {Label: "Compose status", Value: renderStatus(status)}, + {Label: "Compose file", Value: renderDebugValue(diagnostics.ComposePath)}, + } + if len(diagnostics.Services) == 0 { + rows = append(rows, debugRow{Label: "Services", Value: "none found"}) + } else { + for _, service := range diagnostics.Services { + rows = append(rows, debugRow{Label: service.Service, Value: renderDebugValue(service.Image)}) + } + } + if len(diagnostics.BindMounts) > 0 { + for _, mount := range diagnostics.BindMounts { + value := mount.Target + " <- " + mount.Source + if mount.Issue != "" { + value += " (" + mount.Issue + ")" + } else { + value += ": " + humanBytes(mount.AvailableBytes) + " available" + } + rows = append(rows, debugRow{Label: "Bind mount", Value: value}) + } + } + if len(diagnostics.Issues) > 0 { + rows = append(rows, debugRow{Label: "Diagnostics", Value: strings.Join(diagnostics.Issues, "\n")}) + } + return rows +} + +func renderDebugValue(value string) string { + if strings.TrimSpace(value) == "" { + return "unknown" + } + return value +} + +func intValueOrUnknown(value int) string { + if value <= 0 { + return "" + } + return strconv.Itoa(value) +} + +func bytesValueOrUnknown(value int64) string { + if value < 0 { + return "" + } + return humanBytes(value) +} + +func diskValueOrUnknown(value int64, projectDir string) string { + if value < 0 { + return "" + } + if strings.TrimSpace(projectDir) == "" { + return humanBytes(value) + } + return fmt.Sprintf("%s at %s", humanBytes(value), projectDir) +} + func describeContainerLogs(service, containerName string, inspect dockercontainer.InspectResponse) containerLogDiagnostics { item := containerLogDiagnostics{ Service: service, diff --git a/cmd/debug_test.go b/cmd/debug_test.go index a39c0f9..9c3ff9f 100644 --- a/cmd/debug_test.go +++ b/cmd/debug_test.go @@ -3,6 +3,8 @@ package cmd import ( "strings" "testing" + + "github.com/libops/sitectl/internal/debugreport" ) func TestEvaluateLogConfigDetectsUnboundedJSONFileLogs(t *testing.T) { @@ -78,3 +80,19 @@ func TestImageSummaryRowsWarnWhenThresholdExceeded(t *testing.T) { t.Fatalf("expected prune docs link, got:\n%s", rendered) } } + +func TestHostSummaryRowsIncludeRequestedStats(t *testing.T) { + rendered := formatDebugRows(hostSummaryRows(debugreport.HostDiagnostics{ + CPUCount: 8, + MemoryBytes: 16 * 1024 * 1024 * 1024, + SwapBytes: 2 * 1024 * 1024 * 1024, + DiskAvailableBytes: 50 * 1024 * 1024 * 1024, + OSVersion: "Debian GNU/Linux 12 (bookworm)", + }, "/srv/project")) + + for _, expected := range []string{"CPUs", "Memory", "Swap", "Available disk", "OS version", "Debian GNU/Linux 12 (bookworm)", "/srv/project"} { + if !strings.Contains(rendered, expected) { + t.Fatalf("expected %q in rendered rows, got:\n%s", expected, rendered) + } + } +} diff --git a/go.mod b/go.mod index 1553089..60a872b 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 golang.org/x/crypto v0.46.0 + golang.org/x/sys v0.42.0 golang.org/x/term v0.38.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -81,7 +82,6 @@ require ( go.opentelemetry.io/otel/trace v1.39.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect diff --git a/internal/debugreport/collect.go b/internal/debugreport/collect.go new file mode 100644 index 0000000..33b0b61 --- /dev/null +++ b/internal/debugreport/collect.go @@ -0,0 +1,565 @@ +package debugreport + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + + "github.com/docker/docker/client" + "github.com/kballard/go-shellquote" + "github.com/libops/sitectl/pkg/config" + "github.com/libops/sitectl/pkg/docker" + "github.com/pkg/sftp" + "golang.org/x/crypto/ssh" + yaml "gopkg.in/yaml.v3" +) + +type HostDiagnostics struct { + CPUCount int + MemoryBytes int64 + SwapBytes int64 + DiskAvailableBytes int64 + OSVersion string + Issues []string +} + +type ComposeDiagnostics struct { + ComposePath string + Services []ComposeServiceImage + BindMounts []BindMountDiagnostics + Issues []string +} + +type ComposeServiceImage struct { + Service string + Image string +} + +type BindMountDiagnostics struct { + Service string + Source string + Target string + AvailableBytes int64 + Issue string +} + +type Session struct { + ctxCfg *config.Context + sshClient *ssh.Client + dockerClient *docker.DockerClient + fileAccessor *config.FileAccessor +} + +func NewSession(ctxCfg *config.Context) (*Session, error) { + session := &Session{ctxCfg: ctxCfg} + if ctxCfg == nil || ctxCfg.DockerHostType == config.ContextLocal { + return session, nil + } + sshClient, err := ctxCfg.DialSSH() + if err != nil { + return nil, err + } + session.sshClient = sshClient + return session, nil +} + +func (s *Session) Close() error { + if s == nil { + return nil + } + var firstErr error + if s.fileAccessor != nil { + if err := s.fileAccessor.Close(); err != nil && firstErr == nil { + firstErr = err + } + s.fileAccessor = nil + } + if s.dockerClient != nil { + if err := s.dockerClient.Close(); err != nil && firstErr == nil { + firstErr = err + } + s.dockerClient = nil + } + if s.sshClient != nil { + if err := s.sshClient.Close(); err != nil && firstErr == nil { + firstErr = err + } + s.sshClient = nil + } + return firstErr +} + +func (s *Session) DockerClient() (*docker.DockerClient, error) { + if s == nil || s.ctxCfg == nil { + return nil, fmt.Errorf("debug session context is nil") + } + if s.dockerClient != nil { + return s.dockerClient, nil + } + if s.ctxCfg.DockerHostType == config.ContextLocal { + cli, err := docker.GetDockerCli(s.ctxCfg) + if err != nil { + return nil, err + } + s.dockerClient = cli + return s.dockerClient, nil + } + if s.sshClient == nil { + sshClient, err := s.ctxCfg.DialSSH() + if err != nil { + return nil, err + } + s.sshClient = sshClient + } + cli, err := docker.GetDockerCliWithSSH(s.ctxCfg, s.sshClient, false) + if err != nil { + return nil, err + } + s.dockerClient = cli + return s.dockerClient, nil +} + +func (s *Session) fileAccessorForContext() (*config.FileAccessor, error) { + if s == nil || s.ctxCfg == nil { + return nil, fmt.Errorf("debug session context is nil") + } + if s.fileAccessor != nil { + return s.fileAccessor, nil + } + if s.ctxCfg.DockerHostType == config.ContextLocal { + accessor, err := config.NewFileAccessor(s.ctxCfg) + if err != nil { + return nil, err + } + s.fileAccessor = accessor + return s.fileAccessor, nil + } + if s.sshClient == nil { + sshClient, err := s.ctxCfg.DialSSH() + if err != nil { + return nil, err + } + s.sshClient = sshClient + } + accessor, err := config.NewFileAccessorWithSSH(s.ctxCfg, s.sshClient, false) + if err != nil { + return nil, err + } + s.fileAccessor = accessor + return s.fileAccessor, nil +} + +func (s *Session) RunQuietCommandContext(runCtx context.Context, cmd *exec.Cmd) (string, error) { + if s == nil || s.ctxCfg == nil { + return "", fmt.Errorf("debug session context is nil") + } + if s.ctxCfg.DockerHostType == config.ContextLocal { + return s.ctxCfg.RunQuietCommandContext(runCtx, cmd) + } + if s.sshClient == nil { + sshClient, err := s.ctxCfg.DialSSH() + if err != nil { + return "", err + } + s.sshClient = sshClient + } + return runRemoteCommandWithSSH(runCtx, s.ctxCfg, s.sshClient, cmd) +} + +func CollectHostDiagnostics(runCtx context.Context, ctxCfg *config.Context) HostDiagnostics { + session, err := NewSession(ctxCfg) + if err != nil { + return HostDiagnostics{MemoryBytes: -1, SwapBytes: -1, DiskAvailableBytes: -1, Issues: []string{fmt.Sprintf("ssh: %v", err)}} + } + defer session.Close() + return CollectHostDiagnosticsWithSession(runCtx, ctxCfg, session) +} + +func CollectHostDiagnosticsWithSession(runCtx context.Context, ctxCfg *config.Context, session *Session) HostDiagnostics { + if ctxCfg.DockerHostType == config.ContextLocal { + return collectLocalHostDiagnostics(ctxCfg) + } + + diagnostics := HostDiagnostics{MemoryBytes: -1, SwapBytes: -1, DiskAvailableBytes: -1} + cli, err := session.DockerClient() + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("docker info: %v", err)) + } else { + apiClient, ok := cli.CLI.(*client.Client) + if !ok { + diagnostics.Issues = append(diagnostics.Issues, "docker info: docker client does not support host info") + } else if info, err := apiClient.Info(runCtx); err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("docker info: %v", err)) + } else { + if info.NCPU > 0 { + diagnostics.CPUCount = info.NCPU + } + if info.MemTotal > 0 { + diagnostics.MemoryBytes = info.MemTotal + } + if strings.TrimSpace(info.OperatingSystem) != "" { + diagnostics.OSVersion = strings.TrimSpace(info.OperatingSystem) + } + } + } + + accessor, err := session.fileAccessorForContext() + if err != nil { + if diagnostics.MemoryBytes < 0 { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", err)) + } + if strings.TrimSpace(diagnostics.OSVersion) == "" { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("os: %v", err)) + } + if availableDiskBytes, diskErr := availableDiskBytesWithSession(ctxCfg, session); diskErr != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("disk: %v", diskErr)) + } else { + diagnostics.DiskAvailableBytes = availableDiskBytes + } + return diagnostics + } + + meminfo, err := accessor.ReadFileContext(runCtx, "/proc/meminfo") + if err != nil { + if diagnostics.MemoryBytes < 0 { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", err)) + } + } else { + memoryBytes, swapBytes, parseErr := ParseMemInfo(string(meminfo)) + if parseErr != nil { + if diagnostics.MemoryBytes < 0 { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", parseErr)) + } + } else { + if diagnostics.MemoryBytes < 0 { + diagnostics.MemoryBytes = memoryBytes + } + diagnostics.SwapBytes = swapBytes + } + } + + osRelease, err := accessor.ReadFileContext(runCtx, "/etc/os-release") + if err != nil { + if strings.TrimSpace(diagnostics.OSVersion) == "" { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("os: %v", err)) + } + } else if osVersion := parseOSRelease(string(osRelease)); osVersion != "" { + diagnostics.OSVersion = osVersion + } else if strings.TrimSpace(diagnostics.OSVersion) == "" { + diagnostics.Issues = append(diagnostics.Issues, "os: PRETTY_NAME not found in /etc/os-release") + } + + availableDiskBytes, err := availableDiskBytesWithSession(ctxCfg, session) + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("disk: %v", err)) + } else { + diagnostics.DiskAvailableBytes = availableDiskBytes + } + + return diagnostics +} + +func CollectComposeDiagnostics(runCtx context.Context, ctxCfg *config.Context) ComposeDiagnostics { + session, err := NewSession(ctxCfg) + if err != nil { + return ComposeDiagnostics{ComposePath: filepath.Join(ctxCfg.ProjectDir, "docker-compose.yml"), Issues: []string{fmt.Sprintf("ssh: %v", err)}} + } + defer session.Close() + return CollectComposeDiagnosticsWithSession(runCtx, ctxCfg, session) +} + +func CollectComposeDiagnosticsWithSession(runCtx context.Context, ctxCfg *config.Context, session *Session) ComposeDiagnostics { + composePath := filepath.Join(ctxCfg.ProjectDir, "docker-compose.yml") + diagnostics := ComposeDiagnostics{ComposePath: composePath} + if err := runCtx.Err(); err != nil { + diagnostics.Issues = append(diagnostics.Issues, err.Error()) + return diagnostics + } + output, err := session.RunQuietCommandContext(runCtx, exec.Command("docker", composeConfigArgs(*ctxCfg)...)) + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("compose config: %v", err)) + return diagnostics + } + services, bindMounts, parseErr := ParseComposeDiagnostics([]byte(output)) + if parseErr != nil { + diagnostics.Issues = append(diagnostics.Issues, parseErr.Error()) + return diagnostics + } + diagnostics.Services = services + diagnostics.BindMounts = collectBindMountDiskDiagnostics(ctxCfg, session, bindMounts) + return diagnostics +} + +func ParseComposeDiagnostics(data []byte) ([]ComposeServiceImage, []BindMountDiagnostics, error) { + var compose struct { + Services map[string]struct { + Image string `yaml:"image"` + Volumes []any `yaml:"volumes"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(data, &compose); err != nil { + return nil, nil, fmt.Errorf("parse compose file: %w", err) + } + services := make([]ComposeServiceImage, 0, len(compose.Services)) + bindMounts := make([]BindMountDiagnostics, 0) + for serviceName, service := range compose.Services { + image := strings.TrimSpace(service.Image) + if image == "" { + image = "(no image field)" + } + services = append(services, ComposeServiceImage{Service: serviceName, Image: image}) + bindMounts = append(bindMounts, extractBindMounts(serviceName, service.Volumes)...) + } + sort.Slice(services, func(i, j int) bool { return services[i].Service < services[j].Service }) + sort.Slice(bindMounts, func(i, j int) bool { + if bindMounts[i].Source == bindMounts[j].Source { + return bindMounts[i].Service < bindMounts[j].Service + } + return bindMounts[i].Source < bindMounts[j].Source + }) + return services, bindMounts, nil +} + +func ParseComposeServiceImages(data []byte) ([]ComposeServiceImage, error) { + services, _, err := ParseComposeDiagnostics(data) + return services, err +} + +func collectBindMountDiskDiagnostics(ctxCfg *config.Context, session *Session, mounts []BindMountDiagnostics) []BindMountDiagnostics { + seen := map[string]bool{} + results := make([]BindMountDiagnostics, 0, len(mounts)) + for _, mount := range mounts { + key := strings.TrimSpace(mount.Source) + if key == "" || seen[key] { + continue + } + seen[key] = true + mount.AvailableBytes = -1 + availableBytes, err := availableDiskBytesAtPathWithSession(ctxCfg, session, mount.Source) + if err != nil { + mount.Issue = err.Error() + } else { + mount.AvailableBytes = availableBytes + } + results = append(results, mount) + } + return results +} + +func extractBindMounts(serviceName string, volumes []any) []BindMountDiagnostics { + mounts := make([]BindMountDiagnostics, 0) + for _, raw := range volumes { + switch volume := raw.(type) { + case string: + source, target, ok := parseBindVolumeString(volume) + if !ok { + continue + } + mounts = append(mounts, BindMountDiagnostics{Service: serviceName, Source: source, Target: target}) + case map[string]any: + typeName := strings.ToLower(strings.TrimSpace(stringValue(volume["type"]))) + if typeName != "bind" { + continue + } + source := strings.TrimSpace(stringValue(volume["source"])) + target := strings.TrimSpace(stringValue(volume["target"])) + if source == "" || target == "" { + continue + } + mounts = append(mounts, BindMountDiagnostics{Service: serviceName, Source: source, Target: target}) + } + } + return mounts +} + +func parseBindVolumeString(value string) (source string, target string, ok bool) { + parts := splitVolumeSpec(value) + if len(parts) < 2 { + return "", "", false + } + source = strings.TrimSpace(parts[0]) + target = strings.TrimSpace(parts[1]) + if source == "" || target == "" || !looksLikeHostPath(source) { + return "", "", false + } + return source, target, true +} + +func splitVolumeSpec(value string) []string { + if len(value) >= 2 && value[1] == ':' { + parts := strings.SplitN(value[2:], ":", 3) + if len(parts) == 0 { + return []string{value} + } + parts[0] = value[:2] + parts[0] + return parts + } + return strings.SplitN(value, ":", 3) +} + +func looksLikeHostPath(value string) bool { + trimmed := strings.TrimSpace(value) + return strings.HasPrefix(trimmed, "/") || strings.HasPrefix(trimmed, "./") || strings.HasPrefix(trimmed, "../") || strings.HasPrefix(trimmed, "~/") || (len(trimmed) >= 3 && trimmed[1] == ':' && (trimmed[2] == '\\' || trimmed[2] == '/')) +} + +func stringValue(value any) string { + if value == nil { + return "" + } + if str, ok := value.(string); ok { + return str + } + return fmt.Sprint(value) +} + +func ParseMemInfo(data string) (memoryBytes int64, swapBytes int64, err error) { + values := map[string]int64{} + for _, line := range strings.Split(data, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + value, convErr := strconv.ParseInt(fields[1], 10, 64) + if convErr != nil { + continue + } + values[strings.TrimSuffix(fields[0], ":")] = value * 1024 + } + memoryBytes, memoryFound := values["MemTotal"] + swapBytes, swapFound := values["SwapTotal"] + if !memoryFound && !swapFound { + return 0, 0, fmt.Errorf("no MemTotal or SwapTotal entries found") + } + if !memoryFound { + memoryBytes = 0 + } + if !swapFound { + swapBytes = 0 + } + return memoryBytes, swapBytes, nil +} + +func composeConfigArgs(ctxCfg config.Context) []string { + args := []string{"compose"} + for _, file := range ctxCfg.ComposeFile { + args = append(args, "-f", file) + } + for _, env := range ctxCfg.EnvFile { + args = append(args, "--env-file", env) + } + return append(args, "config") +} + +func availableDiskBytes(ctxCfg *config.Context) (int64, error) { + return availableDiskBytesWithSession(ctxCfg, nil) +} + +func availableDiskBytesAtPathWithSession(ctxCfg *config.Context, session *Session, path string) (int64, error) { + trimmedPath := firstNonEmpty(strings.TrimSpace(path), "/") + if ctxCfg.DockerHostType == config.ContextLocal { + var stat syscall.Statfs_t + if err := syscall.Statfs(trimmedPath, &stat); err != nil { + return 0, err + } + return int64(stat.Bavail) * int64(stat.Bsize), nil + } + if session != nil { + accessor, err := session.fileAccessorForContext() + if err != nil { + return 0, err + } + if accessor != nil { + stat, err := accessor.StatVFS(trimmedPath) + if err != nil { + return 0, err + } + fragmentSize := int64(stat.Frsize) + if fragmentSize <= 0 { + fragmentSize = int64(stat.Bsize) + } + return int64(stat.Bavail) * fragmentSize, nil + } + } + sshClient, err := ctxCfg.DialSSH() + if err != nil { + return 0, err + } + defer sshClient.Close() + sftpClient, err := sftp.NewClient(sshClient) + if err != nil { + return 0, err + } + defer sftpClient.Close() + stat, err := sftpClient.StatVFS(trimmedPath) + if err != nil { + return 0, err + } + fragmentSize := int64(stat.Frsize) + if fragmentSize <= 0 { + fragmentSize = int64(stat.Bsize) + } + return int64(stat.Bavail) * fragmentSize, nil +} + +func availableDiskBytesWithSession(ctxCfg *config.Context, session *Session) (int64, error) { + return availableDiskBytesAtPathWithSession(ctxCfg, session, ctxCfg.ProjectDir) +} + +func runRemoteCommandWithSSH(runCtx context.Context, ctxCfg *config.Context, sshClient *ssh.Client, cmd *exec.Cmd) (string, error) { + remoteCmd := fmt.Sprintf("cd %s && %s", shellquote.Join(ctxCfg.ProjectDir), shellquote.Join(cmd.Args...)) + session, err := sshClient.NewSession() + if err != nil { + return "", fmt.Errorf("error creating SSH session: %v", err) + } + var stdout strings.Builder + var stderr strings.Builder + session.Stdout = &stdout + session.Stderr = &stderr + var closeOnce sync.Once + closeSession := func() { _ = session.Close() } + defer closeOnce.Do(closeSession) + go func() { + <-runCtx.Done() + closeOnce.Do(closeSession) + }() + if err := session.Start(remoteCmd); err != nil { + return "", fmt.Errorf("error starting remote command %q: %v", remoteCmd, err) + } + if err := session.Wait(); err != nil { + if exitErr, ok := err.(*ssh.ExitError); ok && exitErr.ExitStatus() == 130 { + return strings.TrimRight(stdout.String()+stderr.String(), "\n"), nil + } + combined := strings.TrimSpace(strings.Join([]string{stdout.String(), stderr.String()}, "\n")) + if combined != "" { + return "", fmt.Errorf("error waiting for remote command %q: %v: %s", remoteCmd, err, combined) + } + return "", fmt.Errorf("error waiting for remote command %q: %v", remoteCmd, err) + } + return strings.TrimRight(stdout.String(), "\n"), nil +} + +func parseOSRelease(data string) string { + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "PRETTY_NAME=") { + continue + } + return strings.Trim(strings.TrimPrefix(line, "PRETTY_NAME="), `"`) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/debugreport/collect_test.go b/internal/debugreport/collect_test.go new file mode 100644 index 0000000..a4b4dab --- /dev/null +++ b/internal/debugreport/collect_test.go @@ -0,0 +1,70 @@ +package debugreport + +import "testing" + +func TestParseComposeServiceImagesIncludesMissingImageMarker(t *testing.T) { + services, err := ParseComposeServiceImages([]byte(`services: + app: + image: nginx:1.27 + worker: + build: . +`)) + if err != nil { + t.Fatalf("ParseComposeServiceImages() error = %v", err) + } + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + if services[0].Service != "app" || services[0].Image != "nginx:1.27" { + t.Fatalf("unexpected first service: %#v", services[0]) + } + if services[1].Service != "worker" || services[1].Image != "(no image field)" { + t.Fatalf("unexpected second service: %#v", services[1]) + } +} + +func TestParseComposeDiagnosticsExtractsBindMounts(t *testing.T) { + services, bindMounts, err := ParseComposeDiagnostics([]byte(`services: + app: + image: nginx:1.27 + volumes: + - type: bind + source: /srv/data + target: /data + - app-data:/named + worker: + image: busybox + volumes: + - ./assets:/assets:ro +volumes: + app-data: {} +`)) + if err != nil { + t.Fatalf("ParseComposeDiagnostics() error = %v", err) + } + if len(services) != 2 { + t.Fatalf("expected 2 services, got %d", len(services)) + } + if len(bindMounts) != 2 { + t.Fatalf("expected 2 bind mounts, got %d (%#v)", len(bindMounts), bindMounts) + } + if bindMounts[0].Source != "./assets" || bindMounts[0].Target != "/assets" { + t.Fatalf("unexpected first bind mount: %#v", bindMounts[0]) + } + if bindMounts[1].Source != "/srv/data" || bindMounts[1].Target != "/data" { + t.Fatalf("unexpected second bind mount: %#v", bindMounts[1]) + } +} + +func TestParseMemInfoReturnsMemoryAndSwapBytes(t *testing.T) { + memoryBytes, swapBytes, err := ParseMemInfo("MemTotal: 1024 kB\nSwapTotal: 2048 kB\n") + if err != nil { + t.Fatalf("ParseMemInfo() error = %v", err) + } + if memoryBytes != 1024*1024 { + t.Fatalf("expected memory bytes %d, got %d", 1024*1024, memoryBytes) + } + if swapBytes != 2048*1024 { + t.Fatalf("expected swap bytes %d, got %d", 2048*1024, swapBytes) + } +} diff --git a/internal/debugreport/hoststats_local_darwin.go b/internal/debugreport/hoststats_local_darwin.go new file mode 100644 index 0000000..3be8179 --- /dev/null +++ b/internal/debugreport/hoststats_local_darwin.go @@ -0,0 +1,100 @@ +//go:build darwin + +package debugreport + +import ( + "encoding/binary" + "encoding/xml" + "fmt" + "os" + "runtime" + "strings" + + "github.com/libops/sitectl/pkg/config" + "golang.org/x/sys/unix" +) + +func collectLocalHostDiagnostics(ctxCfg *config.Context) HostDiagnostics { + diagnostics := HostDiagnostics{ + CPUCount: runtime.NumCPU(), + MemoryBytes: -1, + SwapBytes: -1, + DiskAvailableBytes: -1, + } + + if memoryBytes, err := unix.SysctlUint64("hw.memsize"); err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", err)) + } else { + diagnostics.MemoryBytes = int64(memoryBytes) + } + + if swapBytes, err := readDarwinSwapTotal(); err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("swap: %v", err)) + } else { + diagnostics.SwapBytes = swapBytes + } + + if osVersion, err := readDarwinOSVersion(); err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("os: %v", err)) + } else { + diagnostics.OSVersion = osVersion + } + + availableDiskBytes, err := availableDiskBytes(ctxCfg) + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("disk: %v", err)) + } else { + diagnostics.DiskAvailableBytes = availableDiskBytes + } + + return diagnostics +} + +func readDarwinSwapTotal() (int64, error) { + data, err := unix.SysctlRaw("vm.swapusage") + if err != nil { + return 0, err + } + if len(data) < 8 { + return 0, fmt.Errorf("unexpected vm.swapusage length: %d", len(data)) + } + return int64(binary.LittleEndian.Uint64(data[:8])), nil +} + +func readDarwinOSVersion() (string, error) { + data, err := os.ReadFile("/System/Library/CoreServices/SystemVersion.plist") + if err != nil { + return "", err + } + var plist struct { + Dict struct { + Nodes []struct { + XMLName xml.Name + Value string `xml:",chardata"` + } `xml:",any"` + } `xml:"dict"` + } + if err := xml.Unmarshal(data, &plist); err != nil { + return "", err + } + values := map[string]string{} + var currentKey string + for _, node := range plist.Dict.Nodes { + value := strings.TrimSpace(node.Value) + switch node.XMLName.Local { + case "key": + currentKey = value + case "string": + if currentKey != "" { + values[currentKey] = value + currentKey = "" + } + } + } + name := strings.TrimSpace(values["ProductName"]) + version := strings.TrimSpace(values["ProductVersion"]) + if name == "" || version == "" { + return "", fmt.Errorf("missing ProductName or ProductVersion in SystemVersion.plist") + } + return fmt.Sprintf("%s %s", name, version), nil +} diff --git a/internal/debugreport/hoststats_local_linux.go b/internal/debugreport/hoststats_local_linux.go new file mode 100644 index 0000000..2d89f2f --- /dev/null +++ b/internal/debugreport/hoststats_local_linux.go @@ -0,0 +1,51 @@ +//go:build linux + +package debugreport + +import ( + "fmt" + "os" + "runtime" + + "github.com/libops/sitectl/pkg/config" +) + +func collectLocalHostDiagnostics(ctxCfg *config.Context) HostDiagnostics { + diagnostics := HostDiagnostics{ + CPUCount: runtime.NumCPU(), + MemoryBytes: -1, + SwapBytes: -1, + DiskAvailableBytes: -1, + } + + meminfo, err := os.ReadFile("/proc/meminfo") + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", err)) + } else { + memoryBytes, swapBytes, parseErr := ParseMemInfo(string(meminfo)) + if parseErr != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("memory: %v", parseErr)) + } else { + diagnostics.MemoryBytes = memoryBytes + diagnostics.SwapBytes = swapBytes + } + } + + osRelease, err := os.ReadFile("/etc/os-release") + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("os: %v", err)) + } else if osVersion := parseOSRelease(string(osRelease)); osVersion != "" { + diagnostics.OSVersion = osVersion + } else { + diagnostics.Issues = append(diagnostics.Issues, "os: PRETTY_NAME not found in /etc/os-release") + } + + availableDiskBytes, err := availableDiskBytes(ctxCfg) + if err != nil { + diagnostics.Issues = append(diagnostics.Issues, fmt.Sprintf("disk: %v", err)) + } else { + diagnostics.DiskAvailableBytes = availableDiskBytes + } + + return diagnostics +} diff --git a/pkg/config/file_accessor.go b/pkg/config/file_accessor.go index 93a4379..dfb2eae 100644 --- a/pkg/config/file_accessor.go +++ b/pkg/config/file_accessor.go @@ -210,6 +210,13 @@ enqueue: return results, nil } +func (a *FileAccessor) StatVFS(path string) (*sftp.StatVFS, error) { + if a == nil || a.ctx == nil || a.ctx.DockerHostType == ContextLocal { + return nil, fmt.Errorf("statvfs is only available for remote file access") + } + return a.sftp.StatVFS(path) +} + 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 {