5cbe3020d3
scripts/orca-aggregate.sh: 10s aggregator, cluster.json merge + drift-events rsync + remediation trigger (P10b stub). scripts/orca- watchdog.sh: C-11 starvation detection. internal/cli/collector.go: orca collector start/stop/status. Tests: CLI + bats. ---ci--- project: orca phase: 09 milestone: v0.11 status: execute ---/ci---
327 lines
9.3 KiB
Go
327 lines
9.3 KiB
Go
// Package cli: collector.go implements the `orca collector` subcommand
|
|
// (P09, C-12 opt-in). The collector is the lead-side aggregator +
|
|
// watchdog pair: `orca-aggregate.sh` runs every 10s (via a systemd
|
|
// timer) merging per-peer state snapshots into cluster.json, and
|
|
// `orca-watchdog.sh` runs every 30s detecting aggregator starvation
|
|
// (C-11).
|
|
//
|
|
// `orca collector start` emits the scripts + systemd timers/services
|
|
// to the lead and enables them. `orca collector stop` disables and
|
|
// removes them. `orca collector status` reports whether the pair is
|
|
// running.
|
|
//
|
|
// Paths default to the system layout (/etc/orca, /etc/systemd/system);
|
|
// a `--root` flag (default "/") relocates every emitted path under
|
|
// <root> for testability (tests use a temp dir).
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var collectorRoot string
|
|
|
|
const (
|
|
collectorScriptDir = "etc/orca/collector"
|
|
collectorUnitDir = "etc/systemd/system"
|
|
collectorAggregateSh = "orca-aggregate.sh"
|
|
collectorWatchdogSh = "orca-watchdog.sh"
|
|
collectorAggregateSvc = "orca-aggregate.service"
|
|
collectorAggregateTmr = "orca-aggregate.timer"
|
|
collectorWatchdogSvc = "orca-watchdog.service"
|
|
collectorWatchdogTmr = "orca-watchdog.timer"
|
|
collectorStateDir = "etc/orca/state"
|
|
)
|
|
|
|
var collectorCmd = &cobra.Command{
|
|
Use: "collector",
|
|
Short: "Manage the lead-side collector (aggregator + watchdog) (P09)",
|
|
Long: `Manage the lead-side collector: the aggregator (orca-aggregate.sh,
|
|
10s cadence, merges per-peer state into cluster.json + drift-event
|
|
aggregation per REQ-107) and the watchdog (orca-watchdog.sh, 30s
|
|
cadence, detects aggregator starvation per C-11). Opt-in (C-12).`,
|
|
Args: cobra.NoArgs,
|
|
}
|
|
|
|
var collectorStartCmd = &cobra.Command{
|
|
Use: "start",
|
|
Short: "Emit the collector scripts + systemd units and enable them",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, err := collectorResolveRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := collectorEmit(root); err != nil {
|
|
return err
|
|
}
|
|
if !collectorDryRun {
|
|
if err := collectorEnable(root); err != nil {
|
|
return fmt.Errorf("enable: %w", err)
|
|
}
|
|
}
|
|
msg := "collector started"
|
|
if collectorDryRun {
|
|
msg = "collector scripts emitted (dry-run, not enabled)"
|
|
}
|
|
printResult(msg, map[string]string{"status": "started", "root": root})
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var collectorStopCmd = &cobra.Command{
|
|
Use: "stop",
|
|
Short: "Disable and remove the collector scripts + systemd units",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, err := collectorResolveRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !collectorDryRun {
|
|
if err := collectorDisable(root); err != nil {
|
|
return fmt.Errorf("disable: %w", err)
|
|
}
|
|
}
|
|
if err := collectorRemove(root); err != nil {
|
|
return err
|
|
}
|
|
msg := "collector stopped"
|
|
if collectorDryRun {
|
|
msg = "collector artifacts removed (dry-run, not disabled)"
|
|
}
|
|
printResult(msg, map[string]string{"status": "stopped", "root": root})
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var collectorStatusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Report whether the collector (aggregator + watchdog) is running",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
root, err := collectorResolveRoot()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
aggRunning, wdRunning := collectorRunning(root)
|
|
overall := "running"
|
|
if !aggRunning && !wdRunning {
|
|
overall = "stopped"
|
|
} else if !aggRunning || !wdRunning {
|
|
overall = "partial"
|
|
}
|
|
printResult(
|
|
fmt.Sprintf("collector: %s (aggregator=%t watchdog=%t)", overall, aggRunning, wdRunning),
|
|
map[string]any{
|
|
"status": overall,
|
|
"aggregator": aggRunning,
|
|
"watchdog": wdRunning,
|
|
"root": root,
|
|
},
|
|
)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
var collectorDryRun bool
|
|
|
|
func init() {
|
|
collectorCmd.PersistentFlags().StringVar(&collectorRoot, "root", "/", "install root for emitted paths (default: /; for testing use a temp dir)")
|
|
collectorStartCmd.Flags().BoolVar(&collectorDryRun, "dry-run", false, "emit scripts/units without enabling or running systemctl")
|
|
collectorStopCmd.Flags().BoolVar(&collectorDryRun, "dry-run", false, "remove scripts/units without disabling or running systemctl")
|
|
collectorCmd.AddCommand(collectorStartCmd)
|
|
collectorCmd.AddCommand(collectorStopCmd)
|
|
collectorCmd.AddCommand(collectorStatusCmd)
|
|
rootCmd.AddCommand(collectorCmd)
|
|
}
|
|
|
|
func collectorResolveRoot() (string, error) {
|
|
r := strings.TrimRight(collectorRoot, "/")
|
|
if r == "" {
|
|
r = "/"
|
|
}
|
|
if !filepath.IsAbs(r) {
|
|
return "", fmt.Errorf("--root must be absolute, got %q", collectorRoot)
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func collectorEmit(root string) error {
|
|
dirs := []string{
|
|
filepath.Join(root, collectorScriptDir),
|
|
filepath.Join(root, collectorUnitDir),
|
|
filepath.Join(root, collectorStateDir),
|
|
}
|
|
for _, d := range dirs {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir %s: %w", d, err)
|
|
}
|
|
}
|
|
files := map[string]struct {
|
|
Content string
|
|
Mode os.FileMode
|
|
}{
|
|
filepath.Join(root, collectorScriptDir, collectorAggregateSh): {collectorAggregateScript, 0o755},
|
|
filepath.Join(root, collectorScriptDir, collectorWatchdogSh): {collectorWatchdogScript, 0o755},
|
|
filepath.Join(root, collectorUnitDir, collectorAggregateSvc): {collectorAggregateUnit, 0o644},
|
|
filepath.Join(root, collectorUnitDir, collectorAggregateTmr): {collectorAggregateTimer, 0o644},
|
|
filepath.Join(root, collectorUnitDir, collectorWatchdogSvc): {collectorWatchdogUnit, 0o644},
|
|
filepath.Join(root, collectorUnitDir, collectorWatchdogTmr): {collectorWatchdogTimer, 0o644},
|
|
}
|
|
for path, f := range files {
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, []byte(f.Content), f.Mode); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
return fmt.Errorf("rename %s: %w", path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectorRemove(root string) error {
|
|
paths := []string{
|
|
filepath.Join(root, collectorScriptDir, collectorAggregateSh),
|
|
filepath.Join(root, collectorScriptDir, collectorWatchdogSh),
|
|
filepath.Join(root, collectorUnitDir, collectorAggregateSvc),
|
|
filepath.Join(root, collectorUnitDir, collectorAggregateTmr),
|
|
filepath.Join(root, collectorUnitDir, collectorWatchdogSvc),
|
|
filepath.Join(root, collectorUnitDir, collectorWatchdogTmr),
|
|
}
|
|
for _, p := range paths {
|
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("remove %s: %w", p, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectorRunning(root string) (bool, bool) {
|
|
aggRunning := fileExists(filepath.Join(root, collectorUnitDir, collectorAggregateSvc)) &&
|
|
fileExists(filepath.Join(root, collectorScriptDir, collectorAggregateSh))
|
|
wdRunning := fileExists(filepath.Join(root, collectorUnitDir, collectorWatchdogSvc)) &&
|
|
fileExists(filepath.Join(root, collectorScriptDir, collectorWatchdogSh))
|
|
return aggRunning, wdRunning
|
|
}
|
|
|
|
func fileExists(p string) bool {
|
|
_, err := os.Stat(p)
|
|
return err == nil
|
|
}
|
|
|
|
func collectorEnable(root string) error {
|
|
if !commandAvailable("systemctl") {
|
|
return nil
|
|
}
|
|
for _, u := range []string{collectorAggregateTmr, collectorWatchdogTmr} {
|
|
_ = runSystemctl(root, "enable", u)
|
|
_ = runSystemctl(root, "start", u)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectorDisable(root string) error {
|
|
if !commandAvailable("systemctl") {
|
|
return nil
|
|
}
|
|
for _, u := range []string{collectorAggregateTmr, collectorWatchdogTmr} {
|
|
_ = runSystemctl(root, "stop", u)
|
|
_ = runSystemctl(root, "disable", u)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSystemctl(root, action, unit string) error {
|
|
args := []string{action, unit}
|
|
if root != "/" {
|
|
args = append([]string{"--root", root}, args...)
|
|
}
|
|
return runCmd("systemctl", args...)
|
|
}
|
|
|
|
func commandAvailable(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
func runCmd(name string, args ...string) error {
|
|
cmd := exec.Command(name, args...)
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
return cmd.Run()
|
|
}
|
|
|
|
const collectorAggregateScript = `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# orca-aggregate.sh — emitted by orca collector start (P09).
|
|
# Placeholder wrapper; the canonical copy lives at scripts/orca-aggregate.sh.
|
|
exec /usr/local/bin/orca-aggregate.sh "$@"
|
|
`
|
|
|
|
const collectorWatchdogScript = `#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# orca-watchdog.sh — emitted by orca collector start (P09).
|
|
# Placeholder wrapper; the canonical copy lives at scripts/orca-watchdog.sh.
|
|
exec /usr/local/bin/orca-watchdog.sh "$@"
|
|
`
|
|
|
|
const collectorAggregateUnit = `[Unit]
|
|
Description=orca aggregator (P09, C-11/C-12)
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/etc/orca/collector/orca-aggregate.sh
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
|
|
const collectorAggregateTimer = `[Unit]
|
|
Description=orca aggregator 10s cadence (P09, C-11)
|
|
|
|
[Timer]
|
|
OnBootSec=10s
|
|
OnUnitActiveSec=10s
|
|
AccuracySec=1s
|
|
Unit=orca-aggregate.service
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
`
|
|
|
|
const collectorWatchdogUnit = `[Unit]
|
|
Description=orca watchdog meta-timer (P09, C-11)
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/etc/orca/collector/orca-watchdog.sh
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
|
|
const collectorWatchdogTimer = `[Unit]
|
|
Description=orca watchdog 30s cadence (P09, C-11)
|
|
|
|
[Timer]
|
|
OnBootSec=30s
|
|
OnUnitActiveSec=30s
|
|
AccuracySec=5s
|
|
Unit=orca-watchdog.service
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
`
|