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
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>
207 lines
7.3 KiB
Go
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())
|
|
}
|
|
}
|