Files
DDI/agent/role/integration/harness_test.go
T
beug.k@beugsiud.deandClaude Opus 5.5 d3e5807a09
ci / Protobuf (push) Successful in 18s
ci / Web (lint, test, audit, build) (push) Successful in 1m16s
ci / Shell and licence headers (push) Successful in 20s
ci / Release build (dry run) (push) Successful in 2m9s
ci / Go (lint, test, vuln) (push) Successful in 4m19s
ci / End-to-end (server, agents, BIND, Kea) (push) Successful in 1m49s
ci / Development environment (compose) (push) Successful in 12s
ci / Integration (real BIND and Kea) (push) Successful in 5m12s
test: retry image pulls, executable e2e scripts, fix truncation test
Docker Hub timed out on the shared runner; pulls now retry and fall back
to a local image. The e2e scripts were committed without the executable
bit. The truncation test checked letters that the marker itself contains.

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

284 lines
7.9 KiB
Go

// SPDX-License-Identifier: Apache-2.0
//go:build integration
// Package integration runs the DNS and DHCP roles against real BIND and Kea
// (ADR 0021). Run with
//
// go test -tags integration ./agent/role/integration/
//
// Requires the docker CLI and a Go toolchain. The roles must run next to the
// service, so the test cross-compiles ./driver for the Docker daemon's
// architecture, copies it together with fixture revisions into the service
// container (docker cp, no bind mounts, no published ports) and runs it with
// docker exec. This works with a remote or shared Docker daemon. Image lists
// can be overridden with DDI_BIND_IMAGES and DDI_KEA_IMAGES (space
// separated).
package integration
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"os"
"os/exec"
"path"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/agent/core"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
const (
testDir = "/ddi-test"
driverPath = testDir + "/driver"
)
func images(env, def string) []string {
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
return strings.Fields(v)
}
return strings.Fields(def)
}
// docker runs the docker CLI and returns stdout and, for messages, stdout
// and stderr combined.
func docker(t *testing.T, args ...string) (stdout, combined string, err error) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
var out, all bytes.Buffer
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Stdout = io.MultiWriter(&out, &all)
cmd.Stderr = &all
err = cmd.Run()
return out.String(), all.String(), err
}
func mustDocker(t *testing.T, args ...string) string {
t.Helper()
out, all, err := docker(t, args...)
if err != nil {
t.Fatalf("docker %s: %v\n%s", strings.Join(args, " "), err, all)
}
return out
}
var (
driverOnce sync.Once
driverBin string
driverErr string
)
// buildDriver cross-compiles the driver for the daemon's architecture.
func buildDriver(t *testing.T) string {
t.Helper()
driverOnce.Do(func() {
out, all, err := docker(t, "version", "--format", "{{.Server.Arch}}")
arch := strings.TrimSpace(out)
if err != nil || arch == "" {
driverErr = "cannot determine the Docker daemon's architecture: " + all
return
}
dir, err := os.MkdirTemp("", "ddi-role-driver-")
if err != nil {
driverErr = err.Error()
return
}
bin := filepath.Join(dir, "driver")
cmd := exec.Command("go", "build", "-trimpath", "-o", bin, "./driver")
cmd.Env = append(os.Environ(), "CGO_ENABLED=0", "GOOS=linux", "GOARCH="+arch)
if out, err := cmd.CombinedOutput(); err != nil {
driverErr = "go build ./driver: " + err.Error() + "\n" + string(out)
return
}
driverBin = bin
})
if driverErr != "" {
t.Fatal(driverErr)
}
return driverBin
}
func writeFile(t *testing.T, p string, content []byte, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, content, mode); err != nil {
t.Fatal(err)
}
// os.WriteFile applies the umask; docker cp keeps the mode.
if err := os.Chmod(p, mode); err != nil {
t.Fatal(err)
}
}
// tree is a directory that becomes the container's root via docker cp.
type tree struct {
t *testing.T
root string
}
func newTree(t *testing.T) *tree { return &tree{t: t, root: t.TempDir()} }
func (tr *tree) file(containerPath string, content []byte, mode os.FileMode) {
writeFile(tr.t, filepath.Join(tr.root, filepath.FromSlash(strings.TrimPrefix(containerPath, "/"))), content, mode)
}
// revision stores a revision as the driver expects it and returns its
// path in the container.
func (tr *tree) revision(name string, number uint64, files map[string]string, createOnly ...string) string {
dir := path.Join(testDir, "rev", name)
m := struct {
Number uint64 `json:"number"`
Files []bundle.File `json:"files"`
}{Number: number}
paths := make([]string, 0, len(files))
for p := range files {
paths = append(paths, p)
}
slices.Sort(paths)
for _, p := range paths {
if err := bundle.CheckPath(p); err != nil {
tr.t.Fatal(err)
}
sum := sha256.Sum256([]byte(files[p]))
m.Files = append(m.Files, bundle.File{Path: p, Mode: 0o640, SHA256: hex.EncodeToString(sum[:]),
Size: int64(len(files[p])), CreateOnly: slices.Contains(createOnly, p)})
tr.file(path.Join(dir, "files", p), []byte(files[p]), 0o640)
}
data, err := json.Marshal(m)
if err != nil {
tr.t.Fatal(err)
}
tr.file(path.Join(dir, "revision.json"), data, 0o644)
return dir
}
// container is a started service container with the tree copied in.
type container struct {
t *testing.T
id string
}
// startContainer creates a container that runs script with sh, copies the
// tree into it and starts it. The container is removed after the test; its
// log is printed if the test failed.
func startContainer(t *testing.T, image string, tr *tree, script string, env ...string) *container {
t.Helper()
pullImage(t, image)
args := []string{"create", "--entrypoint", "sh"}
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, image, "-c", script)
c := &container{t: t, id: strings.TrimSpace(mustDocker(t, args...))}
t.Cleanup(func() {
if t.Failed() {
_, logs, _ := docker(t, "logs", "--tail", "200", c.id)
t.Logf("container log (%s):\n%s", image, logs)
}
_, _, _ = docker(t, "rm", "-f", c.id)
})
mustDocker(t, "cp", tr.root+string(filepath.Separator)+".", c.id+":/")
mustDocker(t, "start", c.id)
return c
}
// exec runs a command in the container and returns stdout, combined output
// and whether it succeeded.
func (c *container) exec(args ...string) (string, string, bool) {
c.t.Helper()
out, all, err := docker(c.t, append([]string{"exec", c.id}, args...)...)
return out, all, err == nil
}
// health mirrors core.Health in the driver's JSON.
type health struct {
State core.HealthState
Message string
Checks []core.HealthCheck
}
// result mirrors the driver's Result.
type result struct {
Error string `json:"error"`
Outcome string `json:"outcome"`
Version string `json:"version"`
Health *health `json:"health"`
Rollback *health `json:"rollback_health"`
Leases []core.Lease `json:"leases"`
Response *struct {
Result int `json:"result"`
Text string `json:"text"`
} `json:"response"`
}
// driver runs the driver in the container and decodes its result.
func (c *container) driver(role, config string, args ...string) result {
c.t.Helper()
full := append([]string{driverPath, "-role", role, "-config", config}, args...)
out, all, ok := c.exec(full...)
if !ok {
c.t.Fatalf("driver %s: failed:\n%s", strings.Join(args, " "), all)
}
var r result
if err := json.Unmarshal([]byte(out), &r); err != nil {
c.t.Fatalf("driver %s: invalid output: %v\n%s", strings.Join(args, " "), err, all)
}
c.t.Logf("driver %s -> outcome=%q error=%q", strings.Join(args, " "), r.Outcome, r.Error)
return r
}
func (c *container) cat(p string) string {
c.t.Helper()
out, all, ok := c.exec("cat", p)
if !ok {
c.t.Fatalf("cat %s: %s", p, all)
}
return out
}
func (c *container) exists(p string) bool {
c.t.Helper()
_, _, ok := c.exec("test", "-e", p)
return ok
}
func driverBinary(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile(buildDriver(t))
if err != nil {
t.Fatal(err)
}
return data
}
// pullImage pulls image, retrying because registries time out now and then
// on shared CI runners. An image that is already present is good enough.
func pullImage(t *testing.T, image string) {
t.Helper()
var all string
var err error
for attempt := 1; attempt <= 4; attempt++ {
if _, all, err = docker(t, "pull", "--quiet", image); err == nil {
return
}
time.Sleep(time.Duration(attempt) * 5 * time.Second)
}
if _, _, inspectErr := docker(t, "image", "inspect", image); inspectErr == nil {
t.Logf("docker pull %s failed, using the local image: %v", image, err)
return
}
t.Fatalf("docker pull %s: %v\n%s", image, err, all)
}