diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b222fab..56b9043 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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) diff --git a/.gitignore b/.gitignore index dc4f39e..b08c73a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ Thumbs.db # Local worktrees of Claude Code agents /.claude/worktrees/ +/test/e2e/bin/ diff --git a/Makefile b/Makefile index 1a3b3bb..3e869d1 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/agent/core/agent_test.go b/agent/core/agent_test.go index ab3810d..0e2db44 100644 --- a/agent/core/agent_test.go +++ b/agent/core/agent_test.go @@ -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) } } diff --git a/agent/role/internal/command/command_test.go b/agent/role/internal/command/command_test.go index 39ad932..cdbe711 100644 --- a/agent/role/internal/command/command_test.go +++ b/agent/role/internal/command/command_test.go @@ -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)) } } diff --git a/scripts/ci/smoke-server.sh b/scripts/ci/smoke-server.sh index fa4a313..fe4055c 100755 --- a/scripts/ci/smoke-server.sh +++ b/scripts/ci/smoke-server.sh @@ -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 '
' || fail "SPA fallback" +grep -q '
' <<<"$(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" diff --git a/server/api/openapi.yaml b/server/api/openapi.yaml index dd618f2..121d721 100644 --- a/server/api/openapi.yaml +++ b/server/api/openapi.yaml @@ -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: diff --git a/server/internal/httpapi/api_test.go b/server/internal/httpapi/api_test.go index ca4f745..fd98754 100644 --- a/server/internal/httpapi/api_test.go +++ b/server/internal/httpapi/api_test.go @@ -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) } } diff --git a/server/internal/pki/pki_test.go b/server/internal/pki/pki_test.go index 82d0100..4eebeb1 100644 --- a/server/internal/pki/pki_test.go +++ b/server/internal/pki/pki_test.go @@ -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)] { diff --git a/test/e2e/bind.Dockerfile b/test/e2e/bind.Dockerfile new file mode 100644 index 0000000..e34ec3e --- /dev/null +++ b/test/e2e/bind.Dockerfile @@ -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"] diff --git a/test/e2e/bind/agent-dns.yaml b/test/e2e/bind/agent-dns.yaml new file mode 100644 index 0000000..09f51d7 --- /dev/null +++ b/test/e2e/bind/agent-dns.yaml @@ -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 diff --git a/test/e2e/bind/entrypoint.sh b/test/e2e/bind/entrypoint.sh new file mode 100644 index 0000000..2a568a9 --- /dev/null +++ b/test/e2e/bind/entrypoint.sh @@ -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 diff --git a/test/e2e/bind/named.conf b/test/e2e/bind/named.conf new file mode 100644 index 0000000..4fe8ee7 --- /dev/null +++ b/test/e2e/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"; diff --git a/test/e2e/compose.yaml b/test/e2e/compose.yaml new file mode 100644 index 0000000..6bdd4e2 --- /dev/null +++ b/test/e2e/compose.yaml @@ -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 diff --git a/test/e2e/kea.Dockerfile b/test/e2e/kea.Dockerfile new file mode 100644 index 0000000..2f9fbd1 --- /dev/null +++ b/test/e2e/kea.Dockerfile @@ -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"] diff --git a/test/e2e/kea/agent-dhcp.yaml b/test/e2e/kea/agent-dhcp.yaml new file mode 100644 index 0000000..ae35588 --- /dev/null +++ b/test/e2e/kea/agent-dhcp.yaml @@ -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 diff --git a/test/e2e/kea/entrypoint.sh b/test/e2e/kea/entrypoint.sh new file mode 100644 index 0000000..933c519 --- /dev/null +++ b/test/e2e/kea/entrypoint.sh @@ -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 diff --git a/test/e2e/kea/kea-dhcp4.conf b/test/e2e/kea/kea-dhcp4.conf new file mode 100644 index 0000000..702acdc --- /dev/null +++ b/test/e2e/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": [] + } +} diff --git a/test/e2e/run.sh b/test/e2e/run.sh new file mode 100644 index 0000000..e2fa7a9 --- /dev/null +++ b/test/e2e/run.sh @@ -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" diff --git a/test/e2e/server.Dockerfile b/test/e2e/server.Dockerfile new file mode 100644 index 0000000..fd49917 --- /dev/null +++ b/test/e2e/server.Dockerfile @@ -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"] diff --git a/test/e2e/server/api.sh b/test/e2e/server/api.sh new file mode 100644 index 0000000..952d1f7 --- /dev/null +++ b/test/e2e/server/api.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 [args] +# setup change the password, settings, network, addresses +# token print the enrollment command +# address add a static address +# revision create a revision, print its number +# wait-applied wait until agents run healthily +# agents print the agent list +# deployments print the deployment list +# revoke revoke the agent of a role +# wait-disconnected 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 diff --git a/test/e2e/server/entrypoint.sh b/test/e2e/server/entrypoint.sh new file mode 100644 index 0000000..24220cb --- /dev/null +++ b/test/e2e/server/entrypoint.sh @@ -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 diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index f023b32..9c5d728 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -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). */