Files
DDI/agent/core/cli_test.go
T
sir_melonchenandClaude Opus 5.5 2555028956
ci / Protobuf (push) Successful in 12s
ci / Web (lint, test, audit, build) (push) Successful in 2m44s
ci / Shell and licence headers (push) Successful in 21s
ci / Go (lint, test, vuln) (push) Successful in 6m22s
ci / Release build (dry run) (push) Successful in 3m51s
ci / Integration (real BIND and Kea) (push) Successful in 2m33s
ci / Development environment (compose) (push) Successful in 17s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 4m14s
install / Installation (debian:12) (push) Successful in 4m54s
install / Installation (debian:13) (push) Successful in 5m6s
install / Installation (ubuntu:22.04) (push) Successful in 5m16s
install / Installation (rocky:9) (push) Successful in 7m28s
install / Installation (ubuntu:24.04) (push) Successful in 8m58s
release / release (push) Successful in 4m11s
fix: agents enrolled by hand run as systemd services
An agent installed without token and enrolled with the command of the web
interface never ran as a service: the installer left the unit disabled and
the root-run enrollment left an identity the service user could not read.
The installer now always enables the agent unit (it waits for the
identity via ConditionPathExists), enroll as root hands the state over to
the service user and enables and starts the unit, and rerunning or
upgrading the installer repairs agents enrolled by hand. The web interface
shows the matching installer command next to the enroll command.

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

207 lines
7.3 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package core
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestMainBasics(t *testing.T) {
tests := []struct {
name string
args []string
wantCode int
wantStdout string
wantStderr string
}{
{name: "no args", args: nil, wantCode: 2, wantStderr: "Usage: ddi-agent-test"},
{name: "version", args: []string{"version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "--version", args: []string{"--version"}, wantCode: 0, wantStdout: "ddi-agent-test "},
{name: "help", args: []string{"help"}, wantCode: 0, wantStdout: "Agent role: dns"},
{name: "help lists enroll", args: []string{"--help"}, wantCode: 0, wantStdout: "--ca-hash sha256:<hex>"},
{name: "unknown", args: []string{"explode"}, wantCode: 2, wantStderr: `unknown command "explode"`},
{name: "enroll without flags", args: []string{"enroll"}, wantCode: 2, wantStderr: "enroll needs --server"},
{name: "bad flag", args: []string{"run", "--bogus"}, wantCode: 2, wantStderr: "flag provided but not defined"},
{name: "extra argument", args: []string{"status", "extra"}, wantCode: 2, wantStderr: `unexpected argument "extra"`},
{name: "flag help", args: []string{"status", "-h"}, wantCode: 0, wantStderr: "-config"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var out, errOut bytes.Buffer
code := Main("ddi-agent-test", KindDNS, nil, tt.args, &out, &errOut)
if code != tt.wantCode {
t.Errorf("code = %d, want %d (stderr %q)", code, tt.wantCode, errOut.String())
}
if tt.wantStdout != "" && !strings.Contains(out.String(), tt.wantStdout) {
t.Errorf("stdout %q does not contain %q", out.String(), tt.wantStdout)
}
if tt.wantStderr != "" && !strings.Contains(errOut.String(), tt.wantStderr) {
t.Errorf("stderr %q does not contain %q", errOut.String(), tt.wantStderr)
}
})
}
}
// writeConfig writes a configuration with its own state directory.
func writeConfig(t *testing.T, extra string) (cfgPath, stateDir string) {
t.Helper()
dir := t.TempDir()
stateDir = filepath.Join(dir, "state")
cfgPath = filepath.Join(dir, "agent.yaml")
data := "state_dir: " + filepath.ToSlash(stateDir) + "\nlog:\n format: text\n" + extra
if err := os.WriteFile(cfgPath, []byte(data), 0o600); err != nil {
t.Fatal(err)
}
return cfgPath, stateDir
}
func TestMainEnrollStatusRun(t *testing.T) {
srv := newFakeServer(t, KindDNS)
addr := srv.start(t)
cfgPath, stateDir := writeConfig(t, "dns:\n zone_dir: /var/lib/bind/x\n")
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status before enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), "not enrolled") {
t.Errorf("status = %q", out.String())
}
// run without identity fails.
out.Reset()
errOut.Reset()
factory := func(decode func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if code := Main("ddi-agent-dns", KindDNS, factory, []string{"run", "--config", cfgPath}, &out, &errOut); code != 1 {
t.Fatalf("run before enroll = %d", code)
}
if !strings.Contains(errOut.String(), "not enrolled") {
t.Errorf("stderr = %q", errOut.String())
}
args := []string{"enroll", "--config", cfgPath, "--server", addr, "--token", srv.token,
"--ca-hash", srv.fingerprint(), "--server-name", "localhost"}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 0 {
t.Fatalf("enroll = %d: %s", code, errOut.String())
}
if !strings.Contains(out.String(), srv.agentID) {
t.Errorf("enroll output = %q", out.String())
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "--force") {
t.Errorf("second enroll = %d: %s", code, errOut.String())
}
// Run the agent until revision 1 is reported.
srv.with(func(f *fakeServer) { f.desired = 1 })
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
var gotSection struct {
ZoneDir string `yaml:"zone_dir"`
}
role := newFakeRole(KindDNS)
factory = func(decode func(any) error) (Role, error) {
if err := decode(&gotSection); err != nil {
return nil, err
}
return role, nil
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runAgent(ctx, KindDNS, factory, cfg, testLogger()) }()
waitFor(t, "deployment report", func() bool { return len(serverReports(srv)) == 1 })
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("runAgent = %v", err)
}
case <-time.After(10 * time.Second):
t.Fatal("runAgent did not return")
}
if gotSection.ZoneDir != "/var/lib/bind/x" {
t.Errorf("role section = %+v", gotSection)
}
out.Reset()
errOut.Reset()
if code := Main("ddi-agent-dns", KindDNS, nil, []string{"status", "--config", cfgPath}, &out, &errOut); code != 0 {
t.Fatalf("status = %d: %s", code, errOut.String())
}
for _, want := range []string{srv.agentID, "Applied revision:", "revision 1 succeeded", "Stored revisions: 1", filepath.ToSlash(stateDir)} {
if !strings.Contains(out.String(), want) {
t.Errorf("status lacks %q:\n%s", want, out.String())
}
}
}
func TestRunAgentErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "")
cfg, err := LoadConfig(cfgPath, KindDNS)
if err != nil {
t.Fatal(err)
}
if err := runAgent(context.Background(), KindDNS, nil, cfg, testLogger()); !errors.Is(err, errRoleNotAvailable) {
t.Errorf("nil factory: %v", err)
}
// Enrolled as a DHCP agent, run as DNS agent.
store, err := OpenStore(cfg.StateDir)
if err != nil {
t.Fatal(err)
}
pki := newTestPKI(t)
id, key := testIdentity(t, pki)
id.Role = KindDHCP
if err := store.SaveIdentity(id, key, false); err != nil {
t.Fatal(err)
}
factory := func(func(any) error) (Role, error) { return newFakeRole(KindDNS), nil }
if err := runAgent(context.Background(), KindDNS, factory, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "dhcp agent") {
t.Errorf("role mismatch: %v", err)
}
if err := runAgent(context.Background(), KindDHCP, func(func(any) error) (Role, error) {
return nil, errors.New("kea missing")
}, cfg, testLogger()); err == nil || !strings.Contains(err.Error(), "kea missing") {
t.Errorf("factory error: %v", err)
}
}
func TestMainConfigErrors(t *testing.T) {
cfgPath, _ := writeConfig(t, "bogus: 1\n")
for _, args := range [][]string{
{"run", "--config", cfgPath},
{"status", "--config", cfgPath},
{"enroll", "--config", cfgPath, "--server", "x:1", "--token", "a.b", "--ca-hash", "sha256:00"},
} {
var out, errOut bytes.Buffer
if code := Main("ddi-agent-dns", KindDNS, nil, args, &out, &errOut); code != 1 || !strings.Contains(errOut.String(), "bogus") {
t.Errorf("%v: code %d, stderr %q", args[0], code, errOut.String())
}
}
}
// TestStartServiceHint: with --no-start (or without root/systemd) enroll
// only says how to start the service and never calls systemctl.
func TestStartServiceHint(t *testing.T) {
var out bytes.Buffer
called := false
c := &cli{binary: "ddi-agent-dns", stdout: &out, stderr: &out,
systemctl: func(...string) ([]byte, error) { called = true; return nil, nil }}
c.startService(true)
if called || !strings.Contains(out.String(), "systemctl enable --now ddi-agent-dns.service") {
t.Errorf("called=%v output %q", called, out.String())
}
}