From 73b1c2063b1a6c05c94453cf67ea6a32239df17e Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 5 Apr 2026 13:29:42 -0300 Subject: [PATCH] use hecate intent API for peer guard checks --- cmd/hecate/main.go | 15 +++------ internal/cluster/orchestrator.go | 13 +++----- internal/state/intent_parse.go | 56 ++++++++++++++++++++++++++++++++ internal/state/intent_test.go | 30 +++++++++++++++++ 4 files changed, 94 insertions(+), 20 deletions(-) create mode 100644 internal/state/intent_parse.go diff --git a/cmd/hecate/main.go b/cmd/hecate/main.go index 25f58ec..90c9463 100644 --- a/cmd/hecate/main.go +++ b/cmd/hecate/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "encoding/json" "errors" "flag" "fmt" @@ -481,7 +480,7 @@ func coordinatorAllowsPeerFallbackStartup(ctx context.Context, cfg config.Config if user != "" { target = user + "@" + host } - remoteCmd := "sudo -n sh -lc 'if systemctl is-active --quiet hecate-bootstrap.service; then echo __HECATE_BOOTSTRAP_ACTIVE__; else echo __HECATE_BOOTSTRAP_IDLE__; fi; if [ -s /var/lib/hecate/intent.json ]; then cat /var/lib/hecate/intent.json; else echo \"{}\"; fi'" + remoteCmd := "if sudo -n /usr/bin/systemctl is-active --quiet hecate-bootstrap.service; then echo __HECATE_BOOTSTRAP_ACTIVE__; else echo __HECATE_BOOTSTRAP_IDLE__; fi; sudo -n /usr/local/bin/hecate intent --config /etc/hecate/hecate.yaml" args := append(buildSSHBaseArgs(cfg), target, remoteCmd) out, err := runSSHWithRecovery(ctx, logger, cfg, args, []string{coordinator, host, cfg.SSHJumpHost}) if err != nil { @@ -492,15 +491,9 @@ func coordinatorAllowsPeerFallbackStartup(ctx context.Context, cfg config.Config if strings.Contains(trimmed, "__HECATE_BOOTSTRAP_ACTIVE__") { return false, "coordinator bootstrap service is active", nil } - start := strings.Index(trimmed, "{") - end := strings.LastIndex(trimmed, "}") - if start < 0 || end < start { - return false, "coordinator intent payload missing", nil - } - rawIntent := trimmed[start : end+1] - var remoteIntent state.Intent - if err := json.Unmarshal([]byte(rawIntent), &remoteIntent); err != nil { - return false, "", fmt.Errorf("decode coordinator intent: %w", err) + remoteIntent, parseErr := state.ParseIntentOutput(trimmed) + if parseErr != nil { + return false, "", fmt.Errorf("decode coordinator intent: %w", parseErr) } if remoteIntent.State == "" || remoteIntent.State == state.IntentNormal { return true, "coordinator intent is normal", nil diff --git a/internal/cluster/orchestrator.go b/internal/cluster/orchestrator.go index 940cf50..578db1e 100644 --- a/internal/cluster/orchestrator.go +++ b/internal/cluster/orchestrator.go @@ -1022,18 +1022,13 @@ func (o *Orchestrator) readRemoteIntent(ctx context.Context, node string) (state if !o.sshManaged(node) { return state.Intent{}, fmt.Errorf("%s is not in ssh_managed_nodes", node) } - out, err := o.ssh(ctx, node, "sudo -n sh -lc 'if [ -s /var/lib/hecate/intent.json ]; then cat /var/lib/hecate/intent.json; else echo \"{}\"; fi'") + out, err := o.ssh(ctx, node, "sudo -n /usr/local/bin/hecate intent --config /etc/hecate/hecate.yaml") if err != nil { return state.Intent{}, err } - start := strings.Index(out, "{") - end := strings.LastIndex(out, "}") - if start < 0 || end < start { - return state.Intent{}, fmt.Errorf("remote intent payload missing json object") - } - var in state.Intent - if err := json.Unmarshal([]byte(out[start:end+1]), &in); err != nil { - return state.Intent{}, fmt.Errorf("decode remote intent json: %w", err) + in, err := state.ParseIntentOutput(out) + if err != nil { + return state.Intent{}, fmt.Errorf("parse remote intent output: %w", err) } return in, nil } diff --git a/internal/state/intent_parse.go b/internal/state/intent_parse.go new file mode 100644 index 0000000..7001d06 --- /dev/null +++ b/internal/state/intent_parse.go @@ -0,0 +1,56 @@ +package state + +import ( + "fmt" + "strings" + "time" +) + +// ParseIntentOutput parses `hecate intent` CLI output from local/remote commands. +func ParseIntentOutput(raw string) (Intent, error) { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + idx := strings.Index(line, "intent=") + if idx < 0 { + continue + } + payload := strings.TrimSpace(line[idx:]) + fields := strings.Fields(payload) + if len(fields) == 0 || !strings.HasPrefix(fields[0], "intent=") { + continue + } + stateValue := strings.TrimSpace(strings.TrimPrefix(fields[0], "intent=")) + if stateValue == "" || stateValue == "none" { + return Intent{}, nil + } + in := Intent{State: stateValue} + if strings.Contains(payload, `reason="`) { + parts := strings.SplitN(payload, `reason="`, 2) + if len(parts) == 2 { + if end := strings.Index(parts[1], `"`); end >= 0 { + in.Reason = parts[1][:end] + } + } + } + for _, field := range fields[1:] { + if strings.HasPrefix(field, "source=") { + in.Source = strings.TrimSpace(strings.TrimPrefix(field, "source=")) + } + if strings.HasPrefix(field, "updated_at=") { + ts := strings.TrimSpace(strings.TrimPrefix(field, "updated_at=")) + if ts != "" { + parsed, err := time.Parse(time.RFC3339, ts) + if err != nil { + return Intent{}, fmt.Errorf("parse updated_at %q: %w", ts, err) + } + in.UpdatedAt = parsed + } + } + } + return in, nil + } + return Intent{}, fmt.Errorf("intent output missing intent= token") +} diff --git a/internal/state/intent_test.go b/internal/state/intent_test.go index f7c5f33..50d0c7c 100644 --- a/internal/state/intent_test.go +++ b/internal/state/intent_test.go @@ -59,3 +59,33 @@ func TestReadIntentAutoHealsCorruptJSON(t *testing.T) { t.Fatalf("expected 1 backup file, got %d (%v)", len(matches), matches) } } + +func TestParseIntentOutputParsesStructuredLine(t *testing.T) { + raw := `[hecate] 2026/04/05 11:24:49 intent=normal reason="guard-test-clear-2" source=drill updated_at=2026-04-05T16:24:33Z` + in, err := ParseIntentOutput(raw) + if err != nil { + t.Fatalf("parse intent output: %v", err) + } + if in.State != IntentNormal { + t.Fatalf("expected state=%q got=%q", IntentNormal, in.State) + } + if in.Reason != "guard-test-clear-2" { + t.Fatalf("expected reason parsed, got %q", in.Reason) + } + if in.Source != "drill" { + t.Fatalf("expected source parsed, got %q", in.Source) + } + if in.UpdatedAt.IsZero() { + t.Fatalf("expected updated_at parsed") + } +} + +func TestParseIntentOutputHandlesNone(t *testing.T) { + in, err := ParseIntentOutput(`[hecate] 2026/04/05 11:24:49 intent=none`) + if err != nil { + t.Fatalf("parse none intent output: %v", err) + } + if in.State != "" { + t.Fatalf("expected empty intent for none, got state=%q", in.State) + } +}