diff --git a/agent/internal/workloads/logs.go b/agent/internal/workloads/logs.go new file mode 100644 index 0000000..955ab59 --- /dev/null +++ b/agent/internal/workloads/logs.go @@ -0,0 +1,83 @@ +package workloads + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" + "time" +) + +const ( + // MaxLogLines and MaxLogBytes are BOTH enforced, whichever binds first. + // + // A line count alone does not bound size: 500 lines of a container printing + // 4KB JSON blobs is 2MB travelling over the bus. This is the same reasoning + // that gave workflow logs a per-line cap as well as a per-run one. + MaxLogLines = 500 + MaxLogBytes = 256 * 1024 + + logTimeout = 60 * time.Second +) + +// Logs returns a bounded snapshot of a workload's recent output. +// +// There is no follow mode. The browser console already offers a real terminal +// on the same server where `docker logs -f` works properly, with its own +// scrollback and cancellation. A snapshot answers "why did this restart", +// which is the question that sends people to the console in the first place. +func Logs(ctx context.Context, kind, id string, tail int) (string, bool, error) { + if tail <= 0 || tail > MaxLogLines { + tail = MaxLogLines + } + + ctx, cancel := context.WithTimeout(ctx, logTimeout) + defer cancel() + + var cmd *exec.Cmd + switch kind { + case "container": + cmd = exec.CommandContext(ctx, "docker", "logs", + "--tail", strconv.Itoa(tail), "--timestamps", id) + case "unit": + cmd = exec.CommandContext(ctx, "journalctl", "-u", id, + "-n", strconv.Itoa(tail), "--no-pager", "--output=short-iso") + default: + return "", false, fmt.Errorf("unknown workload kind %q", kind) + } + + // docker logs writes container stderr to our stderr, so both streams must + // be captured or half the output silently disappears. + out, err := cmd.CombinedOutput() + if err != nil && len(out) == 0 { + return "", false, fmt.Errorf("read logs for %s: %s", id, errText(err)) + } + + text, truncated := capLog(string(out)) + return text, truncated, nil +} + +// capLog enforces both limits, trimming from the FRONT: the most recent lines +// are the ones worth keeping. +func capLog(s string) (string, bool) { + truncated := false + + lines := strings.Split(s, "\n") + if len(lines) > MaxLogLines { + lines = lines[len(lines)-MaxLogLines:] + truncated = true + } + s = strings.Join(lines, "\n") + + if len(s) > MaxLogBytes { + s = s[len(s)-MaxLogBytes:] + // Drop the leading partial line left by a byte-wise cut. + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[i+1:] + } + truncated = true + } + + return s, truncated +}