diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index d567930..baadbf2 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -5,36 +5,38 @@ on: branches: - "main" - "develop" + workflow_dispatch: + +# Restrict the default GITHUB_TOKEN to read-only (CodeQL hardening). +permissions: + contents: read jobs: - testing: - runs-on: - group: organization/netbox-docker-agent - labels: - - self-hosted - - Linux - - X64 - steps: - - name: Start test env - run: | - /usr/local/bin/server.sh start github_ci_netbox_docker_agent - - name: executing remote ssh commands using password - uses: appleboy/ssh-action@v1.0.3 - env: - HEAD_REF: ${{ github.head_ref }} - with: - host: agent-1.netbox-docker-agent.github-ci.saashup.com - key: ${{ secrets.KEY }} - username: ${{ secrets.USER }} - envs: HEAD_REF - script: | - git clone https://github.com/SaaShup/netbox-docker-agent.git -b $HEAD_REF - cd netbox-docker-agent; docker build -t saashup/netbox-docker-agent .; cd ../ - docker network create netbox-docker-agent - docker run -d -p 1880:1880 -v /var/run/docker.sock:/var/run/docker.sock:rw -v netbox-docker-agent:/data --name netbox-docker-agent --network netbox-docker-agent saashup/netbox-docker-agent - sleep 30 - cat ./netbox-docker-agent/tests/hurl/tests.hurl | docker run --rm --network netbox-docker-agent -i ghcr.io/orange-opensource/hurl:latest --test --color --variable host=http://netbox-docker-agent:1880 -u admin:saashup - - name: Stop test env - if: ${{ always() }} - run: | - /usr/local/bin/server.sh stop github_ci_netbox_docker_agent + # Read the supported dockerd versions from versions.txt into a matrix. + versions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - id: set + name: Read versions.txt into a matrix + run: | + matrix=$(grep -vE '^[[:space:]]*(#|$)' tests/compat/versions.txt | jq -R . | jq -cs .) + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + echo "Testing versions: $matrix" + + # Run the full test suite (smoke + reads + version + lifecycle + netbox + + # websocket exec) against each pinned dockerd version, in parallel. + test: + needs: versions + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + docker_version: ${{ fromJson(needs.versions.outputs.matrix) }} + name: dockerd ${{ matrix.docker_version }} + steps: + - uses: actions/checkout@v4 + - name: Run test suite + run: tests/compat/run.sh "${{ matrix.docker_version }}" diff --git a/tests/compat/README.md b/tests/compat/README.md new file mode 100644 index 0000000..1766c5f --- /dev/null +++ b/tests/compat/README.md @@ -0,0 +1,72 @@ +# Docker version compatibility tests + +These tests verify that the agent works against a controlled, pinned range of +`dockerd` versions — independent of whatever Docker happens to be installed on +the host. + +## How it works + +The agent talks to the Docker Engine API over a hardcoded +`/var/run/docker.sock`, with no API-version prefix, so it always uses the +daemon's *default* API version. To pin the version under test we use +**Docker-in-Docker**: + +1. `dind` runs the chosen `docker:-dind` and serves its socket on a + shared volume. +2. `agent` mounts that same volume at `/var/run`, so its + `/var/run/docker.sock` *is* the dind daemon — no agent change needed. +3. `tester` (hurl) runs [`../hurl/tests.hurl`](../hurl/tests.hurl) against the + agent. + +See [docker-compose.yml](docker-compose.yml). The stack also includes a +`netbox` service (a [wiremock](https://wiremock.org/) stand-in) so the +agent->netbox callbacks can be exercised; the agent's `netbox_url` is pointed +at it via [fixtures/config.netbox.js](fixtures/config.netbox.js). + +## What gets tested + +For each version `run.sh` runs three things against the standing stack: + +1. **The hurl suite** ([../hurl](../hurl)): + - `tests.hurl` — the original HTTP smoke tests. + - `read.hurl` — field-level assertions on the synchronous read endpoints + (`/api/networks`, `/api/containers`, `/api/images`, `/api/volumes`, + `/system/usage`). Where dockerd field drift shows up first. + - `version.hurl` — asserts `/info` reports the exact dockerd version + under test (requires the `docker_version` variable). + - `lifecycle.hurl` — a real container lifecycle against the daemon: + pull -> create -> start -> logs -> stats -> exec -> stop -> delete, + polling the read endpoints for each async side effect. +2. **The websocket-exec test** ([ws-exec-test.mjs](ws-exec-test.mjs)) — drives + the interactive `/ws` exec channel using the agent's own bundled client lib + (can't be expressed in hurl). Runs inside the agent container. +3. **The netbox-contract test** (`netbox.hurl`) — asserts the agent actually + sends the expected callbacks to netbox after a write, by inspecting the + wiremock request journal. Run against a freshly restarted agent so the + agent's own config-persistence during the suite can't interfere. + +## Supported versions + +The list of tested versions lives in [versions.txt](versions.txt), one per +line. It is the single source of truth, consumed by both `run.sh` and CI. + +## Running locally + +Requires Docker with the Compose plugin. + +```sh +# Test every version in versions.txt +./run.sh + +# Test a single version +./run.sh 29.5.2 +``` + +The script builds the agent image, spins up dind + agent for each version, +runs the hurl suite, tears the stack down, and prints a pass/fail summary. + +## Adding a version + +Add the version to [versions.txt](versions.txt) and open a PR. CI picks it up +automatically via the matrix in +[`../../.github/workflows/compat_ci.yml`](../../.github/workflows/compat_ci.yml). diff --git a/tests/compat/docker-compose.yml b/tests/compat/docker-compose.yml new file mode 100644 index 0000000..251a506 --- /dev/null +++ b/tests/compat/docker-compose.yml @@ -0,0 +1,98 @@ +# Compatibility test stack: pins the dockerd version the agent talks to. +# +# dind -> runs the chosen dockerd version, exposes its unix socket on a +# shared volume at /var/run/docker.sock +# agent -> the netbox-docker-agent image, with that same volume mounted at +# /var/run, so its hardcoded /var/run/docker.sock points at dind +# tester -> hurl, runs the existing suite against the agent +# +# Driven by run.sh; set DOCKER_VERSION to pick the dockerd version. +name: nda-compat + +services: + dind: + image: docker:${DOCKER_VERSION:?set DOCKER_VERSION (e.g. 29.5.2)}-dind + privileged: true + environment: + # Empty cert dir disables TLS, so dockerd serves a plain unix socket. + DOCKER_TLS_CERTDIR: "" + volumes: + - docker-run:/var/run + healthcheck: + test: ["CMD", "docker", "version"] + interval: 3s + timeout: 5s + retries: 30 + start_period: 5s + + # Stand-in for netbox: records the callbacks the agent makes after write ops + # (catch-all returns empty results so flows that read netbox first don't choke). + netbox: + image: wiremock/wiremock:3.9.1 + command: ["--port", "8080", "--disable-banner"] + volumes: + - ./fixtures/wiremock/mappings:/home/wiremock/mappings:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"] + interval: 3s + timeout: 5s + retries: 20 + start_period: 3s + + agent: + build: + context: ../.. + image: saashup/netbox-docker-agent:compat + # The image runs as the unprivileged node-red user, but the dind socket is + # owned by root. Override to root for tests so curl can reach the socket. + user: root + depends_on: + dind: + condition: service_healthy + netbox: + condition: service_healthy + volumes: + # Shares dind's /var/run, so /var/run/docker.sock is the dind daemon. + - docker-run:/var/run + # Seed (read-only) the netbox config; the entrypoint copies it to a + # writable /data/config.js so the agent can rewrite it (it persists + # config after netbox interactions; a :ro mount there would EROFS). + - ./fixtures/config.netbox.js:/seed/config.js:ro + entrypoint: ["sh", "-c", "cp /seed/config.js /data/config.js && exec ./entrypoint.sh"] + healthcheck: + test: ["CMD", "curl", "-fsS", "-u", "admin:saashup", "http://localhost:1880/"] + interval: 5s + timeout: 5s + retries: 30 + start_period: 20s + + # Runs the hurl suite. run.sh overrides the command to inject the per-version + # docker_version variable; the default below mirrors it for a manual + # `docker compose run --rm tester`. + tester: + image: ghcr.io/orange-opensource/hurl:latest + profiles: ["test"] + depends_on: + agent: + condition: service_healthy + netbox: + condition: service_healthy + volumes: + - ../hurl:/tests:ro + entrypoint: ["hurl"] + command: + - "--test" + - "--color" + - "--variable" + - "host=http://agent:1880" + - "--variable" + - "netbox=http://netbox:8080" + - "-u" + - "admin:saashup" + - "/tests/tests.hurl" + - "/tests/read.hurl" + - "/tests/lifecycle.hurl" + - "/tests/netbox.hurl" + +volumes: + docker-run: diff --git a/tests/compat/fixtures/config.netbox.js b/tests/compat/fixtures/config.netbox.js new file mode 100644 index 0000000..2c1b487 --- /dev/null +++ b/tests/compat/fixtures/config.netbox.js @@ -0,0 +1 @@ +{"netbox_url":"http://netbox:8080","netbox_token":"compat-test-token","ui":1,"id":"1","endpoint":"http://netbox:8080","flows":"0"} diff --git a/tests/compat/fixtures/wiremock/mappings/catch-all.json b/tests/compat/fixtures/wiremock/mappings/catch-all.json new file mode 100644 index 0000000..a0e68d0 --- /dev/null +++ b/tests/compat/fixtures/wiremock/mappings/catch-all.json @@ -0,0 +1,13 @@ +{ + "mappings": [ + { + "priority": 10, + "request": { "method": "ANY", "urlPattern": ".*" }, + "response": { + "status": 200, + "headers": { "Content-Type": "application/json" }, + "jsonBody": { "results": [], "count": 0, "id": 1 } + } + } + ] +} diff --git a/tests/compat/run.sh b/tests/compat/run.sh new file mode 100755 index 0000000..74f8872 --- /dev/null +++ b/tests/compat/run.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# +# Run the compatibility suite against the agent for one or more dockerd versions. +# +# ./run.sh # test every version listed in versions.txt +# ./run.sh 29.5.2 # test a single version +# ./run.sh 29.5.2 28.3.1 # test a specific set of versions +# +# For each version it stands up dind() + a netbox mock + the agent, +# then runs: +# 1. the hurl suite (read/version/lifecycle + the original smoke tests) +# 2. the websocket-exec test (not expressible in hurl) +# 3. the netbox-contract test, against a freshly restarted agent so the +# agent's own config-persistence during step 1 can't interfere +# ...before tearing everything down. +# +# Exits non-zero if any version fails. +set -euo pipefail + +cd "$(dirname "$0")" + +COMPOSE=(docker compose -p nda-compat) + +# Main suite (run together against the same agent). +HURL_MAIN=( + /tests/tests.hurl + /tests/read.hurl + /tests/version.hurl + /tests/lifecycle.hurl +) +# Agent->netbox contract: run in isolation (see below). +HURL_NETBOX=(/tests/netbox.hurl) + +if [ "$#" -gt 0 ]; then + versions=("$@") +else + mapfile -t versions < <(grep -vE '^[[:space:]]*(#|$)' versions.txt) +fi + +if [ "${#versions[@]}" -eq 0 ]; then + echo "No versions to test (check versions.txt)." >&2 + exit 1 +fi + +declare -A results +overall=0 + +cleanup() { DOCKER_VERSION="${1:-x}" "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true; } + +wait_healthy() { # $1 = container name + for _ in $(seq 1 45); do + [ "$("${COMPOSE[@]}" ps -q "$1" | xargs -r docker inspect -f '{{.State.Health.Status}}' 2>/dev/null)" = "healthy" ] && return 0 + sleep 2 + done + return 1 +} + +hurl() { # remaining args: hurl files + "${COMPOSE[@]}" run --rm tester \ + --test --color \ + --variable host=http://agent:1880 \ + --variable netbox=http://netbox:8080 \ + --variable docker_version="$DOCKER_VERSION" \ + -u admin:saashup "$@" +} + +for v in "${versions[@]}"; do + echo "===================================================================" + echo "=== Testing agent against dockerd $v" + echo "===================================================================" + export DOCKER_VERSION="$v" + cleanup "$v" + + if ! "${COMPOSE[@]}" up -d --build dind netbox agent; then + results["$v"]="SETUP-FAIL"; overall=1 + "${COMPOSE[@]}" logs || true + cleanup "$v" + continue + fi + + step_fail=0 + + # --- 1. hurl main suite --------------------------------------------------- + hurl "${HURL_MAIN[@]}" || { echo "!!! hurl suite failed for $v"; step_fail=1; } + + # --- 2. websocket exec (F): not expressible in hurl ----------------------- + # Self-contained: it pulls nginx:alpine through the agent itself. + "${COMPOSE[@]}" exec -T agent node --input-type=module - < ws-exec-test.mjs \ + || { echo "!!! websocket-exec test failed for $v"; step_fail=1; } + + # --- 3. netbox contract (E), isolated ------------------------------------- + # The agent rewrites /data/config.js from netbox responses during the suite + # above, which can clear netbox_url. Restarting re-seeds a clean config (the + # entrypoint copies it from /seed), giving this test a known-good agent. + "${COMPOSE[@]}" restart agent >/dev/null 2>&1 || true + if wait_healthy agent; then + hurl "${HURL_NETBOX[@]}" || { echo "!!! netbox-contract test failed for $v"; step_fail=1; } + else + echo "!!! agent did not become healthy after restart for $v"; step_fail=1 + fi + + if [ "$step_fail" -eq 0 ]; then + results["$v"]="PASS" + else + results["$v"]="FAIL"; overall=1 + echo "--- agent logs ($v) ---"; "${COMPOSE[@]}" logs agent | tail -40 || true + fi + + cleanup "$v" +done + +echo +echo "===== Compatibility summary =====" +for v in "${versions[@]}"; do + printf ' dockerd %-12s %s\n' "$v" "${results[$v]:-UNKNOWN}" +done + +exit "$overall" diff --git a/tests/compat/versions.txt b/tests/compat/versions.txt new file mode 100644 index 0000000..2db6b43 --- /dev/null +++ b/tests/compat/versions.txt @@ -0,0 +1,8 @@ +# Supported dockerd versions, one per line. +# Each version is tested by spinning up `docker:-dind` and running +# the full hurl suite against the agent connected to that daemon. +# Lines starting with `#` and blank lines are ignored. +# To support a new version: add it here and open a PR. +29.5.2 +28.5.2 +27.5.1 diff --git a/tests/compat/ws-exec-test.mjs b/tests/compat/ws-exec-test.mjs new file mode 100644 index 0000000..d71264a --- /dev/null +++ b/tests/compat/ws-exec-test.mjs @@ -0,0 +1,158 @@ +// Websocket exec coverage (F). +// +// The agent exposes an interactive exec channel: POST /api/engine/containers/ +// /ws spins up a docker-exec-websocket server, then a client connects to +// the returned ws path and streams a command. This can't be driven by hurl, so +// this script uses the agent's own bundled client lib (exact protocol match). +// +// Designed to run INSIDE the agent container (node + the lib are present there): +// docker compose exec -T agent node --input-type=module - < ws-exec-test.mjs +// +// Self-contained: pulls nginx:alpine through the agent, creates a throwaway +// container from it, runs `echo` over the ws exec channel, asserts the output, +// and cleans up. +// +// Env: BASE_URL (default http://localhost:1880), WS_USER, WS_PASS. + +import http from "http"; +import pkg from "docker-exec-websocket-server"; +const { DockerExecClient } = pkg; + +const BASE = process.env.BASE_URL || "http://localhost:1880"; +const USER = process.env.WS_USER || "admin"; +const PASS = process.env.WS_PASS || "saashup"; +const NAME = "compat-ws-exec"; +const MARKER = "hello-ws-exec"; +const AUTH = "Basic " + Buffer.from(`${USER}:${PASS}`).toString("base64"); + +const base = new URL(BASE); + +function api(method, path, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const req = http.request( + { + host: base.hostname, + port: base.port || 80, + method, + path, + headers: { + authorization: AUTH, + "content-type": "application/json", + ...(data ? { "content-length": Buffer.byteLength(data) } : {}), + }, + }, + (r) => { + let b = ""; + r.on("data", (d) => (b += d)); + r.on("end", () => resolve({ status: r.statusCode, body: b })); + } + ); + req.on("error", reject); + if (data) req.write(data); + req.end(); + }); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +function fail(msg) { + console.error(`ws-exec: FAIL — ${msg}`); + process.exitCode = 1; +} + +async function findContainer() { + const res = await api("GET", "/api/containers"); + const list = JSON.parse(res.body); + return list.find((c) => c.Name === `/${NAME}`); +} + +async function cleanup() { + await api("DELETE", "/api/engine/containers", { + data: { ContainerID: NAME, name: NAME }, + }).catch(() => {}); +} + +async function main() { + await cleanup(); + + // Pull the image through the agent and wait until it lands in the daemon. + await api("POST", "/api/engine/images", { + data: { id: 7, name: "nginx", version: "alpine", size: 0, ImageID: null }, + }); + let pulled = false; + for (let i = 0; i < 90 && !pulled; i++) { + await sleep(2000); + const res = await api("GET", "/api/images"); + const imgs = JSON.parse(res.body); + pulled = Array.isArray(imgs) && imgs.some((im) => (im.RepoTags || []).includes("nginx:alpine")); + } + if (!pulled) throw new Error("nginx:alpine was not pulled"); + + // Create + start a throwaway container. + await api("POST", "/api/engine/containers", { + data: { id: 7, name: NAME, state: "none", image: { name: "nginx", version: "alpine" } }, + }); + + let c; + for (let i = 0; i < 30 && !c; i++) { + await sleep(1000); + c = await findContainer(); + } + if (!c) throw new Error(`container ${NAME} was not created`); + + await api("PUT", "/api/engine/containers", { + data: { id: 7, ContainerID: NAME, operation: "start", name: NAME, image: { name: "nginx" } }, + }); + + for (let i = 0; i < 30; i++) { + await sleep(1000); + c = await findContainer(); + if (c && c.State && c.State.Status === "running") break; + } + if (!c || c.State.Status !== "running") throw new Error(`container ${NAME} did not reach running`); + + // Spin up the ws exec server and get its path. + const wsRes = await api("POST", `/api/engine/containers/${c.Id}/ws`); + if (wsRes.status !== 200) throw new Error(`POST /ws returned ${wsRes.status}: ${wsRes.body}`); + const { path } = JSON.parse(wsRes.body); + if (!path) throw new Error(`no ws path in response: ${wsRes.body}`); + console.log(`ws-exec: server path ${path}`); + + // Connect the client and run a command. + const wsUrl = `ws://${base.hostname}:${base.port || 80}${path}`; + const client = new DockerExecClient({ url: wsUrl, tty: true, command: ["echo", MARKER] }); + + const result = await new Promise(async (resolve) => { + let out = ""; + const timer = setTimeout(() => resolve({ timeout: true, out }), 10000); + try { + await client.execute(); + } catch (e) { + clearTimeout(timer); + return resolve({ error: e && e.message }); + } + client.stdout.on("data", (d) => (out += d.toString("utf8"))); + client.on("error", (e) => console.error("ws-exec: client error:", e && e.message)); + client.on("exit", (code) => { + clearTimeout(timer); + resolve({ code, out }); + }); + }); + + if (result.error) throw new Error(`ws client error: ${result.error}`); + if (result.timeout) throw new Error(`timed out; partial output ${JSON.stringify(result.out)}`); + console.log(`ws-exec: exit=${result.code} output=${JSON.stringify(result.out)}`); + if (!result.out.includes(MARKER)) { + fail(`expected output to contain "${MARKER}"`); + } else { + console.log("ws-exec: PASS"); + } +} + +main() + .catch((e) => fail(e.message)) + .finally(async () => { + await cleanup(); + // Give the websocket a moment to close so the process can exit cleanly. + setTimeout(() => process.exit(process.exitCode || 0), 500); + }); diff --git a/tests/hurl/lifecycle.hurl b/tests/hurl/lifecycle.hurl new file mode 100644 index 0000000..5f3655a --- /dev/null +++ b/tests/hurl/lifecycle.hurl @@ -0,0 +1,125 @@ +# Real container lifecycle (B) — the compatibility centerpiece. +# +# Drives a full workflow through the agent against a real daemon and verifies +# each step by its side effect. The write endpoints (/api/engine/...) return +# 202 and run asynchronously, so we poll the synchronous read endpoints +# (/api/containers, /api/images) with [Options] retry until the daemon +# reflects the change. +# +# The container is addressed by NAME throughout — Docker accepts a name +# wherever an id is expected, so no fragile id-capture is needed. +# Image: nginx:alpine (small, and stays running so logs/stats/exec are valid). + +# === 1. Pull the image through the agent ================================== +# Request a pull of nginx:alpine. size:0 + ImageID:null are the field values +# that make the agent's "should we pull?" condition true, so it pulls. +POST {{host}}/api/engine/images +```json +{ "data": { "id": 1, "name": "nginx", "version": "alpine", "size": 0, "ImageID": null } } +``` +HTTP 202 + +# === 2. Poll until the pull actually lands in the daemon ================== +GET {{host}}/api/images +[Options] +retry: 90 +retry-interval: 2000 +HTTP 200 +[Asserts] +jsonpath "$[*].RepoTags[*]" contains "nginx:alpine" + +# === 3. Create the container (state=none triggers creation) =============== +POST {{host}}/api/engine/containers +```json +{ + "data": { + "id": 1, + "name": "compat-lifecycle", + "state": "none", + "hostname": "compat-lifecycle", + "image": { "name": "nginx", "version": "alpine" } + } +} +``` +HTTP 202 + +# === 4. Poll until the container exists =================================== +GET {{host}}/api/containers +[Options] +retry: 30 +retry-interval: 2000 +HTTP 200 +[Asserts] +jsonpath "$[?(@.Name=='/compat-lifecycle')]" count == 1 + +# === 5. Start it ========================================================== +PUT {{host}}/api/engine/containers +```json +{ "data": { "id": 1, "ContainerID": "compat-lifecycle", "operation": "start", "name": "compat-lifecycle", "image": { "name": "nginx" } } } +``` +HTTP 202 + +# === 6. Poll until it reports running ====================================== +GET {{host}}/api/containers +[Options] +retry: 30 +retry-interval: 2000 +HTTP 200 +[Asserts] +jsonpath "$[?(@.Name=='/compat-lifecycle')].State.Status" contains "running" + +# === 7. Logs (synchronous) ================================================= +GET {{host}}/api/engine/containers/compat-lifecycle/logs +HTTP 200 +[Asserts] +body isString + +# === 8. Stats (synchronous, single snapshot) =============================== +GET {{host}}/api/engine/containers/compat-lifecycle/stats +HTTP 200 +[Asserts] +jsonpath "$" exists + +# === 9. Exec a command and read its output (synchronous) ================== +# The endpoint builds the exec from `cmd`, runs it against the daemon, and +# returns the output in a {stdout} envelope (carrying Docker's stream- +# multiplexing header bytes), so we assert the output contains our marker. +PUT {{host}}/api/engine/containers/compat-lifecycle/exec +```json +{ "cmd": ["echo", "hello-compat"] } +``` +HTTP 200 +[Asserts] +jsonpath "$.stdout" contains "hello-compat" + +# === 10. Stop it ========================================================== +PUT {{host}}/api/engine/containers +```json +{ "data": { "id": 1, "ContainerID": "compat-lifecycle", "operation": "stop", "name": "compat-lifecycle", "image": { "name": "nginx" } } } +``` +HTTP 202 + +# === 11. Poll until stopped ================================================ +GET {{host}}/api/containers +[Options] +retry: 30 +retry-interval: 2000 +HTTP 200 +[Asserts] +jsonpath "$[?(@.Name=='/compat-lifecycle')].State.Status" contains "exited" + +# === 12. Delete it ======================================================== +DELETE {{host}}/api/engine/containers +```json +{ "data": { "id": 1, "ContainerID": "compat-lifecycle", "name": "compat-lifecycle" } } +``` +HTTP 202 + +# === 13. Poll until it is gone ============================================= +GET {{host}}/api/containers +[Options] +retry: 30 +retry-interval: 2000 +HTTP 200 +[Asserts] +jsonpath "$[?(@.Name=='/compat-lifecycle')]" count == 0 diff --git a/tests/hurl/netbox.hurl b/tests/hurl/netbox.hurl new file mode 100644 index 0000000..de05a49 --- /dev/null +++ b/tests/hurl/netbox.hurl @@ -0,0 +1,71 @@ +# Agent -> netbox contract (E). +# +# Write ops report their result back to netbox asynchronously. This test points +# the agent at a wiremock stand-in (the compat harness sets netbox_url to it) +# and asserts the agent actually sends the expected callbacks after a real +# container create. Requires the `netbox` variable (the mock's base URL), +# which run.sh passes. Skipped by `npm test` (which only runs tests.hurl). + +# === Reset the request journal so we only see this test's callbacks ======= +DELETE {{netbox}}/__admin/requests +HTTP 200 + +# === Trigger a real container create through the agent ==================== +POST {{host}}/api/engine/containers +```json +{ + "data": { + "id": 4242, + "name": "compat-netbox-cb", + "state": "none", + "image": { "name": "nginx", "version": "alpine" } + } +} +``` +HTTP 202 + +# === The agent must PATCH the container back to netbox, with correct content +# Poll the mock's journal until a PATCH whose body carries our container has +# arrived, matched on structured fields (not substrings): the container name, +# its state, and the real ImageID the agent read from the daemon. +POST {{netbox}}/__admin/requests/count +[Options] +retry: 30 +retry-interval: 1000 +```json +{ + "method": "PATCH", + "urlPath": "/api/plugins/docker/containers/", + "bodyPatterns": [ + { "matchesJsonPath": "$[?(@.name == 'compat-netbox-cb')]" }, + { "matchesJsonPath": "$[?(@.state == 'created')]" }, + { "matchesJsonPath": "$[?(@.image.ImageID =~ /sha256:.*/)]" } + ] +} +``` +HTTP 200 +[Asserts] +jsonpath "$.count" >= 1 + +# === ...and a success journal entry must be recorded for our container ==== +POST {{netbox}}/__admin/requests/count +```json +{ + "method": "POST", + "urlPath": "/api/extras/journal-entries/", + "bodyPatterns": [ + { "matchesJsonPath": "$[?(@.assigned_object_id == 4242)]" }, + { "matchesJsonPath": "$[?(@.kind == 'success')]" } + ] +} +``` +HTTP 200 +[Asserts] +jsonpath "$.count" >= 1 + +# === Clean up the container =============================================== +DELETE {{host}}/api/engine/containers +```json +{ "data": { "id": 4242, "ContainerID": "compat-netbox-cb", "name": "compat-netbox-cb" } } +``` +HTTP 202 diff --git a/tests/hurl/read.hurl b/tests/hurl/read.hurl new file mode 100644 index 0000000..d2f1cfd --- /dev/null +++ b/tests/hurl/read.hurl @@ -0,0 +1,71 @@ +# Read-path coverage (A). +# +# Two families of read endpoints exist: +# /api/engine/ -> returns 202 immediately (an async ack; it syncs the +# daemon state to netbox, it does NOT return the list). +# /api/ -> returns 200 synchronously with the live Docker data. +# +# The existing tests.hurl only checked status + `isCollection`. Here we assert +# the actual Docker payload shape on the synchronous endpoints (where field +# drift across dockerd versions shows up) and cover /system/usage. + +# --- networks: default bridge/host/none always exist ---------------------- +GET {{host}}/api/networks +HTTP 200 +[Asserts] +jsonpath "$" isCollection +jsonpath "$[*].Name" contains "bridge" +jsonpath "$[*].Name" contains "host" +jsonpath "$[*].Name" contains "none" +jsonpath "$[*].Id" exists +jsonpath "$[*].Driver" exists + +# --- containers: live list (array, possibly empty) ------------------------ +GET {{host}}/api/containers +HTTP 200 +[Asserts] +jsonpath "$" isCollection + +# --- images: live list (array, possibly empty) ---------------------------- +GET {{host}}/api/images +HTTP 200 +[Asserts] +jsonpath "$" isCollection + +# --- volumes -------------------------------------------------------------- +GET {{host}}/api/volumes +HTTP 200 +[Asserts] +jsonpath "$.Volumes" isCollection +jsonpath "$.Warnings" == null + +# --- /system/usage: previously untested; agent's aggregated disk usage ---- +GET {{host}}/system/usage +HTTP 200 +[Asserts] +jsonpath "$.images" isCollection +jsonpath "$.images.size" exists +jsonpath "$.images.shared_size" exists +jsonpath "$.containers" isCollection +jsonpath "$.containers.size_rootfs" exists +jsonpath "$.volumes" exists +jsonpath "$.build_cache" exists + +# --- /api/engine/ acks (202) — smoke only --------------------------- +GET {{host}}/api/engine/containers +HTTP 202 + +GET {{host}}/api/engine/networks +HTTP 202 + +GET {{host}}/api/engine/volumes +HTTP 202 + +GET {{host}}/api/engine/images +HTTP 202 + +GET {{host}}/api/engine/registries +HTTP 202 + +GET {{host}}/api/engine/endpoint +HTTP 202 diff --git a/tests/hurl/version.hurl b/tests/hurl/version.hurl new file mode 100644 index 0000000..9f45ce3 --- /dev/null +++ b/tests/hurl/version.hurl @@ -0,0 +1,16 @@ +# Daemon-version assertion (C). +# +# Proves the agent is actually talking to the dockerd version under test and +# surfaces its version metadata correctly. Requires the `docker_version` +# variable, which the compat harness (run.sh) always passes. Run it manually +# with: hurl --variable docker_version=29.5.2 ... + +GET {{host}}/info +HTTP 200 +[Asserts] +jsonpath "$.docker" isCollection +jsonpath "$.docker.Version" isString +jsonpath "$.docker.Version" == "{{docker_version}}" +jsonpath "$.docker.ApiVersion" matches /^\d+\.\d+$/ +jsonpath "$.docker.MinAPIVersion" matches /^\d+\.\d+$/ +jsonpath "$.docker.Os" == "linux"