test: end-to-end test of the agent path, fix first CI findings
ci / Web (lint, test, audit, build) (push) Successful in 1m1s
ci / Shell and licence headers (push) Successful in 7s
ci / Go (lint, test, vuln) (push) Failing after 2m40s
ci / Protobuf (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 2m27s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 1m26s
ci / Development environment (compose) (push) Successful in 13s
ci / Integration (real BIND and Kea) (push) Failing after 1m58s
ci / Web (lint, test, audit, build) (push) Successful in 1m1s
ci / Shell and licence headers (push) Successful in 7s
ci / Go (lint, test, vuln) (push) Failing after 2m40s
ci / Protobuf (push) Successful in 6s
ci / Release build (dry run) (push) Successful in 2m27s
ci / End-to-end (server, agents, BIND, Kea) (push) Failing after 1m26s
ci / Development environment (compose) (push) Successful in 13s
ci / Integration (real BIND and Kea) (push) Failing after 1m58s
test/e2e runs server, PostgreSQL, BIND and Kea with both agents in containers (make test-e2e, CI job End-to-end): enrollment with the command from the API, revision 1 and 2 applied and served by BIND and Kea, unchanged data creates no revision, revoking disconnects the agent while BIND keeps serving. Fixes from the first CI run: quote a spec description containing a comma, exempt the agent lock-out routes in the route protection test, whole-second certificate lifetimes, a push replacing queued pulls, the truncation marker containing the letter checked, and curl|grep -q under pipefail in the server smoke test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
56892631be
commit
56347ec5ff
@@ -135,6 +135,25 @@ jobs:
|
||||
DDI_DATABASE_URL: postgres://ddi:ddi-ci-only@postgres:5432/ddi?sslmode=disable
|
||||
run: scripts/ci/smoke-server.sh dist
|
||||
|
||||
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
|
||||
e2e:
|
||||
name: End-to-end (server, agents, BIND, Kea)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The Docker daemon may be shared: unique compose project.
|
||||
COMPOSE_PROJECT_NAME: ddi-e2e-${{ github.run_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: API change reaches BIND and Kea through the agents
|
||||
run: make test-e2e
|
||||
|
||||
# Needs a runner with access to a Docker daemon (see docs/adr/0003).
|
||||
integration:
|
||||
name: Integration (real BIND and Kea)
|
||||
|
||||
@@ -38,3 +38,4 @@ Thumbs.db
|
||||
|
||||
# Local worktrees of Claude Code agents
|
||||
/.claude/worktrees/
|
||||
/test/e2e/bin/
|
||||
|
||||
@@ -139,6 +139,11 @@ test-go:
|
||||
test-web:
|
||||
cd web && npm test
|
||||
|
||||
## test-e2e: API change -> revision -> agents -> BIND/Kea in containers (needs docker)
|
||||
.PHONY: test-e2e
|
||||
test-e2e: build
|
||||
test/e2e/run.sh
|
||||
|
||||
## test-integration: generated configs and agent roles against real BIND and Kea (needs docker)
|
||||
.PHONY: test-integration
|
||||
test-integration:
|
||||
|
||||
@@ -444,11 +444,18 @@ func TestDesiredAndPush(t *testing.T) {
|
||||
t.Fatal("request queued for revision 0 or manual mode")
|
||||
}
|
||||
f.agent.Desired(3, true)
|
||||
if r, ok := f.agent.dequeue(); !ok || r.revision != 3 || r.trigger != TriggerPull {
|
||||
t.Errorf("pull request = %+v, %v", r, ok)
|
||||
}
|
||||
// A push replaces queued pulls up to its revision.
|
||||
f.agent.Desired(3, true)
|
||||
f.agent.Push(4, "dep")
|
||||
r1, _ := f.agent.dequeue()
|
||||
r2, _ := f.agent.dequeue()
|
||||
if r1.revision != 3 || r1.trigger != TriggerPull || r2.revision != 4 || r2.deploymentID != "dep" {
|
||||
t.Errorf("requests = %+v %+v", r1, r2)
|
||||
r, _ := f.agent.dequeue()
|
||||
if r.revision != 4 || r.trigger != TriggerPush || r.deploymentID != "dep" {
|
||||
t.Errorf("push request = %+v", r)
|
||||
}
|
||||
if extra, ok := f.agent.dequeue(); ok {
|
||||
t.Errorf("pull for revision 3 was not replaced: %+v", extra)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ func TestExcerptAndLimit(t *testing.T) {
|
||||
_, _ = l.Write([]byte("cc"))
|
||||
_, _ = l.Write([]byte("dd"))
|
||||
b := l.Bytes()
|
||||
if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b), "c") != 1 {
|
||||
if !strings.HasSuffix(string(b), "[output truncated]") || strings.Count(string(b[:MaxCapture]), "c") != 1 ||
|
||||
strings.Contains(string(b), "d") {
|
||||
t.Errorf("limited buffer kept %d bytes", len(b))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
curl -fsS "$base/readyz" | grep -q '"database":"ok"' || fail "readyz"
|
||||
curl -fsS "$base/metrics" | grep -q '^ddi_license_writable 1' || fail "metrics"
|
||||
curl -fsS "$base/api/v1/system/info" | grep -q '"license_state":"trial"' || fail "system info"
|
||||
grep -q '"database":"ok"' <<<"$(curl -fsS "$base/readyz")" || fail "readyz"
|
||||
grep -q '^ddi_license_writable 1' <<<"$(curl -fsS "$base/metrics")" || fail "metrics"
|
||||
grep -q '"license_state":"trial"' <<<"$(curl -fsS "$base/api/v1/system/info")" || fail "system info"
|
||||
|
||||
# The web UI is embedded: index.html references hashed assets that load.
|
||||
index="$(curl -fsS -D "$work/headers" "$base/")"
|
||||
@@ -75,7 +75,7 @@ asset="$(grep -o 'src="/assets/[^"]*\.js"' <<<"$index" | head -n1 | sed 's/src="
|
||||
curl -fsS -o /dev/null -D "$work/asset-headers" "$base$asset" || fail "asset $asset"
|
||||
grep -qi '^cache-control: .*immutable' "$work/asset-headers" || fail "assets must be immutable"
|
||||
# Client-side routes fall back to the UI.
|
||||
curl -fsS "$base/addresses?q=x" | grep -q '<div id="root">' || fail "SPA fallback"
|
||||
grep -q '<div id="root">' <<<"$(curl -fsS "$base/addresses?q=x")" || fail "SPA fallback"
|
||||
# Unknown API paths are JSON errors, not the UI.
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$base/api/v1/nope")"
|
||||
[[ "$code" == "401" || "$code" == "404" ]] || fail "unknown API path returned $code"
|
||||
|
||||
@@ -704,7 +704,7 @@ paths:
|
||||
parameters:
|
||||
- { name: network_id, in: query, schema: { type: string } }
|
||||
- { name: state, in: query, schema: { $ref: "#/components/schemas/LeaseState" } }
|
||||
- { name: q, in: query, schema: { type: string }, description: Substring of IP, MAC or hostname. }
|
||||
- { name: q, in: query, schema: { type: string }, description: "Substring of IP, MAC or hostname." }
|
||||
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 1000 } }
|
||||
- { name: after, in: query, schema: { type: string }, description: Return leases with a higher IP (cursor). }
|
||||
responses:
|
||||
|
||||
@@ -45,6 +45,13 @@ func TestSpecMatchesRoutes(t *testing.T) {
|
||||
// TestEveryRouteIsProtected pins the list of routes reachable without a
|
||||
// session and checks that permission routes name a permission.
|
||||
func TestEveryRouteIsProtected(t *testing.T) {
|
||||
// Locking agents out is a security function that read-only mode must
|
||||
// not block (routes.go).
|
||||
lockOutRoutes := []string{
|
||||
"DELETE /api/v1/agents/{id}",
|
||||
"POST /api/v1/agents/{id}/revoke",
|
||||
"DELETE /api/v1/enrollment-tokens/{id}",
|
||||
}
|
||||
publicAllowed := []string{
|
||||
"GET /api/v1/system/info",
|
||||
"POST /api/v1/auth/login",
|
||||
@@ -67,7 +74,8 @@ func TestEveryRouteIsProtected(t *testing.T) {
|
||||
t.Errorf("%s: GET routes must not be write routes", key)
|
||||
}
|
||||
if r.Method != http.MethodGet && !r.Write && r.Access == permission && !strings.Contains(r.Path, "/license") &&
|
||||
!strings.Contains(r.Path, "/saved-filters") && !strings.HasSuffix(r.Path, "/preview") {
|
||||
!strings.Contains(r.Path, "/saved-filters") && !strings.HasSuffix(r.Path, "/preview") &&
|
||||
!slices.Contains(lockOutRoutes, key) {
|
||||
t.Errorf("%s changes state but is not marked Write (read-only mode would not block it)", key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,8 @@ func TestSignAgentCSR(t *testing.T) {
|
||||
if len(cert.DNSNames) != 0 || len(cert.ExtKeyUsage) != 1 || cert.ExtKeyUsage[0] != x509.ExtKeyUsageClientAuth || cert.IsCA {
|
||||
t.Errorf("%s: unexpected certificate contents", name)
|
||||
}
|
||||
if cert.NotAfter.Sub(clock) != pki.AgentCertLifetime {
|
||||
// Certificates carry whole seconds.
|
||||
if d := cert.NotAfter.Sub(clock); d > pki.AgentCertLifetime || d <= pki.AgentCertLifetime-time.Second {
|
||||
t.Errorf("%s: lifetime %v", name, cert.NotAfter.Sub(clock))
|
||||
}
|
||||
if cert.SerialNumber.BitLen() > 128 || seen[pki.SerialHex(cert.SerialNumber)] {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# BIND with the DNS agent for the end-to-end test. The layout matches a
|
||||
# managed host: the agent owns /etc/bind/ddi-suite and the zone directory.
|
||||
FROM internetsystemsconsortium/bind9:9.20
|
||||
COPY --chmod=0755 bin/ddi-agent-dns /usr/local/bin/ddi-agent-dns
|
||||
COPY --chmod=0644 bind/named.conf /etc/bind/named.conf
|
||||
COPY --chmod=0644 bind/agent-dns.yaml /etc/ddi-suite/agent-dns.yaml
|
||||
COPY --chmod=0755 bind/entrypoint.sh /e2e/entrypoint.sh
|
||||
ENTRYPOINT ["/e2e/entrypoint.sh"]
|
||||
@@ -0,0 +1,10 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# DNS agent configuration for the end-to-end test.
|
||||
log:
|
||||
level: debug
|
||||
dns:
|
||||
conf_dir: /etc/bind/ddi-suite
|
||||
zone_dir: /var/lib/bind/ddi-suite
|
||||
named_conf: /etc/bind/named.conf
|
||||
rndc_args: ["-k", "/etc/bind/rndc.key", "-s", "127.0.0.1", "-p", "953"]
|
||||
dns_address: 127.0.0.1:53
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Prepares the host like the installer would and runs named in the
|
||||
# foreground. The agent is started later by run.sh (after enrollment).
|
||||
set -eu
|
||||
conf=/etc/bind/ddi-suite
|
||||
zones=/var/lib/bind/ddi-suite
|
||||
mkdir -p "$conf" "$zones" /run/named /var/cache/bind
|
||||
if [ ! -f /etc/bind/rndc.key ]; then
|
||||
rndc-confgen -a -A hmac-sha256 -k rndc-key -c /etc/bind/rndc.key
|
||||
fi
|
||||
# Empty includes until the agent writes the first revision.
|
||||
for f in ddi-suite.conf ddi-suite-options.conf; do
|
||||
[ -f "$conf/$f" ] || : >"$conf/$f"
|
||||
done
|
||||
chown -R root:bind "$conf" /etc/bind/rndc.key
|
||||
chmod 750 "$conf"
|
||||
chmod 640 "$conf"/*.conf /etc/bind/rndc.key
|
||||
chown bind:bind "$zones" /run/named /var/cache/bind
|
||||
chmod 750 "$zones"
|
||||
exec named -u bind -g -c /etc/bind/named.conf
|
||||
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Distribution-like named.conf for the end-to-end test: it only adds the
|
||||
// rndc channel and the two include files written by the DNS agent. Options
|
||||
// the generator sets (recursion, forwarders, transfers) must not appear here.
|
||||
|
||||
include "/etc/bind/rndc.key";
|
||||
|
||||
controls {
|
||||
inet 127.0.0.1 port 953 allow { 127.0.0.1; } keys { "rndc-key"; };
|
||||
};
|
||||
|
||||
options {
|
||||
directory "/var/cache/bind";
|
||||
pid-file "/run/named/named.pid";
|
||||
listen-on { 127.0.0.1; };
|
||||
listen-on-v6 { none; };
|
||||
dnssec-validation no;
|
||||
include "/etc/bind/ddi-suite/ddi-suite-options.conf";
|
||||
};
|
||||
|
||||
include "/etc/bind/ddi-suite/ddi-suite.conf";
|
||||
@@ -0,0 +1,77 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# End-to-end environment (M3): management server, PostgreSQL, BIND with the
|
||||
# DNS agent and Kea with the DHCP agent, all in containers. Driven by
|
||||
# test/e2e/run.sh through `docker compose exec`, so it works with a remote or
|
||||
# shared Docker daemon: no bind mounts, no published ports, no fixed subnet.
|
||||
#
|
||||
# TEST ONLY. Passwords and keys are throwaway values.
|
||||
|
||||
name: ddi-e2e
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17.11-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ddi
|
||||
POSTGRES_DB: ddi
|
||||
POSTGRES_PASSWORD: e2e-only
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ddi -d ddi"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server.Dockerfile
|
||||
environment:
|
||||
DDI_DATABASE_URL: postgres://ddi:e2e-only@postgres:5432/ddi?sslmode=disable
|
||||
DDI_HTTP_LISTEN: 127.0.0.1:8080
|
||||
DDI_MASTER_KEY_FILE: /var/lib/ddi/master.key
|
||||
DDI_STATE_DIR: /var/lib/ddi/state
|
||||
DDI_AGENT_API_LISTEN: 0.0.0.0:8444
|
||||
DDI_AGENT_API_PUBLIC_ADDRESS: server:8444
|
||||
DDI_AGENT_API_SERVER_NAMES: server
|
||||
DDI_LOG_LEVEL: debug
|
||||
# Unreachable license server: the server runs in trial mode.
|
||||
KEYGEN_URL: http://127.0.0.1:1
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/readyz | grep -q '\"database\":\"ok\"'"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 60
|
||||
|
||||
bind:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: bind.Dockerfile
|
||||
user: "0:0"
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "rndc -k /etc/bind/rndc.key -s 127.0.0.1 -p 953 status >/dev/null"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
|
||||
kea:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: kea.Dockerfile
|
||||
user: "0:0"
|
||||
environment:
|
||||
KEA_CONTROL_SOCKET_DIR: /run/kea
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "test -S /run/kea/kea4-ctrl-socket"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Kea DHCPv4 with the DHCP agent for the end-to-end test.
|
||||
FROM docker.cloudsmith.io/isc/docker/kea-dhcp4:3.2.1
|
||||
COPY --chmod=0755 bin/ddi-agent-dhcp /usr/local/bin/ddi-agent-dhcp
|
||||
COPY --chmod=0644 kea/kea-dhcp4.conf /etc/kea/kea-dhcp4.conf
|
||||
COPY --chmod=0644 kea/agent-dhcp.yaml /etc/ddi-suite/agent-dhcp.yaml
|
||||
COPY --chmod=0755 kea/entrypoint.sh /e2e/entrypoint.sh
|
||||
ENTRYPOINT ["/e2e/entrypoint.sh"]
|
||||
@@ -0,0 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# DHCP agent configuration for the end-to-end test.
|
||||
log:
|
||||
level: debug
|
||||
dhcp:
|
||||
config_file: /etc/kea/kea-dhcp4.conf
|
||||
control_socket: /run/kea/kea4-ctrl-socket
|
||||
lease_interval: 2s
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Kea 3.x accepts the control socket only in KEA_CONTROL_SOCKET_DIR, which
|
||||
# must not be accessible by others. The agent is started later by run.sh.
|
||||
set -eu
|
||||
mkdir -p "$KEA_CONTROL_SOCKET_DIR" /var/lib/kea
|
||||
chmod 750 "$KEA_CONTROL_SOCKET_DIR"
|
||||
exec kea-dhcp4 -c /etc/kea/kea-dhcp4.conf
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Initial Kea configuration of the end-to-end test: no subnets, control
|
||||
// socket and lease_cmds hook as generated later by the server.
|
||||
{
|
||||
"Dhcp4": {
|
||||
"interfaces-config": { "interfaces": [] },
|
||||
"control-socket": {
|
||||
"socket-type": "unix",
|
||||
"socket-name": "/run/kea/kea4-ctrl-socket"
|
||||
},
|
||||
"lease-database": {
|
||||
"type": "memfile",
|
||||
"persist": true,
|
||||
"name": "/var/lib/kea/kea-leases4.csv"
|
||||
},
|
||||
"hooks-libraries": [{ "library": "libdhcp_lease_cmds.so" }],
|
||||
"subnet4": []
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# End-to-end test of the M3 path: API change -> revision -> agent fetches,
|
||||
# verifies and applies -> BIND/Kea serve it. Needs Docker with compose and
|
||||
# the binaries from `make build` (BIN_DIR, default bin/).
|
||||
#
|
||||
# Usage: test/e2e/run.sh (make test-e2e)
|
||||
# Environment: BIN_DIR, KEEP=1 keeps the containers after the run.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(cd "$here/../.." && pwd)"
|
||||
bin_dir="${BIN_DIR:-$root/bin}"
|
||||
case "$bin_dir" in /*) ;; *) bin_dir="$root/$bin_dir" ;; esac
|
||||
|
||||
dc() { docker compose -f "$here/compose.yaml" "$@"; }
|
||||
api() { dc exec -T server /e2e/api.sh "$@"; }
|
||||
step() { printf '\n=== %s\n' "$*"; }
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
if [[ $rc -ne 0 ]]; then
|
||||
echo "FAILED (exit $rc); logs follow" >&2
|
||||
dc logs --no-color --tail 300 >&2 || true
|
||||
for svc in bind kea; do
|
||||
echo "--- agent log ($svc)" >&2
|
||||
dc exec -T "$svc" sh -c 'cat /tmp/agent.log 2>/dev/null' >&2 || true
|
||||
done
|
||||
fi
|
||||
if [[ "${KEEP:-}" != 1 ]]; then
|
||||
dc down -v --remove-orphans >/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "${here:?}/bin"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
step "Build images"
|
||||
mkdir -p "$here/bin"
|
||||
for b in ddi-server ddi-agent-dns ddi-agent-dhcp; do
|
||||
cp "$bin_dir/$b" "$here/bin/$b"
|
||||
done
|
||||
dc build --pull
|
||||
dc up -d --wait
|
||||
|
||||
step "Set up data through the API"
|
||||
password="$(dc exec -T server ddi-server bootstrap-admin | sed -n 's/^password: //p')"
|
||||
[[ -n "$password" ]] || { echo "bootstrap-admin printed no password" >&2; exit 1; }
|
||||
api setup "$password"
|
||||
|
||||
step "Enroll the agents"
|
||||
dns_cmd="$(api token dns)"
|
||||
dhcp_cmd="$(api token dhcp)"
|
||||
[[ "$dns_cmd" == *'--ca-hash sha256:'* ]] || { echo "unexpected command: $dns_cmd" >&2; exit 1; }
|
||||
# The commands come from our own API and are run as argument lists.
|
||||
read -r -a dns_args <<<"$dns_cmd"
|
||||
read -r -a dhcp_args <<<"$dhcp_cmd"
|
||||
dc exec -T bind "${dns_args[@]}"
|
||||
dc exec -T kea "${dhcp_args[@]}"
|
||||
dc exec -T -d bind sh -c 'exec ddi-agent-dns run >/tmp/agent.log 2>&1'
|
||||
dc exec -T -d kea sh -c 'exec ddi-agent-dhcp run >/tmp/agent.log 2>&1'
|
||||
|
||||
step "Revision 1 reaches both agents"
|
||||
rev="$(api revision "initial data")"
|
||||
[[ "$rev" == 1 ]] || { echo "first revision is $rev" >&2; exit 1; }
|
||||
api wait-applied 1 2
|
||||
|
||||
dig_short() { dc exec -T bind dig @127.0.0.1 "$@" +short +time=2 +tries=3; }
|
||||
expect() {
|
||||
local want="$1"
|
||||
shift
|
||||
local got
|
||||
got="$("$@")"
|
||||
if [[ "$got" != "$want" ]]; then
|
||||
echo "expected '$want', got '$got' from: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "ok $* -> $got"
|
||||
}
|
||||
expect "192.0.2.10" dig_short www.e2e.test A
|
||||
expect "www.e2e.test." dig_short -x 192.0.2.10
|
||||
expect "1" sh -c "docker compose -f '$here/compose.yaml' exec -T bind dig @127.0.0.1 e2e.test SOA +short | awk '{print \$3}'"
|
||||
dc exec -T kea grep -q '"hw-address": "00:11:22:33:44:55"' /etc/kea/kea-dhcp4.conf
|
||||
echo "ok Kea configuration contains the reservation"
|
||||
|
||||
step "Changing an address deploys revision 2"
|
||||
api address 192.0.2.20 mail
|
||||
rev="$(api revision "mail server")"
|
||||
[[ "$rev" == 2 ]] || { echo "second revision is $rev" >&2; exit 1; }
|
||||
api wait-applied 2 2
|
||||
expect "192.0.2.20" dig_short mail.e2e.test A
|
||||
expect "mail.e2e.test." dig_short -x 192.0.2.20
|
||||
|
||||
step "Unchanged data creates no revision"
|
||||
rev="$(api revision "nothing changed")"
|
||||
[[ "$rev" == 2 ]] || { echo "unchanged data produced revision $rev" >&2; exit 1; }
|
||||
|
||||
step "Deployments were reported"
|
||||
api deployments | grep -c '"outcome": "succeeded"' | {
|
||||
read -r n
|
||||
[[ "$n" -ge 4 ]] || { echo "only $n successful deployments" >&2; exit 1; }
|
||||
echo "ok $n successful deployments"
|
||||
}
|
||||
|
||||
step "Revoking an agent disconnects it; BIND keeps serving"
|
||||
api revoke dns
|
||||
api wait-disconnected dns
|
||||
expect "192.0.2.20" dig_short mail.e2e.test A
|
||||
|
||||
step "PASSED"
|
||||
@@ -0,0 +1,9 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Management server for the end-to-end test. bin/ is filled by run.sh with
|
||||
# the binaries built by `make build` (static, CGO disabled).
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache curl jq
|
||||
COPY --chmod=0755 bin/ddi-server /usr/local/bin/ddi-server
|
||||
COPY --chmod=0755 server/entrypoint.sh /e2e/entrypoint.sh
|
||||
COPY --chmod=0755 server/api.sh /e2e/api.sh
|
||||
ENTRYPOINT ["/e2e/entrypoint.sh"]
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# REST API steps of the end-to-end test, run inside the server container
|
||||
# (curl and jq are available there; the API listens on 127.0.0.1 only).
|
||||
#
|
||||
# Usage: api.sh <step> [args]
|
||||
# setup <bootstrap-password> change the password, settings, network, addresses
|
||||
# token <dns|dhcp> print the enrollment command
|
||||
# address <ip> <hostname> add a static address
|
||||
# revision <comment> create a revision, print its number
|
||||
# wait-applied <rev> <count> wait until <count> agents run <rev> healthily
|
||||
# agents print the agent list
|
||||
# deployments print the deployment list
|
||||
# revoke <role> revoke the agent of a role
|
||||
# wait-disconnected <role> wait until that agent is disconnected
|
||||
set -eu
|
||||
|
||||
base=http://127.0.0.1:8080/api/v1
|
||||
jar=/tmp/e2e-cookies
|
||||
csrf_file=/tmp/e2e-csrf
|
||||
password=e2e-admin-password-1
|
||||
|
||||
# req METHOD PATH [JSON] prints the response body; fails on HTTP >= 400.
|
||||
req() {
|
||||
csrf=$(cat "$csrf_file" 2>/dev/null || true)
|
||||
out=$(curl -sS -b "$jar" -c "$jar" -X "$1" -H 'Content-Type: application/json' \
|
||||
-H "X-CSRF-Token: $csrf" ${3:+--data "$3"} -w '\n%{http_code}' "$base$2")
|
||||
code=$(printf '%s\n' "$out" | tail -n1)
|
||||
body=$(printf '%s\n' "$out" | sed '$d')
|
||||
case "$code" in
|
||||
2*) printf '%s\n' "$body" ;;
|
||||
*)
|
||||
echo "$1 $2 -> HTTP $code: $body" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
login() {
|
||||
req POST /auth/login "{\"username\":\"admin\",\"password\":\"$1\"}" | jq -r .csrf_token >"$csrf_file"
|
||||
}
|
||||
|
||||
session() { login "$password"; }
|
||||
|
||||
case "$1" in
|
||||
setup)
|
||||
login "$2"
|
||||
req POST /auth/password "{\"current_password\":\"$2\",\"new_password\":\"$password\"}" >/dev/null
|
||||
session
|
||||
req GET /settings/generator | jq '
|
||||
.dns.primary_ns = "ns1.e2e.test"
|
||||
| .dns.hostmaster = "hostmaster@e2e.test"
|
||||
| .dns.name_servers = [{"name": "ns1.e2e.test", "addresses": ["192.0.2.53"]}]' >/tmp/settings.json
|
||||
req PUT /settings/generator "$(cat /tmp/settings.json)" >/dev/null
|
||||
req POST /networks '{"cidr":"192.0.2.0/24","name":"E2E","gateway":"192.0.2.1","domain":"e2e.test",
|
||||
"dhcp_enabled":true,"pools":[{"start":"192.0.2.100","end":"192.0.2.199"}],"dns_servers":["192.0.2.53"]}' >/dev/null
|
||||
req POST /addresses '{"ip":"192.0.2.53","type":"static","hostname":"ns1"}' >/dev/null
|
||||
req POST /addresses '{"ip":"192.0.2.10","type":"static","hostname":"www"}' >/dev/null
|
||||
req POST /addresses '{"ip":"192.0.2.150","type":"manual_dhcp","hostname":"printer","mac":"00:11:22:33:44:55"}' >/dev/null
|
||||
;;
|
||||
token)
|
||||
session
|
||||
req POST /enrollment-tokens "{\"role\":\"$2\",\"description\":\"e2e\"}" | jq -r .command
|
||||
;;
|
||||
address)
|
||||
session
|
||||
req POST /addresses "{\"ip\":\"$2\",\"type\":\"static\",\"hostname\":\"$3\"}" >/dev/null
|
||||
;;
|
||||
revision)
|
||||
session
|
||||
req POST /revisions "{\"comment\":\"$2\"}" | jq -r .number
|
||||
;;
|
||||
wait-applied)
|
||||
session
|
||||
i=0
|
||||
while [ "$i" -lt 90 ]; do
|
||||
n=$(req GET /agents | jq --argjson rev "$2" \
|
||||
'[.items[] | select(.applied_revision == $rev and .health.state == "ok")] | length')
|
||||
[ "$n" -ge "$3" ] && exit 0
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
echo "agents did not reach revision $2:" >&2
|
||||
req GET /agents | jq . >&2
|
||||
req GET /deployments | jq . >&2
|
||||
exit 1
|
||||
;;
|
||||
agents)
|
||||
session
|
||||
req GET /agents | jq .
|
||||
;;
|
||||
deployments)
|
||||
session
|
||||
req GET /deployments | jq .
|
||||
;;
|
||||
revoke)
|
||||
session
|
||||
id=$(req GET /agents | jq -r --arg role "$2" '.items[] | select(.role == $role) | .id')
|
||||
req POST "/agents/$id/revoke" >/dev/null
|
||||
;;
|
||||
wait-disconnected)
|
||||
session
|
||||
i=0
|
||||
while [ "$i" -lt 30 ]; do
|
||||
c=$(req GET /agents | jq -r --arg role "$2" '.items[] | select(.role == $role) | .connected')
|
||||
[ "$c" = "false" ] && exit 0
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
echo "agent $2 is still connected" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "unknown step $1" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# Creates the master key on first start and runs the server.
|
||||
set -eu
|
||||
mkdir -p "$(dirname "$DDI_MASTER_KEY_FILE")" "$DDI_STATE_DIR"
|
||||
if [ ! -f "$DDI_MASTER_KEY_FILE" ]; then
|
||||
ddi-server gen-master-key >/dev/null
|
||||
fi
|
||||
exec ddi-server serve
|
||||
+1
-1
@@ -2209,7 +2209,7 @@ export interface operations {
|
||||
query?: {
|
||||
network_id?: string;
|
||||
state?: components["schemas"]["LeaseState"];
|
||||
/** @description Substring of IP */
|
||||
/** @description Substring of IP, MAC or hostname. */
|
||||
q?: string;
|
||||
limit?: number;
|
||||
/** @description Return leases with a higher IP (cursor). */
|
||||
|
||||
Reference in New Issue
Block a user