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
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>
284 lines
7.9 KiB
Go
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)
|
|
}
|