From a3a00f585ea7d2386aacc2c476a4af60ad2bd503 Mon Sep 17 00:00:00 2001 From: Aleksandergreg Date: Wed, 30 Sep 2026 18:42:35 +0200 Subject: [PATCH] feat(docker): add Network Plumbing world with internal attempt-owned networks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary Adds Docker World 3, "Network Plumbing" (Missions 35–39), and the network support it needs. Players learn that containers can reach each other only over a network they share. They read network membership, connect and disconnect existing containers, create a private network, and finish by segmenting a flat network. Every lab network is internal, owned by the attempt, labeled, and removed afterwards. This is Stage 3a of the Docker expansion roadmap. Volumes remain future work because they attach only at container creation, so they depend on a limited `docker run`. Player-facing changes - New teaching commands, parsed by OpsQuest and never passed through: `docker network ls`, `inspect NETWORK`, `create NETWORK`, `rm NETWORK`, `connect NETWORK ALIAS`, and `disconnect NETWORK ALIAS`. None accept options, and the built-in bridge, host, none, and default networks are rejected by name. - `docker inspect ALIAS` now lists a container's logical networks under NetworkSettings.Networks. `docker network inspect` shows a network's logical ID, driver, internal flag, and attached containers, with no engine IDs or generated names. - `opsquest doctor` now reports "orphaned lab resources", because leftover networks are counted alongside containers. - Five new missions (80–140 XP): - Network Census (beginner): find the network two services share. - Can't Reach the DB (beginner): connect api to the database network without exposing the database to the web tier. - Need to Know (intermediate): disconnect a batch job from a network it should never have joined. - Private Channel (intermediate): create a network and move two services onto it. - Segmentation (advanced): split a flat network into tiers, then retire a stale network and the container holding it open. Mission schema - A Docker setup can declare up to 8 `networks` by logical name. Fixtures join up to 4 of them through a new `networks` field. A fixture without networks keeps `--network none`, so existing missions behave exactly as before. - New route-independent conditions: - `docker_containers_share_network` and `docker_containers_isolated` take exactly two `containers` and pass only while both still exist. - `docker_network_absent` takes a declared `network`. A player who solves a mission by creating their own network also passes. Safety and cleanup - OpsQuest creates every network with fixed options: the bridge driver and `--internal`, so there is no route to the host network or the internet. Networks carry the same five ownership labels, advisory owner labels, and generated names (`opsquest--nNN`) as containers. - Players can create at most 4 networks per attempt. They can only attach this attempt's containers to this attempt's networks, resolved to exact IDs. - `docker network rm` is refused while any container is attached, even a stopped one. Docker would allow it and leave that container unable to start. - Engine errors are rewritten so network and container IDs and generated names never reach the player. - `Close` removes containers first, then networks, verifies ownership labels on each exact ID, and keeps unresolved networks for a retry. - The orphan janitor now also sweeps networks, after containers, using the same label, generated-name, owner-process, and 24-hour age checks. - The internal flag is not the only control. Players cannot run commands inside containers (there is no exec, and fixtures run fixed programs), so no player-controlled traffic starts inside a lab network. This is documented in the safety model. Compatibility - Schema changes are additive, and all existing missions load unchanged. Older binaries reject the new catalog under strict loading. - Persisted profiles need no migration: completions are keyed by stable mission IDs. - `docker ps` output is unchanged. Container inspect gains a NetworkSettings.Networks list. - Catalog: 39 missions (23 Linux, 16 Docker in three worlds). Documentation Updates README, AGENTS.md, the docs overview, Docker guide, mission map (Missions 35–39 and a World 3 section), learning-journey diagram, safety model (lab networks, new limits, threat rows), mission authoring guide, mission checklist, and roadmap. The roadmap now lists volumes and reachability probes as the next increments. Testing - make check-all: passed, including race detection and the smoke test - make docs-check: passed in strict mode - GOOS=windows and GOOS=linux go vet ./internal/dockerlab: passed - git diff --check: clean - OPSQUEST_DOCKER_TEST=1 go test ./internal/dockerlab -run Integration on Docker Desktop (engine 29.7.2): all 6 integration tests passed. They cover network connect and internal isolation, the full Segmentation route, the orphaned network sweep, and, for the first time against a real engine, Stage 1 health probes, crash loops, `--tail`, and `rm`. - After the run: 0 OpsQuest-labeled containers or networks remained, and `opsquest doctor` reported no orphaned lab resources. New unit coverage: network creation arguments and order, every command with its success, edge, and failure cases, parser rejection before any Docker call, cleanup order and ownership refusal, setup-failure cleanup, janitor network sweeping, error redaction, schema validation and deep copies, and canonical, alternative, and incomplete routes for all five missions. --- .../references/mission-checklist.md | 4 +- AGENTS.md | 2 +- README.md | 4 +- docs/README.md | 2 +- .../play/diagrams/learning-journey.excalidraw | 4 +- docs/play/diagrams/learning-journey.svg | 10 +- docs/play/docker-foundations.md | 21 +- docs/play/mission-map.md | 15 +- docs/roadmap/README.md | 15 +- docs/technical/mission-authoring.md | 5 +- docs/technical/sandbox-and-safety.md | 28 +- internal/cli/app.go | 8 +- internal/cli/app_test.go | 18 +- internal/dockerlab/dockerlab_test.go | 153 +++++- internal/dockerlab/environment.go | 101 +++- internal/dockerlab/factory.go | 22 +- internal/dockerlab/integration_test.go | 105 ++++ internal/dockerlab/janitor.go | 115 +++-- internal/dockerlab/network.go | 486 ++++++++++++++++++ internal/dockerlab/network_test.go | 393 ++++++++++++++ internal/dockerlab/parser.go | 76 +++ internal/game/missions_test.go | 140 +++++ internal/game/validator.go | 12 + internal/mission/catalog.go | 108 +++- internal/mission/catalog_test.go | 192 ++++++- internal/mission/data/35-network-census.json | 90 ++++ .../mission/data/36-cant-reach-the-db.json | 95 ++++ internal/mission/data/37-need-to-know.json | 112 ++++ internal/mission/data/38-private-channel.json | 107 ++++ internal/mission/data/39-segmentation.json | 127 +++++ internal/mission/mission.go | 32 +- internal/mission/world_test.go | 13 +- scripts/smoke-test.sh | 5 +- 33 files changed, 2497 insertions(+), 123 deletions(-) create mode 100644 internal/dockerlab/network.go create mode 100644 internal/dockerlab/network_test.go create mode 100644 internal/mission/data/35-network-census.json create mode 100644 internal/mission/data/36-cant-reach-the-db.json create mode 100644 internal/mission/data/37-need-to-know.json create mode 100644 internal/mission/data/38-private-channel.json create mode 100644 internal/mission/data/39-segmentation.json diff --git a/.agents/skills/add-mission/references/mission-checklist.md b/.agents/skills/add-mission/references/mission-checklist.md index b86ecc0..23db58b 100644 --- a/.agents/skills/add-mission/references/mission-checklist.md +++ b/.agents/skills/add-mission/references/mission-checklist.md @@ -10,7 +10,7 @@ Every mission supplies: - An effective learning `track` and `environment`; omitted values preserve the legacy `linux` and `simulated` defaults. - Teaching content: `story`, `objective`, one or more unique base names in `suggested_commands`, `hints`, and `explanation`. - Simulated labs: an absolute clean `start_dir` plus declarative `setup` containing `directories`, `files`, `processes`, `environment`, and `archives` as needed. -- Docker labs: `track: docker`, `environment: docker`, and a declarative `docker` setup containing digest-pinned image references and logical `running` or `stopped` container fixtures. Fixtures may select fixed Go-implemented behaviors: an optional startup `log`, a `health` probe (`healthy` or `unhealthy`), a stopped one-shot job (`log` + `exit_code`), or a running bounded crash loop (`log` + non-zero `exit_code` + `restart: on-failure`). They do not define simulated setup or a start directory. +- Docker labs: `track: docker`, `environment: docker`, and a declarative `docker` setup containing digest-pinned image references and logical `running` or `stopped` container fixtures. Fixtures may select fixed Go-implemented behaviors: an optional startup `log`, a `health` probe (`healthy` or `unhealthy`), a stopped one-shot job (`log` + `exit_code`), or a running bounded crash loop (`log` + non-zero `exit_code` + `restart: on-failure`). A setup may declare internal `networks` by logical name, and fixtures join them through `networks`; a fixture without networks runs with networking disabled. They do not define simulated setup or a start directory. - Declarative `validation.all`: one or more observable conditions. - `rewards`: positive `xp` and non-negative `hint_penalty`. @@ -26,7 +26,7 @@ Use the smallest combination that proves the outcome without encoding a command - Virtual file state: `file_content_equals`, `file_content_contains`, `file_lines_equal`, `file_mode_equals`, `file_owner_equals`. - Virtual processes: `process_stopped`, `process_running`. - Virtual environment: `env_equals` with `NAME=value`. -- Docker state: `docker_container_running`, `docker_container_stopped`, or `docker_container_absent` with a declared logical container alias, and `docker_container_count_equals` with a non-negative count. Running and stopped both require the container to still exist. +- Docker state: `docker_container_running`, `docker_container_stopped`, or `docker_container_absent` with a declared logical container alias, and `docker_container_count_equals` with a non-negative count. Running and stopped both require the container to still exist. Network state: `docker_containers_share_network` and `docker_containers_isolated` with exactly two declared `containers` (both must exist), and `docker_network_absent` with a declared `network`. Output-only validation is appropriate when producing the exact useful output is the lesson. Prefer filesystem, process, or environment state when the operational result should survive the final command. Combine positive and negative conditions when distractors could otherwise produce a false completion. diff --git a/AGENTS.md b/AGENTS.md index b45d707..482727a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # OpsQuest agent guide -OpsQuest is a Go 1.26 CLI game with 23 in-memory Linux missions and 11 optional Docker labs across two worlds. Linux player commands stay inside the teaching shell. Docker input is parsed into a deliberately small command subset that can affect only disposable, OpsQuest-labeled resources. Kubernetes remains future scope unless a task explicitly adds it. +OpsQuest is a Go 1.26 CLI game with 23 in-memory Linux missions and 16 optional Docker labs across three worlds. Linux player commands stay inside the teaching shell. Docker input is parsed into a deliberately small command subset that can affect only disposable, OpsQuest-labeled resources. Kubernetes remains future scope unless a task explicitly adds it. ## Repository map diff --git a/README.md b/README.md index 55c9d90..1019354 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so ## What is included - 23 Linux missions across four ordered learning worlds -- 11 optional, disposable Docker missions across two worlds: Foundations and Container Triage +- 16 optional, disposable Docker missions across three worlds: Foundations, Container Triage, and Network Plumbing - An isolated in-memory filesystem, environment, process table, and archives - Quote-aware globs and variables, pipelines, redirection, and command history - A compact virtual `vi` and bounded virtual shell scripts @@ -97,7 +97,7 @@ boundary. Player-entered Linux commands—including virtual scripts—are parsed and executed by OpsQuest. They never reach a host shell, host process, or host path. The teaching shell operates only on bounded in-memory state. -Docker input is parsed into a small set of typed teaching actions. OpsQuest constructs Docker arguments itself, uses only exact resources labeled for the current attempt, applies resource restrictions, and verifies ownership again during cleanup. Fixture processes live at most 24 hours, and leftovers from a crashed process are removed only when every ownership label matches and the recorded owner process is gone. It does not expose arbitrary Docker passthrough, privileged mode, host mounts, host networking, devices, or the Docker socket. +Docker input is parsed into a small set of typed teaching actions. OpsQuest constructs Docker arguments itself, uses only exact resources labeled for the current attempt, applies resource restrictions, and verifies ownership again during cleanup. Fixture processes live at most 24 hours, and leftovers from a crashed process are removed only when every ownership label matches and the recorded owner process is gone. Lab networks are always internal, attempt-owned, and labeled; players cannot run commands inside containers. It does not expose arbitrary Docker passthrough, privileged mode, host mounts, host networking, devices, or the Docker socket. The optional web companion binds only to an ephemeral `127.0.0.1` port, uses a one-time pairing URL, and exposes a read-only mission projection. It cannot submit commands, mutate the profile or attempt, address Docker resources, or approve completion. diff --git a/docs/README.md b/docs/README.md index 67b2f6c..ebb83e9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,7 +31,7 @@ The game describes an observable objective, gives you a disposable environment, ## Current scope - 23 Linux missions across four ordered learning worlds -- 11 optional Docker missions across two worlds +- 16 optional Docker missions across three worlds - Outcome-based validation rather than one required command transcript - An in-memory filesystem, process table, environment, archives, editor, and shell scripts - Persistent XP, ranks, command practice, hints, completions, and achievements diff --git a/docs/play/diagrams/learning-journey.excalidraw b/docs/play/diagrams/learning-journey.excalidraw index cdca9f3..a091c63 100644 --- a/docs/play/diagrams/learning-journey.excalidraw +++ b/docs/play/diagrams/learning-journey.excalidraw @@ -22,9 +22,9 @@ {"id":"docker-lane","type":"rectangle","x":40,"y":545,"width":1410,"height":215,"angle":0,"strokeColor":"#8b5cf6","backgroundColor":"#f5f3ff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":214,"version":1,"versionNonce":2014,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false}, {"id":"docker-label","type":"text","x":70,"y":565,"width":370,"height":20,"angle":0,"strokeColor":"#6d28d9","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":215,"version":1,"versionNonce":2015,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"fontSize":16,"fontFamily":2,"text":"DOCKER TRACK · OPTIONAL · INDEPENDENT WORLD NUMBERING","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"DOCKER TRACK · OPTIONAL · INDEPENDENT WORLD NUMBERING","autoResize":true,"lineHeight":1.25}, {"id":"docker-world","type":"rectangle","x":85,"y":610,"width":390,"height":115,"angle":0,"strokeColor":"#7c3aed","backgroundColor":"#ffffff","fillStyle":"solid","strokeWidth":2,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":216,"version":1,"versionNonce":2016,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false}, - {"id":"docker-world-text","type":"text","x":115,"y":630,"width":330,"height":75,"angle":0,"strokeColor":"#4c1d95","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":217,"version":1,"versionNonce":2017,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"fontSize":18,"fontFamily":2,"text":"DOCKER WORLDS 1–2\nIt Works on My Machine → Container Triage\nMissions 20–34 · lifecycle, diagnostics, triage","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"DOCKER WORLDS 1–2\nIt Works on My Machine → Container Triage\nMissions 20–34 · lifecycle, diagnostics, triage","autoResize":true,"lineHeight":1.25}, + {"id":"docker-world-text","type":"text","x":115,"y":630,"width":330,"height":75,"angle":0,"strokeColor":"#4c1d95","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":217,"version":1,"versionNonce":2017,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"fontSize":18,"fontFamily":2,"text":"DOCKER WORLDS 1–3\nFoundations → Triage → Networks\nMissions 20–39 · lifecycle, triage, networks","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"DOCKER WORLDS 1–3\nFoundations → Triage → Networks\nMissions 20–39 · lifecycle, triage, networks","autoResize":true,"lineHeight":1.25}, {"id":"docker-note","type":"rectangle","x":650,"y":610,"width":700,"height":115,"angle":0,"strokeColor":"#a78bfa","backgroundColor":"#ede9fe","fillStyle":"solid","strokeWidth":2,"strokeStyle":"dashed","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":3},"seed":218,"version":1,"versionNonce":2018,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false}, - {"id":"docker-note-text","type":"text","x":685,"y":630,"width":630,"height":75,"angle":0,"strokeColor":"#4c1d95","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":219,"version":1,"versionNonce":2019,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"fontSize":18,"fontFamily":2,"text":"Separate readiness and progress\nPinned local fixture · no automatic pull · Linux never blocked\n11 bounded lifecycle and diagnostic missions","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Separate readiness and progress\nPinned local fixture · no automatic pull · Linux never blocked\n11 bounded lifecycle and diagnostic missions","autoResize":true,"lineHeight":1.25}, + {"id":"docker-note-text","type":"text","x":685,"y":630,"width":630,"height":75,"angle":0,"strokeColor":"#4c1d95","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":1,"strokeStyle":"solid","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":null,"seed":219,"version":1,"versionNonce":2019,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"fontSize":18,"fontFamily":2,"text":"Separate readiness and progress\nPinned local fixture · no automatic pull · Linux never blocked\n16 bounded lifecycle and diagnostic missions","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"Separate readiness and progress\nPinned local fixture · no automatic pull · Linux never blocked\n16 bounded lifecycle and diagnostic missions","autoResize":true,"lineHeight":1.25}, {"id":"a-docker","type":"arrow","x":475,"y":668,"width":175,"height":0,"angle":0,"strokeColor":"#7c3aed","backgroundColor":"transparent","fillStyle":"solid","strokeWidth":3,"strokeStyle":"dashed","roughness":1,"opacity":100,"groupIds":[],"frameId":null,"roundness":{"type":2},"seed":220,"version":1,"versionNonce":2020,"isDeleted":false,"boundElements":null,"updated":1784505600000,"link":null,"locked":false,"points":[[0,0],[175,0]],"lastCommittedPoint":null,"startBinding":null,"endBinding":null,"startArrowhead":null,"endArrowhead":"arrow","elbowed":false} ], "appState": {"gridSize": null, "viewBackgroundColor": "#f8fafc"}, diff --git a/docs/play/diagrams/learning-journey.svg b/docs/play/diagrams/learning-journey.svg index 114efe1..27cfb9d 100644 --- a/docs/play/diagrams/learning-journey.svg +++ b/docs/play/diagrams/learning-journey.svg @@ -1,7 +1,7 @@ OpsQuest learning journey - Two-lane curriculum overview showing four Linux worlds with twenty-three missions and two independent Docker worlds with eleven missions. + Two-lane curriculum overview showing four Linux worlds with twenty-three missions and three independent Docker worlds with sixteen missions. @@ -74,16 +74,16 @@ DOCKER TRACK · OPTIONAL · INDEPENDENT WORLD NUMBERING - Docker Worlds 1–2 - Foundations → Container Triage - Missions 20–34 · 11 stages; Linux 26–29 sit between + Docker Worlds 1–3 + Foundations → Triage → Networks + Missions 20–39 · 16 stages; Linux 26–29 sit between Separate readiness and progress Pinned local fixture · no automatic pull · Docker never blocks Linux - 11 bounded missions · typed list, filter, logs, inspect, lifecycle, and rm only. + 16 bounded missions · typed container and internal-network actions only. OUTCOME-BASED VALIDATION diff --git a/docs/play/docker-foundations.md b/docs/play/docker-foundations.md index ba9a520..0ca928d 100644 --- a/docs/play/docker-foundations.md +++ b/docs/play/docker-foundations.md @@ -39,7 +39,7 @@ $ opsquest play --track docker ## Current lessons -The track has 11 missions in two worlds. **World 1: It Works on My Machine** is 6 beginner missions covering a deliberately narrow lifecycle loop: +The track has 16 missions in three worlds. **World 1: It Works on My Machine** is 6 beginner missions covering a deliberately narrow lifecycle loop: 1. **Container Census** — list containers and start an existing stopped service. 2. **Last Broadcast** — read bounded logs from an exited one-shot job. @@ -56,6 +56,14 @@ The track has 11 missions in two worlds. **World 1: It Works on My Machine** is 4. **Crash Loop** — stop a container its restart policy keeps relaunching, then show why it fails. 5. **Postmortem Triage** — combine health, exit codes, stop, and remove, and keep the evidence the postmortem needs. +**World 3: Network Plumbing** teaches which containers can reach each other: + +1. **Network Census** — read network membership from container and network inspections. +2. **Can't Reach the DB** — connect a service to a second network without exposing the database to the web tier. +3. **Need to Know** — disconnect a job from a network it should never have joined. +4. **Private Channel** — create a network and move two services onto it. +5. **Segmentation** — split a flat network into tiers and retire a stale network along with the container holding it open. + ## Teaching subset | Command | Supported forms | @@ -65,25 +73,28 @@ The track has 11 missions in two worlds. **World 1: It Works on My Machine** is | Remove | `docker rm ALIAS` for a stopped container; `--force` is refused, so stop the container first | | Inspect | `docker inspect ALIAS` shows logical state, exit code, health, restart count, and restart policy | | Logs | `docker logs ALIAS` or `docker logs --tail N ALIAS` (also `-n N`, `--tail=N`, and `--tail all`) | +| Networks | `docker network ls`, `inspect NETWORK`, `create NETWORK`, `rm NETWORK`, `connect NETWORK ALIAS`, and `disconnect NETWORK ALIAS`, with no options | -Every command also has a `docker container ...` form. OpsQuest parses these forms itself and accepts exact logical aliases. It never forwards other Docker CLI arguments or flags, and filters run inside OpsQuest rather than being passed to Docker. +Every container command also has a `docker container ...` form. OpsQuest parses these forms itself and accepts exact logical aliases. It never forwards other Docker CLI arguments or flags, and filters run inside OpsQuest rather than being passed to Docker. ## Isolation boundary OpsQuest generates unique names and ownership labels, maps player-visible aliases to exact container IDs, applies resource restrictions, and removes only resources verified as belonging to the current attempt. Labs do not use privileged mode, host bind mounts, host networking, devices, or a mounted Docker socket. +Every lab network is created by OpsQuest as an internal network, so it has no route to your host network or the internet. Networks carry the same ownership labels as containers, and you can create at most 4 per attempt. `docker network rm` is refused while any container is attached, even a stopped one, so no container is left unable to start. The built-in `bridge`, `host`, `none`, and `default` networks are never reachable. Containers without declared networks run with networking disabled and cannot join a network. + Health probes and restart policies are fixed fixture behaviors chosen by the mission, never player input. A crash-loop fixture's restart policy allows at most 50 retries. ## Cleaning up after a crash -OpsQuest removes a lab's containers when the attempt ends. If the process is killed first, its containers stay behind. Their processes exit on their own within 24 hours, and the next Docker mission removes them. You can also check and clean up yourself: +OpsQuest removes a lab's containers, then its networks, when the attempt ends. If the process is killed first, they stay behind. Their processes exit on their own within 24 hours, and the next Docker mission removes them. You can also check and clean up yourself: ```console -$ opsquest doctor # reports orphaned lab containers +$ opsquest doctor # reports orphaned lab containers and networks $ opsquest doctor --cleanup # removes them ``` -A container counts as orphaned only when it has every OpsQuest ownership label and its generated name, and either its recorded owner process on this machine has exited or it is older than 24 hours. Containers belonging to other running OpsQuest sessions on this machine are left alone for their 24-hour lifetime. +A container or network counts as orphaned only when it has every OpsQuest ownership label and its generated name, and either its recorded owner process on this machine has exited or it is older than 24 hours. Containers belonging to other running OpsQuest sessions on this machine are left alone for their 24-hour lifetime. The selected Docker-compatible engine remains a powerful external dependency. OpsQuest constrains the lesson and cleanup scope; it does not present the engine itself as an untrusted-code security boundary. diff --git a/docs/play/mission-map.md b/docs/play/mission-map.md index 28aa62c..09a5ad0 100644 --- a/docs/play/mission-map.md +++ b/docs/play/mission-map.md @@ -17,9 +17,9 @@ Editable source: [`learning-journey.excalidraw`](diagrams/learning-journey.excal The curriculum has two independent tracks: - **Linux:** 23 missions across four ordered worlds. Bare `opsquest play` follows this track. -- **Docker:** 11 optional missions across two worlds: Foundations, then Container Triage. Docker readiness never blocks Linux play. +- **Docker:** 16 optional missions across three worlds: Foundations, Container Triage, then Network Plumbing. Docker readiness never blocks Linux play. -Display numbers are global across both tracks: Docker World 1 occupies Missions 20–25, Linux World 4 resumes at Mission 26, and Docker World 2 continues at Mission 30. World and stage positions are derived separately inside each track. Persisted completions use stable mission IDs rather than display numbers, so profiles created before this expansion remain compatible. +Display numbers are global across both tracks: Docker World 1 occupies Missions 20–25, Linux World 4 resumes at Mission 26, Docker World 2 continues at Mission 30, and Docker World 3 at Mission 35. World and stage positions are derived separately inside each track. Persisted completions use stable mission IDs rather than display numbers, so profiles created before this expansion remain compatible. The [learning philosophy](../game/learning-philosophy.md) explains the incident loop and feedback model. [Mission authoring](../technical/mission-authoring.md) @@ -80,6 +80,11 @@ The “tools” column records suggested commands, not a mandatory solution. Out | 32 | Docker W2.3 | Intermediate | Replace a running but unhealthy replica with its standby | `docker` | Unhealthy target stopped; healthy and standby running; count equals | | 33 | Docker W2.4 | Intermediate | Stop a restart-policy crash loop and surface its fault | `docker` | Target stopped; service running; count equals; output contains fault | | 34 | Docker W2.5 | Advanced | Triage health and exit codes while preserving evidence | `docker` | Canary and failed job stopped; successful jobs absent; api running; count equals | +| 35 | Docker W3.1 | Beginner | Map which containers share which networks | `docker` | Output contains network name and members | +| 36 | Docker W3.2 | Beginner | Connect a container to a second network | `docker` | Required pairs share a network; web isolated from db; count equals | +| 37 | Docker W3.3 | Intermediate | Remove an unnecessary network path | `docker` | Batch isolated from ledger; required pairs still share networks; count equals | +| 38 | Docker W3.4 | Intermediate | Move a pair of services onto a private network | `docker` | Pair shares a network; both isolated from api; gateway and api connected | +| 39 | Docker W3.5 | Advanced | Segment a flat network and retire a stale one | `docker` | Tier pairs share networks; web isolated from db; legacy network and cron absent; services running | ## World progression @@ -107,9 +112,13 @@ Docker Foundations progresses through listing, logs, sanitized exit status, targ Container Triage separates evidence from clutter. It adds removal of stopped containers, log tails, health probes, and restart policies, then combines them in an advanced boss where the player must decide what to stop, what to remove, and what to keep for the postmortem. +### Docker World 3: Network Plumbing + +Network Plumbing teaches that containers can only reach each other over a network they share. It moves from reading network membership, through connecting and disconnecting existing containers, to creating a private network, and ends with an advanced boss that segments a flat network and retires a stale one. Every lab network is internal and owned by the attempt. + ## Curriculum evolution -The expanded map fills the early file-reading, focused log-preview, simple counting, supplied-script, beginner Docker lifecycle, and Docker triage gaps. Later curriculum work can deepen those skills through new declarative missions without weakening existing outcome validators or widening the Docker boundary casually. +The expanded map fills the early file-reading, focused log-preview, simple counting, supplied-script, beginner Docker lifecycle, Docker triage, and container networking gaps. Later curriculum work can deepen those skills through new declarative missions without weakening existing outcome validators or widening the Docker boundary casually. Read [Missions, hints, and progress](missions-and-progress.md) for route, reward, and persistence behavior, and [Mission authoring](../technical/mission-authoring.md) diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 52d1a34..74f5a7c 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -11,16 +11,21 @@ behavior. Shipped behavior belongs in the player and technical guides. ## Docker Foundations -The current two Docker worlds intentionally support only listing with fixed -filters, logs, sanitized inspection, bounded lifecycle actions, and removal of -stopped attempt-owned containers. Fixture health probes and crash loops are -fixed behaviors chosen by the mission. +The current three Docker worlds intentionally support only listing with fixed +filters, logs, sanitized inspection, bounded lifecycle actions, removal of +stopped attempt-owned containers, and option-free management of internal +attempt-owned networks. Fixture health probes and crash loops are fixed +behaviors chosen by the mission. Possible later increments include: - environment-aware container creation through a limited `docker run`; - limited port publication; -- bounded volumes and networking; +- bounded, attempt-owned volumes (they attach only at container creation, so + they depend on a limited `docker run`, and a fresh named volume is not + writable by the fixture's unprivileged user); +- reachability probes that check traffic between containers, not just shared + network membership; - Dockerfile or Compose concepts, possibly taught in the simulated shell; - restart-to-recover health lessons, which need fixture state that survives a restart. diff --git a/docs/technical/mission-authoring.md b/docs/technical/mission-authoring.md index eeab863..54d58e4 100644 --- a/docs/technical/mission-authoring.md +++ b/docs/technical/mission-authoring.md @@ -78,8 +78,11 @@ Conditions cover output, working directory, path existence, file content and log | `health: healthy` or `unhealthy` | Service with a fixed passing or failing health probe | Not allowed on exiting fixtures; setup waits for a running probe to settle | | `log` + `exit_code` | One-shot diagnostic job | Must be `stopped`; setup runs it to completion | | `log` + non-zero `exit_code` + `restart: on-failure` | Bounded crash loop (`on-failure:50`) | Must be `running`; setup waits until at least two restarts are visible | +| `networks: [...]` | Joins declared attempt networks at creation | Up to 4 declared networks; without it, networking is disabled and the container cannot join a network later | -Container conditions can require running or stopped state (both require the container to still exist), or `docker_container_absent` after the player removes it. Every container a condition names must be declared in the fixture setup. The game layer compares output conditions; the active environment observes state conditions through the shared `Environment` contract. +A Docker setup can declare up to 8 `networks` by logical name (`{"name": "db-net"}`). Names follow the logical-name rules and cannot be `bridge`, `host`, `none`, or `default`. OpsQuest creates every declared network as an internal network before the containers. + +Container conditions can require running or stopped state (both require the container to still exist), or `docker_container_absent` after the player removes it. Network conditions are route-independent: `docker_containers_share_network` and `docker_containers_isolated` take exactly two `containers` and pass only while both still exist, and `docker_network_absent` takes a declared `network`. Prefer shared or isolated pairs over naming a specific network, so a player who creates their own network also succeeds. Every container or network a condition names must be declared in the fixture setup. The game layer compares output conditions; the active environment observes state conditions through the shared `Environment` contract. Suggested commands identify the intended tool family but do not constrain validation. One to five hints should progress from concept, through inspection diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index 096f42e..b914799 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -122,23 +122,31 @@ Docker missions are opt-in and follow a narrower path: 1. The mission catalog accepts only pinned image references and bounded logical fixtures. 2. Availability checks the Docker executable, active Docker-compatible engine, and exact local image without creating resources or pulling images. An active `orbstack` context is identified for provider-specific guidance only. -3. The factory first sweeps orphaned fixtures (see below), then generates a random session ID and creates containers with generated names, five ownership labels, and two advisory owner labels (process ID and host name). -4. Player text is parsed into `list`, `start`, `restart`, `stop`, `rm`, `inspect`, `logs`, or `help`; flags and aliases must match the small grammar. `ps` filters are limited to fixed `status` and `health` values and are evaluated in Go over sanitized inspection data rather than passed to Docker. `logs --tail` accepts only a whole number up to 10,000 or `all`. `rm` has no `--force` form and refuses a running container. +3. The factory first sweeps orphaned fixtures (see below), then generates a random session ID, creates the mission's declared networks, and creates containers. Networks and containers get generated names, five ownership labels, and two advisory owner labels (process ID and host name). +4. Player text is parsed into `list`, `start`, `restart`, `stop`, `rm`, `inspect`, `logs`, or `help`; flags and aliases must match the small grammar. `ps` filters are limited to fixed `status` and `health` values and are evaluated in Go over sanitized inspection data rather than passed to Docker. `logs --tail` accepts only a whole number up to 10,000 or `all`. `rm` has no `--force` form and refuses a running container. `docker network ls`, `inspect`, `create`, `rm`, `connect`, and `disconnect` accept no options, validated logical names only, and never the built-in `bridge`, `host`, `none`, or `default` networks. 5. The adapter resolves a validated logical alias to a tracked exact container ID. 6. `exec.CommandContext` receives fixed arguments constructed by the adapter. 7. Observations inspect exact IDs or enumerate by the session label, then verify the complete ownership-label set. -8. `Close` seals the attempt immediately, re-inspects ownership, removes only matching exact IDs, and retains unresolved resources for a retry. Containers the player already removed count as resolved. +8. `Close` seals the attempt immediately, re-inspects ownership, removes only matching exact IDs (containers first, then networks), and retains unresolved resources for a retry. Resources the player already removed count as resolved. 9. Engine errors that name a tracked container are rewritten to its logical identity, and a missing container is reported as removed, so real IDs and generated names never reach the player. -Created containers use no network, a read-only root filesystem, a bounded temporary filesystem, an unprivileged numeric user, no Linux capabilities, `no-new-privileges`, and explicit PID, memory, CPU, file-descriptor, restart, and stop-timeout limits. Diagnostic and logging fixtures use fixed shell programs; bounded log text, exit status, and lifetime are passed as data arguments rather than interpolated into those programs. Long-lived fixture processes exit after 24 hours. Health probes are the fixed BusyBox applets `true` or `false`, selected by a validated `health` enum. The restart policy is `no` except for a declared crash-loop fixture, which uses `on-failure:50` and therefore stops relaunching on its own. Containers do not receive host bind mounts, devices, privileged mode, host networking, published ports, or a Docker socket. +Created containers use a read-only root filesystem, a bounded temporary filesystem, an unprivileged numeric user, no Linux capabilities, `no-new-privileges`, and explicit PID, memory, CPU, file-descriptor, restart, and stop-timeout limits. Diagnostic and logging fixtures use fixed shell programs; bounded log text, exit status, and lifetime are passed as data arguments rather than interpolated into those programs. Long-lived fixture processes exit after 24 hours. Health probes are the fixed BusyBox applets `true` or `false`, selected by a validated `health` enum. The restart policy is `no` except for a declared crash-loop fixture, which uses `on-failure:50` and therefore stops relaunching on its own. Containers do not receive host bind mounts, devices, privileged mode, host networking, published ports, or a Docker socket. + +### Lab networks + +A fixture without declared networks runs with `--network none`, exactly as before networks existed. A fixture with declared networks starts on the first one and joins the rest before it starts, with its logical alias as its network alias. Every lab network is created by OpsQuest with fixed options: the `bridge` driver and `--internal`, so it has no route to external networks. Players cannot pass network options, can create at most 4 networks per attempt, and can only attach this attempt's containers to this attempt's networks. `docker network rm` is refused while any container is attached, including stopped ones, because Docker would otherwise leave those containers unable to start. `inspect` shows logical network names and members, never engine IDs. + +The internal flag is not the only control. Players cannot run commands inside containers: there is no `exec`, and fixtures run fixed programs. So no player-controlled traffic starts inside a lab network, whatever the engine allows between an internal network and its host gateway. ### Orphaned fixtures -A process that is killed before `Close` leaves its fixtures behind. Before each Docker attempt, and on demand through `opsquest doctor --cleanup`, the factory lists containers labeled `com.opsquest.managed=true` and removes an exact ID only when all of these hold: +A process that is killed before `Close` leaves its fixtures behind. Before each Docker attempt, and on demand through `opsquest doctor --cleanup`, the factory lists containers and networks labeled `com.opsquest.managed=true` and removes an exact ID only when all of these hold: - the managed, schema, session, mission, and alias labels are present and well formed; -- the container name is the generated `opsquest--cNN` form for that same session; -- either the owner-host label matches this machine and the recorded owner process is no longer running, or the container is older than the 24-hour fixture lifetime. +- the name is the generated `opsquest--cNN` (container) or `opsquest--nNN` (network) form for that same session; +- either the owner-host label matches this machine and the recorded owner process is no longer running, or the resource is older than the 24-hour fixture lifetime. + +Orphaned containers are removed before orphaned networks. Owners on other hosts, and fixtures created before owner labels existed, can therefore only age out. A plain `opsquest doctor` counts orphans without removing anything. A failed automatic sweep never blocks mission setup. @@ -149,6 +157,9 @@ Docker-specific ceilings include: | Player Docker line | 64 KiB | | Images per mission | 16 | | Containers per mission | 32 | +| Declared networks per mission | 8 | +| Declared networks per container | 4 | +| Player-created networks per attempt | 4 | | Declarative diagnostic log per container | 8 KiB | | Captured Docker stdout and stderr | 2 MiB each | | Normal Docker operation | 10 seconds | @@ -171,6 +182,9 @@ The Docker-compatible engine remains a powerful external dependency; OpsQuest re | A killed process leaks running fixtures | 24-hour fixture lifetime; owner-process and age-based sweep with complete label and generated-name proof | `internal/dockerlab/janitor.go` and tests | | Orphan sweep removes a live or foreign container | Live same-host owners are never swept; unknown owners only after the fixture lifetime; exact IDs only | `internal/dockerlab/janitor_test.go` | | Removed containers leak engine identity | Missing-container errors mapped to the alias; tracked IDs and names redacted from engine errors | `internal/dockerlab/environment.go` and tests | +| Lab network reaches the host network or internet | Fixed `--internal` bridge networks; no network options; no in-container command execution | `internal/dockerlab/network.go`, `network_test.go` | +| Player attaches a lab container to a host or foreign network | Built-in names rejected by the parser; only tracked attempt networks resolve; exact IDs only | `internal/dockerlab/parser.go`, `network.go` and tests | +| Network cleanup strands or removes the wrong network | Containers removed first; label and generated-name verification; retryable unresolved set; janitor sweeps networks after containers | `internal/dockerlab/network.go`, `janitor.go` and tests | | Persisted display text injects terminal controls | Profile names reject non-printable characters and normalize legacy values | `internal/profile/profile.go` and tests | | Another site reaches the loopback companion | One-time capability pairing, exact Host/Origin checks, same-site HTTP-only cookie, no permissive CORS | `internal/webapp/server.go` and tests | | Browser input reaches a mission environment | Companion exposes only read-only state and SSE routes; `game.Session` has no companion command callback | `internal/webapp`, `internal/game/companion.go` | diff --git a/internal/cli/app.go b/internal/cli/app.go index 53adf97..d8e4407 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -793,7 +793,7 @@ func (a *App) reportOrphanedLabs(cleanup, dockerReady bool) error { } return fmt.Errorf("docker cleanup failed: %w", err) } - fmt.Fprintf(a.out, " %s docker cleanup: removed %d orphaned lab %s\n", check, removed, plural(removed, "container", "containers")) + fmt.Fprintf(a.out, " %s docker cleanup: removed %d orphaned lab %s\n", check, removed, plural(removed, "resource", "resources")) return nil } if !dockerReady { @@ -802,11 +802,11 @@ func (a *App) reportOrphanedLabs(cleanup, dockerReady bool) error { count, err := janitor.OrphanedResources(a.ctx) switch { case err != nil: - fmt.Fprintf(a.out, " %s docker cleanup: could not check for orphaned lab containers · %v\n", warning, err) + fmt.Fprintf(a.out, " %s docker cleanup: could not check for orphaned lab resources · %v\n", warning, err) case count > 0: - fmt.Fprintf(a.out, " %s docker cleanup: %d orphaned lab %s from exited OpsQuest processes; run 'opsquest doctor --cleanup'\n", warning, count, plural(count, "container", "containers")) + fmt.Fprintf(a.out, " %s docker cleanup: %d orphaned lab %s from exited OpsQuest processes; run 'opsquest doctor --cleanup'\n", warning, count, plural(count, "resource", "resources")) default: - fmt.Fprintf(a.out, " %s docker cleanup: no orphaned lab containers\n", check) + fmt.Fprintf(a.out, " %s docker cleanup: no orphaned lab resources\n", check) } return nil } diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index a649829..c5dea72 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -912,9 +912,11 @@ func TestListDockerTrackAndRejectsUnknownTrack(t *testing.T) { "Container Census", "Shift Handoff", "docker-container-census", - "WORLD 2/2 · Container Triage", + "WORLD 2/3 · Container Triage", "Postmortem Triage", - "0/11 missions complete", + "WORLD 3/3 · Network Plumbing", + "Segmentation", + "0/16 missions complete", "Continue: opsquest play --track docker", "Jump: opsquest play --track docker --world N", "IDs: opsquest map --track docker --ids", @@ -1087,7 +1089,7 @@ func TestProfileRenameShowAndDoctor(t *testing.T) { if err := app.Run([]string{"doctor"}); err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), "34 missions (23 Linux, 11 Docker)") || !strings.Contains(out.String(), "Linux labs: in-memory; no host shell or filesystem access") { + if !strings.Contains(out.String(), "39 missions (23 Linux, 16 Docker)") || !strings.Contains(out.String(), "Linux labs: in-memory; no host shell or filesystem access") { t.Fatalf("doctor output = %s", out.String()) } } @@ -1165,7 +1167,7 @@ func TestListCampaignFilterUsesCampaignTotal(t *testing.T) { if !strings.Contains(out.String(), "0/6 missions complete") { t.Fatalf("campaign-filtered total missing:\n%s", out.String()) } - if strings.Contains(out.String(), "0/34 missions complete") || strings.Contains(out.String(), "Production Friday") { + if strings.Contains(out.String(), "0/39 missions complete") || strings.Contains(out.String(), "Production Friday") { t.Fatalf("campaign filter used catalog-wide scope:\n%s", out.String()) } } @@ -1408,7 +1410,7 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { t.Run("plain doctor is read-only", func(t *testing.T) { factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "Docker is ready for this mission."}, orphans: 2} output, err := run(t, factory) - if err != nil || !strings.Contains(output, "docker cleanup: 2 orphaned lab containers from exited OpsQuest processes; run 'opsquest doctor --cleanup'") { + if err != nil || !strings.Contains(output, "docker cleanup: 2 orphaned lab resources from exited OpsQuest processes; run 'opsquest doctor --cleanup'") { t.Fatalf("doctor output = %s, error = %v", output, err) } if factory.checks != 1 || factory.removeCalls != 0 { @@ -1419,7 +1421,7 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { t.Run("clean engine", func(t *testing.T) { factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}} output, err := run(t, factory) - if err != nil || !strings.Contains(output, "docker cleanup: no orphaned lab containers") { + if err != nil || !strings.Contains(output, "docker cleanup: no orphaned lab resources") { t.Fatalf("doctor output = %s, error = %v", output, err) } }) @@ -1427,7 +1429,7 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { t.Run("check failure is a warning", func(t *testing.T) { factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, checkErr: fmt.Errorf("list failed")} output, err := run(t, factory) - if err != nil || !strings.Contains(output, "could not check for orphaned lab containers · list failed") { + if err != nil || !strings.Contains(output, "could not check for orphaned lab resources · list failed") { t.Fatalf("doctor output = %s, error = %v", output, err) } }) @@ -1443,7 +1445,7 @@ func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { t.Run("cleanup removes orphans", func(t *testing.T) { factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 1} output, err := run(t, factory, "--cleanup") - if err != nil || !strings.Contains(output, "docker cleanup: removed 1 orphaned lab container\n") || factory.removeCalls != 1 { + if err != nil || !strings.Contains(output, "docker cleanup: removed 1 orphaned lab resource\n") || factory.removeCalls != 1 { t.Fatalf("doctor output = %s, error = %v, removals = %d", output, err, factory.removeCalls) } }) diff --git a/internal/dockerlab/dockerlab_test.go b/internal/dockerlab/dockerlab_test.go index a0be794..0bb0042 100644 --- a/internal/dockerlab/dockerlab_test.go +++ b/internal/dockerlab/dockerlab_test.go @@ -37,6 +37,15 @@ type fakeContainer struct { restartPolicy string restartCount int healthCmd string + // endpoints maps an attached network's engine name to its ID. + endpoints map[string]string +} + +type fakeNetwork struct { + labels map[string]string + name string + created time.Time + internal bool } type fakeDockerRunner struct { @@ -58,11 +67,16 @@ type fakeDockerRunner struct { failNextRemove map[string]error failManagedList bool stripHealth bool + networks map[string]*fakeNetwork + networkOrder []string + networkCreates int + failNetworkCreate bool } func newFakeDockerRunner() *fakeDockerRunner { return &fakeDockerRunner{ containers: make(map[string]*fakeContainer), + networks: make(map[string]*fakeNetwork), contextName: "default", imageAvailable: true, failNextInspect: make(map[string]error), @@ -106,8 +120,17 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, name := "" restartPolicy := "" healthCmd := "" + endpoints := make(map[string]string) for index := 0; index+1 < len(args); index++ { switch args[index] { + case "--network": + if args[index+1] == "none" { + endpoints["none"] = "none-network-id" + } else if network, exists := r.networks[args[index+1]]; exists { + endpoints[network.name] = args[index+1] + } else { + return runResult{stderr: "Error response from daemon: network " + args[index+1] + " not found"}, errors.New("exit status 1") + } case "--name": name = args[index+1] case "--label": @@ -121,7 +144,7 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, healthCmd = args[index+1] } } - container := &fakeContainer{labels: labels, name: name, created: testNow, status: "created", restartPolicy: restartPolicy, healthCmd: healthCmd} + container := &fakeContainer{labels: labels, name: name, created: testNow, status: "created", restartPolicy: restartPolicy, healthCmd: healthCmd, endpoints: endpoints} if len(args) >= 3 && args[len(args)-3] == "opsquest-service" { container.log = args[len(args)-2] + "\n" } @@ -232,6 +255,16 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, } else { inspection.HostConfig.RestartPolicy.Name = container.restartPolicy } + if len(container.endpoints) > 0 { + inspection.NetworkSettings.Networks = make(map[string]endpointState) + for name, networkID := range container.endpoints { + // Docker can leave NetworkID empty before a first start. + if container.status == "created" { + networkID = "" + } + inspection.NetworkSettings.Networks[name] = endpointState{NetworkID: networkID} + } + } if container.healthCmd != "" && container.running && !r.stripHealth { health := "unhealthy" if container.healthCmd == "true" { @@ -268,6 +301,92 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, output.WriteByte('\n') } return runResult{stdout: output.String()}, nil + case hasPrefix(args, "network", "create"): + r.networkCreates++ + if r.failNetworkCreate { + return runResult{stderr: "network creation failed"}, errors.New("exit status 1") + } + network := &fakeNetwork{labels: make(map[string]string), name: args[len(args)-1], created: testNow} + for index := 2; index < len(args)-1; index++ { + switch args[index] { + case "--internal": + network.internal = true + case "--label": + labelName, value, _ := strings.Cut(args[index+1], "=") + network.labels[labelName] = value + index++ + case "--driver": + index++ + } + } + id := fmt.Sprintf("feed%060x", r.networkCreates) + r.networks[id] = network + r.networkOrder = append(r.networkOrder, id) + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "network", "inspect", "--format"): + reference := args[len(args)-1] + id := r.resolveNetwork(reference) + network, exists := r.networks[id] + if !exists { + return runResult{stderr: "Error response from daemon: network " + reference + " not found"}, errors.New("exit status 1") + } + encoded, err := json.Marshal(networkInspection{ID: id, Name: network.name, Created: network.created.Format(time.RFC3339Nano), Driver: "bridge", Internal: network.internal, Labels: cloneStrings(network.labels)}) + if err != nil { + return runResult{}, err + } + return runResult{stdout: string(encoded) + "\n"}, nil + case hasPrefix(args, "network", "ls", "--quiet", "--no-trunc"): + filter := strings.TrimPrefix(args[len(args)-1], "label=") + name, value, _ := strings.Cut(filter, "=") + var output strings.Builder + for _, id := range r.networkOrder { + if network, exists := r.networks[id]; exists && network.labels[name] == value { + output.WriteString(id + "\n") + } + } + return runResult{stdout: output.String()}, nil + case hasPrefix(args, "network", "rm"): + id := args[len(args)-1] + network, exists := r.networks[id] + if !exists { + return runResult{stderr: "Error response from daemon: network " + id + " not found"}, errors.New("exit status 1") + } + for _, container := range r.containers { + if _, attached := container.endpoints[network.name]; attached && container.running { + return runResult{stderr: "Error response from daemon: error while removing network: network " + network.name + " id " + id + " has active endpoints"}, errors.New("exit status 1") + } + } + delete(r.networks, id) + return runResult{stdout: id + "\n"}, nil + case hasPrefix(args, "network", "connect") || hasPrefix(args, "network", "disconnect"): + networkID, containerID := args[len(args)-2], args[len(args)-1] + network, exists := r.networks[networkID] + if !exists { + return runResult{stderr: "Error response from daemon: network " + networkID + " not found"}, errors.New("exit status 1") + } + container, exists := r.containers[containerID] + if !exists { + return missingContainerResult(containerID) + } + _, attached := container.endpoints[network.name] + if args[1] == "disconnect" { + if !attached { + return runResult{stderr: "Error response from daemon: container " + containerID + " is not connected to network " + network.name}, errors.New("exit status 1") + } + delete(container.endpoints, network.name) + return runResult{}, nil + } + if _, disabled := container.endpoints["none"]; disabled { + return runResult{stderr: "Error response from daemon: container cannot be connected to multiple networks with one of the networks in private (none) mode"}, errors.New("exit status 1") + } + if attached { + return runResult{stderr: "Error response from daemon: endpoint with name " + container.name + " already exists in network " + network.name}, errors.New("exit status 1") + } + if container.endpoints == nil { + container.endpoints = make(map[string]string) + } + container.endpoints[network.name] = networkID + return runResult{}, nil case len(args) == 3 && hasPrefix(args, "container", "rm"): id := args[len(args)-1] container, exists := r.containers[id] @@ -307,6 +426,38 @@ func (r *fakeDockerRunner) resolveContainer(reference string) string { return reference } +func (r *fakeDockerRunner) resolveNetwork(reference string) string { + if _, exists := r.networks[reference]; exists { + return reference + } + for id, network := range r.networks { + if network.name == reference { + return id + } + } + return reference +} + +func (r *fakeDockerRunner) seedNetwork(id string, network *fakeNetwork) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.networks[id] = network + r.networkOrder = append(r.networkOrder, id) +} + +func (r *fakeDockerRunner) hasNetwork(id string) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + _, exists := r.networks[id] + return exists +} + +func (r *fakeDockerRunner) networkCount() int { + r.mutex.Lock() + defer r.mutex.Unlock() + return len(r.networks) +} + func labelsMatch(labels, filters map[string]string) bool { for name, value := range filters { if labels[name] != value { diff --git a/internal/dockerlab/environment.go b/internal/dockerlab/environment.go index d79380a..0ffb2df 100644 --- a/internal/dockerlab/environment.go +++ b/internal/dockerlab/environment.go @@ -59,12 +59,17 @@ type environment struct { readyTimeout time.Duration containers []*trackedContainer byAlias map[string]*trackedContainer + // networks holds every network this attempt created, including ones + // the player later removed, so cleanup can verify each exact ID. + networks []*trackedNetwork + networkByName map[string]*trackedNetwork - cleanupMutex sync.Mutex - mutex sync.RWMutex - closing bool - closed bool - cleanupPending []*trackedContainer + cleanupMutex sync.Mutex + mutex sync.RWMutex + closing bool + closed bool + cleanupPending []*trackedContainer + networksPending []*trackedNetwork } var ( @@ -126,6 +131,27 @@ func (e *environment) Execute(ctx context.Context, line string) (game.Execution, case actionLogs: result.Output, err = e.containerLogs(ctx, action.alias, action.tail) result.PracticedCommands = []string{"docker"} + case actionNetworkList: + result.Output, err = e.listNetworks(ctx) + result.PracticedCommands = []string{"docker"} + case actionNetworkCreate: + result.Output, err = e.createPlayerNetwork(ctx, action.network) + result.PracticedCommands = []string{"docker"} + case actionNetworkRemove: + err = e.removeNetwork(ctx, action.network) + if err == nil { + result.Output = action.network + "\n" + } + result.PracticedCommands = []string{"docker"} + case actionNetworkInspect: + result.Output, err = e.logicalNetworkInspect(ctx, action.network) + result.PracticedCommands = []string{"docker"} + case actionNetworkConnect: + err = e.connectNetwork(ctx, action.network, action.alias) + result.PracticedCommands = []string{"docker"} + case actionNetworkDisconnect: + err = e.disconnectNetwork(ctx, action.network, action.alias) + result.PracticedCommands = []string{"docker"} default: err = fmt.Errorf("unsupported Docker action") } @@ -158,6 +184,17 @@ func (e *environment) Observe(ctx context.Context, condition mission.Condition) } _, exists, err := e.inspect(ctx, tracked.id) return err == nil && !exists, err + case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: + if len(condition.Containers) != 2 { + return false, fmt.Errorf("%s requires exactly two containers", condition.Type) + } + shared, bothExist, err := e.shareNetwork(ctx, condition.Containers[0], condition.Containers[1]) + if err != nil || !bothExist { + return false, err + } + return shared == (condition.Type == mission.ConditionDockerNetworkShared), nil + case mission.ConditionDockerNetworkAbsent: + return e.networkAbsent(ctx, condition.Network) case mission.ConditionDockerContainerCountEqual: if condition.Count == nil { return false, fmt.Errorf("docker_container_count_equals requires count") @@ -184,8 +221,10 @@ func (e *environment) Close() error { if !e.closing { e.closing = true e.cleanupPending = append([]*trackedContainer(nil), e.containers...) + e.networksPending = append([]*trackedNetwork(nil), e.networks...) } containers := append([]*trackedContainer(nil), e.cleanupPending...) + networks := append([]*trackedNetwork(nil), e.networksPending...) e.mutex.Unlock() ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) @@ -216,9 +255,15 @@ func (e *environment) Close() error { } } + // Networks go last: Docker refuses to remove a network while any of + // its containers still exist. + unresolvedNetworks, networkErrors := e.closeNetworks(ctx, networks) + cleanupErrors = append(cleanupErrors, networkErrors...) + e.mutex.Lock() e.cleanupPending = unresolved - e.closed = len(unresolved) == 0 + e.networksPending = unresolvedNetworks + e.closed = len(unresolved) == 0 && len(unresolvedNetworks) == 0 e.mutex.Unlock() return errors.Join(cleanupErrors...) } @@ -265,8 +310,23 @@ func (e *environment) createContainer(ctx context.Context, index int, fixture mi if fixture.Restart == mission.DockerRestartOnFailure { restartPolicy = fmt.Sprintf("on-failure:%d", crashLoopMaxRetries) } + // A fixture without declared networks keeps networking disabled. One + // with networks starts on the first attempt-owned internal network and + // joins the rest before it starts. + networkArgs := []string{"--network", "none"} + var joined []*trackedNetwork + for _, name := range fixture.Networks { + network, exists := e.network(name) + if !exists || network.id == "" { + return nil, fmt.Errorf("create Docker fixture %s: network %q was not created", fixture.Name, name) + } + joined = append(joined, network) + } + if len(joined) > 0 { + networkArgs = []string{"--network", joined[0].id, "--network-alias", fixture.Name} + } + args = append(args, networkArgs...) args = append(args, - "--network", "none", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", "--user", "65532:65532", @@ -324,6 +384,11 @@ func (e *environment) createContainer(ctx context.Context, index int, fixture mi return e.reconcileAmbiguousCreate(tracked, createErr) } tracked.id = id + for _, network := range joined[min(1, len(joined)):] { + if _, err := e.run(ctx, "network", "connect", "--alias", fixture.Name, network.id, id); err != nil { + return tracked, fmt.Errorf("connect Docker fixture %s to network %s: %w", fixture.Name, network.name, err) + } + } return tracked, nil } @@ -608,6 +673,9 @@ func (e *environment) logicalInspect(ctx context.Context, alias string) (string, HostConfig struct { RestartPolicy restartPolicy `json:"RestartPolicy"` } `json:"HostConfig"` + NetworkSettings struct { + Networks []string `json:"Networks"` + } `json:"NetworkSettings"` }{ID: tracked.logicalID, Name: tracked.alias, Image: tracked.imageAlias, RestartCount: inspection.RestartCount} logical.State.Running = inspection.State.Running logical.State.Restarting = inspection.State.Restarting @@ -616,6 +684,7 @@ func (e *environment) logicalInspect(ctx context.Context, alias string) (string, if health := healthStatus(inspection); health != "none" { logical.State.Health = &logicalHealth{Status: health} } + logical.NetworkSettings.Networks = e.logicalNetworks(inspection) logical.HostConfig.RestartPolicy = inspection.HostConfig.RestartPolicy if logical.HostConfig.RestartPolicy.Name == "" { logical.HostConfig.RestartPolicy.Name = "no" @@ -654,6 +723,15 @@ type containerInspection struct { ExitCode int `json:"ExitCode"` Health *healthState `json:"Health"` } `json:"State"` + NetworkSettings struct { + // Networks is keyed by the engine network name. NetworkID can be + // empty before a container first starts, so both identify a network. + Networks map[string]endpointState `json:"Networks"` + } `json:"NetworkSettings"` +} + +type endpointState struct { + NetworkID string `json:"NetworkID"` } func (e *environment) inspect(ctx context.Context, id string) (containerInspection, bool, error) { @@ -791,10 +869,17 @@ func (dockerCompletion) CommandNames() []string { } func (c dockerCompletion) PathCandidates(prefix string) []game.CompletionCandidate { - values := []string{"--all", "--filter", "--tail", "-a", "container", "inspect", "logs", "ls", "ps", "restart", "rm", "start", "stop"} + values := []string{"--all", "--filter", "--tail", "-a", "connect", "container", "create", "disconnect", "inspect", "logs", "ls", "network", "ps", "restart", "rm", "start", "stop"} for _, tracked := range c.environment.snapshotContainers() { values = append(values, tracked.alias) } + seen := make(map[string]bool) + for _, network := range c.environment.snapshotNetworks() { + if !seen[network.name] { + seen[network.name] = true + values = append(values, network.name) + } + } sort.Strings(values) candidates := make([]game.CompletionCandidate, 0) for _, value := range values { diff --git a/internal/dockerlab/factory.go b/internal/dockerlab/factory.go index 095c684..54258d8 100644 --- a/internal/dockerlab/factory.go +++ b/internal/dockerlab/factory.go @@ -125,14 +125,20 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro _, _ = f.removeOrphans(ctx) environment := &environment{ - runner: f.runner, - sessionID: sessionID, - missionID: item.ID, - ownerPID: f.owner.pid, - ownerHost: f.owner.host, - pollInterval: f.pollInterval, - readyTimeout: f.readyTimeout, - byAlias: make(map[string]*trackedContainer), + runner: f.runner, + sessionID: sessionID, + missionID: item.ID, + ownerPID: f.owner.pid, + ownerHost: f.owner.host, + pollInterval: f.pollInterval, + readyTimeout: f.readyTimeout, + byAlias: make(map[string]*trackedContainer), + networkByName: make(map[string]*trackedNetwork), + } + for _, network := range item.Docker.Networks { + if _, err := environment.createNetwork(ctx, network.Name, false); err != nil { + return environment, joinSetupCleanupError(err, environment.Close()) + } } images := make(map[string]string, len(item.Docker.Images)) for _, image := range item.Docker.Images { diff --git a/internal/dockerlab/integration_test.go b/internal/dockerlab/integration_test.go index 57ab6f6..0c45b60 100644 --- a/internal/dockerlab/integration_test.go +++ b/internal/dockerlab/integration_test.go @@ -265,3 +265,108 @@ func TestIntegrationRealDockerOrphanSweep(t *testing.T) { } } } + +func TestIntegrationRealDockerNetworks(t *testing.T) { + if os.Getenv("OPSQUEST_DOCKER_TEST") != "1" { + t.Skip("set OPSQUEST_DOCKER_TEST=1 to run the disposable Docker integration test") + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + factory := NewFactory(game.SandboxFactory{}) + run := func(t *testing.T, created game.Environment, line string) string { + t.Helper() + result, err := created.Execute(context.Background(), line) + if err != nil { + t.Fatalf("Execute(%q) error = %v", line, err) + } + return result.Output + } + assertStateOutcomes := func(t *testing.T, created game.Environment, item mission.Mission) { + t.Helper() + for _, outcome := range item.Validation.All { + if satisfied, err := created.Observe(context.Background(), outcome); err != nil || !satisfied { + t.Fatalf("outcome %#v = %v, %v", outcome, satisfied, err) + } + } + } + + t.Run("connect and internal isolation", func(t *testing.T) { + item, _ := catalog.Find("docker-cant-reach-the-db") + created := createRealDockerEnvironment(t, factory, item) + lab := created.(*environment) + inspected := run(t, created, "docker network inspect db-net") + if !strings.Contains(inspected, `"Internal": true`) || !strings.Contains(inspected, `"db"`) || strings.Contains(inspected, `"api"`) { + t.Fatalf("db-net inspection:\n%s", inspected) + } + run(t, created, "docker network connect db-net api") + if apiInspection := run(t, created, "docker inspect api"); !strings.Contains(apiInspection, `"db-net"`) || !strings.Contains(apiInspection, `"web-net"`) { + t.Fatalf("api networks after connect:\n%s", apiInspection) + } + assertStateOutcomes(t, created, item) + networks := lab.snapshotNetworks() + if err := created.Close(); err != nil { + t.Fatal(err) + } + for _, network := range networks { + if _, exists, err := inspectNetworkReference(context.Background(), lab.runner, network.id); err != nil || exists { + t.Fatalf("network %s after cleanup exists = %v, error = %v", network.name, exists, err) + } + } + }) + + t.Run("segmentation", func(t *testing.T) { + item, _ := catalog.Find("docker-segmentation") + created := createRealDockerEnvironment(t, factory, item) + if _, err := created.Execute(context.Background(), "docker network rm legacy-net"); err == nil || !strings.Contains(err.Error(), "old-cron") { + t.Fatalf("network rm with stopped container attached error = %v", err) + } + for _, line := range []string{ + "docker network create db-net", + "docker network connect db-net api", + "docker network connect db-net db", + "docker network disconnect flat-net db", + "docker rm old-cron", + "docker network rm legacy-net", + } { + run(t, created, line) + } + assertStateOutcomes(t, created, item) + if _, err := created.Execute(context.Background(), "docker restart db"); err != nil { + t.Fatalf("db did not restart on its remaining network: %v", err) + } + }) +} + +func TestIntegrationRealDockerOrphanedNetworkSweep(t *testing.T) { + if os.Getenv("OPSQUEST_DOCKER_TEST") != "1" { + t.Skip("set OPSQUEST_DOCKER_TEST=1 to run the disposable Docker integration test") + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-need-to-know") + deadPID := 0 + for candidate := 4_000_000; candidate > 3_000_000; candidate-- { + if !processAlive(candidate) { + deadPID = candidate + break + } + } + if deadPID == 0 { + t.Skip("no unused PID found to simulate an exited owner") + } + crashed := NewFactory(game.SandboxFactory{}) + crashed.owner.pid = deadPID + abandoned := createRealDockerEnvironment(t, crashed, item).(*environment) + + janitor := NewFactory(game.SandboxFactory{}) + createRealDockerEnvironment(t, janitor, item) + for _, network := range abandoned.snapshotNetworks() { + if _, exists, err := inspectNetworkReference(context.Background(), abandoned.runner, network.id); err != nil || exists { + t.Fatalf("setup did not sweep abandoned network %s: exists = %v, error = %v", network.name, exists, err) + } + } +} diff --git a/internal/dockerlab/janitor.go b/internal/dockerlab/janitor.go index b4be904..7610c31 100644 --- a/internal/dockerlab/janitor.go +++ b/internal/dockerlab/janitor.go @@ -15,22 +15,35 @@ import ( var ( sessionIDPattern = regexp.MustCompile(`^[a-f0-9]{24}$`) generatedNamePattern = regexp.MustCompile(`^/?opsquest-([a-f0-9]{24})-c[0-9]{2}$`) + networkNamePattern = regexp.MustCompile(`^opsquest-([a-f0-9]{24})-n[0-9]{2}$`) ) -// orphanMinimumAge is when a fixture without a verifiable owner process is +// orphanMinimumAge is when a resource without a verifiable owner process is // considered abandoned. Every fixture process exits by then, so removing it // cannot interrupt a playable attempt. const orphanMinimumAge = fixtureLifetime -// OrphanedResources counts OpsQuest fixtures left behind by processes that -// exited without cleanup. It never modifies Docker state. +// orphanSet holds exact IDs of abandoned resources. Containers are removed +// before networks because Docker refuses to remove a network in use. +type orphanSet struct { + containers []string + networks []string +} + +func (o orphanSet) count() int { + return len(o.containers) + len(o.networks) +} + +// OrphanedResources counts OpsQuest containers and networks left behind by +// processes that exited without cleanup. It never modifies Docker state. func (f *Factory) OrphanedResources(ctx context.Context) (int, error) { orphans, err := f.findOrphans(ctx) - return len(orphans), err + return orphans.count(), err } -// RemoveOrphanedResources removes OpsQuest fixtures left behind by processes -// that exited without cleanup and reports how many were removed. +// RemoveOrphanedResources removes OpsQuest containers and networks left +// behind by processes that exited without cleanup and reports how many were +// removed. func (f *Factory) RemoveOrphanedResources(ctx context.Context) (int, error) { return f.removeOrphans(ctx) } @@ -42,7 +55,7 @@ func (f *Factory) removeOrphans(ctx context.Context) (int, error) { } removed := 0 var removeErrors []error - for _, id := range orphans { + for _, id := range orphans.containers { result, err := runDocker(ctx, f.runner, "container", "rm", "--force", id) if err != nil && !isMissingContainer(result) { removeErrors = append(removeErrors, fmt.Errorf("remove orphaned Docker fixture %s: %w", id[:12], err)) @@ -52,69 +65,107 @@ func (f *Factory) removeOrphans(ctx context.Context) (int, error) { removed++ } } + for _, id := range orphans.networks { + result, err := runDocker(ctx, f.runner, "network", "rm", id) + if err != nil && !isMissingNetwork(result) { + removeErrors = append(removeErrors, fmt.Errorf("remove orphaned Docker network %s: %w", id[:12], err)) + continue + } + if err == nil { + removed++ + } + } return removed, errors.Join(removeErrors...) } -// findOrphans lists managed containers and keeps only exact IDs whose -// complete ownership labels and generated name prove OpsQuest created them -// and whose owner is provably gone. -func (f *Factory) findOrphans(ctx context.Context) ([]string, error) { +// findOrphans lists managed containers and networks and keeps only exact IDs +// whose complete ownership labels and generated name prove OpsQuest created +// them and whose owner is provably gone. +func (f *Factory) findOrphans(ctx context.Context) (orphanSet, error) { if f.lookupErr != nil || f.runner == nil { - return nil, fmt.Errorf("docker executable not found in PATH") + return orphanSet{}, fmt.Errorf("docker executable not found in PATH") } if ctx == nil { ctx = context.Background() } - result, err := runDocker(ctx, f.runner, - "container", "ls", "--all", "--quiet", "--no-trunc", - "--filter", "label="+managedLabel+"=true", - ) + var orphans orphanSet + containerIDs, err := f.listManaged(ctx, "container", "ls", "--all") if err != nil { - return nil, err + return orphanSet{}, err } - var orphans []string - for _, id := range strings.Fields(result.stdout) { - if !containerIDPattern.MatchString(id) { - return nil, fmt.Errorf("Docker container listing returned an invalid container ID") - } + for _, id := range containerIDs { inspection, exists, err := inspectReference(ctx, f.runner, id) if err != nil { - return nil, err + return orphanSet{}, err + } + if !exists { + continue + } + if inspection.ID != id { + return orphanSet{}, fmt.Errorf("Docker inspection returned an unexpected container ID") + } + if f.isOrphan(inspection.Config.Labels, inspection.Name, inspection.Created, generatedNamePattern) { + orphans.containers = append(orphans.containers, id) + } + } + networkIDs, err := f.listManaged(ctx, "network", "ls") + if err != nil { + return orphanSet{}, err + } + for _, id := range networkIDs { + inspection, exists, err := inspectNetworkReference(ctx, f.runner, id) + if err != nil { + return orphanSet{}, err } if !exists { continue } if inspection.ID != id { - return nil, fmt.Errorf("Docker inspection returned an unexpected container ID") + return orphanSet{}, fmt.Errorf("Docker inspection returned an unexpected network ID") } - if f.isOrphan(inspection) { - orphans = append(orphans, id) + if f.isOrphan(inspection.Labels, inspection.Name, inspection.Created, networkNamePattern) { + orphans.networks = append(orphans.networks, id) } } return orphans, nil } -func (f *Factory) isOrphan(inspection containerInspection) bool { - labels := inspection.Config.Labels +// listManaged returns the exact IDs of resources carrying the managed label. +func (f *Factory) listManaged(ctx context.Context, command ...string) ([]string, error) { + args := append(append([]string(nil), command...), "--quiet", "--no-trunc", "--filter", "label="+managedLabel+"=true") + result, err := runDocker(ctx, f.runner, args...) + if err != nil { + return nil, err + } + ids := strings.Fields(result.stdout) + for _, id := range ids { + if !containerIDPattern.MatchString(id) { + return nil, fmt.Errorf("Docker %s listing returned an invalid %s ID", command[0], command[0]) + } + } + return ids, nil +} + +func (f *Factory) isOrphan(labels map[string]string, name, created string, namePattern *regexp.Regexp) bool { session := labels[sessionLabel] if labels[managedLabel] != "true" || labels[schemaLabel] != "1" || !sessionIDPattern.MatchString(session) || !mission.ValidDockerLogicalName(labels[missionLabel]) || !mission.ValidDockerLogicalName(labels[aliasLabel]) { return false } - match := generatedNamePattern.FindStringSubmatch(inspection.Name) + match := namePattern.FindStringSubmatch(name) if match == nil || match[1] != session { return false } // An owner on this host can be checked directly. Owners on another host, - // and fixtures created before owner labels existed, fall back to age. + // and resources created before owner labels existed, fall back to age. if host := labels[ownerHostLabel]; host != "" && host == f.owner.host && f.owner.alive != nil { if pid, err := strconv.Atoi(labels[ownerPIDLabel]); err == nil && pid > 0 && !f.owner.alive(pid) { return true } } - created, err := time.Parse(time.RFC3339Nano, inspection.Created) + createdAt, err := time.Parse(time.RFC3339Nano, created) if err != nil || f.owner.now == nil { return false } - return f.owner.now().Sub(created) > orphanMinimumAge + return f.owner.now().Sub(createdAt) > orphanMinimumAge } diff --git a/internal/dockerlab/network.go b/internal/dockerlab/network.go new file mode 100644 index 0000000..6920b7c --- /dev/null +++ b/internal/dockerlab/network.go @@ -0,0 +1,486 @@ +package dockerlab + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" +) + +// maxPlayerNetworks bounds how many networks one attempt lets the player +// create, including networks later removed, so churn cannot grow without +// limit. +const maxPlayerNetworks = 4 + +// trackedNetwork maps one logical network name to the exact attempt-owned +// engine network behind it. +type trackedNetwork struct { + id string + logicalID string + name string + actualName string + playerCreated bool +} + +type networkInspection struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created string `json:"Created"` + Driver string `json:"Driver"` + Internal bool `json:"Internal"` + Labels map[string]string `json:"Labels"` +} + +func (e *environment) network(name string) (*trackedNetwork, bool) { + e.mutex.RLock() + defer e.mutex.RUnlock() + network, exists := e.networkByName[name] + return network, exists +} + +func (e *environment) snapshotNetworks() []*trackedNetwork { + e.mutex.RLock() + defer e.mutex.RUnlock() + return append([]*trackedNetwork(nil), e.networks...) +} + +// createNetwork creates one internal, labeled network with a generated name. +// The network is tracked before creation so cleanup can find it by name if +// the engine's response is ambiguous; the logical name is bound only once +// the exact ID is known. +func (e *environment) createNetwork(ctx context.Context, name string, playerCreated bool) (*trackedNetwork, error) { + e.mutex.Lock() + index := len(e.networks) + 1 + tracked := &trackedNetwork{ + logicalID: fmt.Sprintf("net-%02d", index), + name: name, + actualName: fmt.Sprintf("opsquest-%s-n%02d", e.sessionID, index), + playerCreated: playerCreated, + } + e.networks = append(e.networks, tracked) + e.mutex.Unlock() + + args := []string{ + "network", "create", + "--driver", "bridge", + // Internal networks have no route to the host network or the + // internet; only containers of this attempt can join them. + "--internal", + "--label", managedLabel + "=true", + "--label", schemaLabel + "=1", + "--label", sessionLabel + "=" + e.sessionID, + "--label", missionLabel + "=" + e.missionID, + "--label", aliasLabel + "=" + name, + } + if e.ownerHost != "" && e.ownerPID > 0 { + args = append(args, + "--label", ownerPIDLabel+"="+strconv.Itoa(e.ownerPID), + "--label", ownerHostLabel+"="+e.ownerHost, + ) + } + args = append(args, tracked.actualName) + result, err := e.run(ctx, args...) + id := strings.TrimSpace(result.stdout) + if err != nil || !containerIDPattern.MatchString(id) { + if err == nil { + err = fmt.Errorf("Docker returned an invalid network ID") + } + createErr := fmt.Errorf("create Docker network %s: %w", name, err) + return nil, e.reconcileAmbiguousNetwork(tracked, createErr) + } + tracked.id = id + e.mutex.Lock() + e.networkByName[name] = tracked + e.mutex.Unlock() + return tracked, nil +} + +// reconcileAmbiguousNetwork recovers the exact ID of a network the engine +// may have created despite reporting an error, so cleanup can remove it +// after verifying ownership. The logical name stays unbound either way. +func (e *environment) reconcileAmbiguousNetwork(tracked *trackedNetwork, createErr error) error { + ctx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) + defer cancel() + inspection, exists, err := inspectNetworkReference(ctx, e.runner, tracked.actualName) + if err != nil { + return errors.Join(createErr, fmt.Errorf("reconcile Docker network %s: %w", tracked.name, err)) + } + if !exists { + return createErr + } + labels := inspection.Labels + if labels[managedLabel] != "true" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != tracked.name || !containerIDPattern.MatchString(inspection.ID) { + return errors.Join(createErr, fmt.Errorf("refusing to recover Docker network %s because its ownership does not match", tracked.name)) + } + tracked.id = inspection.ID + return createErr +} + +func (e *environment) inspectNetwork(ctx context.Context, network *trackedNetwork) (networkInspection, bool, error) { + if err := e.ready(ctx); err != nil { + return networkInspection{}, false, err + } + return e.inspectNetworkUnchecked(ctx, network) +} + +func (e *environment) inspectNetworkUnchecked(ctx context.Context, network *trackedNetwork) (networkInspection, bool, error) { + reference := network.id + if reference == "" { + reference = network.actualName + } + inspection, exists, err := inspectNetworkReference(ctx, e.runner, reference) + if err != nil || !exists { + return inspection, exists, err + } + if network.id != "" && inspection.ID != network.id { + return networkInspection{}, false, fmt.Errorf("Docker inspection returned an unexpected network ID") + } + return inspection, true, nil +} + +// inspectNetworkReference inspects one exact network ID or generated name. +func inspectNetworkReference(ctx context.Context, commandRunner runner, reference string) (networkInspection, bool, error) { + result, err := runDocker(ctx, commandRunner, "network", "inspect", "--format", "{{json .}}", reference) + if err != nil { + if isMissingNetwork(result) { + return networkInspection{}, false, nil + } + return networkInspection{}, false, err + } + var inspection networkInspection + if err := json.Unmarshal([]byte(result.stdout), &inspection); err != nil { + return networkInspection{}, false, fmt.Errorf("decode Docker network inspection: %w", err) + } + return inspection, true, nil +} + +func isMissingNetwork(result runResult) bool { + message := strings.ToLower(result.stderr + "\n" + result.stdout) + return strings.Contains(message, "no such network") || strings.Contains(message, "not found") +} + +// attachedTo reports whether a container inspection shows an endpoint on +// network. +func attachedTo(inspection containerInspection, network *trackedNetwork) bool { + for name, endpoint := range inspection.NetworkSettings.Networks { + if name == network.actualName || network.id != "" && endpoint.NetworkID == network.id { + return true + } + } + return false +} + +// networkingDisabled reports whether a fixture was created without +// networking. Docker cannot attach such a container to another network. +func networkingDisabled(inspection containerInspection) bool { + _, disabled := inspection.NetworkSettings.Networks["none"] + return disabled +} + +// logicalNetworks returns the sorted logical names of every attempt network +// a container is attached to, or none for a fixture without networking. +func (e *environment) logicalNetworks(inspection containerInspection) []string { + names := make([]string, 0) + if networkingDisabled(inspection) { + names = append(names, "none") + } + for _, network := range e.snapshotNetworks() { + if network.id != "" && attachedTo(inspection, network) { + names = append(names, network.name) + } + } + sort.Strings(names) + return names +} + +// attachedContainers lists the existing mission containers attached to +// network, in fixture order. +func (e *environment) attachedContainers(ctx context.Context, network *trackedNetwork) ([]string, error) { + var attached []string + for _, tracked := range e.snapshotContainers() { + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return nil, err + } + if exists && attachedTo(inspection, network) { + attached = append(attached, tracked.alias) + } + } + return attached, nil +} + +// existingNetwork resolves a logical name to a network that still exists. +func (e *environment) existingNetwork(ctx context.Context, name string) (*trackedNetwork, error) { + network, exists := e.network(name) + if !exists { + return nil, fmt.Errorf("docker: network %q is not part of this mission", name) + } + _, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return nil, err + } + if !exists { + return nil, fmt.Errorf("docker: network %q has been removed", name) + } + return network, nil +} + +// existingContainer resolves an alias to a container that still exists and +// returns its current inspection. +func (e *environment) existingContainer(ctx context.Context, alias string) (*trackedContainer, containerInspection, error) { + tracked, exists := e.container(alias) + if !exists { + return nil, containerInspection{}, fmt.Errorf("docker: container %q is not part of this mission", alias) + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return nil, containerInspection{}, err + } + if !exists { + return nil, containerInspection{}, fmt.Errorf("docker: container %q has been removed", alias) + } + return tracked, inspection, nil +} + +func (e *environment) listNetworks(ctx context.Context) (string, error) { + type row struct { + logicalID string + name string + driver string + internal bool + } + var rows []row + nameWidth := len("NAME") + for _, network := range e.snapshotNetworks() { + if network.id == "" { + continue + } + if current, bound := e.network(network.name); !bound || current != network { + continue + } + inspection, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return "", err + } + if !exists { + continue + } + nameWidth = max(nameWidth, len(network.name)) + rows = append(rows, row{logicalID: network.logicalID, name: network.name, driver: inspection.Driver, internal: inspection.Internal}) + } + var output strings.Builder + fmt.Fprintf(&output, "%-11s %-*s %-7s %s\n", "NETWORK ID", nameWidth, "NAME", "DRIVER", "INTERNAL") + for _, item := range rows { + fmt.Fprintf(&output, "%-11s %-*s %-7s %t\n", item.logicalID, nameWidth, item.name, item.driver, item.internal) + } + return output.String(), nil +} + +func (e *environment) createPlayerNetwork(ctx context.Context, name string) (string, error) { + if existing, bound := e.network(name); bound { + _, exists, err := e.inspectNetwork(ctx, existing) + if err != nil { + return "", err + } + if exists { + return "", fmt.Errorf("docker: network %q already exists", name) + } + } + created := 0 + for _, network := range e.snapshotNetworks() { + if network.playerCreated { + created++ + } + } + if created >= maxPlayerNetworks { + return "", fmt.Errorf("docker: this lab lets you create at most %d networks per attempt", maxPlayerNetworks) + } + network, err := e.createNetwork(ctx, name, true) + if err != nil { + return "", err + } + return network.logicalID + "\n", nil +} + +func (e *environment) removeNetwork(ctx context.Context, name string) error { + network, exists := e.network(name) + if !exists { + return fmt.Errorf("docker: network %q is not part of this mission", name) + } + _, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("docker: network %q has already been removed", name) + } + // Docker would remove a network that only stopped containers use and + // leave them unable to start. The lab refuses instead, so every + // container stays startable. + attached, err := e.attachedContainers(ctx, network) + if err != nil { + return err + } + if len(attached) > 0 { + return fmt.Errorf("docker: network %q still has containers attached (%s); disconnect or remove them first", name, strings.Join(attached, ", ")) + } + return e.runNetworkAction(ctx, network, nil, "network", "rm", network.id) +} + +func (e *environment) logicalNetworkInspect(ctx context.Context, name string) (string, error) { + network, exists := e.network(name) + if !exists { + return "", fmt.Errorf("docker: network %q is not part of this mission", name) + } + inspection, exists, err := e.inspectNetwork(ctx, network) + if err != nil { + return "", err + } + if !exists { + return "", fmt.Errorf("docker: network %q no longer exists", name) + } + attached, err := e.attachedContainers(ctx, network) + if err != nil { + return "", err + } + sort.Strings(attached) + logical := struct { + ID string `json:"Id"` + Name string `json:"Name"` + Driver string `json:"Driver"` + Internal bool `json:"Internal"` + Containers []string `json:"Containers"` + }{ID: network.logicalID, Name: network.name, Driver: inspection.Driver, Internal: inspection.Internal, Containers: append([]string{}, attached...)} + encoded, err := json.MarshalIndent(logical, "", " ") + if err != nil { + return "", err + } + return string(encoded) + "\n", nil +} + +func (e *environment) connectNetwork(ctx context.Context, name, alias string) error { + network, err := e.existingNetwork(ctx, name) + if err != nil { + return err + } + tracked, inspection, err := e.existingContainer(ctx, alias) + if err != nil { + return err + } + if networkingDisabled(inspection) { + return fmt.Errorf("docker: container %q runs with networking disabled in this lab and cannot join a network", alias) + } + if attachedTo(inspection, network) { + return fmt.Errorf("docker: container %q is already connected to network %q", alias, name) + } + return e.runNetworkAction(ctx, network, tracked, "network", "connect", "--alias", alias, network.id, tracked.id) +} + +func (e *environment) disconnectNetwork(ctx context.Context, name, alias string) error { + network, err := e.existingNetwork(ctx, name) + if err != nil { + return err + } + tracked, inspection, err := e.existingContainer(ctx, alias) + if err != nil { + return err + } + if !attachedTo(inspection, network) { + return fmt.Errorf("docker: container %q is not connected to network %q", alias, name) + } + return e.runNetworkAction(ctx, network, tracked, "network", "disconnect", network.id, tracked.id) +} + +// runNetworkAction runs one fixed network invocation and rewrites engine +// errors so exact network and container identities never reach the player. +func (e *environment) runNetworkAction(ctx context.Context, network *trackedNetwork, container *trackedContainer, args ...string) error { + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + defer cancel() + result, err := e.runner.run(operationCtx, args...) + if err == nil { + return nil + } + message := dockerFailureDetail(result, err) + message = redactNetworkIdentifiers(message, network) + if container != nil { + message = redactIdentifiers(message, container) + } + return fmt.Errorf("Docker command failed%s", message) +} + +func redactNetworkIdentifiers(message string, network *trackedNetwork) string { + if network.id != "" { + message = strings.ReplaceAll(message, network.id, network.logicalID) + if len(network.id) >= 12 { + message = strings.ReplaceAll(message, network.id[:12], network.logicalID) + } + } + return strings.ReplaceAll(message, network.actualName, network.name) +} + +// shareNetwork reports whether both containers exist and have at least one +// attempt network in common. Both results are false when either is gone. +func (e *environment) shareNetwork(ctx context.Context, first, second string) (shared, bothExist bool, err error) { + var inspections [2]containerInspection + for index, alias := range []string{first, second} { + tracked, exists := e.container(alias) + if !exists { + return false, false, nil + } + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil || !exists { + return false, false, err + } + inspections[index] = inspection + } + for _, network := range e.snapshotNetworks() { + if network.id != "" && attachedTo(inspections[0], network) && attachedTo(inspections[1], network) { + return true, true, nil + } + } + return false, true, nil +} + +func (e *environment) networkAbsent(ctx context.Context, name string) (bool, error) { + network, exists := e.network(name) + if !exists { + return false, nil + } + _, exists, err := e.inspectNetwork(ctx, network) + return err == nil && !exists, err +} + +// closeNetworks removes attempt networks after their containers, verifying +// ownership labels on each exact ID. Networks that cannot be resolved yet +// are returned for a later retry. +func (e *environment) closeNetworks(ctx context.Context, networks []*trackedNetwork) ([]*trackedNetwork, []error) { + var unresolved []*trackedNetwork + var cleanupErrors []error + for index := len(networks) - 1; index >= 0; index-- { + network := networks[index] + inspection, exists, err := e.inspectNetworkUnchecked(ctx, network) + if err != nil { + cleanupErrors = append(cleanupErrors, fmt.Errorf("inspect Docker network %s before cleanup: %w", network.name, err)) + unresolved = append(unresolved, network) + continue + } + if !exists { + continue + } + labels := inspection.Labels + if labels[managedLabel] != "true" || labels[schemaLabel] != "1" || labels[sessionLabel] != e.sessionID || labels[missionLabel] != e.missionID || labels[aliasLabel] != network.name || !containerIDPattern.MatchString(inspection.ID) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("refusing to remove Docker network %s because its ownership labels do not match", network.name)) + unresolved = append(unresolved, network) + continue + } + network.id = inspection.ID + if result, err := e.run(ctx, "network", "rm", inspection.ID); err != nil && !isMissingNetwork(result) { + cleanupErrors = append(cleanupErrors, fmt.Errorf("remove Docker network %s: %w", network.name, err)) + unresolved = append(unresolved, network) + } + } + return unresolved, cleanupErrors +} diff --git a/internal/dockerlab/network_test.go b/internal/dockerlab/network_test.go new file mode 100644 index 0000000..2cc72bb --- /dev/null +++ b/internal/dockerlab/network_test.go @@ -0,0 +1,393 @@ +package dockerlab + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func testNetworkDockerMission() mission.Mission { + return mission.Mission{ + ID: "docker-network-lab", + Number: 35, + Track: mission.TrackDocker, + Environment: mission.EnvironmentDocker, + Docker: &mission.DockerSetup{ + Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, + Networks: []mission.DockerNetworkSpec{{Name: "web-net"}, {Name: "db-net"}, {Name: "spare-net"}}, + Containers: []mission.DockerContainerSpec{ + {Name: "web", Image: "fixture", State: mission.DockerStateRunning, Networks: []string{"web-net"}}, + {Name: "api", Image: "fixture", State: mission.DockerStateRunning, Networks: []string{"web-net"}}, + {Name: "db", Image: "fixture", State: mission.DockerStateRunning, Networks: []string{"db-net"}}, + {Name: "job", Image: "fixture", State: mission.DockerStateStopped, Networks: []string{"spare-net", "db-net"}}, + {Name: "iso", Image: "fixture", State: mission.DockerStateRunning}, + }, + }, + } +} + +func networkCondition(conditionType mission.ConditionType, first, second string) mission.Condition { + return mission.Condition{Type: conditionType, Containers: []string{first, second}} +} + +func observe(t *testing.T, environment *environment, condition mission.Condition) bool { + t.Helper() + got, err := environment.Observe(context.Background(), condition) + if err != nil { + t.Fatalf("Observe(%#v) error = %v", condition, err) + } + return got +} + +func TestNetworksAreInternalLabeledAndCreatedBeforeFixtures(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + calls := commandRunner.snapshotCalls() + + networkIndex, create := firstCall(t, calls, "network", "create") + wantCreate := []string{ + "network", "create", + "--driver", "bridge", + "--internal", + "--label", "com.opsquest.managed=true", + "--label", "com.opsquest.schema=1", + "--label", "com.opsquest.session=aaaaaaaaaaaaaaaaaaaaaaaa", + "--label", "com.opsquest.mission=docker-network-lab", + "--label", "com.opsquest.alias=web-net", + "--label", "com.opsquest.owner-pid=4242", + "--label", "com.opsquest.owner-host=test-host", + "opsquest-aaaaaaaaaaaaaaaaaaaaaaaa-n01", + } + if !reflect.DeepEqual(create, wantCreate) { + t.Fatalf("network create args:\n got: %q\nwant: %q", create, wantCreate) + } + containerIndex, _ := firstCall(t, calls, "container", "create") + if networkIndex > containerIndex { + t.Fatal("a fixture was created before its networks") + } + for _, call := range calls { + if hasPrefix(call, "network", "create") && !reflect.DeepEqual(call[2:5], []string{"--driver", "bridge", "--internal"}) { + t.Errorf("network created without fixed internal options: %q", call) + } + } + + webNet, _ := environment.network("web-net") + dbNet, _ := environment.network("db-net") + creates := make(map[string][]string) + for _, call := range calls { + if hasPrefix(call, "container", "create") { + for index := range call { + if call[index] == "--label" && strings.HasPrefix(call[index+1], aliasLabel+"=") { + creates[strings.TrimPrefix(call[index+1], aliasLabel+"=")] = call + } + } + } + } + containsSequence := func(call []string, want ...string) bool { + for index := 0; index+len(want) <= len(call); index++ { + if reflect.DeepEqual(call[index:index+len(want)], want) { + return true + } + } + return false + } + if !containsSequence(creates["api"], "--network", webNet.id, "--network-alias", "api") { + t.Errorf("api create args = %q", creates["api"]) + } + if !containsSequence(creates["iso"], "--network", "none") { + t.Errorf("fixture without networks did not disable networking: %q", creates["iso"]) + } + job := environment.byAlias["job"] + connectIndex, connect := firstCall(t, calls, "network", "connect") + if !reflect.DeepEqual(connect, []string{"network", "connect", "--alias", "job", dbNet.id, job.id}) { + t.Fatalf("additional fixture network connect = %q", connect) + } + for index, call := range calls { + if hasPrefix(call, "container", "start", job.id) && index < connectIndex { + t.Fatal("fixture started before joining all declared networks") + } + } +} + +func TestNetworkCommandsAndObservations(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + run := func(line string) string { + t.Helper() + result, err := environment.Execute(context.Background(), line) + if err != nil { + t.Fatalf("Execute(%q) error = %v", line, err) + } + return result.Output + } + fails := func(line, want string) { + t.Helper() + _, err := environment.Execute(context.Background(), line) + if err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("Execute(%q) error = %v, want substring %q", line, err, want) + } + } + + listed := run("docker network ls") + for _, want := range []string{"NETWORK ID", "net-01", "web-net", "db-net", "spare-net", "bridge", "true"} { + if !strings.Contains(listed, want) { + t.Errorf("network ls missing %q:\n%s", want, listed) + } + } + webNet, _ := environment.network("web-net") + if strings.Contains(listed, webNet.id[:12]) || strings.Contains(listed, webNet.actualName) { + t.Fatalf("network ls leaked engine identity:\n%s", listed) + } + inspected := run("docker network inspect db-net") + for _, want := range []string{`"Id": "net-02"`, `"Name": "db-net"`, `"Internal": true`, `"db"`, `"job"`} { + if !strings.Contains(inspected, want) { + t.Errorf("network inspect missing %q:\n%s", want, inspected) + } + } + if strings.Contains(inspected, `"api"`) || strings.Contains(inspected, "opsquest-") { + t.Fatalf("network inspect shows wrong membership or engine names:\n%s", inspected) + } + + shared := networkCondition(mission.ConditionDockerNetworkShared, "api", "db") + isolated := networkCondition(mission.ConditionDockerNetworkIsolated, "api", "db") + if observe(t, environment, shared) || !observe(t, environment, isolated) { + t.Fatal("api and db started out sharing a network") + } + commandRunner.resetCalls() + if output := run("docker network connect db-net api"); output != "" { + t.Fatalf("connect output = %q", output) + } + dbNet, _ := environment.network("db-net") + api := environment.byAlias["api"] + if _, call := firstCall(t, commandRunner.snapshotCalls(), "network", "connect"); !reflect.DeepEqual(call, []string{"network", "connect", "--alias", "api", dbNet.id, api.id}) { + t.Fatalf("connect transport = %q", call) + } + if !observe(t, environment, shared) || observe(t, environment, isolated) { + t.Fatal("connect did not give api and db a shared network") + } + if inspectedAPI := run("docker inspect api"); !strings.Contains(inspectedAPI, `"db-net",`) || !strings.Contains(inspectedAPI, `"web-net"`) { + t.Fatalf("container inspect networks:\n%s", inspectedAPI) + } + fails("docker network connect db-net api", "already connected") + + run("docker network disconnect web-net api") + if observe(t, environment, networkCondition(mission.ConditionDockerNetworkShared, "web", "api")) { + t.Fatal("disconnect left web and api sharing a network") + } + fails("docker network disconnect web-net api", "is not connected") + fails("docker network connect web-net iso", "networking disabled") + if inspectedIso := run("docker inspect iso"); !strings.Contains(inspectedIso, `"none"`) { + t.Fatalf("isolated container inspect:\n%s", inspectedIso) + } + fails("docker network connect web-net ghost", `container "ghost" is not part of this mission`) + fails("docker network connect ghost-net api", `network "ghost-net" is not part of this mission`) + + fails("docker network rm db-net", "still has containers attached (api, db, job)") + // Docker would remove a network only stopped containers use; the lab + // refuses so the stopped job stays startable. + fails("docker network rm spare-net", "still has containers attached (job)") + run("docker rm job") + spareAbsent := mission.Condition{Type: mission.ConditionDockerNetworkAbsent, Network: "spare-net"} + if observe(t, environment, spareAbsent) { + t.Fatal("spare-net reported absent before removal") + } + if output := run("docker network rm spare-net"); output != "spare-net\n" { + t.Fatalf("network rm output = %q", output) + } + if !observe(t, environment, spareAbsent) { + t.Fatal("spare-net not absent after removal") + } + if strings.Contains(run("docker network ls"), "spare-net") { + t.Fatal("removed network still listed") + } + fails("docker network inspect spare-net", "no longer exists") + fails("docker network connect spare-net api", "has been removed") + fails("docker network rm spare-net", "already been removed") + + if output := run("docker network create spare-net"); output != "net-04\n" { + t.Fatalf("recreated network output = %q", output) + } + if observe(t, environment, spareAbsent) { + t.Fatal("recreated spare-net reported absent") + } + fails("docker network create spare-net", "already exists") + for _, name := range []string{"extra-a", "extra-b", "extra-c"} { + run("docker network create " + name) + } + createsBefore := commandRunner.networkCreates + fails("docker network create extra-d", "at most 4 networks") + if commandRunner.networkCreates != createsBefore { + t.Fatal("network over the player limit reached Docker") + } + + run("docker stop db") + run("docker rm db") + if observe(t, environment, isolated) || observe(t, environment, shared) { + t.Fatal("a removed container satisfied a network condition") + } +} + +func TestNetworkParserSubset(t *testing.T) { + accepted := []struct { + line string + want dockerAction + }{ + {line: "docker network ls", want: dockerAction{kind: actionNetworkList}}, + {line: "docker network list", want: dockerAction{kind: actionNetworkList}}, + {line: "docker network create app-net", want: dockerAction{kind: actionNetworkCreate, network: "app-net"}}, + {line: "docker network rm app-net", want: dockerAction{kind: actionNetworkRemove, network: "app-net"}}, + {line: "docker network remove app-net", want: dockerAction{kind: actionNetworkRemove, network: "app-net"}}, + {line: "docker network inspect app-net", want: dockerAction{kind: actionNetworkInspect, network: "app-net"}}, + {line: "docker network connect app-net api", want: dockerAction{kind: actionNetworkConnect, network: "app-net", alias: "api"}}, + {line: "docker network disconnect app-net api", want: dockerAction{kind: actionNetworkDisconnect, network: "app-net", alias: "api"}}, + } + for _, test := range accepted { + got, err := parseAction(test.line) + if err != nil || !reflect.DeepEqual(got, test.want) { + t.Errorf("parseAction(%q) = %#v, %v; want %#v", test.line, got, err, test.want) + } + } + + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + commandRunner.resetCalls() + rejected := []struct { + line string + wantErr string + }{ + {line: "docker network", wantErr: "usage: docker network"}, + {line: "docker network create --driver host app-net", wantErr: "options are fixed"}, + {line: "docker network create --internal app-net", wantErr: "options are fixed"}, + {line: "docker network create --subnet 10.0.0.0/8 app-net", wantErr: "options are fixed"}, + {line: "docker network connect --alias db web-net api", wantErr: "options are outside"}, + {line: "docker network connect --ip 10.0.0.5 web-net api", wantErr: "options are outside"}, + {line: "docker network disconnect -f web-net api", wantErr: "options are outside"}, + {line: "docker network ls -q", wantErr: "options are outside"}, + {line: "docker network rm host", wantErr: `built-in "host" network`}, + {line: "docker network inspect bridge", wantErr: `built-in "bridge" network`}, + {line: "docker network connect none api", wantErr: `built-in "none" network`}, + {line: "docker network create default", wantErr: `built-in "default" network`}, + {line: "docker network create App_Net", wantErr: "lowercase logical name"}, + {line: "docker network create " + strings.Repeat("a", 10) + "/x", wantErr: "lowercase logical name"}, + {line: "docker network connect web-net", wantErr: "usage: docker network connect NETWORK ALIAS"}, + {line: "docker network connect web-net api db", wantErr: "usage: docker network connect NETWORK ALIAS"}, + {line: "docker network connect web-net $(whoami)", wantErr: "usage: docker network connect NETWORK ALIAS"}, + {line: "docker network rm web-net db-net", wantErr: "usage: docker network rm NETWORK"}, + {line: "docker network prune", wantErr: "outside this mission's teaching subset"}, + {line: "docker container network ls", wantErr: "outside this mission's teaching subset"}, + } + for _, test := range rejected { + if _, err := environment.Execute(context.Background(), test.line); err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Errorf("Execute(%q) error = %v, want substring %q", test.line, err, test.wantErr) + } + } + if calls := commandRunner.snapshotCalls(); len(calls) != 0 { + t.Fatalf("rejected network input reached Docker transport: %q", calls) + } +} + +func TestCloseRemovesNetworksAfterContainersAndVerifiesOwnership(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + if _, err := environment.Execute(context.Background(), "docker network create extra-net"); err != nil { + t.Fatal(err) + } + commandRunner.resetCalls() + if err := environment.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if commandRunner.containerCount() != 0 || commandRunner.networkCount() != 0 { + t.Fatalf("resources after Close: %d containers, %d networks", commandRunner.containerCount(), commandRunner.networkCount()) + } + lastContainerRemoval, firstNetworkRemoval := -1, -1 + for index, call := range commandRunner.snapshotCalls() { + if hasPrefix(call, "container", "rm") { + lastContainerRemoval = index + } + if hasPrefix(call, "network", "rm") && firstNetworkRemoval < 0 { + firstNetworkRemoval = index + } + } + if firstNetworkRemoval < lastContainerRemoval { + t.Fatal("a network was removed before every container") + } + + commandRunner = newFakeDockerRunner() + environment = createEnvironmentForMission(t, commandRunner, testNetworkDockerMission()) + webNet, _ := environment.network("web-net") + commandRunner.mutex.Lock() + commandRunner.networks[webNet.id].labels[sessionLabel] = strings.Repeat("b", 24) + commandRunner.mutex.Unlock() + if err := environment.Close(); err == nil || !strings.Contains(err.Error(), "refusing to remove Docker network web-net") { + t.Fatalf("Close() with foreign network error = %v", err) + } + if !commandRunner.hasNetwork(webNet.id) || commandRunner.networkCount() != 1 { + t.Fatalf("cleanup removed a network with foreign ownership or left others: %d", commandRunner.networkCount()) + } + commandRunner.mutex.Lock() + commandRunner.networks[webNet.id].labels[sessionLabel] = environment.sessionID + commandRunner.mutex.Unlock() + if err := environment.Close(); err != nil || commandRunner.networkCount() != 0 { + t.Fatalf("Close() retry = %v, networks left %d", err, commandRunner.networkCount()) + } +} + +func TestNetworkSetupFailureCleansUp(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failNetworkCreate = true + if _, err := testFactory(commandRunner).Create(context.Background(), testNetworkDockerMission()); err == nil || !strings.Contains(err.Error(), "create Docker network web-net") { + t.Fatalf("Create() error = %v", err) + } + for _, call := range commandRunner.snapshotCalls() { + if hasPrefix(call, "container", "create") { + t.Fatal("fixtures were created after network setup failed") + } + } + if commandRunner.containerCount() != 0 || commandRunner.networkCount() != 0 { + t.Fatalf("resources left after failed setup: %d containers, %d networks", commandRunner.containerCount(), commandRunner.networkCount()) + } +} + +func TestJanitorSweepsOrphanedNetworksAfterContainers(t *testing.T) { + commandRunner := newFakeDockerRunner() + session := strings.Repeat("1", 24) + orphanNetwork := "feed" + strings.Repeat("1", 60) + commandRunner.seedNetwork(orphanNetwork, &fakeNetwork{labels: orphanLabels(session, "999", testOwnerHost), name: "opsquest-" + session + "-n01", created: testNow, internal: true}) + orphanContainer := strings.Repeat("1", 64) + commandRunner.seedContainer(orphanContainer, &fakeContainer{labels: orphanLabels(session, "999", testOwnerHost), name: "opsquest-" + session + "-c01", created: testNow, running: true, endpoints: map[string]string{"opsquest-" + session + "-n01": orphanNetwork}}) + liveSession := strings.Repeat("2", 24) + liveNetwork := "feed" + strings.Repeat("2", 60) + commandRunner.seedNetwork(liveNetwork, &fakeNetwork{labels: orphanLabels(liveSession, "4242", testOwnerHost), name: "opsquest-" + liveSession + "-n01", created: testNow}) + userNetwork := "feed" + strings.Repeat("3", 60) + commandRunner.seedNetwork(userNetwork, &fakeNetwork{labels: orphanLabels(strings.Repeat("3", 24), "999", testOwnerHost), name: "production-db", created: testNow}) + + factory := testFactory(commandRunner) + if count, err := factory.OrphanedResources(context.Background()); err != nil || count != 2 { + t.Fatalf("OrphanedResources() = %d, %v; want container plus network", count, err) + } + commandRunner.resetCalls() + removed, err := factory.RemoveOrphanedResources(context.Background()) + if err != nil || removed != 2 { + t.Fatalf("RemoveOrphanedResources() = %d, %v", removed, err) + } + containerRemoval, _ := firstCall(t, commandRunner.snapshotCalls(), "container", "rm", "--force", orphanContainer) + networkRemoval, _ := firstCall(t, commandRunner.snapshotCalls(), "network", "rm", orphanNetwork) + if networkRemoval < containerRemoval { + t.Fatal("orphaned network removed before its container") + } + if commandRunner.hasNetwork(orphanNetwork) || !commandRunner.hasNetwork(liveNetwork) || !commandRunner.hasNetwork(userNetwork) { + t.Fatal("janitor removed the wrong networks") + } +} + +func TestNetworkErrorsRedactExactIdentity(t *testing.T) { + network := &trackedNetwork{id: "feed" + strings.Repeat("9", 60), logicalID: "net-01", name: "db-net", actualName: "opsquest-" + strings.Repeat("a", 24) + "-n01"} + message := "network " + network.id + " (" + network.id[:12] + ") named " + network.actualName + " failed" + got := redactNetworkIdentifiers(message, network) + if strings.Contains(got, network.id[:12]) || strings.Contains(got, network.actualName) || !strings.Contains(got, "net-01") || !strings.Contains(got, "db-net") { + t.Fatalf("redactNetworkIdentifiers() = %q", got) + } +} diff --git a/internal/dockerlab/parser.go b/internal/dockerlab/parser.go index 3610e2d..e167e38 100644 --- a/internal/dockerlab/parser.go +++ b/internal/dockerlab/parser.go @@ -35,6 +35,14 @@ const dockerHelp = `Docker lab commands: docker logs ALIAS Read a mission container's logs docker logs --tail N ALIAS Read only the last N log lines docker container logs ALIAS Read a mission container's logs + docker network ls List mission networks + docker network inspect NETWORK Inspect a network and its containers + docker network create NETWORK Create an internal mission network + docker network rm NETWORK Remove a network no container uses + docker network connect NETWORK ALIAS + Attach a container to a network + docker network disconnect NETWORK ALIAS + Detach a container from a network help Show this help ` @@ -49,6 +57,12 @@ const ( actionRemove actionInspect actionLogs + actionNetworkList + actionNetworkCreate + actionNetworkRemove + actionNetworkInspect + actionNetworkConnect + actionNetworkDisconnect ) // listFilter is one parsed docker ps filter. Filters are evaluated in Go over @@ -65,6 +79,8 @@ type dockerAction struct { filters []listFilter // tail is the number of trailing log lines to show; negative means all. tail int + // network is the logical network name for docker network actions. + network string } var listFilterValues = map[string]map[string]bool{ @@ -96,6 +112,9 @@ func parseAction(line string) (dockerAction, error) { return dockerAction{}, fmt.Errorf("usage: docker ps [-a|--all] | docker start ALIAS | docker stop ALIAS | docker rm ALIAS | docker inspect ALIAS | docker logs ALIAS") } + if fields[0] == "network" { + return parseNetwork(fields[1:]) + } if fields[0] == "container" { fields = fields[1:] if len(fields) == 0 { @@ -135,6 +154,63 @@ func parseAction(line string) (dockerAction, error) { } } +func parseNetwork(fields []string) (dockerAction, error) { + const usage = "usage: docker network ls | inspect NETWORK | create NETWORK | rm NETWORK | connect NETWORK ALIAS | disconnect NETWORK ALIAS" + if len(fields) == 0 { + return dockerAction{}, errors.New(usage) + } + for _, field := range fields[1:] { + if strings.HasPrefix(field, "-") { + if fields[0] == "create" { + return dockerAction{}, fmt.Errorf("docker network create options are fixed in this lab: every network OpsQuest creates is internal") + } + return dockerAction{}, fmt.Errorf("docker network %s options are outside this teaching subset", fields[0]) + } + } + name := func(value string) error { + if mission.ReservedDockerNetworkName(value) { + return fmt.Errorf("the built-in %q network is outside this lab", value) + } + if !mission.ValidDockerNetworkName(value) { + return fmt.Errorf("network name %q must be a lowercase logical name", value) + } + return nil + } + switch fields[0] { + case "ls", "list": + if len(fields) != 1 { + return dockerAction{}, fmt.Errorf("usage: docker network ls") + } + return dockerAction{kind: actionNetworkList}, nil + case "create", "rm", "remove", "inspect": + if len(fields) != 2 { + return dockerAction{}, fmt.Errorf("usage: docker network %s NETWORK", fields[0]) + } + if err := name(fields[1]); err != nil { + return dockerAction{}, err + } + kind := map[string]actionKind{"create": actionNetworkCreate, "rm": actionNetworkRemove, "remove": actionNetworkRemove, "inspect": actionNetworkInspect}[fields[0]] + return dockerAction{kind: kind, network: fields[1]}, nil + case "connect", "disconnect": + if len(fields) != 3 { + return dockerAction{}, fmt.Errorf("usage: docker network %s NETWORK ALIAS", fields[0]) + } + if err := name(fields[1]); err != nil { + return dockerAction{}, err + } + if !mission.ValidDockerLogicalName(fields[2]) { + return dockerAction{}, fmt.Errorf("usage: docker network %s NETWORK ALIAS", fields[0]) + } + kind := actionNetworkConnect + if fields[0] == "disconnect" { + kind = actionNetworkDisconnect + } + return dockerAction{kind: kind, network: fields[1], alias: fields[2]}, nil + default: + return dockerAction{}, fmt.Errorf("docker network %s is outside this mission's teaching subset; type help", fields[0]) + } +} + func parseList(fields []string) (dockerAction, error) { const usage = "usage: docker ps [-a|--all] [--filter status=VALUE|health=VALUE]" action := dockerAction{kind: actionList} diff --git a/internal/game/missions_test.go b/internal/game/missions_test.go index fa3e635..2f9bf71 100644 --- a/internal/game/missions_test.go +++ b/internal/game/missions_test.go @@ -2,7 +2,9 @@ package game import ( "context" + "encoding/json" "fmt" + "sort" "strconv" "strings" "testing" @@ -57,6 +59,24 @@ func TestEveryMissionHasAWorkingOutcome(t *testing.T) { "docker rm sync-orders", "docker rm sync-users", }, + "docker-network-census": {"docker inspect api", "docker network inspect cache-net"}, + "docker-cant-reach-the-db": {"docker inspect api", "docker network connect db-net api"}, + "docker-need-to-know": {"docker network inspect payments-net", "docker network disconnect payments-net batch"}, + "docker-private-channel": { + "docker network create metrics-net", + "docker network connect metrics-net exporter", + "docker network connect metrics-net collector", + "docker network disconnect app-net exporter", + "docker network disconnect app-net collector", + }, + "docker-segmentation": { + "docker network create db-net", + "docker network connect db-net api", + "docker network connect db-net db", + "docker network disconnect flat-net db", + "docker rm old-cron", + "docker network rm legacy-net", + }, "linux-runbook-runner": {"sh publish-health.sh"}, "linux-vi-first-aid": { `printf 'SERVICE=checkout\nLOG_LEVEL=info\n' > release.env`, @@ -154,6 +174,9 @@ type missionDockerEnvironment struct { crashLoop map[string]bool health map[string]string count int + networks map[string]bool + endpoints map[string]map[string]bool + disabled map[string]bool } func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment { @@ -165,15 +188,26 @@ func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment oneShot: make(map[string]bool), crashLoop: make(map[string]bool), health: make(map[string]string), + networks: make(map[string]bool), + endpoints: make(map[string]map[string]bool), + disabled: make(map[string]bool), } if item.Docker == nil { return environment } + for _, network := range item.Docker.Networks { + environment.networks[network.Name] = true + } for _, container := range item.Docker.Containers { environment.aliases = append(environment.aliases, container.Name) environment.running[container.Name] = container.State == mission.DockerStateRunning environment.logs[container.Name] = container.Log environment.health[container.Name] = container.Health + environment.endpoints[container.Name] = make(map[string]bool) + for _, network := range container.Networks { + environment.endpoints[container.Name][network] = true + } + environment.disabled[container.Name] = len(container.Networks) == 0 if container.ExitCode != nil { environment.exitCodes[container.Name] = *container.ExitCode environment.oneShot[container.Name] = true @@ -209,6 +243,9 @@ func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Exec return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) } fields = fields[1:] + if fields[0] == "network" { + return e.executeNetwork(line, fields[1:]) + } if fields[0] == "container" { fields = fields[1:] } @@ -297,8 +334,101 @@ func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Exec return result, nil } +func (e *missionDockerEnvironment) executeNetwork(line string, fields []string) (Execution, error) { + result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} + attached := func(network string) []string { + var aliases []string + for _, alias := range e.aliases { + if !e.removed[alias] && e.endpoints[alias][network] { + aliases = append(aliases, alias) + } + } + sort.Strings(aliases) + return aliases + } + switch { + case len(fields) == 1 && fields[0] == "ls": + var names []string + for name, exists := range e.networks { + if exists { + names = append(names, name) + } + } + sort.Strings(names) + result.Output = strings.Join(names, "\n") + "\n" + case len(fields) == 2 && fields[0] == "inspect": + if !e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q no longer exists", fields[1]) + } + encoded, err := json.MarshalIndent(struct { + Name string `json:"Name"` + Containers []string `json:"Containers"` + }{Name: fields[1], Containers: attached(fields[1])}, "", " ") + if err != nil { + return Execution{}, err + } + result.Output = string(encoded) + "\n" + case len(fields) == 2 && fields[0] == "create": + if e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q already exists", fields[1]) + } + e.networks[fields[1]] = true + result.Output = fields[1] + "\n" + case len(fields) == 2 && fields[0] == "rm": + if !e.networks[fields[1]] { + return Execution{}, fmt.Errorf("docker: network %q has already been removed", fields[1]) + } + if users := attached(fields[1]); len(users) > 0 { + return Execution{}, fmt.Errorf("docker: network %q still has containers attached (%s)", fields[1], strings.Join(users, ", ")) + } + e.networks[fields[1]] = false + result.Output = fields[1] + "\n" + case len(fields) == 3 && (fields[0] == "connect" || fields[0] == "disconnect"): + network, alias := fields[1], fields[2] + if !e.networks[network] { + return Execution{}, fmt.Errorf("docker: network %q has been removed", network) + } + if _, exists := e.running[alias]; !exists || e.removed[alias] { + return Execution{}, fmt.Errorf("docker: container %q is not available", alias) + } + if fields[0] == "connect" { + if e.disabled[alias] || e.endpoints[alias][network] { + return Execution{}, fmt.Errorf("docker: cannot connect %q to %q", alias, network) + } + e.endpoints[alias][network] = true + } else { + if !e.endpoints[alias][network] { + return Execution{}, fmt.Errorf("docker: container %q is not connected to network %q", alias, network) + } + delete(e.endpoints[alias], network) + } + default: + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + return result, nil +} + +func (e *missionDockerEnvironment) shareNetwork(first, second string) (shared, bothExist bool) { + for _, alias := range []string{first, second} { + if _, exists := e.running[alias]; !exists || e.removed[alias] { + return false, false + } + } + for network := range e.endpoints[first] { + if e.networks[network] && e.endpoints[second][network] { + return true, true + } + } + return false, true +} + func (e *missionDockerEnvironment) Observe(_ context.Context, condition mission.Condition) (bool, error) { switch condition.Type { + case mission.ConditionDockerNetworkShared, mission.ConditionDockerNetworkIsolated: + shared, bothExist := e.shareNetwork(condition.Containers[0], condition.Containers[1]) + return bothExist && shared == (condition.Type == mission.ConditionDockerNetworkShared), nil + case mission.ConditionDockerNetworkAbsent: + return !e.networks[condition.Network], nil case mission.ConditionDockerContainerRunning: return !e.removed[condition.Container] && e.running[condition.Container], nil case mission.ConditionDockerContainerStopped: @@ -387,6 +517,16 @@ func TestNewDockerMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testi {id: "docker-crash-loop", alternative: []string{"docker stop payments", "docker ps -a", "docker container logs payments"}, incomplete: []string{"docker stop payments", "docker logs payments", "docker rm payments"}}, {id: "docker-postmortem-triage", alternative: []string{"docker ps --filter status=exited", "docker rm sync-users", "docker container rm sync-orders", "docker ps --filter health=unhealthy", "docker container stop api-canary"}, incomplete: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker rm sync-invoices"}}, {id: "docker-postmortem-triage", alternative: []string{"docker stop api-canary", "docker rm sync-orders", "docker rm sync-users", "docker logs sync-invoices"}, incomplete: []string{"docker stop api-canary", "docker rm api-canary", "docker rm sync-orders", "docker rm sync-users"}}, + {id: "docker-network-census", alternative: []string{"docker network ls", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect web-net"}}, + {id: "docker-network-census", alternative: []string{"docker network inspect db-net", "docker network inspect cache-net"}, incomplete: []string{"docker network inspect cache-net", "docker inspect api"}}, + {id: "docker-cant-reach-the-db", alternative: []string{"docker network create api-db", "docker network connect api-db api", "docker network connect api-db db"}, incomplete: []string{"docker network connect web-net db"}}, + {id: "docker-cant-reach-the-db", alternative: []string{"docker network connect db-net api", "docker network inspect db-net"}, incomplete: []string{"docker network disconnect web-net api", "docker network connect db-net api"}}, + {id: "docker-need-to-know", alternative: []string{"docker stop batch", "docker network disconnect payments-net batch", "docker start batch"}, incomplete: []string{"docker network disconnect payments-net ledger-db"}}, + {id: "docker-need-to-know", alternative: []string{"docker network inspect app-net", "docker network disconnect payments-net batch"}, incomplete: []string{"docker network disconnect app-net batch"}}, + {id: "docker-private-channel", alternative: []string{"docker network create edge-net", "docker network connect edge-net gateway", "docker network connect edge-net api", "docker network disconnect app-net gateway", "docker network disconnect app-net api"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector"}}, + {id: "docker-private-channel", alternative: []string{"docker network create metrics-net", "docker network connect metrics-net collector", "docker network connect metrics-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net exporter"}, incomplete: []string{"docker network create metrics-net", "docker network connect metrics-net exporter", "docker network connect metrics-net collector", "docker network disconnect app-net exporter", "docker network disconnect app-net collector", "docker network disconnect app-net gateway"}}, + {id: "docker-segmentation", alternative: []string{"docker network create web-net", "docker network connect web-net web", "docker network connect web-net api", "docker network disconnect flat-net web", "docker network disconnect legacy-net old-cron", "docker network rm legacy-net", "docker rm old-cron"}, incomplete: []string{"docker network create db-net", "docker network connect db-net api", "docker network connect db-net db", "docker network disconnect flat-net db", "docker rm old-cron"}}, + {id: "docker-segmentation", alternative: []string{"docker network ls", "docker rm old-cron", "docker network rm legacy-net", "docker network create db-net", "docker network connect db-net db", "docker network connect db-net api", "docker network disconnect flat-net db"}, incomplete: []string{"docker network disconnect flat-net db", "docker rm old-cron", "docker network rm legacy-net"}}, } for _, test := range tests { item, found := catalog.Find(test.id) diff --git a/internal/game/validator.go b/internal/game/validator.go index 1b21f29..4a58b33 100644 --- a/internal/game/validator.go +++ b/internal/game/validator.go @@ -112,6 +112,18 @@ func describeCondition(condition mission.Condition) string { return "The required number of mission containers exist" case mission.ConditionDockerContainerAbsent: return fmt.Sprintf("Container %s has been removed", condition.Container) + case mission.ConditionDockerNetworkShared: + if len(condition.Containers) == 2 { + return fmt.Sprintf("Containers %s and %s share a network", condition.Containers[0], condition.Containers[1]) + } + return "The required containers share a network" + case mission.ConditionDockerNetworkIsolated: + if len(condition.Containers) == 2 { + return fmt.Sprintf("Containers %s and %s share no network", condition.Containers[0], condition.Containers[1]) + } + return "The required containers share no network" + case mission.ConditionDockerNetworkAbsent: + return fmt.Sprintf("Network %s has been removed", condition.Network) default: return fmt.Sprintf("Outcome condition %s is satisfied", condition.Type) } diff --git a/internal/mission/catalog.go b/internal/mission/catalog.go index c44f151..89b2dbb 100644 --- a/internal/mission/catalog.go +++ b/internal/mission/catalog.go @@ -107,8 +107,27 @@ const ( maxDockerImagesPerMission = 16 maxDockerContainersPerMission = 32 maxDockerFixtureLogBytes = 8 * 1024 + maxDockerNetworksPerMission = 8 + maxDockerNetworksPerContainer = 4 ) +// reservedDockerNetworkNames are Docker's built-in network names. Lab +// networks cannot use them, so a logical name never suggests host or default +// bridge networking. +var reservedDockerNetworkNames = map[string]bool{"bridge": true, "default": true, "host": true, "none": true} + +// ValidDockerNetworkName reports whether value is a safe logical network name +// that does not shadow one of Docker's built-in networks. +func ValidDockerNetworkName(value string) bool { + return ValidDockerLogicalName(value) && !reservedDockerNetworkNames[value] +} + +// ReservedDockerNetworkName reports whether value names a built-in Docker +// network that labs never expose. +func ReservedDockerNetworkName(value string) bool { + return reservedDockerNetworkNames[value] +} + // ValidDockerLogicalName reports whether value is safe to use as a stable // mission alias. Runtime adapters use this same rule so catalog-valid content // cannot become unplayable at environment setup time. @@ -215,6 +234,14 @@ func validateMission(item Mission) error { if condition.Container != "" && !dockerSetupHasContainer(item.Docker, condition.Container) { return fmt.Errorf("validation condition %d: unknown docker container %q", index+1, condition.Container) } + for _, container := range condition.Containers { + if !dockerSetupHasContainer(item.Docker, container) { + return fmt.Errorf("validation condition %d: unknown docker container %q", index+1, container) + } + } + if condition.Network != "" && !dockerSetupHasNetwork(item.Docker, condition.Network) { + return fmt.Errorf("validation condition %d: unknown docker network %q", index+1, condition.Network) + } } return nil } @@ -239,6 +266,19 @@ func ValidateDockerSetup(setup DockerSetup) error { if len(setup.Containers) > maxDockerContainersPerMission { return fmt.Errorf("docker setup exceeds the %d-container limit", maxDockerContainersPerMission) } + if len(setup.Networks) > maxDockerNetworksPerMission { + return fmt.Errorf("docker setup exceeds the %d-network limit", maxDockerNetworksPerMission) + } + networks := make(map[string]bool, len(setup.Networks)) + for _, network := range setup.Networks { + if !ValidDockerNetworkName(network.Name) { + return fmt.Errorf("docker network name %q must be a lowercase logical name other than bridge, default, host, or none", network.Name) + } + if networks[network.Name] { + return fmt.Errorf("duplicate docker network name %q", network.Name) + } + networks[network.Name] = true + } images := make(map[string]bool, len(setup.Images)) for _, image := range setup.Images { if !ValidDockerLogicalName(image.Alias) { @@ -277,6 +317,19 @@ func ValidateDockerSetup(setup DockerSetup) error { if err := validateDockerFixtureBehavior(container); err != nil { return err } + if len(container.Networks) > maxDockerNetworksPerContainer { + return fmt.Errorf("docker container %q exceeds the %d-network limit", container.Name, maxDockerNetworksPerContainer) + } + joined := make(map[string]bool, len(container.Networks)) + for _, network := range container.Networks { + if !networks[network] { + return fmt.Errorf("docker container %q references unknown network %q", container.Name, network) + } + if joined[network] { + return fmt.Errorf("docker container %q joins network %q twice", container.Name, network) + } + joined[network] = true + } containers[container.Name] = true } return nil @@ -326,6 +379,18 @@ func validateDockerFixtureBehavior(container DockerContainerSpec) error { return nil } +func dockerSetupHasNetwork(setup *DockerSetup, name string) bool { + if setup == nil { + return false + } + for _, network := range setup.Networks { + if network.Name == name { + return true + } + } + return false +} + func dockerSetupHasContainer(setup *DockerSetup, name string) bool { if setup == nil { return false @@ -444,6 +509,8 @@ const ( conditionPID conditionContainer conditionCount + conditionNetwork + conditionContainers ) var allowedConditionFields = map[ConditionType]conditionFields{ @@ -467,15 +534,20 @@ var allowedConditionFields = map[ConditionType]conditionFields{ ConditionDockerContainerStopped: conditionContainer, ConditionDockerContainerCountEqual: conditionCount, ConditionDockerContainerAbsent: conditionContainer, + ConditionDockerNetworkShared: conditionContainers, + ConditionDockerNetworkIsolated: conditionContainers, + ConditionDockerNetworkAbsent: conditionNetwork, } var conditionFieldFlags = map[string]conditionFields{ - "path": conditionPath, - "value": conditionValue, - "values": conditionValues, - "pid": conditionPID, - "container": conditionContainer, - "count": conditionCount, + "path": conditionPath, + "value": conditionValue, + "values": conditionValues, + "pid": conditionPID, + "container": conditionContainer, + "count": conditionCount, + "network": conditionNetwork, + "containers": conditionContainers, } func validateCondition(condition Condition, environment string) error { @@ -495,6 +567,8 @@ func validateCondition(condition Condition, environment string) error { {name: "pid", present: condition.PID != 0, flag: conditionPID}, {name: "container", present: condition.Container != "", flag: conditionContainer}, {name: "count", present: condition.Count != nil, flag: conditionCount}, + {name: "network", present: condition.Network != "", flag: conditionNetwork}, + {name: "containers", present: len(condition.Containers) != 0, flag: conditionContainers}, } { if field.present { present |= field.flag @@ -569,6 +643,25 @@ func validateCondition(condition Condition, environment string) error { if !ValidDockerLogicalName(condition.Container) { return fmt.Errorf("container %q must be a lowercase logical name", condition.Container) } + case ConditionDockerNetworkShared, ConditionDockerNetworkIsolated: + if environment != EnvironmentDocker { + return fmt.Errorf("%s requires a docker environment", condition.Type) + } + if len(condition.Containers) != 2 || condition.Containers[0] == condition.Containers[1] { + return fmt.Errorf("containers must name exactly two different containers") + } + for _, container := range condition.Containers { + if !ValidDockerLogicalName(container) { + return fmt.Errorf("container %q must be a lowercase logical name", container) + } + } + case ConditionDockerNetworkAbsent: + if environment != EnvironmentDocker { + return fmt.Errorf("%s requires a docker environment", condition.Type) + } + if !ValidDockerNetworkName(condition.Network) { + return fmt.Errorf("network %q must be a lowercase logical name other than bridge, default, host, or none", condition.Network) + } case ConditionDockerContainerCountEqual: if environment != EnvironmentDocker { return fmt.Errorf("docker_container_count_equals requires a docker environment") @@ -717,8 +810,10 @@ func cloneMission(item Mission) Mission { if item.Docker != nil { dockerSetup := *item.Docker dockerSetup.Images = slices.Clone(item.Docker.Images) + dockerSetup.Networks = slices.Clone(item.Docker.Networks) dockerSetup.Containers = slices.Clone(item.Docker.Containers) for index, container := range dockerSetup.Containers { + dockerSetup.Containers[index].Networks = slices.Clone(container.Networks) if container.ExitCode != nil { exitCode := *container.ExitCode dockerSetup.Containers[index].ExitCode = &exitCode @@ -730,6 +825,7 @@ func cloneMission(item Mission) Mission { for index, condition := range item.Validation.All { cloned.Validation.All[index] = condition cloned.Validation.All[index].Values = slices.Clone(condition.Values) + cloned.Validation.All[index].Containers = slices.Clone(condition.Containers) if condition.Count != nil { count := *condition.Count cloned.Validation.All[index].Count = &count diff --git a/internal/mission/catalog_test.go b/internal/mission/catalog_test.go index dc6615e..e105b7e 100644 --- a/internal/mission/catalog_test.go +++ b/internal/mission/catalog_test.go @@ -12,8 +12,8 @@ func TestEmbeddedCatalog(t *testing.T) { t.Fatalf("LoadCatalog() error = %v", err) } items := catalog.All() - if len(items) != 34 { - t.Fatalf("len(All()) = %d, want 34", len(items)) + if len(items) != 39 { + t.Fatalf("len(All()) = %d, want 39", len(items)) } for index, item := range items { if item.Number != index+1 { @@ -46,7 +46,7 @@ func TestLegacyMissionDefaultsAndCatalogTrackFiltering(t *testing.T) { linux := catalog.InTrack("") docker := catalog.InTrack(TrackDocker) - if len(linux) != 23 || len(docker) != 11 { + if len(linux) != 23 || len(docker) != 16 { t.Fatalf("track sizes = linux %d, docker %d", len(linux), len(docker)) } if docker[0].ID != "docker-container-census" || docker[0].Number != 20 { @@ -248,8 +248,8 @@ func TestContainerTriageCurriculum(t *testing.T) { } } worlds := catalog.Worlds(TrackDocker) - if len(worlds) != 2 || worlds[1].Name != "Container Triage" || len(worlds[1].Missions) != 5 { - t.Fatalf("Docker worlds = %d, want Foundations plus Container Triage", len(worlds)) + if len(worlds) < 2 || worlds[1].Name != "Container Triage" || len(worlds[1].Missions) != 5 { + t.Fatalf("Docker worlds = %d, want Container Triage as World 2", len(worlds)) } crashLoop, _ := catalog.Find("docker-crash-loop") payments := crashLoop.Docker.Containers[0] @@ -258,6 +258,42 @@ func TestContainerTriageCurriculum(t *testing.T) { } } +func TestNetworkPlumbingCurriculum(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + difficulty string + hints int + }{ + {id: "docker-network-census", number: 35, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-cant-reach-the-db", number: 36, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-need-to-know", number: 37, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-private-channel", number: 38, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-segmentation", number: 39, difficulty: DifficultyAdvanced, hints: 5}, + } + for _, want := range wants { + item, found := catalog.Find(want.id) + if !found { + t.Errorf("mission %q missing", want.id) + continue + } + if item.Number != want.number || item.Campaign != "Network Plumbing" || item.EffectiveTrack() != TrackDocker || item.Difficulty != want.difficulty || len(item.Hints) != want.hints { + t.Errorf("mission %q curriculum metadata = number %d, campaign %q, track %q, difficulty %q, hints %d", item.ID, item.Number, item.Campaign, item.EffectiveTrack(), item.Difficulty, len(item.Hints)) + } + if len(item.Docker.Networks) == 0 { + t.Errorf("network mission %q declares no networks", item.ID) + } + } + worlds := catalog.Worlds(TrackDocker) + if len(worlds) != 3 || worlds[2].Name != "Network Plumbing" || len(worlds[2].Missions) != 5 { + t.Fatalf("Docker worlds = %d, want Network Plumbing as World 3", len(worlds)) + } +} + func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { tests := []struct { name string @@ -379,6 +415,97 @@ func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { }, wantErr: "crash-loop fixture must use running state", }, + { + name: "reserved network name", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "host"}} + }, + wantErr: "other than bridge, default, host, or none", + }, + { + name: "duplicate network", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "app-net"}} + }, + wantErr: "duplicate docker network name", + }, + { + name: "too many networks", + mutate: func(item *Mission) { + for index := 0; index <= maxDockerNetworksPerMission; index++ { + item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: "net-" + strings.Repeat("a", index+1)}) + } + }, + wantErr: "network limit", + }, + { + name: "container joins unknown network", + mutate: func(item *Mission) { + item.Docker.Containers[0].Networks = []string{"missing"} + }, + wantErr: "references unknown network", + }, + { + name: "container joins network twice", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} + item.Docker.Containers[0].Networks = []string{"app-net", "app-net"} + }, + wantErr: "joins network \"app-net\" twice", + }, + { + name: "container joins too many networks", + mutate: func(item *Mission) { + for _, name := range []string{"a-net", "b-net", "c-net", "d-net", "e-net"} { + item.Docker.Networks = append(item.Docker.Networks, DockerNetworkSpec{Name: name}) + item.Docker.Containers[0].Networks = append(item.Docker.Containers[0].Networks, name) + } + }, + wantErr: "exceeds the 4-network limit", + }, + { + name: "shared network with one container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api"}} + }, + wantErr: "exactly two different containers", + }, + { + name: "isolated container compared with itself", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "api"}} + }, + wantErr: "exactly two different containers", + }, + { + name: "shared network with unknown container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "missing"}} + }, + wantErr: "unknown docker container", + }, + { + name: "shared network condition with network field", + mutate: func(item *Mission) { + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}} + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}, Network: "app-net"} + }, + wantErr: "does not support network", + }, + { + name: "unknown absent network", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "missing"} + }, + wantErr: "unknown docker network", + }, + { + name: "reserved absent network", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerNetworkAbsent, Network: "none"} + }, + wantErr: "other than bridge, default, host, or none", + }, { name: "unknown absent validation container", mutate: func(item *Mission) { @@ -511,16 +638,63 @@ func TestMissionValidationAcceptsDockerFixtureBehaviors(t *testing.T) { } } +func TestMissionValidationAcceptsDockerNetworks(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + item.Docker.Networks = []DockerNetworkSpec{{Name: "app-net"}, {Name: "db-net"}} + item.Docker.Containers[0].Networks = []string{"app-net", "db-net"} + item.Docker.Containers[1].Networks = []string{"app-net"} + item.Validation.All = append(item.Validation.All, + Condition{Type: ConditionDockerNetworkShared, Containers: []string{"api", "metrics"}}, + Condition{Type: ConditionDockerNetworkIsolated, Containers: []string{"metrics", "api"}}, + Condition{Type: ConditionDockerNetworkAbsent, Network: "db-net"}, + ) + if err := validateMission(item); err != nil { + t.Fatalf("validateMission() error = %v", err) + } + for _, name := range []string{"bridge", "default", "host", "none"} { + if ValidDockerNetworkName(name) || !ReservedDockerNetworkName(name) { + t.Errorf("network name %q was not treated as reserved", name) + } + } +} + +func TestDockerNetworkFieldsAreDeepCopied(t *testing.T) { + item := Mission{ + Docker: &DockerSetup{ + Networks: []DockerNetworkSpec{{Name: "app-net"}}, + Containers: []DockerContainerSpec{{Name: "api", Networks: []string{"app-net"}}}, + }, + Validation: Validation{All: []Condition{{Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}}}, + } + cloned := cloneMission(item) + cloned.Docker.Networks[0].Name = "changed" + cloned.Docker.Containers[0].Networks[0] = "changed" + cloned.Validation.All[0].Containers[0] = "changed" + if item.Docker.Networks[0].Name != "app-net" || item.Docker.Containers[0].Networks[0] != "app-net" || item.Validation.All[0].Containers[0] != "api" { + t.Fatalf("cloneMission shared network data: %#v", item) + } +} + func TestMissionValidationRejectsDockerConditionOnSimulatedMission(t *testing.T) { catalog, err := LoadCatalog() if err != nil { t.Fatal(err) } item, _ := catalog.Find("linux-orientation") - for _, conditionType := range []ConditionType{ConditionDockerContainerRunning, ConditionDockerContainerAbsent} { - item.Validation.All = []Condition{{Type: conditionType, Container: "api"}} + for _, condition := range []Condition{ + {Type: ConditionDockerContainerRunning, Container: "api"}, + {Type: ConditionDockerContainerAbsent, Container: "api"}, + {Type: ConditionDockerNetworkShared, Containers: []string{"api", "db"}}, + {Type: ConditionDockerNetworkIsolated, Containers: []string{"api", "db"}}, + {Type: ConditionDockerNetworkAbsent, Network: "app-net"}, + } { + item.Validation.All = []Condition{condition} if err := validateMission(item); err == nil || !strings.Contains(err.Error(), "requires a docker environment") { - t.Fatalf("validateMission(%s) error = %v", conditionType, err) + t.Fatalf("validateMission(%s) error = %v", condition.Type, err) } } } @@ -749,7 +923,7 @@ func TestCatalogTrackBoundariesAndAdjacency(t *testing.T) { t.Fatalf("LastInTrack(linux) = %#v, %v", last, found) } lastDocker, found := catalog.LastInTrack(TrackDocker) - if !found || lastDocker.ID != "docker-postmortem-triage" || lastDocker.Number != 34 { + if !found || lastDocker.ID != "docker-segmentation" || lastDocker.Number != 39 { t.Fatalf("LastInTrack(docker) = %#v, %v", lastDocker, found) } crossWorld, found := catalog.AdjacentInTrack("docker-shift-handoff", 1) diff --git a/internal/mission/data/35-network-census.json b/internal/mission/data/35-network-census.json new file mode 100644 index 0000000..9b8a1a6 --- /dev/null +++ b/internal/mission/data/35-network-census.json @@ -0,0 +1,90 @@ +{ + "id": "docker-network-census", + "number": 35, + "track": "docker", + "environment": "docker", + "title": "Network Census", + "campaign": "Network Plumbing", + "difficulty": "beginner", + "story": "A new teammate wants to know which network the api uses to reach its cache. The network diagram on the wiki is two reorgs out of date.", + "objective": "Find the network that api and cache share, and finish by displaying that network's inspection.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "docker network ls lists the lab's networks.", + "docker inspect api shows which networks a container is attached to under NetworkSettings.Networks.", + "docker network inspect cache-net lists every container attached to that network." + ], + "explanation": "Containers can only talk to each other over a network they share. A network's inspection lists every container attached to it, and a container's inspection lists its networks. Between them you can map who can reach whom without guessing.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "networks": [ + { + "name": "web-net" + }, + { + "name": "cache-net" + }, + { + "name": "db-net" + } + ], + "containers": [ + { + "name": "web", + "image": "fixture", + "state": "running", + "networks": [ + "web-net" + ] + }, + { + "name": "api", + "image": "fixture", + "state": "running", + "networks": [ + "web-net", + "cache-net" + ] + }, + { + "name": "cache", + "image": "fixture", + "state": "running", + "networks": [ + "cache-net" + ] + }, + { + "name": "db", + "image": "fixture", + "state": "running", + "networks": [ + "db-net" + ] + } + ] + }, + "validation": { + "all": [ + { + "type": "output_contains_all", + "values": [ + "\"Name\": \"cache-net\"", + "\"api\"", + "\"cache\"" + ] + } + ] + }, + "rewards": { + "xp": 80, + "hint_penalty": 10 + } +} diff --git a/internal/mission/data/36-cant-reach-the-db.json b/internal/mission/data/36-cant-reach-the-db.json new file mode 100644 index 0000000..095f1f1 --- /dev/null +++ b/internal/mission/data/36-cant-reach-the-db.json @@ -0,0 +1,95 @@ +{ + "id": "docker-cant-reach-the-db", + "number": 36, + "track": "docker", + "environment": "docker", + "title": "Can't Reach the DB", + "campaign": "Network Plumbing", + "difficulty": "beginner", + "story": "The api was redeployed onto the web network only, and every request now times out waiting for the database. Policy says the web frontend must never talk to the database directly.", + "objective": "Give api a network path to db, keep web and api connected, keep web away from db, and leave exactly three mission containers.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "Containers can talk only when they share a network. docker inspect shows each container's networks.", + "docker network connect NETWORK ALIAS attaches an existing container to another network without restarting or replacing it.", + "Try docker network connect db-net api. Connecting db to web-net instead would expose the database to web." + ], + "explanation": "A container can belong to several networks at once. Connecting api to the database network gives it a second path while it stays on the web network, and web gains no route to the database.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "networks": [ + { + "name": "web-net" + }, + { + "name": "db-net" + } + ], + "containers": [ + { + "name": "web", + "image": "fixture", + "state": "running", + "networks": [ + "web-net" + ] + }, + { + "name": "api", + "image": "fixture", + "state": "running", + "networks": [ + "web-net" + ] + }, + { + "name": "db", + "image": "fixture", + "state": "running", + "networks": [ + "db-net" + ] + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_containers_share_network", + "containers": [ + "api", + "db" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "web", + "api" + ] + }, + { + "type": "docker_containers_isolated", + "containers": [ + "web", + "db" + ] + }, + { + "type": "docker_container_count_equals", + "count": 3 + } + ] + }, + "rewards": { + "xp": 85, + "hint_penalty": 10 + } +} diff --git a/internal/mission/data/37-need-to-know.json b/internal/mission/data/37-need-to-know.json new file mode 100644 index 0000000..140f3a7 --- /dev/null +++ b/internal/mission/data/37-need-to-know.json @@ -0,0 +1,112 @@ +{ + "id": "docker-need-to-know", + "number": 37, + "track": "docker", + "environment": "docker", + "title": "Need to Know", + "campaign": "Network Plumbing", + "difficulty": "intermediate", + "story": "An audit found the nightly batch job attached to the payments network, which means it can reach the ledger database. Batch only needs the queue.", + "objective": "Cut batch off from ledger-db while batch keeps reaching queue and api keeps reaching both ledger-db and queue.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "docker network inspect payments-net shows every container that can reach ledger-db.", + "docker network disconnect removes one container from one network and leaves its other networks alone.", + "Try docker network disconnect payments-net batch." + ], + "explanation": "Least privilege applies to networks too: a container should join only the networks its job requires. Disconnecting batch from the payments network removes its route to the ledger and keeps its path to the queue.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "networks": [ + { + "name": "app-net" + }, + { + "name": "payments-net" + } + ], + "containers": [ + { + "name": "api", + "image": "fixture", + "state": "running", + "networks": [ + "app-net", + "payments-net" + ] + }, + { + "name": "ledger-db", + "image": "fixture", + "state": "running", + "networks": [ + "payments-net" + ] + }, + { + "name": "queue", + "image": "fixture", + "state": "running", + "networks": [ + "app-net" + ] + }, + { + "name": "batch", + "image": "fixture", + "state": "running", + "networks": [ + "app-net", + "payments-net" + ] + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_containers_isolated", + "containers": [ + "batch", + "ledger-db" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "batch", + "queue" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "api", + "ledger-db" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "api", + "queue" + ] + }, + { + "type": "docker_container_count_equals", + "count": 4 + } + ] + }, + "rewards": { + "xp": 95, + "hint_penalty": 15 + } +} diff --git a/internal/mission/data/38-private-channel.json b/internal/mission/data/38-private-channel.json new file mode 100644 index 0000000..7367bc1 --- /dev/null +++ b/internal/mission/data/38-private-channel.json @@ -0,0 +1,107 @@ +{ + "id": "docker-private-channel", + "number": 38, + "track": "docker", + "environment": "docker", + "title": "Private Channel", + "campaign": "Network Plumbing", + "difficulty": "intermediate", + "story": "The metrics exporter and its collector share app-net with the public gateway and the api. Security wants their traffic moved to a channel the api cannot reach.", + "objective": "Give exporter and collector a network they share, keep gateway and api connected, and make sure neither exporter nor collector shares a network with api.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "docker network create makes a new internal network for this lab.", + "Connect both services to the new network before disconnecting them from app-net, so they never lose each other.", + "For example: docker network create metrics-net, connect exporter and collector to it, then disconnect both from app-net." + ], + "explanation": "Separate networks are the simplest segmentation tool. Moving a pair of services onto a network of their own keeps their traffic private, and connecting before disconnecting keeps them talking throughout the move.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "networks": [ + { + "name": "app-net" + } + ], + "containers": [ + { + "name": "gateway", + "image": "fixture", + "state": "running", + "networks": [ + "app-net" + ] + }, + { + "name": "api", + "image": "fixture", + "state": "running", + "networks": [ + "app-net" + ] + }, + { + "name": "exporter", + "image": "fixture", + "state": "running", + "networks": [ + "app-net" + ] + }, + { + "name": "collector", + "image": "fixture", + "state": "running", + "networks": [ + "app-net" + ] + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_containers_share_network", + "containers": [ + "exporter", + "collector" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "gateway", + "api" + ] + }, + { + "type": "docker_containers_isolated", + "containers": [ + "exporter", + "api" + ] + }, + { + "type": "docker_containers_isolated", + "containers": [ + "collector", + "api" + ] + }, + { + "type": "docker_container_count_equals", + "count": 4 + } + ] + }, + "rewards": { + "xp": 100, + "hint_penalty": 15 + } +} diff --git a/internal/mission/data/39-segmentation.json b/internal/mission/data/39-segmentation.json new file mode 100644 index 0000000..fdd5266 --- /dev/null +++ b/internal/mission/data/39-segmentation.json @@ -0,0 +1,127 @@ +{ + "id": "docker-segmentation", + "number": 39, + "track": "docker", + "environment": "docker", + "title": "Segmentation", + "campaign": "Network Plumbing", + "difficulty": "advanced", + "story": "Everything runs on one flat network, so the public web tier can reach the database. A retired cron container is still holding the old legacy network open.", + "objective": "Keep web reaching api and api reaching db, make sure web can no longer reach db, remove the legacy network and the retired cron container attached to it, and keep web, api, and db running.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "Map it before you cut: docker network ls and docker network inspect show which containers share each network.", + "Create a second network for the database tier, and connect api and db to it before you change flat-net.", + "Then disconnect db from flat-net so web and db no longer share any network.", + "The lab refuses docker network rm while any container, even a stopped one, is still attached. Remove old-cron first; it has already stopped, so docker rm works.", + "docker network create db-net, docker network connect db-net api, docker network connect db-net db, docker network disconnect flat-net db, docker rm old-cron, docker network rm legacy-net." + ], + "explanation": "Segmentation gives each tier only the paths it needs: web to api, and api to db. Connect new paths before cutting old ones so services never lose each other, and retire unused networks along with the containers that kept them alive.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "networks": [ + { + "name": "flat-net" + }, + { + "name": "legacy-net" + } + ], + "containers": [ + { + "name": "web", + "image": "fixture", + "state": "running", + "networks": [ + "flat-net" + ] + }, + { + "name": "api", + "image": "fixture", + "state": "running", + "networks": [ + "flat-net" + ] + }, + { + "name": "db", + "image": "fixture", + "state": "running", + "networks": [ + "flat-net" + ] + }, + { + "name": "old-cron", + "image": "fixture", + "state": "stopped", + "log": "cron: final run complete; job retired", + "exit_code": 0, + "networks": [ + "legacy-net" + ] + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_containers_share_network", + "containers": [ + "web", + "api" + ] + }, + { + "type": "docker_containers_share_network", + "containers": [ + "api", + "db" + ] + }, + { + "type": "docker_containers_isolated", + "containers": [ + "web", + "db" + ] + }, + { + "type": "docker_network_absent", + "network": "legacy-net" + }, + { + "type": "docker_container_absent", + "container": "old-cron" + }, + { + "type": "docker_container_running", + "container": "web" + }, + { + "type": "docker_container_running", + "container": "api" + }, + { + "type": "docker_container_running", + "container": "db" + }, + { + "type": "docker_container_count_equals", + "count": 3 + } + ] + }, + "rewards": { + "xp": 140, + "hint_penalty": 20 + } +} diff --git a/internal/mission/mission.go b/internal/mission/mission.go index f3caaf9..4717efd 100644 --- a/internal/mission/mission.go +++ b/internal/mission/mission.go @@ -51,6 +51,9 @@ const ( ConditionDockerContainerStopped ConditionType = "docker_container_stopped" ConditionDockerContainerCountEqual ConditionType = "docker_container_count_equals" ConditionDockerContainerAbsent ConditionType = "docker_container_absent" + ConditionDockerNetworkShared ConditionType = "docker_containers_share_network" + ConditionDockerNetworkIsolated ConditionType = "docker_containers_isolated" + ConditionDockerNetworkAbsent ConditionType = "docker_network_absent" ) // Mission is a declarative OpsQuest exercise. Setup describes the isolated @@ -135,9 +138,16 @@ type ArchiveEntry struct { // code maps these aliases and names to isolated engine resources. type DockerSetup struct { Images []DockerImageSpec `json:"images"` + Networks []DockerNetworkSpec `json:"networks,omitempty"` Containers []DockerContainerSpec `json:"containers"` } +// DockerNetworkSpec is one logical, attempt-owned network. The Docker adapter +// always creates it as an internal network with a generated name. +type DockerNetworkSpec struct { + Name string `json:"name"` +} + type DockerImageSpec struct { Alias string `json:"alias"` Reference string `json:"reference"` @@ -154,6 +164,10 @@ type DockerContainerSpec struct { ExitCode *int `json:"exit_code,omitempty"` Health string `json:"health,omitempty"` Restart string `json:"restart,omitempty"` + // Networks lists declared networks the fixture joins at creation. A + // fixture without networks runs with networking disabled and cannot be + // connected to a network later. + Networks []string `json:"networks,omitempty"` } type Validation struct { @@ -161,14 +175,16 @@ type Validation struct { } type Condition struct { - Type ConditionType `json:"type"` - Path string `json:"path,omitempty"` - Value string `json:"value,omitempty"` - Values []string `json:"values,omitempty"` - PID int `json:"pid,omitempty"` - Container string `json:"container,omitempty"` - Count *int `json:"count,omitempty"` - present conditionFields + Type ConditionType `json:"type"` + Path string `json:"path,omitempty"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` + PID int `json:"pid,omitempty"` + Container string `json:"container,omitempty"` + Count *int `json:"count,omitempty"` + Network string `json:"network,omitempty"` + Containers []string `json:"containers,omitempty"` + present conditionFields } // UnmarshalJSON retains field presence so validation can reject an unsupported diff --git a/internal/mission/world_test.go b/internal/mission/world_test.go index f388237..334cbb0 100644 --- a/internal/mission/world_test.go +++ b/internal/mission/world_test.go @@ -28,12 +28,15 @@ func TestCatalogWorldsAreOrderedWithinEachTrack(t *testing.T) { } docker := catalog.Worlds(TrackDocker) - if len(docker) != 2 || docker[0].Number != 1 || docker[0].Name != "It Works on My Machine" || len(docker[0].Missions) != 6 || docker[0].Missions[0].ID != "docker-container-census" { + if len(docker) != 3 || docker[0].Number != 1 || docker[0].Name != "It Works on My Machine" || len(docker[0].Missions) != 6 || docker[0].Missions[0].ID != "docker-container-census" { t.Fatalf("Worlds(docker) first world = %d worlds, %q", len(docker), docker[0].Name) } if docker[1].Number != 2 || docker[1].Name != "Container Triage" || len(docker[1].Missions) != 5 || docker[1].Missions[0].ID != "docker-janitor-duty" { t.Fatalf("Worlds(docker) second world = number %d, name %q, stages %d", docker[1].Number, docker[1].Name, len(docker[1].Missions)) } + if docker[2].Number != 3 || docker[2].Name != "Network Plumbing" || len(docker[2].Missions) != 5 || docker[2].Missions[0].ID != "docker-network-census" { + t.Fatalf("Worlds(docker) third world = number %d, name %q, stages %d", docker[2].Number, docker[2].Name, len(docker[2].Missions)) + } if worlds := catalog.Worlds("missing"); worlds != nil { t.Fatalf("Worlds(missing) = %#v, want nil", worlds) } @@ -84,11 +87,15 @@ func TestCatalogWorldLookupAndPlacement(t *testing.T) { }, { missionID: "docker-container-census", - want: Placement{Track: TrackDocker, WorldNumber: 1, WorldTotal: 2, WorldName: "It Works on My Machine", StageNumber: 1, StageTotal: 6}, + want: Placement{Track: TrackDocker, WorldNumber: 1, WorldTotal: 3, WorldName: "It Works on My Machine", StageNumber: 1, StageTotal: 6}, }, { missionID: "docker-postmortem-triage", - want: Placement{Track: TrackDocker, WorldNumber: 2, WorldTotal: 2, WorldName: "Container Triage", StageNumber: 5, StageTotal: 5}, + want: Placement{Track: TrackDocker, WorldNumber: 2, WorldTotal: 3, WorldName: "Container Triage", StageNumber: 5, StageTotal: 5}, + }, + { + missionID: "docker-segmentation", + want: Placement{Track: TrackDocker, WorldNumber: 3, WorldTotal: 3, WorldName: "Network Plumbing", StageNumber: 5, StageTotal: 5}, }, } for _, test := range tests { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 1d181ee..585c429 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -127,7 +127,8 @@ assert_no_ansi "docker list" "${docker_list_output}" assert_contains "docker list" "${docker_list_output}" "DOCKER LABS" assert_contains "docker list" "${docker_list_output}" "Container Census" assert_contains "docker list" "${docker_list_output}" "Container Triage" -assert_contains "docker list" "${docker_list_output}" "0/11 missions complete" +assert_contains "docker list" "${docker_list_output}" "Network Plumbing" +assert_contains "docker list" "${docker_list_output}" "0/16 missions complete" assert_contains "docker list" "${docker_list_output}" "Continue: opsquest play --track docker" assert_contains "docker list" "${docker_list_output}" "Jump: opsquest play --track docker --world N" @@ -153,7 +154,7 @@ assert_contains "profile initialization" "${profile_output}" "Operator: Smoke Op doctor_output="$(run_opsquest doctor)" assert_no_ansi "doctor" "${doctor_output}" -assert_contains "doctor" "${doctor_output}" "embedded catalog: 34 missions (23 Linux, 11 Docker)" +assert_contains "doctor" "${doctor_output}" "embedded catalog: 39 missions (23 Linux, 16 Docker)" assert_contains "doctor" "${doctor_output}" "profile path: ${PROFILE_HOME}/profile.json" assert_contains "doctor" "${doctor_output}" "Linux labs: in-memory; no host shell or filesystem access" assert_contains "doctor" "${doctor_output}" "docker labs:"