Files
DDI/agent/core/cli.go
T
beug.k@beugsiud.deandClaude Opus 5.5 3065175673 feat(agent): enrollment, server connection and apply pipeline
- CLI: enroll, run, status, version, help (core.Main with a role
  factory); the agent binaries use a placeholder factory until the
  BIND and Kea roles are wired in.
- Enrollment: local ECDSA P-256 key and CSR, TLS 1.3 to the agent API
  with the server chain pinned to the CA fingerprint.
- ServerSource: mTLS gRPC with keepalive, Connect stream (Hello,
  Welcome, heartbeats, apply and renew commands), reconnect with
  exponential backoff and jitter, GetDesiredState polling as fallback,
  certificate renewal after half of the lifetime.
- Apply pipeline: fetch, bundle verification with replay protection,
  store, Validate, Activate, Health with retries, rollback to the last
  good revision; outcomes succeeded/rejected/rolled_back/failed are
  queued in state.json and reported; failed revisions are retried only
  by a newer revision or an explicit push.
- Lease reporting with change detection, persistent sequence and full
  snapshots at start, per connection and periodically.
- Tests with fake roles, a fake source and an in-process fake
  AgentService over real mTLS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:06:05 +02:00

308 lines
8.3 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Package core is the role-independent part of the agents: command line,
// configuration, local state, enrollment, the connection to the management
// server and the apply pipeline (see docs/ARCHITECTURE.md, section 4, and
// docs/adr/0021-agent-strecke-m3.md). The roles (BIND, Kea) implement Role.
package core
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"strings"
"syscall"
"time"
"git.beugsiud.de/beug.k/DDI/internal/agentpki"
"git.beugsiud.de/beug.k/DDI/internal/buildinfo"
"git.beugsiud.de/beug.k/DDI/internal/logging"
)
// Main dispatches the agent command line and returns the process exit code.
// factory creates the role for the run command.
func Main(binary string, kind Kind, factory RoleFactory, args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr, binary, kind)
return 2
}
c := &cli{binary: binary, kind: kind, factory: factory, stdout: stdout, stderr: stderr}
switch args[0] {
case "version", "--version":
if err := buildinfo.Print(stdout, binary); err != nil {
return 1
}
return 0
case "help", "-h", "--help":
usage(stdout, binary, kind)
return 0
case "enroll":
return c.enroll(args[1:])
case "run":
return c.run(args[1:])
case "status":
return c.status(args[1:])
default:
_, _ = fmt.Fprintf(stderr, "%s: unknown command %q\n\n", binary, args[0])
usage(stderr, binary, kind)
return 2
}
}
func usage(w io.Writer, binary string, kind Kind) {
_, _ = fmt.Fprintf(w, `Usage: %[1]s <command> [flags]
Agent role: %[2]s
Commands:
enroll register with the management server
--server <host:port> --token <token> --ca-hash sha256:<hex>
[--server-name <name>] [--force]
run run the agent (until SIGINT/SIGTERM)
status show identity, revisions and the last result
version print version information
help show this help
Common flags:
--config <file> configuration file (default %[3]s)
`, binary, kind, DefaultConfigPath(kind))
}
type cli struct {
binary string
kind Kind
factory RoleFactory
stdout io.Writer
stderr io.Writer
}
func (c *cli) flags(name string) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(c.binary+" "+name, flag.ContinueOnError)
fs.SetOutput(c.stderr)
cfg := fs.String("config", DefaultConfigPath(c.kind), "configuration file")
return fs, cfg
}
func (c *cli) fail(err error) int {
_, _ = fmt.Fprintf(c.stderr, "%s: %v\n", c.binary, err)
return 1
}
// parse parses flags and rejects positional arguments.
func parse(fs *flag.FlagSet, args []string) int {
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() > 0 {
_, _ = fmt.Fprintf(fs.Output(), "unexpected argument %q\n", fs.Arg(0))
return 2
}
return -1
}
func (c *cli) enroll(args []string) int {
fs, cfgPath := c.flags("enroll")
var opts EnrollOptions
fs.StringVar(&opts.Server, "server", "", "agent API address of the management server (host:port)")
fs.StringVar(&opts.Token, "token", "", "one-time enrollment token")
fs.StringVar(&opts.CAHash, "ca-hash", "", "fingerprint of the agent CA (sha256:<hex>)")
fs.StringVar(&opts.ServerName, "server-name", "", "TLS server name (default: host of --server)")
fs.BoolVar(&opts.Force, "force", false, "replace an existing identity")
if code := parse(fs, args); code >= 0 {
return code
}
if opts.Server == "" || opts.Token == "" || opts.CAHash == "" {
_, _ = fmt.Fprintln(c.stderr, "enroll needs --server, --token and --ca-hash")
return 2
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, unlock, err := openLocked(cfg)
if err != nil {
return c.fail(err)
}
defer unlock()
opts.Kind = c.kind
opts.Hostname, _ = os.Hostname()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
id, err := Enroll(ctx, store, opts)
if err != nil {
return c.fail(err)
}
_, _ = fmt.Fprintf(c.stdout, "Enrolled as agent %s (%s) with %s.\nIdentity stored in %s.\n",
id.AgentID, c.kind, id.Server, store.Dir())
return 0
}
func openLocked(cfg *Config) (*Store, func(), error) {
store, err := OpenStore(cfg.StateDir)
if err != nil {
return nil, nil, err
}
unlock, err := store.Lock()
if err != nil {
return nil, nil, err
}
return store, unlock, nil
}
func (c *cli) run(args []string) int {
fs, cfgPath := c.flags("run")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
log, err := logging.New(c.stderr, cfg.Log)
if err != nil {
return c.fail(err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := runAgent(ctx, c.kind, c.factory, cfg, log); err != nil {
log.Error("agent stopped", "error", err)
return 1
}
return 0
}
// runAgent runs the agent until ctx is canceled.
func runAgent(ctx context.Context, kind Kind, factory RoleFactory, cfg *Config, log *slog.Logger) error {
if factory == nil {
return errRoleNotAvailable
}
store, unlock, err := openLocked(cfg)
if err != nil {
return err
}
defer unlock()
id, err := store.LoadIdentity()
if err != nil {
return err
}
if id.Role != kind {
return fmt.Errorf("identity in %s belongs to a %s agent", store.Dir(), id.Role)
}
role, err := factory(cfg.DecodeRole)
if err != nil {
return fmt.Errorf("%s role: %w", kind, err)
}
log = log.With("agent_id", id.AgentID, "role", string(kind))
hostname, _ := os.Hostname()
src := NewServerSource(store, id, hostname, log)
agent, err := NewAgent(kind, cfg, role, store, id, src, log)
if err != nil {
return err
}
log.Info("agent started", "version", buildinfo.Get().Version, "server", id.Server, "state_dir", store.Dir())
err = agent.Run(ctx)
log.Info("agent stopped; the managed service keeps its configuration")
return err
}
func (c *cli) status(args []string) int {
fs, cfgPath := c.flags("status")
if code := parse(fs, args); code >= 0 {
return code
}
cfg, err := LoadConfig(*cfgPath, c.kind)
if err != nil {
return c.fail(err)
}
store, err := OpenStore(cfg.StateDir)
if err != nil {
return c.fail(err)
}
if err := writeStatus(c.stdout, store); err != nil {
return c.fail(err)
}
return 0
}
// writeStatus prints identity, revisions and the last result.
func writeStatus(w io.Writer, store *Store) error {
var b strings.Builder
line := func(k, format string, a ...any) { fmt.Fprintf(&b, "%-20s %s\n", k+":", fmt.Sprintf(format, a...)) }
line("State directory", "%s", store.Dir())
id, err := store.LoadIdentity()
switch {
case errors.Is(err, ErrNotEnrolled):
line("Identity", "not enrolled")
case err != nil:
return err
default:
leaf := id.Certificate.Leaf
line("Agent ID", "%s", id.AgentID)
line("Role", "%s", id.Role)
line("Server", "%s (TLS name %s)", id.Server, id.ServerName)
line("Enrolled", "%s", id.EnrolledAt.Format(time.RFC3339))
line("Certificate", "valid until %s, renewal from %s", leaf.NotAfter.UTC().Format(time.RFC3339),
renewalDue(leaf).UTC().Format(time.RFC3339))
line("CA fingerprint", "%s", agentpki.Fingerprint(id.CA))
}
st, err := store.LoadState()
if err != nil {
return err
}
revs, err := store.ListRevisions()
if err != nil {
return err
}
line("Applied revision", "%s", revOrNone(st.Applied))
line("Last good revision", "%s", revOrNone(st.LastGood))
if st.LastUnsuccessful != 0 {
line("Last unsuccessful", "%d (retried only by a newer revision or an explicit push)", st.LastUnsuccessful)
}
nums := make([]string, len(revs))
for i, n := range revs {
nums[i] = fmt.Sprint(n)
}
line("Stored revisions", "%s", orNone(strings.Join(nums, ", ")))
if r := st.LastResult; r != nil {
dep := ""
if r.DeploymentID != "" {
dep = ", deployment " + r.DeploymentID
}
line("Last result", "revision %d %s at %s (%s%s)", r.Revision, r.Outcome,
r.FinishedAt.UTC().Format(time.RFC3339), r.Trigger, dep)
if r.Message != "" {
line("Message", "%s", r.Message)
}
} else {
line("Last result", "none")
}
line("Pending reports", "%d", len(st.PendingReports))
if st.LeaseSequence != 0 {
line("Lease sequence", "%d", st.LeaseSequence)
}
_, err = io.WriteString(w, b.String())
return err
}
func revOrNone(n uint64) string {
if n == 0 {
return "none"
}
return fmt.Sprint(n)
}
func orNone(s string) string {
if s == "" {
return "none"
}
return s
}