Files
DDI/agent/core/cli.go
T
sir_melonchenandClaude Opus 5.5 3e957d7b33 feat(agent): command runner with stdin and optional role logger
The DNS role needs to feed nsupdate scripts on stdin and to log its
dynamic updates. command.InputRunner adds RunInput (errors never quote the
input); roles implementing core.LogSetter get the core's logger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 05:47:32 +02:00

354 lines
10 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/exec"
"os/signal"
"strings"
"sync"
"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
// systemctl runs systemctl (tests replace it).
systemctl func(args ...string) ([]byte, error)
}
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")
noStart := fs.Bool("no-start", false, "do not enable and start the systemd service afterwards")
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, release, err := openLocked(cfg)
if err != nil {
return c.fail(err)
}
unlock := sync.OnceFunc(release) // released early before starting the service
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())
if uid, err := handOverStateDir(store.Dir()); err != nil {
_, _ = fmt.Fprintf(c.stderr, "warning: could not hand %s over to its owner: %v\n", store.Dir(), err)
} else if uid >= 0 {
_, _ = fmt.Fprintf(c.stdout, "Files in %s now belong to the owner of the directory (UID %d).\n", store.Dir(), uid)
}
// The service takes the state lock itself.
unlock()
c.startService(*noStart)
return 0
}
// startService enables and starts the agent's systemd service after an
// enrollment as root on a systemd host, so that a manual enrollment ends
// with a running agent (the installer leaves the unit waiting for the
// identity). Otherwise, or with --no-start, it says what to run.
func (c *cli) startService(noStart bool) {
unit := c.binary + ".service"
hint := func() {
_, _ = fmt.Fprintf(c.stdout, "Start the agent with: sudo systemctl enable --now %s\n", unit)
}
if noStart || os.Geteuid() != 0 || !systemdRunning() {
hint()
return
}
run := c.systemctl
if run == nil {
run = func(args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() //nolint:gosec // fixed program; arguments are "enable --now <own unit>"
}
}
if out, err := run("enable", "--now", unit); err != nil {
_, _ = fmt.Fprintf(c.stderr, "warning: systemctl enable --now %s failed: %v\n%s", unit, err, out)
hint()
return
}
_, _ = fmt.Fprintf(c.stdout, "Service %s enabled and started.\n", unit)
}
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))
if ls, ok := role.(LogSetter); ok {
ls.SetLogger(log)
}
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
}