Files
orca/internal/cli/logs.go
T
Jon Chery 531b36924c fix(P09): migration + operational safety — job stop, retention, logs cap (REQ-158)
- job stop: real systemctl stop via SSH (was DB-only soft stop)
  resolves node from alloc_history or --peer flag
- doctor db-retention: row count check for jobs/tasks/audit_log
  warns at 100k rows, suggests backup + cleanup
- logs --lines: cap at 50000 (default 1000); --since upper bound 7d
  prevents OOM from unbounded journalctl
- cache DB mode 0600 (was 0644; matches store.Open)
- upgrade cutover: backup file + atomic rename (was sed -i)
  rollback restores from backup on failure

Tests: job stop SSH, DB retention warning, logs lines cap, cache mode,
cutover backup-restore + atomic rename.

---ci---
project: orca
phase: 9
milestone: v0.13
status: complete
requirements:
  covered: [158]
---/ci---
2026-08-10 13:37:28 +00:00

368 lines
11 KiB
Go

package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"log/slog"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/spf13/cobra"
"git.cloudinit.dev/coreci/orca/internal/certpaths"
"git.cloudinit.dev/coreci/orca/internal/model"
"git.cloudinit.dev/coreci/orca/internal/sshpush"
"git.cloudinit.dev/coreci/orca/internal/store"
)
// logsExecer is the SSH command-execution seam used by `orca logs`.
// *sshpush.Transport satisfies it via Exec; tests inject a mock
// without a real SSH server (same pattern as drain.go / stepca mockExec).
type logsExecer interface {
Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}
// logsExecOverride is the package-level exec seam. When non-nil it
// replaces the production transport; tests set it and restore nil in
// cleanup. nil means "build the real transport on first use".
var logsExecOverride logsExecer
func logsExecFromCtx(_ context.Context) (logsExecer, error) {
if logsExecOverride != nil {
return logsExecOverride, nil
}
keyPath := certpaths.SSHKeyPath()
khPath := certpaths.KnownHostsPath()
return sshpush.NewTransport(keyPath, khPath), nil
}
// LogLine is a single journald log entry parsed from journalctl --output
// json. Host is the peer the line came from (set by the aggregator).
type LogLine struct {
Host string `json:"host"`
Timestamp time.Time `json:"timestamp"`
Unit string `json:"unit"`
Message string `json:"message"`
Priority string `json:"priority"`
}
// journalRaw is the subset of journalctl --output json fields we
// decode. Extra fields are ignored.
type journalRaw struct {
Realtime int64 `json:"__REALTIME_TIMESTAMP"`
Unit string `json:"_SYSTEMD_UNIT"`
Identifier string `json:"SYSLOG_IDENTIFIER"`
Comm string `json:"_COMM"`
Message string `json:"MESSAGE"`
Priority any `json:"PRIORITY"`
}
func (j journalRaw) unit() string {
if j.Unit != "" {
return strings.TrimSuffix(j.Unit, ".service")
}
if j.Identifier != "" {
return j.Identifier
}
if j.Comm != "" {
return j.Comm
}
return ""
}
func (j journalRaw) priority() string {
switch p := j.Priority.(type) {
case string:
return p
case float64:
return fmt.Sprintf("%v", int(p))
default:
return ""
}
}
func (j journalRaw) timestamp() time.Time {
if j.Realtime == 0 {
return time.Time{}
}
return time.Unix(0, j.Realtime).UTC()
}
var (
logsAllNodes bool
logsNode string
logsJob string
logsSince string
logsJSON bool
logsLines int
)
// logsMaxLines is the hard cap on --lines to prevent OOM from
// unbounded journalctl output (REQ-158, P09 T3).
const logsMaxLines = 50000
// logsDefaultLines is the default --lines value.
const logsDefaultLines = 1000
// logsMaxSince is the maximum lookback for --since (7 days) to
// prevent OOM from unbounded journalctl queries (REQ-158, P09 T3).
const logsMaxSince = 7 * 24 * time.Hour
var logsCmd = &cobra.Command{
Use: "logs",
Short: "Aggregate journald logs across nodes (REQ-117)",
Long: `Aggregate journald logs across registered nodes via SSH fanout.
orca logs --all-nodes --since 5m
orca logs --node web-1 --since 1h
orca logs --all-nodes --job web --since 30m --json
Runs 'journalctl -u 'orca-alloc-*' --since <dur> --output json' on each
peer, parses the JSON-per-line output, and streams the entries with a
[<hostname>] prefix (multi-node) or raw (single-node). --json outputs
the raw journalctl JSON lines verbatim.
Use --all-nodes to fan out to every registered node, or --node <host>
for a single node. --job <name> filters units to orca-alloc-<name>-*.
Ctrl-C cancels the fan-out via signal.NotifyContext.`,
RunE: func(cmd *cobra.Command, args []string) error {
if !logsAllNodes && logsNode == "" {
return fmt.Errorf("specify --all-nodes or --node <host>")
}
if logsAllNodes && logsNode != "" {
return fmt.Errorf("--all-nodes and --node are mutually exclusive")
}
// F1: validate --job before interpolation into the journalctl
// unit pattern. Go's %q does not escape backticks and bash
// executes command substitution inside double quotes, so an
// unvalidated job name is a remote RCE vector.
if logsJob != "" && !validSafeName(logsJob) {
return fmt.Errorf("logs: --job %q contains disallowed characters (allowed: A-Z a-z 0-9 _ -)", logsJob)
}
since, err := parseSince(logsSince)
if err != nil {
return err
}
// REQ-158 / P09 T3: clamp --since to 7 days max to prevent
// OOM from unbounded journalctl queries. If the requested
// lookback exceeds the cap, clamp it and warn.
now := time.Now().UTC()
maxSince := now.Add(-logsMaxSince)
if since.Before(maxSince) {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠ --since %s exceeds 7d cap; clamping to 7d\n", logsSince)
since = maxSince
}
// REQ-158 / P09 T3: clamp --lines to [1, logsMaxLines].
lines := logsLines
if lines <= 0 {
lines = logsDefaultLines
}
if lines > logsMaxLines {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠ --lines %d exceeds max %d; clamping\n", lines, logsMaxLines)
lines = logsMaxLines
}
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer cancel()
nodes, err := resolveLogNodes(ctx)
if err != nil {
return err
}
if len(nodes) == 0 {
return fmt.Errorf("no nodes to query")
}
ex, err := logsExecFromCtx(cmd.Context())
if err != nil {
return fmt.Errorf("ssh transport: %w", err)
}
out := cmd.OutOrStdout()
multi := len(nodes) > 1
for line := range streamLogs(ctx, ex, nodes, since, logsJob, lines) {
if logsJSON {
raw, _ := json.Marshal(line)
fmt.Fprintln(out, string(raw))
continue
}
if multi {
fmt.Fprintf(out, "[%s] %s %s\n", line.Host, line.Timestamp.Format(time.RFC3339), line.Message)
} else {
fmt.Fprintln(out, line.Message)
}
}
return nil
},
}
// parseSince parses a duration string like "5m", "1h30m", "500ms". The
// returned time is time.Now().UTC().Add(-d). An empty string defaults
// to 5 minutes.
func parseSince(s string) (time.Time, error) {
if s == "" {
s = "5m"
}
d, err := time.ParseDuration(s)
if err != nil {
return time.Time{}, fmt.Errorf("--since %q: %w", s, err)
}
if d <= 0 {
return time.Time{}, fmt.Errorf("--since must be positive, got %s", d)
}
return time.Now().UTC().Add(-d), nil
}
// resolveLogNodes returns the set of nodes to query. For --all-nodes it
// lists every registered node; for --node <host> it resolves a single
// node by name or id.
func resolveLogNodes(ctx context.Context) ([]*model.Node, error) {
db, closer, err := openDB()
if err != nil {
return nil, err
}
defer closer()
reg := store.NewNodeRepo(db)
if logsAllNodes {
return reg.List(ctx)
}
n, err := reg.GetByName(ctx, logsNode)
if err == nil {
return []*model.Node{n}, nil
}
if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("lookup node %q: %w", logsNode, err)
}
n, err = reg.Get(ctx, logsNode)
if err == nil {
return []*model.Node{n}, nil
}
if errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("node %q not found", logsNode)
}
return nil, fmt.Errorf("lookup node %q: %w", logsNode, err)
}
// streamLogs fans out journalctl across nodes and yields parsed
// LogLine values as they arrive. It runs each node's exec in its own
// goroutine, scans the output line-by-line, and yields each parsed
// JSON entry immediately. The stream ends when every node has
// completed (or the context is cancelled). The caller drives the
// iteration via range-over-func (D-017 iter.Seq pattern).
func streamLogs(ctx context.Context, ex logsExecer, nodes []*model.Node, since time.Time, job string, lines int) iter.Seq[LogLine] {
return func(yield func(LogLine) bool) {
merged := make(chan LogLine)
var wg sync.WaitGroup
for _, n := range nodes {
wg.Add(1)
go func(n *model.Node) {
defer wg.Done()
streamNodeLines(ctx, ex, n, since, job, lines, merged)
}(n)
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
// Pump merged lines to the yield function until either all
// nodes finish or the consumer stops pulling (yield==false)
// or the context is cancelled.
for {
select {
case <-done:
return
case <-ctx.Done():
return
case line := <-merged:
if !yield(line) {
return
}
}
}
}
}
// streamNodeLines runs journalctl on a single node and pushes each
// parsed line into out. It blocks until the exec completes (or the
// context is cancelled); the caller is responsible for waiting on the
// goroutine. Send is non-blocking via select on ctx.Done so a slow
// consumer does not stall the fanout forever.
func streamNodeLines(ctx context.Context, ex logsExecer, n *model.Node, since time.Time, job string, lines int, out chan<- LogLine) {
peer := peerAddrForNode(n)
if peer == "" {
slog.Default().Warn("logs: cannot resolve SSH address for node", "node", n.Name)
return
}
unitPattern := "orca-alloc-*"
if job != "" {
unitPattern = "orca-alloc-" + job + "-*"
}
sinceStr := since.Format("2006-01-02 15:04:05")
// REQ-158 / P09 T3: pass --lines=N to journalctl to cap output
// and prevent OOM from unbounded log queries.
if lines <= 0 {
lines = logsDefaultLines
}
// F1: shellQuote (single-quote wrap) instead of %q — %q does not
// escape backticks, enabling command substitution in double quotes.
cmd := fmt.Sprintf("journalctl -u %s --since %s --lines %d --output json --no-pager", shellQuote(unitPattern), shellQuote(sinceStr), lines)
raw, err := ex.Exec(ctx, peer, cmd)
if err != nil {
slog.Default().Warn("logs: exec failed", "node", n.Name, "peer", peer, "error", err)
return
}
host := n.Name
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
ll, perr := parseJournalLine(line)
if perr != nil {
continue
}
ll.Host = host
select {
case out <- ll:
case <-ctx.Done():
return
}
}
}
// parseJournalLine decodes a single journalctl --output json line into
// a LogLine. Unknown fields are ignored.
func parseJournalLine(s string) (LogLine, error) {
var j journalRaw
if err := json.Unmarshal([]byte(s), &j); err != nil {
return LogLine{}, fmt.Errorf("parse journal line: %w", err)
}
return LogLine{
Timestamp: j.timestamp(),
Unit: j.unit(),
Message: j.Message,
Priority: j.priority(),
}, nil
}
func init() {
logsCmd.Flags().BoolVar(&logsAllNodes, "all-nodes", false, "fan out to all registered nodes")
logsCmd.Flags().StringVar(&logsNode, "node", "", "restrict to a single node (name or id)")
logsCmd.Flags().StringVar(&logsJob, "job", "", "filter by job name (matches orca-alloc-<name>-* units)")
logsCmd.Flags().StringVar(&logsSince, "since", "5m", "duration lookback (e.g. 5m, 1h, 30m); default 5m; max 7d")
logsCmd.Flags().IntVar(&logsLines, "lines", logsDefaultLines, fmt.Sprintf("max number of journal lines per node (default %d, max %d)", logsDefaultLines, logsMaxLines))
logsCmd.Flags().BoolVar(&logsJSON, "json", false, "output raw JSON (one LogLine per line)")
rootCmd.AddCommand(logsCmd)
}