use hecate intent API for peer guard checks

This commit is contained in:
Brad Stein 2026-04-05 13:29:42 -03:00
parent ad4361322d
commit 73b1c2063b
4 changed files with 94 additions and 20 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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")
}

View File

@ -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)
}
}