From f31197c879fe0bf2af319e36f5f6119704ddd092 Mon Sep 17 00:00:00 2001 From: Aleksandergreg Date: Mon, 24 Aug 2026 08:40:34 +0200 Subject: [PATCH 1/4] feat: Add a web app that shows missions hints and tips and connects to the terminal through a token --- AGENTS.md | 2 + README.md | 6 + docs/README.md | 5 + docs/play/README.md | 5 +- docs/play/controls-and-commands.md | 4 + docs/play/missions-and-progress.md | 1 + docs/play/quick-start.md | 2 + docs/play/web-companion.md | 65 ++++ docs/technical/architecture.md | 36 +- docs/technical/contributing.md | 1 + .../diagrams/component-architecture.mmd | 5 +- .../diagrams/component-architecture.svg | 9 +- .../diagrams/mission-runtime-sequence.mmd | 18 + .../diagrams/mission-runtime-sequence.svg | 28 +- docs/technical/sandbox-and-safety.md | 32 +- internal/cli/app.go | 121 ++++-- internal/cli/app_test.go | 112 ++++++ internal/game/companion.go | 96 +++++ internal/game/companion_test.go | 201 ++++++++++ internal/game/session.go | 135 ++++++- internal/webapp/server.go | 353 ++++++++++++++++++ internal/webapp/server_test.go | 290 ++++++++++++++ internal/webapp/static/app.css | 292 +++++++++++++++ internal/webapp/static/app.js | 153 ++++++++ internal/webapp/static/index.html | 135 +++++++ mkdocs.yml | 1 + scripts/smoke-test.sh | 8 + 27 files changed, 2052 insertions(+), 64 deletions(-) create mode 100644 docs/play/web-companion.md create mode 100644 internal/game/companion.go create mode 100644 internal/game/companion_test.go create mode 100644 internal/webapp/server.go create mode 100644 internal/webapp/server_test.go create mode 100644 internal/webapp/static/app.css create mode 100644 internal/webapp/static/app.js create mode 100644 internal/webapp/static/index.html diff --git a/AGENTS.md b/AGENTS.md index 266491c..35c47b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ OpsQuest is a Go 1.26 CLI game with 23 in-memory Linux missions and 6 optional D - `internal/mission`: JSON schema, strict embedded catalog loading, track-local world/stage derivation, integrity checks, and `data/` mission content. - `internal/sandbox`: parser, dispatcher, virtual filesystem, virtual processes, archives, and the supported command subset. - `internal/dockerlab`: optional Docker capability detection, typed teaching commands, isolated resource lifecycle, and Docker observations. +- `internal/webapp`: loopback-only web companion, one-time browser pairing, sanitized attempt projection, and embedded static assets. - `internal/profile`: versioned profile model and atomic JSON persistence. - `scripts`: deterministic repository checks used by both Make and CI. - `docs/game`: learning principles and the high-level product explanation. @@ -35,6 +36,7 @@ Safe local edits, tests, builds, and documentation updates within the requested - Never execute player-entered commands through a host shell or against the host filesystem. - Sandbox changes must preserve isolation from host processes and files. - Docker labs must reject raw command passthrough and operate only on exact resources owned and labeled by the current attempt. +- The web companion must remain loopback-only and must not accept player command text, mutate mission state, or approve completion. - Mission validators must check observable outcomes, not require one exact command sequence. - Mission content remains declarative in embedded JSON; engine behavior belongs in Go. - Mission schema and persisted-profile changes are compatibility-sensitive and require explicit migration or compatibility reasoning. diff --git a/README.md b/README.md index 639c249..09fcf08 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ OpsQuest validates the result, not a prescribed command. Equivalent supported so - Quote-aware globs and variables, pipelines, redirection, and command history - A compact virtual `vi` and bounded virtual shell scripts - Progressive hints, free command guidance, XP, ranks, and six achievements +- An optional loopback-only web companion for mission guidance and live progress - World maps, direct mission practice, replay, and persistent profile progress - Outcome validation for output, paths, content, permissions, processes, environment, and Docker state @@ -63,12 +64,14 @@ $ opsquest guide $ opsquest map $ opsquest play --world 2 $ opsquest play --once 4 +$ opsquest play --web $ opsquest profile $ opsquest achievements $ opsquest doctor ``` The complete player guide covers [installation and first play](docs/play/quick-start.md), +[the local web companion](docs/play/web-companion.md), [missions and progress](docs/play/missions-and-progress.md), and [controls and commands](docs/play/controls-and-commands.md). @@ -93,6 +96,8 @@ Player-entered Linux commands—including virtual scripts—are parsed and execu 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. 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. + Read [Sandbox and safety](docs/technical/sandbox-and-safety.md) for trust boundaries, quotas, cleanup behavior, and threat controls. ## Documentation @@ -119,6 +124,7 @@ internal/mission/ Strict embedded catalog, worlds, and mission JSON internal/profile/ XP, ranks, achievements, and atomic persistence internal/sandbox/ Virtual filesystem, shell parser, and commands internal/dockerlab/ Optional, label-scoped Docker adapter +internal/webapp/ Loopback-only mission companion and embedded browser UI docs/ Zensical documentation source and diagrams project/ Repository-only decisions and delivery history ``` diff --git a/docs/README.md b/docs/README.md index 39a3ab6..5bf1fa6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ The game describes an observable objective, gives you a disposable environment, | I want to… | Start here | | --- | --- | | Install the game and begin a mission | [Quick start](play/quick-start.md) | +| Move mission guidance into a local browser | [Web mission companion](play/web-companion.md) | | Understand objectives, hints, routes, and rewards | [Missions, hints, and progress](play/missions-and-progress.md) | | Look up terminal controls and supported commands | [Controls and commands](play/controls-and-commands.md) | | See what every world and mission teaches | [Mission map](play/mission-map.md) | @@ -34,12 +35,16 @@ The game describes an observable objective, gives you a disposable environment, - 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 +- An optional local web companion with live objective progress - Interactive completion, editing, history, and mission navigation ## The safety promise Player-entered Linux commands are parsed and executed by OpsQuest's Go teaching shell. They never reach a host shell or host path. Docker gameplay is opt-in, accepts only typed teaching actions, and operates on exact disposable resources labeled for the current attempt. +The optional browser companion is a loopback-only read model. It receives +sanitized mission snapshots but cannot execute commands or declare success. + Read [Sandbox and safety](technical/sandbox-and-safety.md) for the complete trust model and limits. ## About these docs diff --git a/docs/play/README.md b/docs/play/README.md index ab4d82e..7dfba5e 100644 --- a/docs/play/README.md +++ b/docs/play/README.md @@ -11,8 +11,9 @@ OpsQuest teaches through short incidents inside disposable environments. You ins ## Start here 1. [Install and launch OpsQuest](quick-start.md). -2. Learn how [missions, hints, routes, and rewards work](missions-and-progress.md). -3. Keep the [controls and command reference](controls-and-commands.md) nearby while playing. +2. Optionally move guidance into the [local web companion](web-companion.md). +3. Learn how [missions, hints, routes, and rewards work](missions-and-progress.md). +4. Keep the [controls and command reference](controls-and-commands.md) nearby while playing. The default route follows the four Linux worlds in the [mission map](mission-map.md). The separate [Docker Foundations](docker-foundations.md) track is optional and diff --git a/docs/play/controls-and-commands.md b/docs/play/controls-and-commands.md index 74600d5..418cc37 100644 --- a/docs/play/controls-and-commands.md +++ b/docs/play/controls-and-commands.md @@ -8,6 +8,10 @@ status: current OpsQuest provides line editing and a focused Linux teaching shell inside each mission. +With `opsquest play --web`, command entry, completion, history, and raw command +output remain in the terminal. Mission narrative, objectives, revealed hints, +outcome checks, and the field guide appear in the [web mission companion](web-companion.md). + ## Interactive editing | Keys | Action | diff --git a/docs/play/missions-and-progress.md b/docs/play/missions-and-progress.md index b00fdcc..b507e6d 100644 --- a/docs/play/missions-and-progress.md +++ b/docs/play/missions-and-progress.md @@ -43,6 +43,7 @@ recorded while a mission is incomplete, so quitting cannot erase the cost. - `opsquest play --world N` stays within one Linux world. - A global mission number or stable ID begins there and follows that track. - `--once` returns after one completed mission. +- `--web` moves mission guidance and live outcome progress into a local browser companion while commands remain in the terminal. - Completed missions remain replayable and never award duplicate XP. Inside a mission, `play N` means Stage N of the current world. A stable ID from diff --git a/docs/play/quick-start.md b/docs/play/quick-start.md index b00c5e1..0287b24 100644 --- a/docs/play/quick-start.md +++ b/docs/play/quick-start.md @@ -42,6 +42,7 @@ $ opsquest map --ids # include stable mission IDs $ opsquest play --world 2 # stay within Linux World 2 $ opsquest play 4 # begin at global Mission 4 $ opsquest play --once 4 # return after one mission +$ opsquest play --web # show guidance in a local browser $ opsquest show 16 # preview without starting $ opsquest profile # view XP, rank, and progress $ opsquest commands # view practiced commands @@ -54,6 +55,7 @@ $ opsquest doctor # check catalog, profile, and Docker readiness ## Next steps - [Missions, hints, and progress](missions-and-progress.md) +- [Web mission companion](web-companion.md) - [Controls and commands](controls-and-commands.md) - [Mission map](mission-map.md) - [Optional Docker setup](docker-foundations.md) diff --git a/docs/play/web-companion.md b/docs/play/web-companion.md new file mode 100644 index 0000000..d11ab34 --- /dev/null +++ b/docs/play/web-companion.md @@ -0,0 +1,65 @@ +--- +description: Run the local OpsQuest browser companion while commands remain in the teaching terminal. +audience: players +status: current +--- + +# Web mission companion + +The optional web companion separates guidance from command entry. OpsQuest +continues to execute and validate every command in the CLI-owned teaching +environment, while a local browser page shows the incident, objective, +suggested tools, progressive hints, live outcome checks, rewards, and the +completion explanation. + +Start any Linux or Docker route with `--web`: + +```console +$ opsquest play --web +$ opsquest play --web --once 5 +$ opsquest play --web --track docker +``` + +OpsQuest prints a one-time URL such as +`http://127.0.0.1:49152/pair?token=...`. Open it in a browser before entering +commands. The URL exchanges its token for a browser-session cookie and cannot +be paired a second time. + +## Split-screen play + +Keep the terminal and browser visible together: + +- Enter lab commands, mission navigation, and controls in the terminal. +- Read the incident, objective, suggested tools, and outcome checklist in the browser. +- Type `hint` in the terminal to reveal the next hint in the browser. The usual XP penalty and persisted hint progress still apply. +- Type `status` to force a fresh outcome observation. Successful lab commands also refresh progress automatically. +- Type `restart` to rebuild the disposable environment and reset its outcome checks. + +In companion mode the terminal intentionally omits the mission narrative, +hint text, detailed progress checklist, and completion explanation. Raw command +output remains there because it is both player feedback and, for some +missions, validation input. + +## Lifetime and reconnection + +The companion exists only while that `opsquest play --web` process is active. +Reloading or reconnecting from the paired browser restores the latest complete +snapshot without replaying commands. Continuous play reuses the same page as +the route advances to another mission. Quitting or interrupting the CLI closes +the local server; the already-rendered page may remain visible but no longer +receives updates. + +## Safety boundary + +The server binds only to an ephemeral IPv4 loopback port. It uses one-time +pairing, an HTTP-only same-site cookie, exact Host and Origin checks, restrictive +browser security headers, and no permissive CORS policy. + +The browser receives a presentation projection rather than the mission's raw +setup or validation structures. It has no API for submitting command text, +selecting Docker resources, mutating the profile, or declaring completion. +`game.Session` still evaluates observable outcomes, closes the active +environment, and saves the profile before publishing a completed state. + +The companion adds no hosted service, account, external network requirement, +or third-party production dependency. diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 844f619..f3b70a3 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -14,13 +14,17 @@ OpsQuest separates product flow, declarative mission content, isolated execution Editable source: [`system-landscape.excalidraw`](diagrams/system-landscape.excalidraw) -There are three materially different state domains: +The landscape diagram focuses on the command-execution and persistence path; +the optional read-only companion projection is detailed separately below. + +There are four materially different state domains: | Domain | Owner | Lifetime | Host interaction | | --- | --- | --- | --- | | Mission definition | `internal/mission` | Embedded in the binary | Reads embedded JSON only | | Mission attempt | `internal/sandbox` or `internal/dockerlab` | One attempt or restart | In-memory for Linux; exact labeled Docker resources for Docker labs | | Player progress | `internal/profile` | Across processes | Atomic `profile.json` replacement in the platform config directory | +| Companion projection | `internal/webapp` | One `play --web` process | Loopback HTTP only; no durable writes or command execution | Linux mission state is never persisted. Completing, quitting, switching, or restarting a Linux mission discards its virtual filesystem, environment, processes, archives, and history. Hints, command practice, completions, XP, and achievements belong to the profile instead. @@ -38,6 +42,7 @@ Editable source: [`component-architecture.mmd`](diagrams/component-architecture. | `internal/mission` | Strict schema, embedded catalog, tracks, worlds, and defensive copies | Content stays declarative; rejects invalid catalogs before play starts | | `internal/sandbox` | Teaching-shell lexer, parser, dispatcher, virtual filesystem/processes/archives | Never invokes a host shell or accesses host paths | | `internal/dockerlab` | Optional Docker-compatible engine availability, typed teaching actions, fixtures, observations, and cleanup | Only this adapter may launch the Docker CLI, using constructed arguments and tracked resource IDs | +| `internal/webapp` | Optional embedded mission companion, one-time pairing, current snapshot, and server-sent events | Serves a read-only projection on IPv4 loopback; accepts no command or completion input | | `internal/profile` | Versioned progress model and atomic JSON storage | The only normal durable state written by the application | | `internal/ui` | Terminal capability detection and semantic ANSI roles | Styling stays out of execution results and validators | | `internal/buildinfo` | Release-managed executable version | Updated by release automation | @@ -54,7 +59,7 @@ OrbStack does not introduce a second execution backend. On macOS it is selected 2. Load and validate every embedded mission with `mission.LoadCatalog`. 3. Resolve the profile store with `profile.DefaultStore`. 4. Construct the CLI with standard streams, catalog, store, and the combined environment factory. -5. Dispatch `os.Args[1:]` through `cli.App.Run`. +5. Dispatch `os.Args[1:]` through `cli.App.Run`; `play --web` additionally starts an ephemeral loopback companion for that command. 6. Render any returned error once at the process boundary and exit non-zero. Catalog loading is fail-fast. JSON decoding disallows unknown fields, mission numbers must be globally contiguous, IDs and numbers must be unique, setup and validation fields must match the selected environment, and campaigns must remain contiguous within each track. @@ -76,6 +81,7 @@ The runtime flow is: 7. If any outcome is missing, the same attempt continues. `status` describes satisfied and missing outcomes without prescribing a command sequence. 8. Once every outcome passes, the session closes the environment **before** awarding XP. This prevents a Docker attempt with unresolved cleanup from being recorded as complete. 9. First completion records hint-adjusted XP and achievements. Replays retain the original completion and award no duplicate XP. +10. When a companion reporter is configured, the session publishes a sanitized complete snapshot after start, hints, observations, restart, pause, and persisted completion. Publishing never participates in the validation decision. `restart` closes the active environment and creates a new one from the same declarative mission. Mission switching returns a validated mission ID to the CLI, which starts a separate fresh session. @@ -104,6 +110,31 @@ The contract carries several design decisions: The simulated adapter wraps one `sandbox.Sandbox`. The Docker adapter maps logical mission aliases to generated names and exact container IDs, so neither the session nor player sees engine-owned resource identifiers. +## Web companion contract + +`play --web` starts `internal/webapp` on `127.0.0.1` with an ephemeral port. +The printed one-time pairing URL establishes an HTTP-only, same-site browser +session, then the browser loads static HTML, CSS, and JavaScript embedded in the +same executable. The current state is available as a JSON snapshot, and +subsequent updates use Server-Sent Events. A reconnect receives the latest +complete snapshot rather than querying the environment or replaying commands. + +`game.AttemptSnapshot` is a dedicated public projection. It contains narrative, +suggested tool names, revealed hints, described outcome status, placement, +reward information, and completion feedback. It does not contain mission setup +objects, condition structs, player command text, terminal output, Docker IDs, +or a profile mutation capability. + +The direction of authority is one-way: + +1. Terminal input reaches `game.Session` and the selected `Environment`. +2. `game.Session` evaluates outcomes and persists progress exactly as in terminal-only play. +3. Only after those decisions does it publish a presentation snapshot. +4. `internal/webapp` retains and broadcasts the snapshot without calling back into the session. + +Completion is not published until environment cleanup and profile persistence +both succeed. This retains the existing Docker cleanup-before-XP invariant. + ## Mission and world model Each JSON file decodes into a `mission.Mission` containing narrative fields, suggested tools, hints, setup, validation conditions, and rewards. The catalog sorts missions by stable global number and builds lookup indexes by ID and number. @@ -135,6 +166,7 @@ Changing one of these requires explicit compatibility reasoning even when the Go | Add a validation outcome | `internal/mission`, `internal/game`, environment adapters | Strict schema checks and canonical mission coverage | | Change attempt controls or progression | `internal/game` | `internal/cli` routes and profile persistence | | Add a top-level command | `internal/cli` | Smoke test and README examples | +| Change the browser companion or projection | `internal/webapp`, `internal/game` | Pairing/HTTP isolation, lifecycle ordering, CLI fallback, and race tests | | Expand Docker teaching behavior | `internal/dockerlab` | Parser rejection tests, ownership cleanup, real integration gate | | Change colors or terminal presentation | `internal/ui` | CLI/session rendering tests and non-terminal output | | Change durable progress | `internal/profile` | Migration/compatibility tests and atomic-write behavior | diff --git a/docs/technical/contributing.md b/docs/technical/contributing.md index d09cdff..2b05158 100644 --- a/docs/technical/contributing.md +++ b/docs/technical/contributing.md @@ -17,6 +17,7 @@ Start with the repository [`AGENTS.md`](https://github.com/Aleksandergreg/go-cli | Mission schema, catalog, worlds, or JSON content | `internal/mission` | | Teaching-shell parsing, files, processes, archives, or commands | `internal/sandbox` | | Optional Docker actions, fixtures, observation, or cleanup | `internal/dockerlab` | +| Local browser companion, pairing, HTTP projection, or assets | `internal/webapp` | | Durable progress and migrations | `internal/profile` | | Terminal color policy | `internal/ui` | diff --git a/docs/technical/diagrams/component-architecture.mmd b/docs/technical/diagrams/component-architecture.mmd index 9673a8b..e044a56 100644 --- a/docs/technical/diagrams/component-architecture.mmd +++ b/docs/technical/diagrams/component-architecture.mmd @@ -7,6 +7,7 @@ flowchart TB PROFILE["internal/profile
progress and atomic storage"] SANDBOX["internal/sandbox
in-memory teaching shell"] DOCKER["internal/dockerlab
typed Docker adapter"] + WEB["internal/webapp
loopback companion"] UI["internal/ui
terminal-aware styling"] BUILD["internal/buildinfo
release version"] @@ -22,6 +23,7 @@ flowchart TB CLI --> MISSION CLI --> PROFILE CLI --> UI + CLI --> WEB GAME --> MISSION GAME --> PROFILE @@ -31,6 +33,7 @@ flowchart TB SANDBOX --> MISSION DOCKER --> GAME DOCKER --> MISSION + WEB --> GAME classDef entry fill:#172554,color:#ffffff,stroke:#172554,stroke-width:2px classDef orchestration fill:#dbeafe,color:#172554,stroke:#2563eb,stroke-width:2px @@ -42,4 +45,4 @@ flowchart TB class CLI,GAME orchestration class MISSION,PROFILE domain class SANDBOX,DOCKER adapter - class UI,BUILD support + class UI,BUILD,WEB support diff --git a/docs/technical/diagrams/component-architecture.svg b/docs/technical/diagrams/component-architecture.svg index af57383..916ab0d 100644 --- a/docs/technical/diagrams/component-architecture.svg +++ b/docs/technical/diagrams/component-architecture.svg @@ -1,7 +1,7 @@ OpsQuest component architecture - Package dependency diagram showing the composition root, CLI and game orchestration, mission and profile domains, sandbox and Docker adapters, and UI and build information support packages. + Package dependency diagram showing the composition root, CLI and game orchestration, mission and profile domains, sandbox and Docker adapters, and UI, web companion, and build information support packages. @@ -38,6 +38,8 @@ + + @@ -80,6 +82,11 @@ internal/dockerlab typed optional Docker adapter + + + internal/webapp + loopback mission companion + internal/ui diff --git a/docs/technical/diagrams/mission-runtime-sequence.mmd b/docs/technical/diagrams/mission-runtime-sequence.mmd index 8802fd7..e9d06b5 100644 --- a/docs/technical/diagrams/mission-runtime-sequence.mmd +++ b/docs/technical/diagrams/mission-runtime-sequence.mmd @@ -8,14 +8,24 @@ sequenceDiagram participant Factory as game.Factory participant Env as Environment participant Validator as outcome evaluator + participant Web as webapp.Server + actor Browser Player->>CLI: opsquest play [route] CLI->>Store: Load() Store-->>CLI: normalized profile CLI->>CLI: select mission and route + opt play --web + CLI->>Web: Start(loopback, one-time pairing) + Browser->>Web: pair and subscribe + end CLI->>Session: Run(mission, profile, factory) Session->>Factory: Create(context, mission) Factory-->>Session: fresh managed environment + opt play --web + Session-->>Web: sanitized attempt snapshot + Web-->>Browser: current state + SSE updates + end loop Until quit, switch, or complete Player->>Session: command or mission control @@ -31,12 +41,20 @@ sequenceDiagram Env-->>Validator: satisfied? end Validator-->>Session: outcome results + opt web companion active + Session-->>Web: sanitized outcome snapshot + Web-->>Browser: SSE snapshot + end alt Outcomes remain Session-->>Player: progress and missing-count guidance else Every outcome passes Session->>Env: Close() Env-->>Session: cleanup complete Session->>Store: Save(completion, XP, achievements) + opt web companion active + Session-->>Web: persisted completion snapshot + Web-->>Browser: SSE completion + end Session-->>CLI: completed SessionResult CLI-->>Player: completion and next route end diff --git a/docs/technical/diagrams/mission-runtime-sequence.svg b/docs/technical/diagrams/mission-runtime-sequence.svg index c366970..6adcf3a 100644 --- a/docs/technical/diagrams/mission-runtime-sequence.svg +++ b/docs/technical/diagrams/mission-runtime-sequence.svg @@ -1,7 +1,7 @@ - + OpsQuest mission runtime sequence - Sequence from player route selection through profile load, environment creation, repeated command execution and outcome evaluation, cleanup, and completion persistence. + Sequence from player route selection through profile load, optional loopback browser pairing, environment creation, repeated command execution and outcome projection, cleanup, and completion persistence. @@ -11,7 +11,7 @@ .life{stroke:#94a3b8;stroke-width:1.5;stroke-dasharray:6 6}.call{stroke:#334155;stroke-width:1.8;marker-end:url(#arrow)}.return{stroke:#2563eb;stroke-width:1.8;stroke-dasharray:6 4;marker-end:url(#arrowBlue)} - + One mission attempt The environment closes successfully before first-completion XP is persisted. @@ -23,20 +23,26 @@ game.Factory Environment Outcome evaluator + webapp.Server + Browser - - - - + + + + + play [route] Load() normalized profile select mission + route + play --web: start ephemeral loopback companion + one-time pair + subscribe Run(mission, profile, factory) Create(context, mission) fresh managed environment + sanitized attempt snapshot LOOP · until quit, switch, or complete @@ -50,12 +56,16 @@ Observe(state condition) satisfied? outcome results · continue if incomplete + sanitized outcome snapshot + SSE update EVERY OUTCOME PASSES Close() · cleanup first cleanup complete Save(completion + XP) - completed SessionResult - completion + next route + persisted completion snapshot + SSE completion + completed SessionResult + completion + next route diff --git a/docs/technical/sandbox-and-safety.md b/docs/technical/sandbox-and-safety.md index 0c7c114..d89eca9 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -6,10 +6,11 @@ status: current # OpsQuest sandbox and safety -OpsQuest has two execution models with different trust boundaries: +OpsQuest has two execution models and one optional presentation boundary: - Linux missions use a teaching shell implemented entirely in Go over in-memory state. - Optional Docker missions parse a deliberately small command language and translate typed actions into fixed Docker CLI arguments for attempt-owned resources on the active Docker-compatible engine. +- `play --web` serves sanitized mission snapshots from an ephemeral IPv4 loopback port; the browser cannot submit commands or approve completion. Neither model passes a raw player line to a host shell. @@ -19,10 +20,34 @@ Neither model passes a raw player line to a host shell. Editable source: [`trust-boundaries.excalidraw`](diagrams/trust-boundaries.excalidraw) -The player controls command text and navigation choices. Trusted OpsQuest code decides whether that text is a session control, a simulated-shell command, or one of the supported Docker teaching forms. Only two components normally interact with durable or external host facilities: +The diagram focuses on command and storage authority. The separate loopback +presentation boundary is described in [Web companion boundary](#web-companion-boundary). + +The player controls command text and navigation choices. Trusted OpsQuest code decides whether that text is a session control, a simulated-shell command, or one of the supported Docker teaching forms. Only three components normally interact with durable or external host facilities: - `profile.Store` reads and atomically replaces one configured profile file. Player commands cannot address this path. - `dockerlab.execRunner` invokes the discovered Docker executable with arguments constructed inside `internal/dockerlab`. It never invokes `sh -c` and never forwards the original line. +- `webapp.Server` listens on `127.0.0.1` for the lifetime of one `play --web` command. It has no environment, runner, profile store, or command-input dependency. + +## Web companion boundary + +The companion uses an ephemeral port and a cryptographically random one-time +pairing token. A successful exchange creates an HTTP-only, same-site cookie and +redirects away from the token-bearing URL. Exact Host and Origin checks reduce +loopback confusion and DNS-rebinding exposure; restrictive CSP, frame, referrer, +content-type, permissions, and cross-origin headers constrain the embedded page. + +After pairing, the page may read the latest sanitized snapshot and subscribe to +Server-Sent Events. It cannot submit terminal lines, address virtual files, +choose Docker aliases or IDs, alter profile data, reveal a hint without the CLI +control, or mark an objective complete. Static assets use no external scripts, +fonts, trackers, or network service. + +Snapshots are complete current projections with monotonically increasing event +IDs. A slow subscriber skips obsolete queued snapshots and converges on the +latest state. They exclude raw mission setup and condition objects, command +text, terminal output, and internal resource identifiers. The page constructs +all mission text with DOM text nodes rather than HTML interpolation. ## Linux command execution @@ -133,6 +158,9 @@ The Docker-compatible engine remains a powerful external dependency; OpsQuest re | Cleanup removes another container | Exact ID plus managed/schema/session/mission/alias label verification | `internal/dockerlab/environment.go` and tests | | Partial Docker setup leaks resources silently | Factory may return a partial environment; managed cleanup and retryable `Close` | `internal/dockerlab/factory.go`, environment contract 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` | +| Browser observes private attempt internals | Dedicated sanitized snapshot type; no setup, raw condition, transcript, or resource-ID fields | `internal/game/companion.go` and tests | ## Safety review checklist diff --git a/internal/cli/app.go b/internal/cli/app.go index 76fe0c0..d859de5 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -16,18 +16,20 @@ import ( "github.com/aleksandergregersen/opsquest/internal/mission" "github.com/aleksandergregersen/opsquest/internal/profile" "github.com/aleksandergregersen/opsquest/internal/ui" + "github.com/aleksandergregersen/opsquest/internal/webapp" ) type App struct { - in io.Reader - out io.Writer - errOut io.Writer - catalog mission.Catalog - store profile.Store - ctx context.Context - factory game.Factory - style ui.Style - errorStyle ui.Style + in io.Reader + out io.Writer + errOut io.Writer + catalog mission.Catalog + store profile.Store + ctx context.Context + factory game.Factory + startCompanion CompanionStarter + style ui.Style + errorStyle ui.Style } type playRouteKind uint8 @@ -43,17 +45,31 @@ type playRoute struct { worldNumber int } +// Companion is the lifecycle and presentation boundary used by web-assisted +// play. It receives sanitized game snapshots and never accepts player command +// text or completion decisions. +type Companion interface { + game.AttemptReporter + URL() string + Close(context.Context) error +} + +// CompanionStarter creates one companion for the lifetime of a play command. +type CompanionStarter func(context.Context) (Companion, error) + // Config contains the process-level dependencies required by the CLI. The // executable supplies persistent storage and the combined environment factory; -// focused tests may omit Context and Factory to use safe in-process defaults. +// focused tests may omit Context, Factory, and StartCompanion to use safe local +// defaults. type Config struct { - Context context.Context - In io.Reader - Out io.Writer - ErrOut io.Writer - Catalog mission.Catalog - Store profile.Store - Factory game.Factory + Context context.Context + In io.Reader + Out io.Writer + ErrOut io.Writer + Catalog mission.Catalog + Store profile.Store + Factory game.Factory + StartCompanion CompanionStarter } func New(config Config) *App { @@ -72,16 +88,22 @@ func New(config Config) *App { if config.Factory == nil { config.Factory = game.SandboxFactory{} } + if config.StartCompanion == nil { + config.StartCompanion = func(ctx context.Context) (Companion, error) { + return webapp.Start(ctx) + } + } return &App{ - in: config.In, - out: config.Out, - errOut: config.ErrOut, - catalog: config.Catalog, - store: config.Store, - ctx: config.Context, - factory: config.Factory, - style: ui.Auto(config.Out), - errorStyle: ui.Auto(config.ErrOut), + in: config.In, + out: config.Out, + errOut: config.ErrOut, + catalog: config.Catalog, + store: config.Store, + ctx: config.Context, + factory: config.Factory, + startCompanion: config.StartCompanion, + style: ui.Auto(config.Out), + errorStyle: ui.Auto(config.ErrOut), } } @@ -156,16 +178,17 @@ func (a *App) Run(args []string) error { } } -func (a *App) runPlay(args []string) error { +func (a *App) runPlay(args []string) (returnErr error) { flags := a.newFlagSet("play", func() { a.printPlayUsage(a.errOut) }) track := flags.String("track", mission.TrackLinux, "play the linux or docker track") worldNumber := flags.Int("world", 0, "start the next incomplete stage in a world") once := flags.Bool("once", false, "return after one completed mission") + web := flags.Bool("web", false, "show mission guidance in a local browser companion") if help, err := parseFlags(flags, args); help || err != nil { return err } if flags.NArg() > 1 { - return fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [MISSION]") + return fmt.Errorf("usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") } trackProvided, worldProvided := flagProvided(flags, "track"), flagProvided(flags, "world") selectedTrack := strings.ToLower(strings.TrimSpace(*track)) @@ -212,8 +235,27 @@ func (a *App) runPlay(args []string) error { return nil } } + var activeCompanion Companion + if *web { + activeCompanion, err = a.startCompanion(a.ctx) + if err != nil { + return fmt.Errorf("start web companion: %w", err) + } + defer func() { + closeContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := activeCompanion.Close(closeContext); err != nil { + returnErr = errors.Join(returnErr, fmt.Errorf("close web companion: %w", err)) + } + }() + fmt.Fprintln(a.out, a.style.Header("WEB MISSION COMPANION")) + fmt.Fprintf(a.out, "Open this one-time local URL in your browser:\n%s\n", a.style.Accent(activeCompanion.URL())) + fmt.Fprintln(a.out, a.style.Muted("Keep this terminal for commands. The browser can display progress but cannot execute anything.")) + } if item.EffectiveTrack() == mission.TrackLinux && isPristineProfile(player) { - a.printQuickStart() + if !*web { + a.printQuickStart() + } player.Onboarded = true if err := a.store.Save(player); err != nil { return err @@ -225,14 +267,16 @@ func (a *App) runPlay(args []string) error { currentTrack := item.EffectiveTrack() wasComplete := player.IsComplete(item.ID) session := game.Session{ - Mission: item, - Player: &player, - Saver: a.store, - In: a.in, - Out: a.out, - ErrOut: a.errOut, - Reader: reader, - Catalog: a.catalog, + Mission: item, + Player: &player, + Saver: a.store, + Reporter: activeCompanion, + Companion: activeCompanion != nil, + In: a.in, + Out: a.out, + ErrOut: a.errOut, + Reader: reader, + Catalog: a.catalog, ListMissions: func(args []string) error { if !hasFlag(args, "--track") { args = append([]string{"--track", currentTrack}, args...) @@ -793,10 +837,11 @@ func (a *App) runHelp(args []string) error { } func (a *App) printPlayUsage(out io.Writer) { - fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [MISSION]") + fmt.Fprintln(out, "Usage: opsquest play [--track linux|docker] [--world NUMBER] [--once] [--web] [MISSION]") fmt.Fprintln(out, "Without a selector, resume the first incomplete mission and continue the recommended path.") fmt.Fprintln(out, "A mission number or ID follows catalog order from that exact stage; --world stays inside one world.") fmt.Fprintln(out, "Use --once to return after one completed mission.") + fmt.Fprintln(out, "Use --web to show guidance and live objective progress in a local browser companion.") } func (a *App) printListUsage(out io.Writer) { diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 924dbcd..6d5af61 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -49,6 +49,23 @@ func testApp(t *testing.T, input string, store profile.Store) (*App, *bytes.Buff }), out, errOut } +type testWebCompanion struct { + events []game.AttemptEvent + closeCount int + closeErr error +} + +func (c *testWebCompanion) ReportAttempt(event game.AttemptEvent) { + c.events = append(c.events, game.CloneAttemptEvent(event)) +} + +func (c *testWebCompanion) URL() string { return "http://127.0.0.1:43210/pair?token=test" } + +func (c *testWebCompanion) Close(context.Context) error { + c.closeCount++ + return c.closeErr +} + func seedCompletedMissions(t *testing.T, store profile.Store, missionIDs ...string) { t.Helper() player := profile.New("alex") @@ -148,6 +165,101 @@ func TestPlayAwardsHintAdjustedXPAndPersistsProgress(t *testing.T) { } } +func TestWebPlayUsesCompanionForMissionPresentationAndLiveProgress(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, out, errOut := testApp(t, "hint\npwd\n", store) + companion := &testWebCompanion{} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + if err := app.Run([]string{"play", "--web", "--once", "1"}); err != nil { + t.Fatalf("web play error = %v; stderr = %s", err, errOut.String()) + } + if companion.closeCount != 1 { + t.Fatalf("companion close count = %d, want 1", companion.closeCount) + } + if len(companion.events) != 4 || companion.events[0].Type != game.AttemptStarted || companion.events[1].Type != game.AttemptHint || companion.events[3].Type != game.AttemptCompleted { + t.Fatalf("companion event types = %#v", eventTypesForCLI(companion.events)) + } + output := out.String() + for _, visible := range []string{ + "WEB MISSION COMPANION", + companion.URL(), + "Keep this terminal for commands", + "Mission 01 ready in the web companion", + "Hint 1/2 revealed in the web companion", + "/home/operator", + "✓ Mission complete!", + } { + if !strings.Contains(output, visible) { + t.Errorf("web play output missing %q:\n%s", visible, output) + } + } + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("1") + for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "FIRST SHIFT BRIEFING", "Commands you may need to solve this level:"} { + if strings.Contains(output, hidden) { + t.Errorf("web play terminal leaked companion content %q:\n%s", hidden, output) + } + } + player, err := store.Load() + if err != nil { + t.Fatal(err) + } + if player.XP != 30 || !player.IsComplete(item.ID) || !player.Onboarded { + t.Fatalf("web play profile = %#v", player) + } +} + +func TestWebPlayFailsBeforeOnboardingWhenCompanionCannotStart(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "", store) + app.startCompanion = func(context.Context) (Companion, error) { + return nil, fmt.Errorf("loopback unavailable") + } + + err := app.Run([]string{"play", "--web", "1"}) + if err == nil || !strings.Contains(err.Error(), "start web companion: loopback unavailable") { + t.Fatalf("web companion start error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if player.Onboarded || len(player.Completed) != 0 || player.XP != 0 { + t.Fatalf("failed companion start changed profile = %#v", player) + } +} + +func TestWebPlayReportsCompanionCloseFailureAfterPersistingCompletion(t *testing.T) { + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + app, _, _ := testApp(t, "pwd\n", store) + companion := &testWebCompanion{closeErr: fmt.Errorf("shutdown stalled")} + app.startCompanion = func(context.Context) (Companion, error) { return companion, nil } + + err := app.Run([]string{"play", "--web", "--once", "1"}) + if err == nil || !strings.Contains(err.Error(), "close web companion: shutdown stalled") { + t.Fatalf("web companion close error = %v", err) + } + player, loadErr := store.Load() + if loadErr != nil { + t.Fatal(loadErr) + } + if !player.IsComplete("linux-orientation") || player.XP != 40 { + t.Fatalf("completion was not persisted before companion close error: %#v", player) + } +} + +func eventTypesForCLI(events []game.AttemptEvent) []game.AttemptEventType { + types := make([]game.AttemptEventType, len(events)) + for index, event := range events { + types[index] = event.Type + } + return types +} + func TestReplayDoesNotAwardXPAgain(t *testing.T) { store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") first, _, _ := testApp(t, "pwd\n", store) diff --git a/internal/game/companion.go b/internal/game/companion.go new file mode 100644 index 0000000..d571cad --- /dev/null +++ b/internal/game/companion.go @@ -0,0 +1,96 @@ +package game + +import "slices" + +// AttemptEventType identifies one presentation-safe mission lifecycle update. +// Companion consumers may render these events, but they never participate in +// command execution or outcome validation. +type AttemptEventType string + +const ( + AttemptStarted AttemptEventType = "attempt_started" + AttemptProgress AttemptEventType = "attempt_progress" + AttemptHint AttemptEventType = "hint_revealed" + AttemptRestarted AttemptEventType = "attempt_restarted" + AttemptPaused AttemptEventType = "attempt_paused" + AttemptCompleted AttemptEventType = "attempt_completed" +) + +const ( + AttemptStateActive = "active" + AttemptStatePaused = "paused" + AttemptStateCompleted = "completed" +) + +// AttemptReporter receives sanitized snapshots for a presentation companion. +// ReportAttempt must return promptly and must not call back into Session. A +// reporter is not an authority boundary: Session remains the only component +// that executes commands, validates outcomes, and records completion. +type AttemptReporter interface { + ReportAttempt(AttemptEvent) +} + +// AttemptEvent carries a complete current snapshot so a browser can reconnect +// without replaying terminal input or querying the active environment. +type AttemptEvent struct { + Type AttemptEventType `json:"type"` + Snapshot AttemptSnapshot `json:"snapshot"` +} + +// AttemptSnapshot is the public projection of one mission attempt. It +// deliberately excludes setup data, raw validation structures, command text, +// terminal output, and environment internals. +type AttemptSnapshot struct { + MissionID string `json:"mission_id"` + Number int `json:"number"` + Title string `json:"title"` + Track string `json:"track"` + WorldNumber int `json:"world_number,omitempty"` + WorldTotal int `json:"world_total,omitempty"` + WorldName string `json:"world_name,omitempty"` + StageNumber int `json:"stage_number,omitempty"` + StageTotal int `json:"stage_total,omitempty"` + Difficulty string `json:"difficulty"` + Story string `json:"story"` + Objective string `json:"objective"` + SuggestedCommands []string `json:"suggested_commands"` + RevealedHints []string `json:"revealed_hints"` + HintCount int `json:"hint_count"` + HintsUsed int `json:"hints_used"` + Outcomes []AttemptOutcome `json:"outcomes"` + SatisfiedOutcomes int `json:"satisfied_outcomes"` + RewardAvailable int `json:"reward_available"` + BaseReward int `json:"base_reward"` + Replaying bool `json:"replaying"` + State string `json:"state"` + Explanation string `json:"explanation,omitempty"` + XPAwarded int `json:"xp_awarded,omitempty"` + FirstCompletion bool `json:"first_completion,omitempty"` + PracticedCommands []string `json:"practiced_commands,omitempty"` + DiscoveredCommands []string `json:"discovered_commands,omitempty"` + UnlockedAchievements []string `json:"unlocked_achievements,omitempty"` +} + +// AttemptOutcome exposes the same human-readable observable check available to +// the terminal status control without exposing its mission-schema structure. +type AttemptOutcome struct { + Description string `json:"description"` + Satisfied bool `json:"satisfied"` +} + +func cloneAttemptSnapshot(snapshot AttemptSnapshot) AttemptSnapshot { + snapshot.SuggestedCommands = slices.Clone(snapshot.SuggestedCommands) + snapshot.RevealedHints = slices.Clone(snapshot.RevealedHints) + snapshot.Outcomes = slices.Clone(snapshot.Outcomes) + snapshot.PracticedCommands = slices.Clone(snapshot.PracticedCommands) + snapshot.DiscoveredCommands = slices.Clone(snapshot.DiscoveredCommands) + snapshot.UnlockedAchievements = slices.Clone(snapshot.UnlockedAchievements) + return snapshot +} + +// CloneAttemptEvent returns a defensive copy suitable for retaining beyond a +// ReportAttempt call. +func CloneAttemptEvent(event AttemptEvent) AttemptEvent { + event.Snapshot = cloneAttemptSnapshot(event.Snapshot) + return event +} diff --git a/internal/game/companion_test.go b/internal/game/companion_test.go new file mode 100644 index 0000000..0bd0ee7 --- /dev/null +++ b/internal/game/companion_test.go @@ -0,0 +1,201 @@ +package game + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/aleksandergregersen/opsquest/internal/mission" + "github.com/aleksandergregersen/opsquest/internal/profile" +) + +type recordingAttemptReporter struct { + events []AttemptEvent + report func(AttemptEvent) +} + +func (r *recordingAttemptReporter) ReportAttempt(event AttemptEvent) { + event = CloneAttemptEvent(event) + r.events = append(r.events, event) + if r.report != nil { + r.report(event) + } +} + +func (r *recordingAttemptReporter) event(eventType AttemptEventType) (AttemptEvent, bool) { + for _, event := range r.events { + if event.Type == eventType { + return event, true + } + } + return AttemptEvent{}, false +} + +func TestCompanionSessionPublishesGuidanceProgressHintsAndCompletion(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + player := profile.New("tester") + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") + reporter := &recordingAttemptReporter{} + out := &bytes.Buffer{} + session := Session{ + Mission: item, + Player: &player, + Saver: store, + Reporter: reporter, + Companion: true, + Out: out, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"hint", "pwd"}}, + Catalog: catalog, + } + + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Completed || result.XPAwarded != 30 { + t.Fatalf("result = %#v", result) + } + wantTypes := []AttemptEventType{AttemptStarted, AttemptHint, AttemptProgress, AttemptCompleted} + if len(reporter.events) != len(wantTypes) { + t.Fatalf("event types = %#v, want %#v", eventTypes(reporter.events), wantTypes) + } + for index, want := range wantTypes { + if reporter.events[index].Type != want { + t.Fatalf("event %d type = %q, want %q", index, reporter.events[index].Type, want) + } + } + + started := reporter.events[0].Snapshot + if started.State != AttemptStateActive || started.Title != item.Title || started.Objective != item.Objective || started.SatisfiedOutcomes != 0 { + t.Fatalf("started snapshot = %#v", started) + } + if started.WorldNumber != 1 || started.StageNumber != 1 || started.HintCount != 2 || len(started.RevealedHints) != 0 { + t.Fatalf("started placement and hints = %#v", started) + } + hinted := reporter.events[1].Snapshot + if hinted.HintsUsed != 1 || len(hinted.RevealedHints) != 1 || hinted.RevealedHints[0] != item.Hints[0] || hinted.RewardAvailable != 30 { + t.Fatalf("hint snapshot = %#v", hinted) + } + completed := reporter.events[3].Snapshot + if completed.State != AttemptStateCompleted || completed.SatisfiedOutcomes != 1 || completed.XPAwarded != 30 || !completed.FirstCompletion { + t.Fatalf("completed snapshot = %#v", completed) + } + if completed.Explanation != item.Explanation || len(completed.DiscoveredCommands) != 1 || completed.DiscoveredCommands[0] != "pwd" { + t.Fatalf("completed explanation and commands = %#v", completed) + } + + terminal := out.String() + for _, hidden := range []string{item.Story, item.Objective, item.Hints[0], item.Explanation, "Commands you may need to solve this level:"} { + if strings.Contains(terminal, hidden) { + t.Errorf("companion terminal output leaked presentation text %q:\n%s", hidden, terminal) + } + } + for _, visible := range []string{"Mission 01 ready in the web companion", "Hint 1/2 revealed in the web companion", "/home/operator", "✓ Mission complete!", "+30 XP"} { + if !strings.Contains(terminal, visible) { + t.Errorf("companion terminal output missing %q:\n%s", visible, terminal) + } + } + + encoded, err := json.Marshal(completed) + if err != nil { + t.Fatal(err) + } + for _, private := range []string{`"setup"`, `"validation"`, "WELCOME.txt"} { + if strings.Contains(string(encoded), private) { + t.Errorf("public companion snapshot contains private mission data %q: %s", private, encoded) + } + } +} + +func TestCompanionPublishesCompletionOnlyAfterCleanupAndPersistence(t *testing.T) { + catalog, item := seamCatalogMission(t, "1") + player := profile.New("tester") + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester") + environment := &seamEnvironment{ + prompt: "/generic", + execute: func(_ context.Context, _ string) (Execution, error) { + return Execution{Output: "/home/operator\n"}, nil + }, + } + reporter := &recordingAttemptReporter{} + reporter.report = func(event AttemptEvent) { + if event.Type != AttemptCompleted { + return + } + if environment.closeCount != 1 { + t.Errorf("completion published after %d closes, want 1", environment.closeCount) + } + persisted, err := store.Load() + if err != nil { + t.Errorf("load profile during completion event: %v", err) + return + } + if !persisted.IsComplete(item.ID) { + t.Error("completion published before profile persistence") + } + } + session := Session{ + Mission: item, + Player: &player, + Saver: store, + Reporter: reporter, + Companion: true, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"solve"}}, + Catalog: catalog, + Factory: FactoryFunc(func(context.Context, mission.Mission) (Environment, error) { + return environment, nil + }), + } + if _, err := session.Run(); err != nil { + t.Fatal(err) + } + if _, found := reporter.event(AttemptCompleted); !found { + t.Fatal("completion event was not published") + } +} + +func TestCompanionReportsRestartAndPause(t *testing.T) { + catalog, item := seamCatalogMission(t, "4") + player := profile.New("tester") + reporter := &recordingAttemptReporter{} + session := Session{ + Mission: item, + Player: &player, + Saver: profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "tester"), + Reporter: reporter, + Companion: true, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + Reader: &seamReader{lines: []string{"touch report.txt", "restart", "quit"}}, + Catalog: catalog, + } + result, err := session.Run() + if err != nil { + t.Fatal(err) + } + if !result.Quit { + t.Fatalf("result = %#v", result) + } + restarted, found := reporter.event(AttemptRestarted) + if !found || restarted.Snapshot.SatisfiedOutcomes != 0 || restarted.Snapshot.State != AttemptStateActive { + t.Fatalf("restart event = %#v, found = %v", restarted, found) + } + paused, found := reporter.event(AttemptPaused) + if !found || paused.Snapshot.State != AttemptStatePaused { + t.Fatalf("pause event = %#v, found = %v", paused, found) + } +} + +func eventTypes(events []AttemptEvent) []AttemptEventType { + types := make([]AttemptEventType, len(events)) + for index, event := range events { + types[index] = event.Type + } + return types +} diff --git a/internal/game/session.go b/internal/game/session.go index e1b526f..a990ab5 100644 --- a/internal/game/session.go +++ b/internal/game/session.go @@ -20,6 +20,8 @@ type Session struct { Mission mission.Mission Player *profile.Profile Saver ProfileSaver + Reporter AttemptReporter + Companion bool In io.Reader Out io.Writer ErrOut io.Writer @@ -85,7 +87,19 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { replaying := s.Player.IsComplete(s.Mission.ID) hintsUsed := s.Player.MissionHints(s.Mission.ID) - printMission(s.Out, s.Mission, hintsUsed, replaying, s.Catalog, s.Style) + currentOutcomes := []outcomeResult(nil) + if s.Reporter != nil { + currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") + if err != nil { + return SessionResult{}, fmt.Errorf("prepare companion mission status: %w", err) + } + s.reportAttempt(AttemptStarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, nil, nil, nil) + } + if s.Companion { + fmt.Fprintf(s.Out, "\nMission %02d ready in the web companion. Enter lab commands below.\n", s.Mission.Number) + } else { + printMission(s.Out, s.Mission, hintsUsed, replaying, s.Catalog, s.Style) + } reader := s.Reader if reader == nil { reader = NewCommandLineReader(s.In, s.Out) @@ -102,6 +116,7 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { line, readErr := reader.ReadLine(s.Style.Prompt(environment.PromptLabel()), environment.CompletionSource()) if errors.Is(readErr, io.EOF) { fmt.Fprintln(s.Out) + s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil } if readErr != nil { @@ -230,6 +245,7 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { switch line { case "quit", "exit", ":q": fmt.Fprintln(s.Out, s.Style.Accent("Mission paused. Your profile progress is safe.")) + s.reportAttempt(AttemptPaused, AttemptStatePaused, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) return SessionResult{Quit: true, HintsUsed: hintsUsed}, nil case "hint": if hintsUsed >= len(s.Mission.Hints) { @@ -251,18 +267,34 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if cost == 0 { costLabel = "no XP cost" } - prefix := fmt.Sprintf("Hint %d/%d (%s):", hintsUsed, len(s.Mission.Hints), costLabel) - fmt.Fprintf(s.Out, "%s %s\n", s.Style.Warning(prefix), s.Mission.Hints[hintsUsed-1]) + s.reportAttempt(AttemptHint, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + if s.Companion { + message := fmt.Sprintf("Hint %d/%d revealed in the web companion (%s).", hintsUsed, len(s.Mission.Hints), costLabel) + fmt.Fprintln(s.Out, s.Style.Warning(message)) + } else { + prefix := fmt.Sprintf("Hint %d/%d (%s):", hintsUsed, len(s.Mission.Hints), costLabel) + fmt.Fprintf(s.Out, "%s %s\n", s.Style.Warning(prefix), s.Mission.Hints[hintsUsed-1]) + } continue case "objective": - fmt.Fprintf(s.Out, "%s\n%s\n\n%s\n", s.Style.Section("OBJECTIVE"), s.Mission.Objective, s.Style.CommandGuide(s.Mission.SuggestedCommands)) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("The objective and suggested commands are shown in the web companion.")) + } else { + fmt.Fprintf(s.Out, "%s\n%s\n\n%s\n", s.Style.Section("OBJECTIVE"), s.Mission.Objective, s.Style.CommandGuide(s.Mission.SuggestedCommands)) + } continue case "status": outcomes, err := evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, lastOutput) if err != nil { return SessionResult{}, fmt.Errorf("check mission status: %w", err) } - printOutcomeStatus(s.Out, outcomes, s.Style) + currentOutcomes = outcomes + s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("Mission status refreshed in the web companion.")) + } else { + printOutcomeStatus(s.Out, outcomes, s.Style) + } continue case "restart": if err := environment.close(); err != nil { @@ -273,10 +305,21 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { return SessionResult{}, fmt.Errorf("restart mission: %w", err) } lastOutput = "" + if s.Reporter != nil { + currentOutcomes, err = evaluateOutcomes(ctx, s.Mission.Validation, environment.Environment, "") + if err != nil { + return SessionResult{}, fmt.Errorf("refresh companion mission status: %w", err) + } + s.reportAttempt(AttemptRestarted, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, nil) + } fmt.Fprintln(s.Out, s.Style.Accent("Mission environment restarted. Hints and command mastery are retained.")) continue case "?", "guide": - printMissionControls(s.Out, s.Style) + if s.Companion { + fmt.Fprintln(s.Out, s.Style.Accent("Mission controls and guidance are shown in the web companion.")) + } else { + printMissionControls(s.Out, s.Style) + } continue } @@ -336,10 +379,14 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if err != nil { return SessionResult{}, fmt.Errorf("validate mission: %w", err) } + currentOutcomes = outcomes + s.reportAttempt(AttemptProgress, AttemptStateActive, currentOutcomes, hintsUsed, replaying, 0, false, practiced, discovered, unlocked) if !allOutcomesSatisfied(outcomes) { printAchievements(s.Out, unlocked, s.Style) - message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) - fmt.Fprintln(s.Out, s.Style.Accent(message)) + if !s.Companion { + message := fmt.Sprintf("Progress — %d/%d outcome checks satisfied. Type status to see what remains.", satisfiedOutcomeCount(outcomes), len(outcomes)) + fmt.Fprintln(s.Out, s.Style.Accent(message)) + } continue } if err := environment.close(); err != nil { @@ -357,11 +404,70 @@ func (s Session) Run() (returnResult SessionResult, returnErr error) { if err := s.Saver.Save(*s.Player); err != nil { return SessionResult{}, err } - printCompletion(s.Out, s.Mission, xp, firstCompletion, practiced, discovered, unlocked, s.Style) + s.reportAttempt(AttemptCompleted, AttemptStateCompleted, currentOutcomes, hintsUsed, replaying, xp, firstCompletion, practiced, discovered, unlocked) + if s.Companion { + printCompanionCompletion(s.Out, xp, firstCompletion, unlocked, s.Style) + } else { + printCompletion(s.Out, s.Mission, xp, firstCompletion, practiced, discovered, unlocked, s.Style) + } return SessionResult{Completed: true, XPAwarded: xp, HintsUsed: hintsUsed, WorldRoute: worldRoute}, nil } } +func (s Session) reportAttempt(eventType AttemptEventType, state string, outcomes []outcomeResult, hintsUsed int, replaying bool, xp int, firstCompletion bool, practiced, discovered []string, unlocked []profile.Achievement) { + if s.Reporter == nil { + return + } + placement, _ := s.Catalog.Placement(s.Mission.ID) + revealedCount := min(hintsUsed, len(s.Mission.Hints)) + revealedHints := slices.Clone(s.Mission.Hints[:revealedCount]) + publicOutcomes := make([]AttemptOutcome, len(outcomes)) + for index, outcome := range outcomes { + publicOutcomes[index] = AttemptOutcome{Description: outcome.Description, Satisfied: outcome.Satisfied} + } + achievements := make([]string, len(unlocked)) + for index, achievement := range unlocked { + achievements[index] = achievement.Title + } + explanation := "" + if state == AttemptStateCompleted { + explanation = s.Mission.Explanation + } + s.Reporter.ReportAttempt(AttemptEvent{ + Type: eventType, + Snapshot: AttemptSnapshot{ + MissionID: s.Mission.ID, + Number: s.Mission.Number, + Title: s.Mission.Title, + Track: s.Mission.EffectiveTrack(), + WorldNumber: placement.WorldNumber, + WorldTotal: placement.WorldTotal, + WorldName: placement.WorldName, + StageNumber: placement.StageNumber, + StageTotal: placement.StageTotal, + Difficulty: s.Mission.Difficulty, + Story: s.Mission.Story, + Objective: s.Mission.Objective, + SuggestedCommands: slices.Clone(s.Mission.SuggestedCommands), + RevealedHints: revealedHints, + HintCount: len(s.Mission.Hints), + HintsUsed: hintsUsed, + Outcomes: publicOutcomes, + SatisfiedOutcomes: satisfiedOutcomeCount(outcomes), + RewardAvailable: AdjustedReward(s.Mission, hintsUsed), + BaseReward: s.Mission.Rewards.XP, + Replaying: replaying, + State: state, + Explanation: explanation, + XPAwarded: xp, + FirstCompletion: firstCompletion, + PracticedCommands: slices.Clone(practiced), + DiscoveredCommands: slices.Clone(discovered), + UnlockedAchievements: achievements, + }, + }) +} + func printMission(out io.Writer, item mission.Mission, hintsUsed int, completed bool, catalog mission.Catalog, style ui.Style) { heading := fmt.Sprintf("MISSION %02d: %s", item.Number, item.Title) fmt.Fprintf(out, "\n%s\n", style.Header(heading)) @@ -568,6 +674,17 @@ func printCompletion(out io.Writer, item mission.Mission, xp int, first bool, pr fmt.Fprintf(out, "\n%s\n", item.Explanation) } +func printCompanionCompletion(out io.Writer, xp int, first bool, unlocked []profile.Achievement, style ui.Style) { + fmt.Fprintf(out, "\n%s\n", style.Success("✓ Mission complete!")) + if first { + fmt.Fprintln(out, style.Reward(fmt.Sprintf("+%d XP", xp))) + } else { + fmt.Fprintln(out, style.Accent("Replay complete — XP was already claimed.")) + } + printAchievements(out, unlocked, style) + fmt.Fprintln(out, style.Accent("The explanation and completed objective are shown in the web companion.")) +} + func printAchievements(out io.Writer, unlocked []profile.Achievement, style ui.Style) { for _, achievement := range unlocked { fmt.Fprintf(out, "%s %s — %s\n", style.Achievement("★ Achievement unlocked:"), achievement.Title, achievement.Description) diff --git a/internal/webapp/server.go b/internal/webapp/server.go new file mode 100644 index 0000000..896b661 --- /dev/null +++ b/internal/webapp/server.go @@ -0,0 +1,353 @@ +// Package webapp serves the local, read-only OpsQuest mission companion. +package webapp + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "embed" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +const companionCookie = "opsquest_companion" + +//go:embed static/* +var staticFiles embed.FS + +type publication struct { + id uint64 + data []byte +} + +// Server is a loopback-only HTTP projection of the active game session. +// Command execution and validation never enter this package. +type Server struct { + baseURL string + host string + pairToken string + sessionToken string + httpServer *http.Server + done chan struct{} + closeOnce sync.Once + + pairMu sync.Mutex + paired bool + + mu sync.Mutex + sequence uint64 + current publication + hasCurrent bool + subscribers map[chan publication]struct{} +} + +// Start creates a companion bound to an ephemeral IPv4 loopback port. The +// returned URL performs a one-time pairing exchange and must be opened while +// the play command remains active. +func Start(ctx context.Context) (*Server, error) { + if ctx == nil { + ctx = context.Background() + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen on loopback: %w", err) + } + pairToken, err := randomToken() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("create pairing token: %w", err) + } + sessionToken, err := randomToken() + if err != nil { + _ = listener.Close() + return nil, fmt.Errorf("create companion session: %w", err) + } + + host := listener.Addr().String() + server := &Server{ + baseURL: "http://" + host, + host: host, + pairToken: pairToken, + sessionToken: sessionToken, + done: make(chan struct{}), + subscribers: make(map[chan publication]struct{}), + } + mux := http.NewServeMux() + mux.HandleFunc("/pair", server.handlePair) + mux.HandleFunc("/api/state", server.requireSession(server.handleState)) + mux.HandleFunc("/api/events", server.requireSession(server.handleEvents)) + mux.HandleFunc("/app.css", server.requireSession(server.handleCSS)) + mux.HandleFunc("/app.js", server.requireSession(server.handleJavaScript)) + mux.HandleFunc("/", server.requireSession(server.handleIndex)) + server.httpServer = &http.Server{ + Handler: server.validateRequest(mux), + ReadHeaderTimeout: 5 * time.Second, + IdleTimeout: 75 * time.Second, + MaxHeaderBytes: 16 * 1024, + } + + go func() { + _ = server.httpServer.Serve(listener) + }() + go func() { + select { + case <-ctx.Done(): + shutdownContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Close(shutdownContext) + case <-server.done: + } + }() + return server, nil +} + +func randomToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +// URL returns the one-time local pairing URL. +func (s *Server) URL() string { + return s.baseURL + "/pair?token=" + url.QueryEscape(s.pairToken) +} + +// Close stops accepting companion requests and closes active event streams. +func (s *Server) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + var closeErr error + s.closeOnce.Do(func() { + close(s.done) + closeErr = s.httpServer.Shutdown(ctx) + if errors.Is(closeErr, http.ErrServerClosed) { + closeErr = nil + } + }) + return closeErr +} + +// ReportAttempt publishes the latest sanitized session snapshot without +// blocking command execution. Slow browser subscribers skip stale snapshots +// and converge on the newest one. +func (s *Server) ReportAttempt(event game.AttemptEvent) { + event = game.CloneAttemptEvent(event) + data, err := json.Marshal(event) + if err != nil { + return + } + + s.mu.Lock() + s.sequence++ + item := publication{id: s.sequence, data: data} + s.current = item + s.hasCurrent = true + for subscriber := range s.subscribers { + select { + case subscriber <- item: + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- item: + default: + } + } + } + s.mu.Unlock() +} + +func (s *Server) validateRequest(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + s.writeSecurityHeaders(writer) + if request.Host != s.host { + http.Error(writer, "invalid companion host", http.StatusMisdirectedRequest) + return + } + if origin := request.Header.Get("Origin"); origin != "" && origin != s.baseURL { + http.Error(writer, "invalid companion origin", http.StatusForbidden) + return + } + next.ServeHTTP(writer, request) + }) +} + +func (s *Server) writeSecurityHeaders(writer http.ResponseWriter) { + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'") + writer.Header().Set("Cross-Origin-Resource-Policy", "same-origin") + writer.Header().Set("Permissions-Policy", "camera=(), geolocation=(), microphone=()") + writer.Header().Set("Referrer-Policy", "no-referrer") + writer.Header().Set("X-Content-Type-Options", "nosniff") + writer.Header().Set("X-Frame-Options", "DENY") +} + +func (s *Server) handlePair(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + token := request.URL.Query().Get("token") + s.pairMu.Lock() + valid := !s.paired && constantTimeEqual(token, s.pairToken) + if valid { + s.paired = true + } + s.pairMu.Unlock() + if !valid { + http.Error(writer, "pairing link is invalid or has already been used", http.StatusUnauthorized) + return + } + http.SetCookie(writer, &http.Cookie{ + Name: companionCookie, + Value: s.sessionToken, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + http.Redirect(writer, request, "/", http.StatusSeeOther) +} + +func constantTimeEqual(actual, expected string) bool { + return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} + +func (s *Server) requireSession(next http.HandlerFunc) http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + cookie, err := request.Cookie(companionCookie) + if err != nil || !constantTimeEqual(cookie.Value, s.sessionToken) { + http.Error(writer, "open the pairing URL printed by OpsQuest", http.StatusUnauthorized) + return + } + next(writer, request) + } +} + +func (s *Server) handleIndex(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet || request.URL.Path != "/" { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.NotFound(writer, request) + return + } + s.serveStatic(writer, "static/index.html", "text/html; charset=utf-8") +} + +func (s *Server) handleCSS(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.css", "text/css; charset=utf-8") +} + +func (s *Server) handleJavaScript(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.serveStatic(writer, "static/app.js", "text/javascript; charset=utf-8") +} + +func (s *Server) serveStatic(writer http.ResponseWriter, name, contentType string) { + content, err := staticFiles.ReadFile(name) + if err != nil { + http.Error(writer, "companion asset unavailable", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", contentType) + _, _ = writer.Write(content) +} + +func (s *Server) handleState(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.Lock() + item, available := s.current, s.hasCurrent + s.mu.Unlock() + if !available { + writer.WriteHeader(http.StatusNoContent) + return + } + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("X-OpsQuest-Event-ID", fmt.Sprint(item.id)) + _, _ = writer.Write(item.data) +} + +func (s *Server) handleEvents(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writer.Header().Set("Allow", http.MethodGet) + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return + } + flusher, supported := writer.(http.Flusher) + if !supported { + http.Error(writer, "streaming unsupported", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Type", "text/event-stream") + writer.Header().Set("Connection", "keep-alive") + writer.Header().Set("X-Accel-Buffering", "no") + + subscriber := make(chan publication, 1) + s.mu.Lock() + s.subscribers[subscriber] = struct{}{} + current, available := s.current, s.hasCurrent + s.mu.Unlock() + defer func() { + s.mu.Lock() + delete(s.subscribers, subscriber) + s.mu.Unlock() + }() + + if available { + writeSSE(writer, current) + } else { + fmt.Fprint(writer, "event: waiting\ndata: {}\n\n") + } + flusher.Flush() + + keepAlive := time.NewTicker(15 * time.Second) + defer keepAlive.Stop() + for { + select { + case item := <-subscriber: + writeSSE(writer, item) + flusher.Flush() + case <-keepAlive.C: + fmt.Fprint(writer, ": keep-alive\n\n") + flusher.Flush() + case <-request.Context().Done(): + return + case <-s.done: + return + } + } +} + +func writeSSE(writer http.ResponseWriter, item publication) { + fmt.Fprintf(writer, "id: %d\nevent: snapshot\ndata: %s\n\n", item.id, item.data) +} diff --git a/internal/webapp/server_test.go b/internal/webapp/server_test.go new file mode 100644 index 0000000..a21159f --- /dev/null +++ b/internal/webapp/server_test.go @@ -0,0 +1,290 @@ +package webapp + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +func startTestServer(t *testing.T) *Server { + t.Helper() + server, err := Start(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + closeContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Close(closeContext); err != nil { + t.Errorf("close companion: %v", err) + } + }) + return server +} + +func pairedClient(t *testing.T, server *Server) (*http.Client, string) { + t.Helper() + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + client := &http.Client{Jar: jar} + response, err := client.Get(server.URL()) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK || response.Request.URL.Path != "/" { + t.Fatalf("pairing response = %d at %s", response.StatusCode, response.Request.URL) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "OpsQuest Mission Companion") { + t.Fatalf("paired index missing companion title: %s", body) + } + parsed, err := url.Parse(server.URL()) + if err != nil { + t.Fatal(err) + } + return client, parsed.Scheme + "://" + parsed.Host +} + +func TestServerRequiresOneTimePairingAndSetsSecurityHeaders(t *testing.T) { + server := startTestServer(t) + parsed, err := url.Parse(server.URL()) + if err != nil { + t.Fatal(err) + } + baseURL := parsed.Scheme + "://" + parsed.Host + + response, err := http.Get(baseURL + "/") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("unpaired status = %d, want %d", response.StatusCode, http.StatusUnauthorized) + } + response, err = http.Get(baseURL + "/pair?token=wrong") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("invalid pairing status = %d, want %d", response.StatusCode, http.StatusUnauthorized) + } + + client, _ := pairedClient(t, server) + response, err = client.Get(baseURL + "/app.css") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/css") { + t.Fatalf("authorized CSS response = %d %q", response.StatusCode, response.Header.Get("Content-Type")) + } + for _, header := range []string{"Content-Security-Policy", "Referrer-Policy", "X-Content-Type-Options", "X-Frame-Options"} { + if response.Header.Get(header) == "" { + t.Errorf("authorized response missing %s", header) + } + } + + secondResponse, err := http.Get(server.URL()) + if err != nil { + t.Fatal(err) + } + secondResponse.Body.Close() + if secondResponse.StatusCode != http.StatusUnauthorized { + t.Fatalf("reused pairing URL status = %d, want %d", secondResponse.StatusCode, http.StatusUnauthorized) + } +} + +func TestServerPublishesCurrentStateAndLiveEvents(t *testing.T) { + server := startTestServer(t) + client, baseURL := pairedClient(t, server) + + response, err := client.Get(baseURL + "/api/state") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusNoContent { + t.Fatalf("empty state status = %d, want %d", response.StatusCode, http.StatusNoContent) + } + + started := game.AttemptEvent{ + Type: game.AttemptStarted, + Snapshot: game.AttemptSnapshot{ + MissionID: "linux-orientation", + Number: 1, + Title: "Where Am I?", + Objective: "Print the current path.", + SuggestedCommands: []string{"pwd"}, + Outcomes: []game.AttemptOutcome{{Description: "Output matches", Satisfied: false}}, + State: game.AttemptStateActive, + }, + } + server.ReportAttempt(started) + started.Snapshot.SuggestedCommands[0] = "changed-after-publish" + started.Snapshot.Outcomes[0].Description = "changed-after-publish" + + response, err = client.Get(baseURL + "/api/state") + if err != nil { + t.Fatal(err) + } + var current game.AttemptEvent + if err := json.NewDecoder(response.Body).Decode(¤t); err != nil { + response.Body.Close() + t.Fatal(err) + } + response.Body.Close() + if current.Snapshot.SuggestedCommands[0] != "pwd" || current.Snapshot.Outcomes[0].Description != "Output matches" { + t.Fatalf("server retained caller-owned slices: %#v", current) + } + if response.Header.Get("X-OpsQuest-Event-ID") != "1" { + t.Fatalf("state event ID = %q, want 1", response.Header.Get("X-OpsQuest-Event-ID")) + } + + streamContext, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + request, err := http.NewRequestWithContext(streamContext, http.MethodGet, baseURL+"/api/events", nil) + if err != nil { + t.Fatal(err) + } + stream, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + defer stream.Body.Close() + reader := bufio.NewReader(stream.Body) + first := readSSEEvent(t, reader) + if !strings.Contains(first, `"type":"attempt_started"`) || !strings.Contains(first, "id: 1") { + t.Fatalf("initial SSE event = %q", first) + } + + completed := current + completed.Type = game.AttemptCompleted + completed.Snapshot.State = game.AttemptStateCompleted + completed.Snapshot.Outcomes[0].Satisfied = true + completed.Snapshot.SatisfiedOutcomes = 1 + server.ReportAttempt(completed) + second := readSSEEvent(t, reader) + if !strings.Contains(second, `"type":"attempt_completed"`) || !strings.Contains(second, "id: 2") { + t.Fatalf("live SSE event = %q", second) + } +} + +func TestServerRejectsConfusedHostsOriginsAndMethods(t *testing.T) { + server := startTestServer(t) + client, baseURL := pairedClient(t, server) + + request, err := http.NewRequest(http.MethodGet, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + request.Host = "attacker.example" + response, err := client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusMisdirectedRequest { + t.Fatalf("confused Host status = %d, want %d", response.StatusCode, http.StatusMisdirectedRequest) + } + + request, err = http.NewRequest(http.MethodGet, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Origin", "https://attacker.example") + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("cross-origin status = %d, want %d", response.StatusCode, http.StatusForbidden) + } + + request, err = http.NewRequest(http.MethodPost, baseURL+"/api/state", nil) + if err != nil { + t.Fatal(err) + } + response, err = client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusMethodNotAllowed || response.Header.Get("Allow") != http.MethodGet { + t.Fatalf("POST state response = %d Allow %q", response.StatusCode, response.Header.Get("Allow")) + } +} + +func TestServerClosesWhenApplicationContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + server, err := Start(ctx) + if err != nil { + t.Fatal(err) + } + cancel() + select { + case <-server.done: + case <-time.After(3 * time.Second): + t.Fatal("companion did not close after application context cancellation") + } +} + +func TestEmbeddedAssetsStaySelfContainedAndUseTextOnlyRendering(t *testing.T) { + html, err := staticFiles.ReadFile("static/index.html") + if err != nil { + t.Fatal(err) + } + javascript, err := staticFiles.ReadFile("static/app.js") + if err != nil { + t.Fatal(err) + } + stylesheet, err := staticFiles.ReadFile("static/app.css") + if err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"http://", "https://", "style="} { + if strings.Contains(string(html), forbidden) || strings.Contains(string(stylesheet), forbidden) { + t.Errorf("embedded browser assets contain forbidden external or inline value %q", forbidden) + } + } + for _, forbidden := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "eval(", ".style."} { + if strings.Contains(string(javascript), forbidden) { + t.Errorf("companion JavaScript uses forbidden rendering sink %q", forbidden) + } + } + if !strings.Contains(string(javascript), "textContent") { + t.Error("companion JavaScript does not use text-only DOM rendering") + } +} + +func readSSEEvent(t *testing.T, reader *bufio.Reader) string { + t.Helper() + var lines []string + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read SSE event: %v", err) + } + line = strings.TrimSuffix(line, "\n") + if line == "" { + return strings.Join(lines, "\n") + } + lines = append(lines, line) + } +} diff --git a/internal/webapp/static/app.css b/internal/webapp/static/app.css new file mode 100644 index 0000000..e3ddd38 --- /dev/null +++ b/internal/webapp/static/app.css @@ -0,0 +1,292 @@ +:root { + color-scheme: dark; + --bg: #07110f; + --surface: rgba(15, 31, 27, 0.88); + --surface-strong: #122620; + --line: rgba(184, 214, 197, 0.14); + --text: #eef8f2; + --muted: #9db4a8; + --green: #77f2a1; + --green-strong: #2bd66f; + --amber: #ffc86b; + --blue: #8bd8ff; + --danger: #ff8f8f; + --shadow: 0 24px 80px rgba(0, 0, 0, 0.35); +} + +* { box-sizing: border-box; } + +html { min-height: 100%; } + +body { + min-height: 100vh; + margin: 0; + overflow-x: hidden; + color: var(--text); + background: + linear-gradient(rgba(119, 242, 161, 0.025) 1px, transparent 1px), + linear-gradient(90deg, rgba(119, 242, 161, 0.025) 1px, transparent 1px), + var(--bg); + background-size: 38px 38px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +body::before { + position: fixed; + inset: 0; + z-index: -2; + content: ""; + background: radial-gradient(circle at 50% -10%, rgba(43, 214, 111, 0.13), transparent 43%); +} + +.ambient { + position: fixed; + z-index: -1; + width: 22rem; + height: 22rem; + border-radius: 999px; + filter: blur(90px); + opacity: 0.12; + pointer-events: none; +} + +.ambient-one { top: 18%; left: -12rem; background: var(--green); } +.ambient-two { right: -12rem; bottom: 8%; background: var(--blue); } + +.topbar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 74px; + padding: 0 4vw; + border-bottom: 1px solid var(--line); + background: rgba(7, 17, 15, 0.82); + backdrop-filter: blur(18px); +} + +.brand { + display: flex; + gap: 0.75rem; + align-items: center; + color: var(--text); + text-decoration: none; +} + +.brand-mark { + display: grid; + width: 2.45rem; + height: 2.45rem; + place-items: center; + border: 1px solid rgba(119, 242, 161, 0.45); + border-radius: 0.65rem; + color: var(--green); + background: rgba(119, 242, 161, 0.08); + box-shadow: inset 0 0 18px rgba(119, 242, 161, 0.07); + font: 700 0.8rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.05em; +} + +.brand strong, .brand small { display: block; } +.brand strong { font-size: 0.96rem; letter-spacing: 0.02em; } +.brand small { margin-top: 0.12rem; color: var(--muted); font-size: 0.72rem; } + +.connection { + display: flex; + gap: 0.55rem; + align-items: center; + padding: 0.48rem 0.72rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.025); + font: 600 0.72rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.connection-dot { + width: 0.52rem; + height: 0.52rem; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 12px currentColor; +} + +.connection[data-state="live"] { color: var(--green); } +.connection[data-state="live"] .connection-dot { background: var(--green); } +.connection[data-state="offline"] { color: var(--danger); } +.connection[data-state="offline"] .connection-dot { background: var(--danger); } + +.shell { + width: min(1180px, 92vw); + margin: 0 auto; + padding: 3.5rem 0 2rem; +} + +.panel { + border: 1px solid var(--line); + border-radius: 1rem; + background: var(--surface); + box-shadow: var(--shadow); + backdrop-filter: blur(15px); +} + +.waiting { + max-width: 680px; + margin: 10vh auto 0; + padding: 3.5rem; + text-align: center; +} + +.terminal-glyph { + display: grid; + width: 4.5rem; + height: 4.5rem; + margin: 0 auto 1.5rem; + place-items: center; + border: 1px solid rgba(119, 242, 161, 0.4); + border-radius: 1rem; + color: var(--green); + background: rgba(119, 242, 161, 0.06); + font: 700 1.2rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.waiting h1 { margin: 0.35rem 0 0.8rem; font-size: clamp(2rem, 5vw, 3.4rem); } +.waiting > p:last-child { max-width: 32rem; margin: auto; color: var(--muted); line-height: 1.7; } + +.mission-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 1.1rem; +} + +.hero, .objective, .completion { grid-column: 1; } +.hero { padding: clamp(1.5rem, 4vw, 3.2rem); } + +.mission-kicker, .section-heading, .hero-title-row, .progress-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.eyebrow { + margin: 0; + color: var(--green); + font: 700 0.72rem/1.25 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.difficulty, .state-pill, .hint-count { + padding: 0.36rem 0.58rem; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; + text-transform: uppercase; +} + +.hero-title-row { align-items: end; margin-top: 2rem; } +.mission-number { margin: 0 0 0.4rem; color: var(--muted); font: 600 0.78rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +h1, h2 { letter-spacing: -0.035em; } +.hero h1 { max-width: 18ch; margin: 0; font-size: clamp(2.2rem, 6vw, 4.9rem); line-height: 0.98; } + +.reward { + min-width: 7.4rem; + padding: 0.9rem; + border: 1px solid rgba(255, 200, 107, 0.24); + border-radius: 0.85rem; + text-align: right; + background: rgba(255, 200, 107, 0.055); +} + +.reward span { display: block; color: var(--amber); font: 760 1.8rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.reward small { display: block; margin-top: 0.25rem; color: var(--muted); font-size: 0.68rem; } + +.story { max-width: 69ch; margin: 2.5rem 0 0; color: #cadbd2; font-size: 1.03rem; line-height: 1.75; } + +.objective { padding: clamp(1.4rem, 3vw, 2.2rem); } +.panel-accent { border-top-color: rgba(119, 242, 161, 0.52); } +.section-heading { align-items: flex-start; } +.section-heading.tight { align-items: center; } +.section-heading h2 { max-width: 25ch; margin: 0.55rem 0 0; font-size: clamp(1.35rem, 3vw, 2.15rem); line-height: 1.22; } +.state-pill[data-state="completed"] { border-color: rgba(119, 242, 161, 0.35); color: var(--green); background: rgba(119, 242, 161, 0.08); } +.state-pill[data-state="paused"] { color: var(--amber); } + +.progress-summary { margin: 2rem 0 1rem; } +.progress-track { flex: 1; height: 0.45rem; overflow: hidden; border: 0; border-radius: 1rem; appearance: none; background: rgba(255, 255, 255, 0.07); } +.progress-track::-webkit-progress-bar { border-radius: 1rem; background: rgba(255, 255, 255, 0.07); } +.progress-track::-webkit-progress-value { border-radius: 1rem; background: linear-gradient(90deg, var(--green-strong), var(--green)); transition: width 260ms ease; } +.progress-track::-moz-progress-bar { border-radius: 1rem; background: linear-gradient(90deg, var(--green-strong), var(--green)); transition: width 260ms ease; } +.progress-summary strong { min-width: 6.5rem; color: var(--muted); font: 650 0.75rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-align: right; } + +.outcomes { display: grid; gap: 0.55rem; margin: 0; padding: 0; list-style: none; } +.outcome { display: flex; gap: 0.72rem; align-items: center; padding: 0.8rem 0.9rem; border-radius: 0.72rem; color: var(--muted); background: rgba(255, 255, 255, 0.025); transition: color 180ms ease, background 180ms ease; } +.outcome-marker { display: grid; flex: 0 0 1.35rem; width: 1.35rem; height: 1.35rem; place-items: center; border: 1px solid rgba(157, 180, 168, 0.32); border-radius: 50%; font-size: 0.72rem; } +.outcome.complete { color: var(--text); background: rgba(119, 242, 161, 0.055); } +.outcome.complete .outcome-marker { border-color: rgba(119, 242, 161, 0.55); color: var(--green); background: rgba(119, 242, 161, 0.08); } + +.sidebar { grid-column: 2; grid-row: 1 / span 3; display: grid; align-content: start; gap: 1.1rem; } +.compact { padding: 1.25rem; box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22); } +.command-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } +code, .command-chip { border: 1px solid rgba(139, 216, 255, 0.18); border-radius: 0.38rem; color: var(--blue); background: rgba(139, 216, 255, 0.055); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +code { padding: 0.08rem 0.3rem; } +.command-chip { padding: 0.45rem 0.58rem; font-size: 0.76rem; } +.muted { margin: 1rem 0 0; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } + +.hints { display: grid; gap: 0.55rem; margin-top: 1rem; } +.hint { padding: 0.82rem; border: 1px solid var(--line); border-radius: 0.7rem; color: #d7e6de; background: rgba(255, 255, 255, 0.025); font-size: 0.83rem; line-height: 1.55; } +.hint strong { display: block; margin-bottom: 0.35rem; color: var(--amber); font: 700 0.66rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; } +.hint.locked { color: #71847a; } +.hint.locked strong { color: #71847a; } + +.field-guide details { border-bottom: 1px solid var(--line); } +.field-guide details:last-of-type { border-bottom: 0; } +.field-guide summary { padding: 0.78rem 0; color: #d8e7df; cursor: pointer; font-size: 0.8rem; font-weight: 650; } +.field-guide summary::marker { color: var(--green); } +.field-guide dl { display: grid; gap: 0.1rem; margin: 0 0 0.65rem; } +.field-guide dl div { display: grid; grid-template-columns: 5.3rem 1fr; gap: 0.55rem; padding: 0.62rem 0; border-bottom: 1px solid var(--line); } +.field-guide dl div:last-child { border-bottom: 0; } +.field-guide dt, .field-guide dd { margin: 0; } +.field-guide dd { color: var(--muted); font-size: 0.78rem; line-height: 1.45; } +.guide-copy { margin: 0 0 0.9rem; color: var(--muted); font-size: 0.78rem; line-height: 1.55; } +.safety-note { margin: 1rem 0 0; padding: 0.75rem; border-left: 2px solid rgba(119, 242, 161, 0.45); color: var(--muted); background: rgba(119, 242, 161, 0.035); font-size: 0.75rem; line-height: 1.5; } +.safety-note span { color: var(--green); } + +.completion { display: grid; grid-template-columns: auto 1fr; gap: 1.15rem; padding: 1.6rem; border-color: rgba(119, 242, 161, 0.38); background: linear-gradient(125deg, rgba(43, 214, 111, 0.11), var(--surface)); } +.completion-mark { display: grid; width: 2.8rem; height: 2.8rem; place-items: center; border-radius: 50%; color: #062714; background: var(--green); font-weight: 900; } +.completion h2 { margin: 0.35rem 0 0.6rem; } +.completion p:not(.eyebrow) { margin: 0; color: #c8dbd1; line-height: 1.65; } +.completion-meta { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; } +.completion-meta span { padding: 0.38rem 0.56rem; border: 1px solid rgba(119, 242, 161, 0.2); border-radius: 999px; color: var(--green); background: rgba(119, 242, 161, 0.05); font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; } + +footer { display: flex; align-items: center; justify-content: center; gap: 0.75rem; padding: 1.5rem 1rem 2.5rem; color: #667b70; font: 650 0.68rem/1 ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: uppercase; letter-spacing: 0.08em; } +.footer-divider { width: 2.5rem; height: 1px; background: var(--line); } +.hidden { display: none !important; } + +@media (max-width: 850px) { + .mission-layout { grid-template-columns: 1fr; } + .hero, .objective, .completion, .sidebar { grid-column: 1; grid-row: auto; } + .sidebar { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .field-guide { grid-column: 1 / -1; } +} + +@media (max-width: 580px) { + .topbar { padding: 0 1rem; } + .connection-label { max-width: 9rem; } + .shell { width: min(100% - 1.2rem, 1180px); padding-top: 1.2rem; } + .waiting { padding: 2rem 1.3rem; } + .hero-title-row { align-items: flex-start; flex-direction: column; } + .reward { min-width: 0; text-align: left; } + .story { margin-top: 1.7rem; } + .section-heading:not(.tight) { flex-direction: column; } + .state-pill { align-self: flex-start; } + .sidebar { grid-template-columns: 1fr; } + .field-guide { grid-column: auto; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; } +} diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js new file mode 100644 index 0000000..cb51251 --- /dev/null +++ b/internal/webapp/static/app.js @@ -0,0 +1,153 @@ +"use strict"; + +const byID = (id) => document.getElementById(id); +let latestState = ""; +const elements = { + waiting: byID("waiting"), + layout: byID("mission-layout"), + connection: byID("connection"), + connectionLabel: byID("connection-label"), + location: byID("location"), + difficulty: byID("difficulty"), + missionNumber: byID("mission-number"), + title: byID("title"), + reward: byID("reward"), + story: byID("story"), + objective: byID("objective"), + statePill: byID("state-pill"), + progressTrack: byID("progress-track"), + progressLabel: byID("progress-label"), + outcomes: byID("outcomes"), + commands: byID("commands"), + hintCount: byID("hint-count"), + hints: byID("hints"), + hintInstruction: byID("hint-instruction"), + completion: byID("completion"), + completionTitle: byID("completion-title"), + explanation: byID("explanation"), + completionMeta: byID("completion-meta"), +}; + +function textNode(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + node.textContent = text; + return node; +} + +function setConnection(state, label) { + elements.connection.dataset.state = state; + elements.connectionLabel.textContent = label; +} + +function locationLabel(snapshot) { + const track = snapshot.track === "docker" ? "Docker" : "Linux"; + if (!snapshot.world_number) return track; + return `${track} · World ${snapshot.world_number}/${snapshot.world_total}: ${snapshot.world_name} · Stage ${snapshot.stage_number}/${snapshot.stage_total}`; +} + +function renderOutcomes(snapshot) { + elements.outcomes.replaceChildren(); + for (const outcome of snapshot.outcomes || []) { + const row = textNode("li", `outcome${outcome.satisfied ? " complete" : ""}`, ""); + row.append(textNode("span", "outcome-marker", outcome.satisfied ? "✓" : "")); + row.append(textNode("span", "", outcome.description)); + elements.outcomes.append(row); + } +} + +function renderCommands(snapshot) { + elements.commands.replaceChildren(); + for (const command of snapshot.suggested_commands || []) { + elements.commands.append(textNode("code", "command-chip", command)); + } +} + +function renderHints(snapshot) { + elements.hints.replaceChildren(); + const revealed = snapshot.revealed_hints || []; + for (let index = 0; index < snapshot.hint_count; index += 1) { + const visible = index < revealed.length; + const hint = textNode("div", `hint${visible ? "" : " locked"}`, ""); + hint.append(textNode("strong", "", `Hint ${index + 1}`)); + hint.append(textNode("span", "", visible ? revealed[index] : "Locked — reveal from the terminal")); + elements.hints.append(hint); + } + elements.hintCount.textContent = `${snapshot.hints_used} / ${snapshot.hint_count}`; + elements.hintInstruction.classList.toggle("hidden", snapshot.hints_used >= snapshot.hint_count); +} + +function addMeta(text) { + elements.completionMeta.append(textNode("span", "", text)); +} + +function renderCompletion(snapshot) { + const complete = snapshot.state === "completed"; + elements.completion.classList.toggle("hidden", !complete); + if (!complete) return; + elements.completionTitle.textContent = snapshot.first_completion + ? `Objective confirmed · +${snapshot.xp_awarded} XP` + : "Replay objective confirmed"; + elements.explanation.textContent = snapshot.explanation || "The observable mission outcome has been confirmed."; + elements.completionMeta.replaceChildren(); + for (const command of snapshot.discovered_commands || []) addMeta(`New command: ${command}`); + for (const achievement of snapshot.unlocked_achievements || []) addMeta(`Achievement: ${achievement}`); +} + +function render(event) { + const snapshot = event && event.snapshot; + if (!snapshot) return; + latestState = snapshot.state; + elements.waiting.classList.add("hidden"); + elements.layout.classList.remove("hidden"); + elements.location.textContent = locationLabel(snapshot); + elements.difficulty.textContent = snapshot.difficulty; + elements.missionNumber.textContent = `Mission ${String(snapshot.number).padStart(2, "0")}`; + elements.title.textContent = snapshot.title; + elements.reward.textContent = snapshot.replaying ? "Claimed" : String(snapshot.reward_available); + elements.story.textContent = snapshot.story; + elements.objective.textContent = snapshot.objective; + elements.statePill.textContent = snapshot.state; + elements.statePill.dataset.state = snapshot.state; + + const total = (snapshot.outcomes || []).length; + const satisfied = snapshot.satisfied_outcomes || 0; + elements.progressLabel.textContent = `${satisfied} / ${total} checks`; + elements.progressTrack.max = total || 1; + elements.progressTrack.value = satisfied; + + renderOutcomes(snapshot); + renderCommands(snapshot); + renderHints(snapshot); + renderCompletion(snapshot); +} + +async function loadCurrentState() { + try { + const response = await fetch("/api/state", { cache: "no-store" }); + if (response.status === 204) return; + if (!response.ok) throw new Error(`state request failed: ${response.status}`); + render(await response.json()); + } catch (_error) { + setConnection("offline", "CLI unavailable"); + } +} + +function connect() { + const events = new EventSource("/api/events"); + events.addEventListener("open", () => setConnection("live", "CLI connected")); + events.addEventListener("snapshot", (message) => { + try { + render(JSON.parse(message.data)); + } catch (_error) { + setConnection("offline", "Invalid update"); + } + }); + events.addEventListener("error", () => { + const ended = latestState === "completed" || latestState === "paused"; + setConnection("offline", ended ? "CLI session ended" : "Reconnecting to CLI"); + }); +} + +loadCurrentState(); +connect(); diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html new file mode 100644 index 0000000..aab3718 --- /dev/null +++ b/internal/webapp/static/index.html @@ -0,0 +1,135 @@ + + + + + + OpsQuest Mission Companion + + + +
+
+
+ + + + OpsQuest + Mission companion + + +
+ + Connecting to CLI +
+
+ +
+
+ +

Local session

+

Waiting for the mission

+

Keep this page open and return to your terminal. The objective will appear as soon as OpsQuest prepares the isolated lab.

+
+ + +
+ + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 4663fea..6d4eae4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -45,6 +45,7 @@ nav: - Play: - play/README.md - Quick start: play/quick-start.md + - Web mission companion: play/web-companion.md - Missions, hints, and progress: play/missions-and-progress.md - Controls and commands: play/controls-and-commands.md - Mission map: play/mission-map.md diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 7a92927..311d57b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -91,6 +91,14 @@ once_output="$(printf 'pwd\n' | run_fresh_opsquest play --once 1)" assert_contains "one-shot play" "${once_output}" "NEXT RECOMMENDED" assert_not_contains "one-shot play" "${once_output}" "MISSION 02: Configuration Crawl" +web_output="$(printf 'pwd\n' | run_fresh_opsquest play --web --once 1)" +assert_no_ansi "web companion play" "${web_output}" +assert_contains "web companion play" "${web_output}" "WEB MISSION COMPANION" +assert_contains "web companion play" "${web_output}" "http://127.0.0.1:" +assert_contains "web companion play" "${web_output}" "Mission 01 ready in the web companion" +assert_contains "web companion play" "${web_output}" "✓ Mission complete!" +assert_not_contains "web companion play" "${web_output}" "Your first ticket simply says" + printf 'pwd\n' | run_route_opsquest play --once 1 >/dev/null replay_hints_output="$(printf 'hint\nhint\nhint\nquit\n' | run_route_opsquest play 1)" assert_contains "replay hints" "${replay_hints_output}" "Hint 1/2 (no XP cost):" From f9b5cbfa73974dca65efd06c406de1891ddf9c5e Mon Sep 17 00:00:00 2001 From: Aleksandergreg Date: Thu, 24 Sep 2026 15:33:48 +0200 Subject: [PATCH 2/4] fix(webapp): close unused connections so companion shutdown is prompt http.Server.Shutdown only closes connections that have not sent a request yet once they have sat for about 5 seconds. Browsers routinely preconnect spare sockets, so closing the companion ran into the CLI's 2s shutdown timeout. Quitting `opsquest play --web` could then report "close web companion: context deadline exceeded", and TestServerRequiresOneTimePairingAndSetsSecurityHeaders failed intermittently in CI. Track connections through http.Server.ConnState and close the ones that have not sent a request when Close begins. Connections accepted after that are closed straight away. If Shutdown still runs out of time, force the server closed so no connections stay open, and keep returning the error to the caller. Add a regression test that opens a TCP connection, never sends a request, and checks that Close succeeds within its deadline and that the connection is closed. --- internal/webapp/server.go | 40 +++++++++++++++++++++++++++++++- internal/webapp/server_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/webapp/server.go b/internal/webapp/server.go index 896b661..57175ac 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -43,6 +43,13 @@ type Server struct { pairMu sync.Mutex paired bool + // connMu guards connections that have not started a request yet. + // http.Server.Shutdown leaves such connections open for several seconds, + // and browsers routinely preconnect, so Close must end them itself. + connMu sync.Mutex + closing bool + unused map[net.Conn]struct{} + mu sync.Mutex sequence uint64 current publication @@ -79,6 +86,7 @@ func Start(ctx context.Context) (*Server, error) { pairToken: pairToken, sessionToken: sessionToken, done: make(chan struct{}), + unused: make(map[net.Conn]struct{}), subscribers: make(map[chan publication]struct{}), } mux := http.NewServeMux() @@ -93,6 +101,7 @@ func Start(ctx context.Context) (*Server, error) { ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 75 * time.Second, MaxHeaderBytes: 16 * 1024, + ConnState: server.trackConnection, } go func() { @@ -123,7 +132,8 @@ func (s *Server) URL() string { return s.baseURL + "/pair?token=" + url.QueryEscape(s.pairToken) } -// Close stops accepting companion requests and closes active event streams. +// Close stops accepting companion requests, closes active event streams and +// unused connections, and force-closes anything still open when ctx expires. func (s *Server) Close(ctx context.Context) error { if ctx == nil { ctx = context.Background() @@ -131,14 +141,42 @@ func (s *Server) Close(ctx context.Context) error { var closeErr error s.closeOnce.Do(func() { close(s.done) + s.closeUnusedConnections() closeErr = s.httpServer.Shutdown(ctx) if errors.Is(closeErr, http.ErrServerClosed) { closeErr = nil } + if closeErr != nil { + _ = s.httpServer.Close() + } }) return closeErr } +func (s *Server) trackConnection(conn net.Conn, state http.ConnState) { + s.connMu.Lock() + defer s.connMu.Unlock() + if state != http.StateNew { + delete(s.unused, conn) + return + } + if s.closing { + _ = conn.Close() + return + } + s.unused[conn] = struct{}{} +} + +func (s *Server) closeUnusedConnections() { + s.connMu.Lock() + defer s.connMu.Unlock() + s.closing = true + for conn := range s.unused { + _ = conn.Close() + delete(s.unused, conn) + } +} + // ReportAttempt publishes the latest sanitized session snapshot without // blocking command execution. Slow browser subscribers skip stale snapshots // and converge on the newest one. diff --git a/internal/webapp/server_test.go b/internal/webapp/server_test.go index a21159f..2b9555d 100644 --- a/internal/webapp/server_test.go +++ b/internal/webapp/server_test.go @@ -4,7 +4,9 @@ import ( "bufio" "context" "encoding/json" + "errors" "io" + "net" "net/http" "net/http/cookiejar" "net/url" @@ -245,6 +247,46 @@ func TestServerClosesWhenApplicationContextIsCancelled(t *testing.T) { } } +func TestServerCloseDoesNotWaitForUnusedConnections(t *testing.T) { + server, err := Start(context.Background()) + if err != nil { + t.Fatal(err) + } + // A browser preconnect: accepted by the server but never sends a request. + conn, err := net.Dial("tcp4", server.host) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + deadline := time.Now().Add(2 * time.Second) + for { + server.connMu.Lock() + tracked := len(server.unused) + server.connMu.Unlock() + if tracked == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("companion did not track the unused connection") + } + time.Sleep(5 * time.Millisecond) + } + + closeContext, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := server.Close(closeContext); err != nil { + t.Fatalf("close companion with unused connection: %v", err) + } + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + _, err = conn.Read(make([]byte, 1)) + var netErr net.Error + if err == nil || (errors.As(err, &netErr) && netErr.Timeout()) { + t.Fatalf("unused connection still open after close: %v", err) + } +} + func TestEmbeddedAssetsStaySelfContainedAndUseTextOnlyRendering(t *testing.T) { html, err := staticFiles.ReadFile("static/index.html") if err != nil { From dc25b2776747fde01d7fb9b01a1f586575d09ee2 Mon Sep 17 00:00:00 2001 From: Aleksandergreg Date: Wed, 30 Sep 2026 18:12:35 +0200 Subject: [PATCH 3/4] feat: New docker missions --- internal/dockerlab/dockerlab_test.go | 151 +++++++++-- internal/dockerlab/environment.go | 375 ++++++++++++++++++++++---- internal/dockerlab/factory.go | 65 ++++- internal/dockerlab/janitor.go | 120 +++++++++ internal/dockerlab/janitor_test.go | 197 ++++++++++++++ internal/dockerlab/parser.go | 169 ++++++++++-- internal/dockerlab/process_unix.go | 18 ++ internal/dockerlab/process_windows.go | 31 +++ internal/dockerlab/triage_test.go | 319 ++++++++++++++++++++++ internal/game/environment.go | 9 + internal/game/validator.go | 2 + internal/mission/catalog.go | 74 +++-- internal/mission/catalog_test.go | 102 ++++++- internal/mission/mission.go | 11 + 14 files changed, 1521 insertions(+), 122 deletions(-) create mode 100644 internal/dockerlab/janitor.go create mode 100644 internal/dockerlab/janitor_test.go create mode 100644 internal/dockerlab/process_unix.go create mode 100644 internal/dockerlab/process_windows.go create mode 100644 internal/dockerlab/triage_test.go diff --git a/internal/dockerlab/dockerlab_test.go b/internal/dockerlab/dockerlab_test.go index 1561b31..a0be794 100644 --- a/internal/dockerlab/dockerlab_test.go +++ b/internal/dockerlab/dockerlab_test.go @@ -10,21 +10,33 @@ import ( "strings" "sync" "testing" + "time" "github.com/aleksandergregersen/opsquest/internal/game" "github.com/aleksandergregersen/opsquest/internal/mission" ) -const testImageReference = "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" +const ( + testImageReference = "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + testOwnerPID = 4242 + testOwnerHost = "test-host" +) + +var testNow = time.Date(2026, time.September, 30, 12, 0, 0, 0, time.UTC) type fakeContainer struct { - labels map[string]string - name string - running bool - status string - log string - exitCode int - oneShot bool + labels map[string]string + name string + created time.Time + running bool + restarting bool + status string + log string + exitCode int + oneShot bool + restartPolicy string + restartCount int + healthCmd string } type fakeDockerRunner struct { @@ -44,6 +56,8 @@ type fakeDockerRunner struct { failNextInspect map[string]error hideNextInspect map[string]bool failNextRemove map[string]error + failManagedList bool + stripHealth bool } func newFakeDockerRunner() *fakeDockerRunner { @@ -86,9 +100,12 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, if r.failCreateAt == r.createAttempts { return runResult{stderr: "fixture creation failed"}, errors.New("exit status 1") } - id := strings.Repeat(string(rune('b'+len(r.containerOrder))), 64) + // Created IDs use a prefix no seeded test container shares. + id := fmt.Sprintf("c0ffee%058x", r.createAttempts) labels := make(map[string]string) name := "" + restartPolicy := "" + healthCmd := "" for index := 0; index+1 < len(args); index++ { switch args[index] { case "--name": @@ -98,9 +115,16 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, if found { labels[labelName] = value } + case "--restart": + restartPolicy = args[index+1] + case "--health-cmd": + healthCmd = args[index+1] } } - container := &fakeContainer{labels: labels, name: name, status: "created"} + container := &fakeContainer{labels: labels, name: name, created: testNow, status: "created", restartPolicy: restartPolicy, healthCmd: healthCmd} + if len(args) >= 3 && args[len(args)-3] == "opsquest-service" { + container.log = args[len(args)-2] + "\n" + } if len(args) >= 3 && args[len(args)-3] == "opsquest-diagnostic" { container.oneShot = true container.log = args[len(args)-2] + "\n" @@ -126,7 +150,15 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, if !exists { return missingContainerResult(id) } - if container.oneShot { + if container.oneShot && strings.HasPrefix(container.restartPolicy, "on-failure") { + // Model a crash loop observed between restarts: Docker reports a + // restarting container as running and appends each attempt's log. + container.running = true + container.restarting = true + container.status = "restarting" + container.restartCount += crashLoopReadyRestarts + container.log = strings.Repeat(strings.SplitAfter(container.log, "\n")[0], container.restartCount+1) + } else if container.oneShot { container.running = false container.status = "exited" } else { @@ -142,6 +174,7 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, return missingContainerResult(id) } container.running = false + container.restarting = false container.status = "exited" return runResult{stdout: id + "\n"}, nil case hasPrefix(args, "container", "wait"): @@ -157,6 +190,20 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, if !exists { return missingContainerResult(id) } + if len(args) == 5 && args[2] == "--tail" { + tail, err := strconv.Atoi(args[3]) + if err != nil { + return runResult{}, err + } + lines := strings.SplitAfter(container.log, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if tail < len(lines) { + lines = lines[len(lines)-tail:] + } + return runResult{stdout: strings.Join(lines, "")}, nil + } return runResult{stdout: container.log}, nil case hasPrefix(args, "container", "inspect", "--format"): reference := args[len(args)-1] @@ -173,16 +220,32 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, if !exists { return missingContainerResult(id) } - inspection := containerInspection{ID: id} + inspection := containerInspection{ID: id, Name: "/" + container.name, Created: container.created.Format(time.RFC3339Nano), RestartCount: container.restartCount} inspection.Config.Labels = cloneStrings(container.labels) inspection.State.Running = container.running + inspection.State.Restarting = container.restarting inspection.State.Status = container.status inspection.State.ExitCode = container.exitCode + if policy, retries, found := strings.Cut(container.restartPolicy, ":"); found { + inspection.HostConfig.RestartPolicy.Name = policy + inspection.HostConfig.RestartPolicy.MaximumRetryCount, _ = strconv.Atoi(retries) + } else { + inspection.HostConfig.RestartPolicy.Name = container.restartPolicy + } + if container.healthCmd != "" && container.running && !r.stripHealth { + health := "unhealthy" + if container.healthCmd == "true" { + health = "healthy" + } + inspection.State.Health = &healthState{Status: health} + } encoded, err := json.Marshal(inspection) if err != nil { return runResult{}, err } return runResult{stdout: string(encoded) + "\n"}, nil + case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc") && r.failManagedList && args[len(args)-1] == "label="+managedLabel+"=true": + return runResult{stderr: "list failed"}, errors.New("exit status 1") case hasPrefix(args, "container", "ls", "--all", "--quiet", "--no-trunc"): filters := make(map[string]string) for index := 0; index+1 < len(args); index++ { @@ -205,6 +268,17 @@ func (r *fakeDockerRunner) run(ctx context.Context, args ...string) (runResult, output.WriteByte('\n') } return runResult{stdout: output.String()}, nil + case len(args) == 3 && hasPrefix(args, "container", "rm"): + id := args[len(args)-1] + container, exists := r.containers[id] + if !exists { + return missingContainerResult(id) + } + if container.running { + return runResult{stderr: "Error response from daemon: cannot remove container: container is running: stop the container before removing or force remove"}, errors.New("exit status 1") + } + delete(r.containers, id) + return runResult{stdout: id + "\n"}, nil case hasPrefix(args, "container", "rm", "--force"): id := args[len(args)-1] if err := r.failNextRemove[id]; err != nil { @@ -242,6 +316,20 @@ func labelsMatch(labels, filters map[string]string) bool { return true } +func (r *fakeDockerRunner) seedContainer(id string, container *fakeContainer) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.containers[id] = container + r.containerOrder = append(r.containerOrder, id) +} + +func (r *fakeDockerRunner) hasContainer(id string) bool { + r.mutex.Lock() + defer r.mutex.Unlock() + _, exists := r.containers[id] + return exists +} + func (r *fakeDockerRunner) resetCalls() { r.mutex.Lock() defer r.mutex.Unlock() @@ -335,9 +423,29 @@ func testDiagnosticDockerMission() mission.Mission { } func testFactory(commandRunner runner) *Factory { - return newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { + factory := newFactory(game.SandboxFactory{}, commandRunner, nil, func() (string, error) { return strings.Repeat("a", 24), nil }) + factory.owner = testOwner(func(pid int) bool { return pid == testOwnerPID }) + factory.pollInterval = time.Millisecond + return factory +} + +func testOwner(alive func(int) bool) processOwner { + return processOwner{pid: testOwnerPID, host: testOwnerHost, alive: alive, now: func() time.Time { return testNow }} +} + +// firstCall returns the first recorded Docker invocation with prefix and its +// position, so setup assertions do not depend on unrelated preflight calls. +func firstCall(t *testing.T, calls [][]string, prefix ...string) (int, []string) { + t.Helper() + for index, call := range calls { + if hasPrefix(call, prefix...) { + return index, call + } + } + t.Fatalf("no Docker call with prefix %q in %q", prefix, calls) + return -1, nil } func createTestEnvironment(t *testing.T, commandRunner *fakeDockerRunner) *environment { @@ -471,6 +579,8 @@ func TestCreateUsesExactLabelsAndResourceLimits(t *testing.T) { "--label", "com.opsquest.session=aaaaaaaaaaaaaaaaaaaaaaaa", "--label", "com.opsquest.mission=docker-container-census", "--label", "com.opsquest.alias=api", + "--label", "com.opsquest.owner-pid=4242", + "--label", "com.opsquest.owner-host=test-host", "--network", "none", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", @@ -488,12 +598,13 @@ func TestCreateUsesExactLabelsAndResourceLimits(t *testing.T) { testImageReference, "86400", } - if !reflect.DeepEqual(calls[3], wantCreate) { - t.Fatalf("first create args:\n got: %q\nwant: %q", calls[3], wantCreate) + createIndex, create := firstCall(t, calls, "container", "create") + if !reflect.DeepEqual(create, wantCreate) { + t.Fatalf("first create args:\n got: %q\nwant: %q", create, wantCreate) } metricsID := environment.byAlias["metrics"].id - if !reflect.DeepEqual(calls[5], []string{"container", "start", metricsID}) { - t.Fatalf("running fixture start = %q", calls[5]) + if !reflect.DeepEqual(calls[createIndex+2], []string{"container", "start", metricsID}) { + t.Fatalf("running fixture start = %q", calls[createIndex+2]) } for _, call := range calls { joined := strings.Join(call, " ") @@ -519,7 +630,7 @@ func TestDiagnosticFixtureUsesFixedCommandAndExposesSanitizedObservations(t *tes "-c", `printf '%s\n' "$1"; exit "$2"`, "opsquest-diagnostic", logText, "23", } - create := calls[3] + createIndex, create := firstCall(t, calls, "container", "create") if len(create) < len(wantTail) || !reflect.DeepEqual(create[len(create)-len(wantTail):], wantTail) { t.Fatalf("diagnostic create tail:\n got: %q\nwant: %q", create, wantTail) } @@ -527,8 +638,8 @@ func TestDiagnosticFixtureUsesFixedCommandAndExposesSanitizedObservations(t *tes t.Fatalf("diagnostic data was interpolated into shell program: %q", create[len(create)-4]) } jobID := environment.byAlias["job"].id - if !reflect.DeepEqual(calls[4], []string{"container", "start", jobID}) || !reflect.DeepEqual(calls[5], []string{"container", "wait", jobID}) { - t.Fatalf("diagnostic lifecycle calls = %q", calls[4:6]) + if !reflect.DeepEqual(calls[createIndex+1], []string{"container", "start", jobID}) || !reflect.DeepEqual(calls[createIndex+2], []string{"container", "wait", jobID}) { + t.Fatalf("diagnostic lifecycle calls = %q", calls[createIndex+1:createIndex+3]) } logged, err := environment.Execute(context.Background(), "docker container logs job") diff --git a/internal/dockerlab/environment.go b/internal/dockerlab/environment.go index f3d7276..a94be92 100644 --- a/internal/dockerlab/environment.go +++ b/internal/dockerlab/environment.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/aleksandergregersen/opsquest/internal/game" "github.com/aleksandergregersen/opsquest/internal/mission" @@ -20,6 +21,24 @@ const ( sessionLabel = "com.opsquest.session" missionLabel = "com.opsquest.mission" aliasLabel = "com.opsquest.alias" + // Owner labels let a later process recognize fixtures whose OpsQuest + // process exited without cleanup. They are advisory: removal still + // requires every ownership label above to match. + ownerPIDLabel = "com.opsquest.owner-pid" + ownerHostLabel = "com.opsquest.owner-host" + + // fixtureLifetime bounds every long-lived fixture process, so a fixture + // orphaned by an abnormal exit stops consuming resources within a day. + fixtureLifetime = 24 * time.Hour + // crashLoopMaxRetries bounds a crash-loop fixture's restart policy. With + // Docker's capped exponential backoff it keeps looping for roughly 40 + // minutes, which comfortably covers a mission attempt. + crashLoopMaxRetries = 50 + // crashLoopReadyRestarts is how many restarts setup waits for, so the + // player's first logs and inspection already show a loop. + crashLoopReadyRestarts = 2 + fixtureReadyTimeout = 10 * time.Second + fixtureReadyInterval = 100 * time.Millisecond ) type trackedContainer struct { @@ -31,11 +50,15 @@ type trackedContainer struct { } type environment struct { - runner runner - sessionID string - missionID string - containers []*trackedContainer - byAlias map[string]*trackedContainer + runner runner + sessionID string + missionID string + ownerPID int + ownerHost string + pollInterval time.Duration + readyTimeout time.Duration + containers []*trackedContainer + byAlias map[string]*trackedContainer cleanupMutex sync.Mutex mutex sync.RWMutex @@ -71,7 +94,7 @@ func (e *environment) Execute(ctx context.Context, line string) (game.Execution, result.Output = dockerHelp result.PracticedCommands = []string{"help"} case actionList: - result.Output, err = e.listContainers(ctx, action.all) + result.Output, err = e.listContainers(ctx, action.all, action.filters) result.PracticedCommands = []string{"docker"} case actionStart: err = e.startContainer(ctx, action.alias) @@ -91,11 +114,17 @@ func (e *environment) Execute(ctx context.Context, line string) (game.Execution, result.Output = action.alias + "\n" } result.PracticedCommands = []string{"docker"} + case actionRemove: + err = e.removeContainer(ctx, action.alias) + if err == nil { + result.Output = action.alias + "\n" + } + result.PracticedCommands = []string{"docker"} case actionInspect: result.Output, err = e.logicalInspect(ctx, action.alias) result.PracticedCommands = []string{"docker"} case actionLogs: - result.Output, err = e.containerLogs(ctx, action.alias) + result.Output, err = e.containerLogs(ctx, action.alias, action.tail) result.PracticedCommands = []string{"docker"} default: err = fmt.Errorf("unsupported Docker action") @@ -122,6 +151,13 @@ func (e *environment) Observe(ctx context.Context, condition mission.Condition) } inspection, exists, err := e.inspect(ctx, tracked.id) return exists && !inspection.State.Running, err + case mission.ConditionDockerContainerAbsent: + tracked, exists := e.container(condition.Container) + if !exists { + return false, nil + } + _, exists, err := e.inspect(ctx, tracked.id) + return err == nil && !exists, err case mission.ConditionDockerContainerCountEqual: if condition.Count == nil { return false, fmt.Errorf("docker_container_count_equals requires count") @@ -218,6 +254,18 @@ func (e *environment) createContainer(ctx context.Context, index int, fixture mi "--label", sessionLabel + "=" + e.sessionID, "--label", missionLabel + "=" + e.missionID, "--label", aliasLabel + "=" + fixture.Name, + } + if e.ownerHost != "" && e.ownerPID > 0 { + args = append(args, + "--label", ownerPIDLabel+"="+strconv.Itoa(e.ownerPID), + "--label", ownerHostLabel+"="+e.ownerHost, + ) + } + restartPolicy := "no" + if fixture.Restart == mission.DockerRestartOnFailure { + restartPolicy = fmt.Sprintf("on-failure:%d", crashLoopMaxRetries) + } + args = append(args, "--network", "none", "--read-only", "--tmpfs", "/tmp:rw,noexec,nosuid,nodev,size=8m", @@ -229,21 +277,40 @@ func (e *environment) createContainer(ctx context.Context, index int, fixture mi "--memory-swap", "128m", "--cpus", "0.5", "--ulimit", "nofile=256:256", - "--restart", "no", + "--restart", restartPolicy, "--stop-timeout", "1", + ) + if probe, configured := healthProbes[fixture.Health]; configured { + // The probe is a fixed BusyBox applet selected by a validated enum; + // mission content and player input never supply the probe command. + args = append(args, + "--health-cmd", probe, + "--health-interval", "1s", + "--health-timeout", "1s", + "--health-retries", "1", + ) } - if fixture.ExitCode != nil { + lifetime := strconv.Itoa(int(fixtureLifetime / time.Second)) + switch { + case fixture.ExitCode != nil: args = append(args, "--entrypoint", "/bin/sh", reference, "-c", `printf '%s\n' "$1"; exit "$2"`, "opsquest-diagnostic", fixture.Log, strconv.Itoa(*fixture.ExitCode), ) - } else { + case fixture.Log != "": + args = append(args, + "--entrypoint", "/bin/sh", + reference, + "-c", `printf '%s\n' "$1"; exec sleep "$2"`, + "opsquest-service", fixture.Log, lifetime, + ) + default: args = append(args, "--entrypoint", "/bin/sleep", reference, - "86400", + lifetime, ) } result, err := e.run(ctx, args...) @@ -291,39 +358,70 @@ func (e *environment) reconcileAmbiguousCreate(tracked *trackedContainer, create return tracked, createErr } +// healthProbes maps a fixture health enum to a fixed probe command. +var healthProbes = map[string]string{ + mission.DockerHealthHealthy: "true", + mission.DockerHealthUnhealthy: "false", +} + func (e *environment) startContainer(ctx context.Context, alias string) error { - tracked, exists := e.container(alias) - if !exists { - return fmt.Errorf("docker: container %q is not part of this mission", alias) - } - _, err := e.run(ctx, "container", "start", tracked.id) + _, err := e.runOnAlias(ctx, alias, "container", "start") return err } func (e *environment) restartContainer(ctx context.Context, alias string) error { - tracked, exists := e.container(alias) - if !exists { - return fmt.Errorf("docker: container %q is not part of this mission", alias) - } - _, err := e.run(ctx, "container", "restart", tracked.id) + _, err := e.runOnAlias(ctx, alias, "container", "restart") return err } func (e *environment) stopContainer(ctx context.Context, alias string) error { + _, err := e.runOnAlias(ctx, alias, "container", "stop") + return err +} + +// removeContainer deletes one stopped mission container. Running containers +// are refused rather than forced so the lesson keeps stop and remove distinct. +func (e *environment) removeContainer(ctx context.Context, alias string) error { tracked, exists := e.container(alias) if !exists { return fmt.Errorf("docker: container %q is not part of this mission", alias) } - _, err := e.run(ctx, "container", "stop", tracked.id) + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("docker: container %q has already been removed", alias) + } + if inspection.State.Running { + return fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) + } + _, err = e.runOnAlias(ctx, alias, "container", "rm") return err } -func (e *environment) waitContainer(ctx context.Context, alias string, expectedExitCode int) error { +// runOnAlias runs one fixed Docker action against the exact container ID +// behind alias. Engine errors about a missing container are translated so +// removed fixtures never reveal their real container ID. +func (e *environment) runOnAlias(ctx context.Context, alias string, args ...string) (runResult, error) { tracked, exists := e.container(alias) if !exists { - return fmt.Errorf("docker: container %q is not part of this mission", alias) + return runResult{}, fmt.Errorf("docker: container %q is not part of this mission", alias) } - result, err := e.run(ctx, "container", "wait", tracked.id) + operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) + defer cancel() + result, err := e.runner.run(operationCtx, append(args, tracked.id)...) + if err != nil { + if isMissingContainer(result) { + return result, fmt.Errorf("docker: container %q has been removed", alias) + } + return result, fmt.Errorf("Docker command failed%s", redactIdentifiers(dockerFailureDetail(result, err), tracked)) + } + return result, nil +} + +func (e *environment) waitContainer(ctx context.Context, alias string, expectedExitCode int) error { + result, err := e.runOnAlias(ctx, alias, "container", "wait") if err != nil { return err } @@ -334,40 +432,149 @@ func (e *environment) waitContainer(ctx context.Context, alias string, expectedE return nil } -func (e *environment) containerLogs(ctx context.Context, alias string) (string, error) { +// waitForFixture polls sanitized inspection until ready reports true. Setup +// uses it for fixtures whose teaching state (a health verdict or a visible +// crash loop) appears shortly after start. +func (e *environment) waitForFixture(ctx context.Context, alias string, ready func(containerInspection) bool) error { tracked, exists := e.container(alias) if !exists { - return "", fmt.Errorf("docker: container %q is not part of this mission", alias) + return fmt.Errorf("docker: container %q is not part of this mission", alias) } - result, err := e.run(ctx, "container", "logs", tracked.id) + interval := e.pollInterval + if interval <= 0 { + interval = fixtureReadyInterval + } + timeout := e.readyTimeout + if timeout <= 0 { + timeout = fixtureReadyTimeout + } + deadline := time.NewTimer(timeout) + defer deadline.Stop() + for { + inspection, exists, err := e.inspect(ctx, tracked.id) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("Docker fixture %s disappeared during setup", alias) + } + if ready(inspection) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("Docker fixture %s did not reach its teaching state within %s", alias, timeout) + case <-time.After(interval): + } + } +} + +func (e *environment) containerLogs(ctx context.Context, alias string, tail int) (string, error) { + args := []string{"container", "logs"} + if tail >= 0 { + args = append(args, "--tail", strconv.Itoa(tail)) + } + result, err := e.runOnAlias(ctx, alias, args...) if err != nil { return "", err } return result.stdout, nil } -func (e *environment) listContainers(ctx context.Context, all bool) (string, error) { - var output strings.Builder - output.WriteString("CONTAINER ID IMAGE STATUS NAMES\n") +func (e *environment) listContainers(ctx context.Context, all bool, filters []listFilter) (string, error) { + type row struct { + logicalID string + image string + status string + alias string + } + rows := make([]row, 0, len(e.containers)) + statusWidth := len("STATUS") for _, tracked := range e.snapshotContainers() { inspection, exists, err := e.inspect(ctx, tracked.id) if err != nil { return "", err } - if !exists || !all && !inspection.State.Running { + // A status filter selects stopped containers even without --all, + // matching docker ps. + if !exists || !all && !hasFilterKey(filters, "status") && !inspection.State.Running || !matchesFilters(inspection, filters) { continue } - status := inspection.State.Status - if status == "" { + status := displayStatus(inspection) + statusWidth = max(statusWidth, len(status)) + rows = append(rows, row{logicalID: tracked.logicalID, image: tracked.imageAlias, status: status, alias: tracked.alias}) + } + var output strings.Builder + fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", "CONTAINER ID", "IMAGE", statusWidth, "STATUS", "NAMES") + for _, item := range rows { + fmt.Fprintf(&output, "%-13s %-9s %-*s %s\n", item.logicalID, item.image, statusWidth, item.status, item.alias) + } + return output.String(), nil +} + +func displayStatus(inspection containerInspection) string { + status := inspection.State.Status + if status == "" { + if inspection.State.Running { + status = "running" + } else { + status = "stopped" + } + } + if health := healthStatus(inspection); inspection.State.Running && health != "none" { + status += " (" + health + ")" + } + return status +} + +// healthStatus reports the probe verdict, or none for a fixture without a +// health probe. Like docker ps, listings only show it for running containers. +func healthStatus(inspection containerInspection) string { + if inspection.State.Health == nil || inspection.State.Health.Status == "" { + return "none" + } + return inspection.State.Health.Status +} + +func hasFilterKey(filters []listFilter, key string) bool { + for _, filter := range filters { + if filter.key == key { + return true + } + } + return false +} + +// matchesFilters follows docker ps semantics: values for one key are +// alternatives, and different keys must all match. +func matchesFilters(inspection containerInspection, filters []listFilter) bool { + matched := make(map[string]bool, len(filters)) + for _, filter := range filters { + if _, seen := matched[filter.key]; !seen { + matched[filter.key] = false + } + var actual string + switch filter.key { + case "status": + actual = inspection.State.Status + case "health": + actual = "none" if inspection.State.Running { - status = "running" - } else { - status = "stopped" + actual = healthStatus(inspection) } } - fmt.Fprintf(&output, "%-13s %-9s %-8s %s\n", tracked.logicalID, tracked.imageAlias, status, tracked.alias) + if actual == filter.value { + matched[filter.key] = true + } } - return output.String(), nil + for _, ok := range matched { + if !ok { + return false + } + } + return true } func (e *environment) logicalInspect(ctx context.Context, alias string) (string, error) { @@ -382,19 +589,36 @@ func (e *environment) logicalInspect(ctx context.Context, alias string) (string, if !exists { return "", fmt.Errorf("docker: container %q no longer exists", alias) } + type logicalHealth struct { + Status string `json:"Status"` + } logical := struct { - ID string `json:"Id"` - Name string `json:"Name"` - Image string `json:"Image"` - State struct { - Running bool `json:"Running"` - Status string `json:"Status"` - ExitCode int `json:"ExitCode"` + ID string `json:"Id"` + Name string `json:"Name"` + Image string `json:"Image"` + RestartCount int `json:"RestartCount"` + State struct { + Running bool `json:"Running"` + Restarting bool `json:"Restarting"` + Status string `json:"Status"` + ExitCode int `json:"ExitCode"` + Health *logicalHealth `json:"Health,omitempty"` } `json:"State"` - }{ID: tracked.logicalID, Name: tracked.alias, Image: tracked.imageAlias} + HostConfig struct { + RestartPolicy restartPolicy `json:"RestartPolicy"` + } `json:"HostConfig"` + }{ID: tracked.logicalID, Name: tracked.alias, Image: tracked.imageAlias, RestartCount: inspection.RestartCount} logical.State.Running = inspection.State.Running + logical.State.Restarting = inspection.State.Restarting logical.State.Status = inspection.State.Status logical.State.ExitCode = inspection.State.ExitCode + if health := healthStatus(inspection); health != "none" { + logical.State.Health = &logicalHealth{Status: health} + } + logical.HostConfig.RestartPolicy = inspection.HostConfig.RestartPolicy + if logical.HostConfig.RestartPolicy.Name == "" { + logical.HostConfig.RestartPolicy.Name = "no" + } encoded, err := json.MarshalIndent(logical, "", " ") if err != nil { return "", err @@ -402,15 +626,32 @@ func (e *environment) logicalInspect(ctx context.Context, alias string) (string, return string(encoded) + "\n", nil } +type restartPolicy struct { + Name string `json:"Name"` + MaximumRetryCount int `json:"MaximumRetryCount"` +} + +type healthState struct { + Status string `json:"Status"` +} + type containerInspection struct { - ID string `json:"Id"` - Config struct { + ID string `json:"Id"` + Name string `json:"Name"` + Created string `json:"Created"` + RestartCount int `json:"RestartCount"` + Config struct { Labels map[string]string `json:"Labels"` } `json:"Config"` + HostConfig struct { + RestartPolicy restartPolicy `json:"RestartPolicy"` + } `json:"HostConfig"` State struct { - Running bool `json:"Running"` - Status string `json:"Status"` - ExitCode int `json:"ExitCode"` + Running bool `json:"Running"` + Restarting bool `json:"Restarting"` + Status string `json:"Status"` + ExitCode int `json:"ExitCode"` + Health *healthState `json:"Health"` } `json:"State"` } @@ -433,7 +674,13 @@ func (e *environment) inspectUnchecked(ctx context.Context, id string) (containe } func (e *environment) inspectReferenceUnchecked(ctx context.Context, reference string) (containerInspection, bool, error) { - result, err := e.run(ctx, "container", "inspect", "--format", "{{json .}}", reference) + return inspectReference(ctx, e.runner, reference) +} + +// inspectReference inspects one exact container ID or generated name. It is +// shared by attempt environments and the orphan janitor. +func inspectReference(ctx context.Context, commandRunner runner, reference string) (containerInspection, bool, error) { + result, err := runDocker(ctx, commandRunner, "container", "inspect", "--format", "{{json .}}", reference) if err != nil { if isMissingContainer(result) { return containerInspection{}, false, nil @@ -477,15 +724,33 @@ func (e *environment) countOwnedContainers(ctx context.Context) (int, error) { } func (e *environment) run(ctx context.Context, args ...string) (runResult, error) { + return runDocker(ctx, e.runner, args...) +} + +// runDocker runs one fixed Docker invocation with the standard operation +// timeout and a bounded error summary. +func runDocker(ctx context.Context, commandRunner runner, args ...string) (runResult, error) { operationCtx, cancel := context.WithTimeout(ctx, dockerOperationTimeout) defer cancel() - result, err := e.runner.run(operationCtx, args...) + result, err := commandRunner.run(operationCtx, args...) if err != nil { return result, fmt.Errorf("Docker command failed%s", dockerFailureDetail(result, err)) } return result, nil } +// redactIdentifiers replaces a tracked container's engine ID and generated +// name in player-visible error text with its logical identity. +func redactIdentifiers(message string, tracked *trackedContainer) string { + if tracked.id != "" { + message = strings.ReplaceAll(message, tracked.id, tracked.logicalID) + if len(tracked.id) >= 12 { + message = strings.ReplaceAll(message, tracked.id[:12], tracked.logicalID) + } + } + return strings.ReplaceAll(message, tracked.actualName, tracked.alias) +} + func (e *environment) ready(ctx context.Context) error { if err := ctx.Err(); err != nil { return err @@ -525,7 +790,7 @@ func (dockerCompletion) CommandNames() []string { } func (c dockerCompletion) PathCandidates(prefix string) []game.CompletionCandidate { - values := []string{"--all", "-a", "container", "inspect", "logs", "ls", "ps", "restart", "start", "stop"} + values := []string{"--all", "--filter", "--tail", "-a", "container", "inspect", "logs", "ls", "ps", "restart", "rm", "start", "stop"} for _, tracked := range c.environment.snapshotContainers() { values = append(values, tracked.alias) } diff --git a/internal/dockerlab/factory.go b/internal/dockerlab/factory.go index 6093ae0..c5d69c9 100644 --- a/internal/dockerlab/factory.go +++ b/internal/dockerlab/factory.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "os" "os/exec" "regexp" "strings" @@ -32,11 +33,34 @@ type Factory struct { runner runner lookupErr error newSession func() (string, error) + owner processOwner + // pollInterval and readyTimeout override fixture readiness polling in + // tests. + pollInterval time.Duration + readyTimeout time.Duration +} + +// processOwner identifies the current OpsQuest process for orphan detection +// and decides whether another recorded owner process is still alive. +type processOwner struct { + pid int + host string + alive func(int) bool + now func() time.Time +} + +func currentProcessOwner() processOwner { + host, err := os.Hostname() + if err != nil { + host = "" + } + return processOwner{pid: os.Getpid(), host: host, alive: processAlive, now: time.Now} } var ( _ game.Factory = (*Factory)(nil) _ game.AvailabilityChecker = (*Factory)(nil) + _ game.ResourceJanitor = (*Factory)(nil) ) // NewFactory creates a combined environment factory. Docker remains optional: @@ -56,6 +80,7 @@ func NewFactory(fallback game.Factory) *Factory { runner: commandRunner, lookupErr: err, newSession: randomSessionID, + owner: currentProcessOwner(), } } @@ -66,7 +91,7 @@ func newFactory(fallback game.Factory, commandRunner runner, lookupErr error, ne if newSession == nil { newSession = randomSessionID } - return &Factory{fallback: fallback, runner: commandRunner, lookupErr: lookupErr, newSession: newSession} + return &Factory{fallback: fallback, runner: commandRunner, lookupErr: lookupErr, newSession: newSession, owner: currentProcessOwner()} } func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Environment, error) { @@ -94,11 +119,20 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro return nil, fmt.Errorf("create Docker session ID: generated value is invalid") } + // Reclaim fixtures abandoned by earlier crashed processes before adding + // new ones. This is best effort: a failed sweep must not block play, and + // the same cleanup is available explicitly through opsquest doctor. + _, _ = f.removeOrphans(ctx) + environment := &environment{ - runner: f.runner, - sessionID: sessionID, - missionID: item.ID, - 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), } images := make(map[string]string, len(item.Docker.Images)) for _, image := range item.Docker.Images { @@ -115,6 +149,18 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro } } for _, fixture := range item.Docker.Containers { + if fixture.Restart == mission.DockerRestartOnFailure { + if err := environment.startContainer(ctx, fixture.Name); err != nil { + return environment, joinSetupCleanupError(fmt.Errorf("start crash-loop Docker fixture %s: %w", fixture.Name, err), environment.Close()) + } + looping := func(inspection containerInspection) bool { + return inspection.RestartCount >= crashLoopReadyRestarts + } + if err := environment.waitForFixture(ctx, fixture.Name, looping); err != nil { + return environment, joinSetupCleanupError(fmt.Errorf("wait for crash-loop Docker fixture %s: %w", fixture.Name, err), environment.Close()) + } + continue + } if fixture.ExitCode != nil { if err := environment.startContainer(ctx, fixture.Name); err != nil { return environment, joinSetupCleanupError(fmt.Errorf("start diagnostic Docker fixture %s: %w", fixture.Name, err), environment.Close()) @@ -130,6 +176,15 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro if err := environment.startContainer(ctx, fixture.Name); err != nil { return environment, joinSetupCleanupError(fmt.Errorf("start Docker fixture %s: %w", fixture.Name, err), environment.Close()) } + if fixture.Health != "" { + expected := fixture.Health + settled := func(inspection containerInspection) bool { + return healthStatus(inspection) == expected + } + if err := environment.waitForFixture(ctx, fixture.Name, settled); err != nil { + return environment, joinSetupCleanupError(fmt.Errorf("wait for Docker fixture %s health: %w", fixture.Name, err), environment.Close()) + } + } } return environment, nil } diff --git a/internal/dockerlab/janitor.go b/internal/dockerlab/janitor.go new file mode 100644 index 0000000..b4be904 --- /dev/null +++ b/internal/dockerlab/janitor.go @@ -0,0 +1,120 @@ +package dockerlab + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +var ( + sessionIDPattern = regexp.MustCompile(`^[a-f0-9]{24}$`) + generatedNamePattern = regexp.MustCompile(`^/?opsquest-([a-f0-9]{24})-c[0-9]{2}$`) +) + +// orphanMinimumAge is when a fixture 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. +func (f *Factory) OrphanedResources(ctx context.Context) (int, error) { + orphans, err := f.findOrphans(ctx) + return len(orphans), err +} + +// RemoveOrphanedResources removes OpsQuest fixtures 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) +} + +func (f *Factory) removeOrphans(ctx context.Context) (int, error) { + orphans, err := f.findOrphans(ctx) + if err != nil { + return 0, err + } + removed := 0 + var removeErrors []error + for _, id := range orphans { + 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)) + 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) { + if f.lookupErr != nil || f.runner == nil { + return nil, 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", + ) + if err != nil { + return nil, 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") + } + inspection, exists, err := inspectReference(ctx, f.runner, id) + if err != nil { + return nil, err + } + if !exists { + continue + } + if inspection.ID != id { + return nil, fmt.Errorf("Docker inspection returned an unexpected container ID") + } + if f.isOrphan(inspection) { + orphans = append(orphans, id) + } + } + return orphans, nil +} + +func (f *Factory) isOrphan(inspection containerInspection) bool { + labels := inspection.Config.Labels + 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) + 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. + 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) + if err != nil || f.owner.now == nil { + return false + } + return f.owner.now().Sub(created) > orphanMinimumAge +} diff --git a/internal/dockerlab/janitor_test.go b/internal/dockerlab/janitor_test.go new file mode 100644 index 0000000..cdd0f10 --- /dev/null +++ b/internal/dockerlab/janitor_test.go @@ -0,0 +1,197 @@ +package dockerlab + +import ( + "context" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/game" +) + +func orphanLabels(session, pid, host string) map[string]string { + labels := map[string]string{ + managedLabel: "true", + schemaLabel: "1", + sessionLabel: session, + missionLabel: "docker-container-census", + aliasLabel: "api", + } + if pid != "" { + labels[ownerPIDLabel] = pid + } + if host != "" { + labels[ownerHostLabel] = host + } + return labels +} + +func seedOrphanCandidates(commandRunner *fakeDockerRunner) (orphans, kept []string) { + session := func(char string) string { return strings.Repeat(char, 24) } + id := func(char string) string { return strings.Repeat(char, 64) } + old := testNow.Add(-orphanMinimumAge - time.Minute) + young := testNow.Add(-time.Hour) + candidates := []struct { + id string + container *fakeContainer + orphan bool + }{ + // The owner process on this host has exited. + {id: id("1"), orphan: true, container: &fakeContainer{labels: orphanLabels(session("1"), "999", testOwnerHost), name: "opsquest-" + session("1") + "-c01", created: young, running: true}}, + // The owner process on this host is alive. + {id: id("2"), container: &fakeContainer{labels: orphanLabels(session("2"), "4242", testOwnerHost), name: "opsquest-" + session("2") + "-c01", created: young, running: true}}, + // Another host cannot be probed; a young fixture may be live. + {id: id("3"), container: &fakeContainer{labels: orphanLabels(session("3"), "999", "other-host"), name: "opsquest-" + session("3") + "-c01", created: young}}, + // Another host's fixture past its lifetime is abandoned. + {id: id("4"), orphan: true, container: &fakeContainer{labels: orphanLabels(session("4"), "999", "other-host"), name: "opsquest-" + session("4") + "-c02", created: old}}, + // Fixtures from before owner labels fall back to age. + {id: id("5"), orphan: true, container: &fakeContainer{labels: orphanLabels(session("5"), "", ""), name: "opsquest-" + session("5") + "-c01", created: old}}, + {id: id("6"), container: &fakeContainer{labels: orphanLabels(session("6"), "", ""), name: "opsquest-" + session("6") + "-c01", created: young}}, + // Ownership proofs that fail keep the container regardless of age. + {id: id("7"), container: &fakeContainer{labels: orphanLabels(session("7"), "999", testOwnerHost), name: "user-database", created: old}}, + {id: id("8"), container: &fakeContainer{labels: orphanLabels(session("8"), "999", testOwnerHost), name: "opsquest-" + session("9") + "-c01", created: old}}, + {id: id("9"), container: &fakeContainer{labels: func() map[string]string { + labels := orphanLabels(session("9"), "999", testOwnerHost) + labels[schemaLabel] = "2" + return labels + }(), name: "opsquest-" + session("9") + "-c01", created: old}}, + {id: id("a"), container: &fakeContainer{labels: func() map[string]string { + labels := orphanLabels(session("a"), "999", testOwnerHost) + delete(labels, aliasLabel) + return labels + }(), name: "opsquest-" + session("a") + "-c01", created: old}}, + {id: id("d"), container: &fakeContainer{labels: orphanLabels("not-a-session", "999", testOwnerHost), name: "opsquest-not-a-session-c01", created: old}}, + // An unparsable owner PID is not proof of death; it falls back to age. + {id: id("e"), container: &fakeContainer{labels: orphanLabels(session("e"), "pid", testOwnerHost), name: "opsquest-" + session("e") + "-c01", created: young}}, + } + for _, candidate := range candidates { + commandRunner.seedContainer(candidate.id, candidate.container) + if candidate.orphan { + orphans = append(orphans, candidate.id) + } else { + kept = append(kept, candidate.id) + } + } + return orphans, kept +} + +func TestJanitorFindsOnlyProvablyAbandonedFixtures(t *testing.T) { + commandRunner := newFakeDockerRunner() + orphans, kept := seedOrphanCandidates(commandRunner) + factory := testFactory(commandRunner) + var _ game.ResourceJanitor = factory + + count, err := factory.OrphanedResources(context.Background()) + if err != nil || count != len(orphans) { + t.Fatalf("OrphanedResources() = %d, %v; want %d", count, err, len(orphans)) + } + for _, call := range commandRunner.snapshotCalls() { + if hasPrefix(call, "container", "rm") { + t.Fatalf("read-only orphan check removed a container: %q", call) + } + } + + commandRunner.resetCalls() + removed, err := factory.RemoveOrphanedResources(context.Background()) + if err != nil || removed != len(orphans) { + t.Fatalf("RemoveOrphanedResources() = %d, %v; want %d", removed, err, len(orphans)) + } + var removals [][]string + for _, call := range commandRunner.snapshotCalls() { + if hasPrefix(call, "container", "rm") { + removals = append(removals, call) + } + } + var wantRemovals [][]string + for _, id := range orphans { + wantRemovals = append(wantRemovals, []string{"container", "rm", "--force", id}) + } + if !reflect.DeepEqual(removals, wantRemovals) { + t.Fatalf("removals = %q, want %q", removals, wantRemovals) + } + for _, id := range orphans { + if commandRunner.hasContainer(id) { + t.Errorf("orphan %s survived cleanup", id[:12]) + } + } + for _, id := range kept { + if !commandRunner.hasContainer(id) { + t.Errorf("non-orphan %s was removed", id[:12]) + } + } + if count, err := factory.OrphanedResources(context.Background()); err != nil || count != 0 { + t.Fatalf("OrphanedResources() after cleanup = %d, %v", count, err) + } +} + +func TestCreateSweepsOrphansButNeverLiveAttempts(t *testing.T) { + commandRunner := newFakeDockerRunner() + orphans, kept := seedOrphanCandidates(commandRunner) + first := createTestEnvironment(t, commandRunner) + for _, id := range orphans { + if commandRunner.hasContainer(id) { + t.Errorf("Create() left orphan %s", id[:12]) + } + } + for _, id := range kept { + if !commandRunner.hasContainer(id) { + t.Errorf("Create() removed non-orphan %s", id[:12]) + } + } + + factory := testFactory(commandRunner) + factory.newSession = func() (string, error) { return strings.Repeat("f", 24), nil } + second, err := factory.Create(context.Background(), testDockerMission()) + if err != nil { + t.Fatal(err) + } + defer second.Close() + for _, tracked := range first.snapshotContainers() { + if !commandRunner.hasContainer(tracked.id) { + t.Fatalf("a second attempt swept live fixture %s", tracked.alias) + } + } +} + +func TestSweepFailureDoesNotBlockSetup(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.failManagedList = true + environment := createTestEnvironment(t, commandRunner) + if len(environment.snapshotContainers()) != 2 { + t.Fatalf("setup after failed sweep created %d fixtures", len(environment.snapshotContainers())) + } + if _, err := testFactory(commandRunner).OrphanedResources(context.Background()); err == nil || !strings.Contains(err.Error(), "list failed") { + t.Fatalf("OrphanedResources() error = %v", err) + } +} + +func TestJanitorReportsMissingDocker(t *testing.T) { + factory := newFactory(game.SandboxFactory{}, nil, execNotFoundError{}, nil) + if _, err := factory.OrphanedResources(context.Background()); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("OrphanedResources() error = %v", err) + } + if _, err := factory.RemoveOrphanedResources(context.Background()); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("RemoveOrphanedResources() error = %v", err) + } +} + +func TestJanitorRejectsMalformedListing(t *testing.T) { + commandRunner := newFakeDockerRunner() + commandRunner.seedContainer("not-a-container-id", &fakeContainer{labels: orphanLabels(strings.Repeat("1", 24), "999", testOwnerHost)}) + if _, err := testFactory(commandRunner).RemoveOrphanedResources(context.Background()); err == nil || !strings.Contains(err.Error(), "invalid container ID") { + t.Fatalf("RemoveOrphanedResources() error = %v", err) + } +} + +func TestProcessAlive(t *testing.T) { + if !processAlive(os.Getpid()) { + t.Fatal("current process reported dead") + } + for _, pid := range []int{0, -1} { + if processAlive(pid) { + t.Errorf("processAlive(%d) = true", pid) + } + } +} diff --git a/internal/dockerlab/parser.go b/internal/dockerlab/parser.go index e839064..3610e2d 100644 --- a/internal/dockerlab/parser.go +++ b/internal/dockerlab/parser.go @@ -1,27 +1,39 @@ package dockerlab import ( + "errors" "fmt" + "sort" + "strconv" "strings" "unicode" "github.com/aleksandergregersen/opsquest/internal/mission" ) -const maxDockerCommandLineBytes = 64 * 1024 +const ( + maxDockerCommandLineBytes = 64 * 1024 + maxLogTailLines = 10000 + maxListFilters = 4 +) const dockerHelp = `Docker lab commands: docker ps [-a|--all] List mission containers - docker container ls [-a|--all] List mission containers + docker ps --filter KEY=VALUE Filter by status=created|running|restarting|exited + or health=starting|healthy|unhealthy|none + docker container ls [OPTIONS] List mission containers docker start ALIAS Start a mission container docker container start ALIAS Start a mission container docker restart ALIAS Restart a mission container docker container restart ALIAS Restart a mission container docker stop ALIAS Stop a mission container docker container stop ALIAS Stop a mission container + docker rm ALIAS Remove a stopped mission container + docker container rm ALIAS Remove a stopped mission container docker inspect ALIAS Inspect logical container state docker container inspect ALIAS Inspect logical container state 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 help Show this help ` @@ -34,14 +46,30 @@ const ( actionStart actionRestart actionStop + actionRemove actionInspect actionLogs ) +// listFilter is one parsed docker ps filter. Filters are evaluated in Go over +// sanitized inspection data and are never forwarded to the Docker CLI. +type listFilter struct { + key string + value string +} + type dockerAction struct { - kind actionKind - alias string - all bool + kind actionKind + alias string + all bool + filters []listFilter + // tail is the number of trailing log lines to show; negative means all. + tail int +} + +var listFilterValues = map[string]map[string]bool{ + "status": {"created": true, "running": true, "restarting": true, "exited": true}, + "health": {"starting": true, "healthy": true, "unhealthy": true, "none": true}, } func parseAction(line string) (dockerAction, error) { @@ -65,25 +93,31 @@ func parseAction(line string) (dockerAction, error) { } fields = fields[1:] if len(fields) == 0 { - return dockerAction{}, fmt.Errorf("usage: docker ps [-a|--all] | docker start ALIAS | docker stop ALIAS | docker inspect ALIAS | docker logs ALIAS") + 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] == "container" { fields = fields[1:] if len(fields) == 0 { - return dockerAction{}, fmt.Errorf("usage: docker container ls [-a|--all] | docker container start ALIAS | docker container stop ALIAS | docker container inspect ALIAS | docker container logs ALIAS") + return dockerAction{}, fmt.Errorf("usage: docker container ls [-a|--all] | docker container start ALIAS | docker container stop ALIAS | docker container rm ALIAS | docker container inspect ALIAS | docker container logs ALIAS") } } switch fields[0] { case "ps", "ls": - if len(fields) == 1 { - return dockerAction{kind: actionList}, nil + return parseList(fields[1:]) + case "logs": + return parseLogs(fields[1:]) + case "rm": + for _, field := range fields[1:] { + if field == "-f" || field == "--force" { + return dockerAction{}, fmt.Errorf("docker rm --force is outside this teaching subset; stop the container first, then remove it") + } } - if len(fields) == 2 && (fields[1] == "-a" || fields[1] == "--all") { - return dockerAction{kind: actionList, all: true}, nil + if len(fields) != 2 || !mission.ValidDockerLogicalName(fields[1]) { + return dockerAction{}, fmt.Errorf("usage: docker rm ALIAS") } - return dockerAction{}, fmt.Errorf("usage: docker ps [-a|--all]") - case "start", "restart", "stop", "inspect", "logs": + return dockerAction{kind: actionRemove, alias: fields[1]}, nil + case "start", "restart", "stop", "inspect": if len(fields) != 2 || !mission.ValidDockerLogicalName(fields[1]) { return dockerAction{}, fmt.Errorf("usage: docker %s ALIAS", fields[0]) } @@ -94,11 +128,116 @@ func parseAction(line string) (dockerAction, error) { kind = actionStop } else if fields[0] == "inspect" { kind = actionInspect - } else if fields[0] == "logs" { - kind = actionLogs } return dockerAction{kind: kind, alias: fields[1]}, nil default: return dockerAction{}, fmt.Errorf("docker %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} + for index := 0; index < len(fields); index++ { + field := fields[index] + var filter string + switch { + case field == "-a" || field == "--all": + if action.all { + return dockerAction{}, errors.New(usage) + } + action.all = true + continue + case field == "-f" || field == "--filter": + if index+1 >= len(fields) { + return dockerAction{}, fmt.Errorf("docker ps %s requires KEY=VALUE", field) + } + index++ + filter = fields[index] + case strings.HasPrefix(field, "--filter="): + filter = strings.TrimPrefix(field, "--filter=") + default: + return dockerAction{}, errors.New(usage) + } + parsed, err := parseListFilter(filter) + if err != nil { + return dockerAction{}, err + } + if len(action.filters) == maxListFilters { + return dockerAction{}, fmt.Errorf("docker ps accepts at most %d filters in this lab", maxListFilters) + } + action.filters = append(action.filters, parsed) + } + return action, nil +} + +func parseListFilter(value string) (listFilter, error) { + key, filterValue, found := strings.Cut(value, "=") + allowed, knownKey := listFilterValues[key] + if !found || !knownKey { + return listFilter{}, fmt.Errorf("docker ps filter %q is outside this teaching subset; use status=VALUE or health=VALUE", value) + } + if !allowed[filterValue] { + return listFilter{}, fmt.Errorf("docker ps filter %s accepts %s", key, strings.Join(sortedKeys(allowed), ", ")) + } + return listFilter{key: key, value: filterValue}, nil +} + +func parseLogs(fields []string) (dockerAction, error) { + const usage = "usage: docker logs [--tail N] ALIAS" + action := dockerAction{kind: actionLogs, tail: -1} + tailSet := false + for len(fields) > 1 { + var value string + switch field := fields[0]; { + case field == "--tail" || field == "-n": + if len(fields) < 3 { + return dockerAction{}, errors.New(usage) + } + value = fields[1] + fields = fields[2:] + case strings.HasPrefix(field, "--tail="): + value = strings.TrimPrefix(field, "--tail=") + fields = fields[1:] + default: + return dockerAction{}, errors.New(usage) + } + if tailSet { + return dockerAction{}, errors.New(usage) + } + tail, err := parseTail(value) + if err != nil { + return dockerAction{}, err + } + action.tail = tail + tailSet = true + } + if len(fields) != 1 || !mission.ValidDockerLogicalName(fields[0]) { + return dockerAction{}, errors.New(usage) + } + action.alias = fields[0] + return action, nil +} + +func parseTail(value string) (int, error) { + if value == "all" { + return -1, nil + } + if value == "" || strings.TrimLeft(value, "0123456789") != "" { + return 0, fmt.Errorf("docker logs --tail requires a whole number or all") + } + tail, err := strconv.Atoi(value) + if err != nil || tail > maxLogTailLines { + return 0, fmt.Errorf("docker logs --tail accepts at most %d lines in this lab", maxLogTailLines) + } + return tail, nil +} + +func sortedKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/dockerlab/process_unix.go b/internal/dockerlab/process_unix.go new file mode 100644 index 0000000..dac8e31 --- /dev/null +++ b/internal/dockerlab/process_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package dockerlab + +import ( + "errors" + "syscall" +) + +// processAlive reports whether pid names a live process. Signal 0 performs +// only the existence and permission check; EPERM still proves existence. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} diff --git a/internal/dockerlab/process_windows.go b/internal/dockerlab/process_windows.go new file mode 100644 index 0000000..424883a --- /dev/null +++ b/internal/dockerlab/process_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package dockerlab + +import ( + "errors" + "syscall" +) + +const ( + processQueryLimitedInformation = 0x1000 + processStillActive = 259 +) + +// processAlive reports whether pid names a live process. Uncertain results +// report alive so orphan cleanup stays conservative. +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) + if err != nil { + return errors.Is(err, syscall.ERROR_ACCESS_DENIED) + } + defer syscall.CloseHandle(handle) + var exitCode uint32 + if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil { + return true + } + return exitCode == processStillActive +} diff --git a/internal/dockerlab/triage_test.go b/internal/dockerlab/triage_test.go new file mode 100644 index 0000000..e1f4e41 --- /dev/null +++ b/internal/dockerlab/triage_test.go @@ -0,0 +1,319 @@ +package dockerlab + +import ( + "context" + "reflect" + "strings" + "testing" + "time" + + "github.com/aleksandergregersen/opsquest/internal/mission" +) + +func testTriageDockerMission() mission.Mission { + success := 0 + crash := 70 + return mission.Mission{ + ID: "docker-triage", + Number: 30, + Track: mission.TrackDocker, + Environment: mission.EnvironmentDocker, + Docker: &mission.DockerSetup{ + Images: []mission.DockerImageSpec{{Alias: "fixture", Reference: testImageReference}}, + Containers: []mission.DockerContainerSpec{ + {Name: "web-a", Image: "fixture", State: mission.DockerStateRunning, Health: mission.DockerHealthHealthy}, + {Name: "web-b", Image: "fixture", State: mission.DockerStateRunning, Health: mission.DockerHealthUnhealthy, Log: "INFO boot\nWARN pool 80%\nERROR pool exhausted"}, + {Name: "web-c", Image: "fixture", State: mission.DockerStateStopped, Health: mission.DockerHealthHealthy}, + {Name: "report", Image: "fixture", State: mission.DockerStateStopped, Log: "report complete", ExitCode: &success}, + {Name: "payments", Image: "fixture", State: mission.DockerStateRunning, Log: "FATAL key missing", ExitCode: &crash, Restart: mission.DockerRestartOnFailure}, + }, + }, + } +} + +func TestParserAcceptsTriageSubset(t *testing.T) { + tests := []struct { + line string + want dockerAction + }{ + {line: "docker rm report", want: dockerAction{kind: actionRemove, alias: "report"}}, + {line: "docker container rm report", want: dockerAction{kind: actionRemove, alias: "report"}}, + {line: "docker logs web-b", want: dockerAction{kind: actionLogs, alias: "web-b", tail: -1}}, + {line: "docker logs --tail 3 web-b", want: dockerAction{kind: actionLogs, alias: "web-b", tail: 3}}, + {line: "docker logs -n 0 web-b", want: dockerAction{kind: actionLogs, alias: "web-b", tail: 0}}, + {line: "docker container logs --tail=10000 web-b", want: dockerAction{kind: actionLogs, alias: "web-b", tail: 10000}}, + {line: "docker logs --tail all web-b", want: dockerAction{kind: actionLogs, alias: "web-b", tail: -1}}, + {line: "docker ps --filter status=exited", want: dockerAction{kind: actionList, filters: []listFilter{{key: "status", value: "exited"}}}}, + {line: "docker ps -a -f health=unhealthy", want: dockerAction{kind: actionList, all: true, filters: []listFilter{{key: "health", value: "unhealthy"}}}}, + {line: "docker container ls --filter=status=running --all", want: dockerAction{kind: actionList, all: true, filters: []listFilter{{key: "status", value: "running"}}}}, + {line: "docker ps -f status=exited -f status=created -f health=none", want: dockerAction{kind: actionList, filters: []listFilter{{key: "status", value: "exited"}, {key: "status", value: "created"}, {key: "health", value: "none"}}}}, + } + for _, test := range tests { + got, err := parseAction(test.line) + if err != nil { + t.Errorf("parseAction(%q) error = %v", test.line, err) + continue + } + if !reflect.DeepEqual(got, test.want) { + t.Errorf("parseAction(%q) = %#v, want %#v", test.line, got, test.want) + } + } +} + +func TestParserRejectsOutsideTriageSubsetBeforeTransport(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + commandRunner.resetCalls() + tests := []struct { + line string + wantErr string + }{ + {line: "docker rm -f api", wantErr: "--force is outside"}, + {line: "docker rm api --force", wantErr: "--force is outside"}, + {line: "docker rm api metrics", wantErr: "usage: docker rm ALIAS"}, + {line: "docker rm", wantErr: "usage: docker rm ALIAS"}, + {line: "docker rm $(docker ps -aq)", wantErr: "usage: docker rm ALIAS"}, + {line: "docker logs --tail api", wantErr: "usage: docker logs"}, + {line: "docker logs --tail -1 api", wantErr: "whole number"}, + {line: "docker logs --tail 3x api", wantErr: "whole number"}, + {line: "docker logs --tail 10001 api", wantErr: "at most 10000"}, + {line: "docker logs --tail 99999999999999999999 api", wantErr: "at most 10000"}, + {line: "docker logs --tail 3 --tail 4 api", wantErr: "usage: docker logs"}, + {line: "docker logs --since 1m api", wantErr: "usage: docker logs"}, + {line: "docker logs --follow api", wantErr: "usage: docker logs"}, + {line: "docker logs api --tail 3", wantErr: "usage: docker logs"}, + {line: "docker ps --filter name=api", wantErr: "outside this teaching subset"}, + {line: "docker ps --filter label=com.opsquest.managed=true", wantErr: "outside this teaching subset"}, + {line: "docker ps --filter status=paused", wantErr: "status accepts"}, + {line: "docker ps --filter status", wantErr: "outside this teaching subset"}, + {line: "docker ps --filter", wantErr: "requires KEY=VALUE"}, + {line: "docker ps -a -a", wantErr: "usage: docker ps"}, + {line: "docker ps --format '{{.ID}}'", wantErr: "usage: docker ps"}, + {line: "docker ps -f status=exited -f status=created -f status=running -f status=restarting -f health=none", wantErr: "at most 4 filters"}, + } + for _, test := range tests { + 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 player input reached Docker transport: %q", calls) + } +} + +func TestRemoveOnlyStoppedContainersAndHideRemovedIdentity(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createTestEnvironment(t, commandRunner) + api := environment.byAlias["api"] + metricsID := environment.byAlias["metrics"].id + commandRunner.resetCalls() + + if _, err := environment.Execute(context.Background(), "docker rm metrics"); err == nil || !strings.Contains(err.Error(), "stop it first") { + t.Fatalf("removing running container error = %v", err) + } + for _, call := range commandRunner.snapshotCalls() { + if hasPrefix(call, "container", "rm") { + t.Fatalf("running container removal reached Docker: %q", call) + } + } + if !commandRunner.hasContainer(metricsID) { + t.Fatal("running container was removed") + } + + absent := mission.Condition{Type: mission.ConditionDockerContainerAbsent, Container: "api"} + if got, err := environment.Observe(context.Background(), absent); err != nil || got { + t.Fatalf("absent before rm = %v, %v", got, err) + } + removed, err := environment.Execute(context.Background(), "docker container rm api") + if err != nil || removed.Output != "api\n" { + t.Fatalf("rm output = %q, error = %v", removed.Output, err) + } + if _, call := firstCall(t, commandRunner.snapshotCalls(), "container", "rm"); !reflect.DeepEqual(call, []string{"container", "rm", api.id}) { + t.Fatalf("rm transport = %q, want exact ID without --force", call) + } + if got, err := environment.Observe(context.Background(), absent); err != nil || !got { + t.Fatalf("absent after rm = %v, %v", got, err) + } + stopped := mission.Condition{Type: mission.ConditionDockerContainerStopped, Container: "api"} + if got, err := environment.Observe(context.Background(), stopped); err != nil || got { + t.Fatalf("removed container reported stopped = %v, %v", got, err) + } + count := 1 + if got, err := environment.Observe(context.Background(), mission.Condition{Type: mission.ConditionDockerContainerCountEqual, Count: &count}); err != nil || !got { + t.Fatalf("count after rm = %v, %v", got, err) + } + listed, err := environment.Execute(context.Background(), "docker ps -a") + if err != nil || strings.Contains(listed.Output, "api") || !strings.Contains(listed.Output, "metrics") { + t.Fatalf("list after rm = %q, error = %v", listed.Output, err) + } + + for _, line := range []string{"docker start api", "docker stop api", "docker restart api", "docker logs api", "docker logs --tail 1 api"} { + _, err := environment.Execute(context.Background(), line) + if err == nil || !strings.Contains(err.Error(), `container "api" has been removed`) { + t.Errorf("Execute(%q) error = %v", line, err) + continue + } + if strings.Contains(err.Error(), api.id) || strings.Contains(err.Error(), api.id[:12]) || strings.Contains(err.Error(), api.actualName) { + t.Errorf("Execute(%q) leaked engine identity: %v", line, err) + } + } + if _, err := environment.Execute(context.Background(), "docker rm api"); err == nil || !strings.Contains(err.Error(), "already been removed") { + t.Fatalf("second rm error = %v", err) + } + if _, err := environment.Execute(context.Background(), "docker inspect api"); err == nil || !strings.Contains(err.Error(), "no longer exists") { + t.Fatalf("inspect removed error = %v", err) + } + if err := environment.Close(); err != nil { + t.Fatalf("Close() after player removal = %v", err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers remaining after Close = %d", commandRunner.containerCount()) + } +} + +func TestEngineErrorsRedactExactIdentity(t *testing.T) { + tracked := &trackedContainer{id: strings.Repeat("c", 64), logicalID: "lab-01", alias: "api", actualName: "opsquest-" + strings.Repeat("a", 24) + "-c01"} + message := "Error: cannot start " + tracked.id + " (" + tracked.id[:12] + ") named " + tracked.actualName + got := redactIdentifiers(message, tracked) + if strings.Contains(got, tracked.id[:12]) || strings.Contains(got, tracked.actualName) || !strings.Contains(got, "lab-01") || !strings.Contains(got, "api") { + t.Fatalf("redactIdentifiers() = %q", got) + } +} + +func TestTriageFixturesUseFixedBehaviors(t *testing.T) { + commandRunner := newFakeDockerRunner() + environment := createEnvironmentForMission(t, commandRunner, testTriageDockerMission()) + calls := commandRunner.snapshotCalls() + 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 + } + } + } + } + assertArgs := func(alias string, want ...string) { + t.Helper() + call := creates[alias] + for index := 0; index+len(want) <= len(call); index++ { + if reflect.DeepEqual(call[index:index+len(want)], want) { + return + } + } + t.Errorf("%s create args %q do not contain %q", alias, call, want) + } + refuteArg := func(alias, flag string) { + t.Helper() + for _, arg := range creates[alias] { + if arg == flag { + t.Errorf("%s create args unexpectedly contain %q: %q", alias, flag, creates[alias]) + } + } + } + assertArgs("web-a", "--health-cmd", "true", "--health-interval", "1s", "--health-timeout", "1s", "--health-retries", "1") + assertArgs("web-b", "--health-cmd", "false") + assertArgs("web-b", "--entrypoint", "/bin/sh", testImageReference, "-c", `printf '%s\n' "$1"; exec sleep "$2"`, "opsquest-service", "INFO boot\nWARN pool 80%\nERROR pool exhausted", "86400") + assertArgs("web-a", "--entrypoint", "/bin/sleep", testImageReference, "86400") + assertArgs("web-a", "--restart", "no") + assertArgs("payments", "--restart", "on-failure:50") + assertArgs("payments", "opsquest-diagnostic", "FATAL key missing", "70") + refuteArg("payments", "--health-cmd") + refuteArg("report", "--health-cmd") + for alias, call := range creates { + for _, forbidden := range []string{"--privileged", "--volume", "--mount", "-p", "--publish", "--network=host"} { + for _, arg := range call { + if arg == forbidden { + t.Errorf("%s create args contain unsafe %q", alias, forbidden) + } + } + } + } + for _, call := range calls { + if hasPrefix(call, "container", "wait", environment.byAlias["payments"].id) { + t.Fatal("setup waited for a crash-loop fixture to stop") + } + } + + listed, err := environment.Execute(context.Background(), "docker ps") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"running (healthy)", "running (unhealthy)", "restarting", "web-a", "web-b", "payments"} { + if !strings.Contains(listed.Output, want) { + t.Errorf("ps output missing %q:\n%s", want, listed.Output) + } + } + if strings.Contains(listed.Output, "web-c") || strings.Contains(listed.Output, "report") { + t.Errorf("ps without --all listed stopped containers:\n%s", listed.Output) + } + + unhealthy, err := environment.Execute(context.Background(), "docker ps --filter health=unhealthy") + if err != nil || !strings.Contains(unhealthy.Output, "web-b") || strings.Contains(unhealthy.Output, "web-a") || strings.Contains(unhealthy.Output, "payments") { + t.Fatalf("health filter output = %q, error = %v", unhealthy.Output, err) + } + exited, err := environment.Execute(context.Background(), "docker ps --filter status=exited") + if err != nil || !strings.Contains(exited.Output, "report") || strings.Contains(exited.Output, "web-a") || strings.Contains(exited.Output, "web-c") { + t.Fatalf("status filter without --all output = %q, error = %v", exited.Output, err) + } + created, err := environment.Execute(context.Background(), "docker ps -f status=created -f status=exited") + if err != nil || !strings.Contains(created.Output, "report") || !strings.Contains(created.Output, "web-c") || strings.Contains(created.Output, "web-a") { + t.Fatalf("alternative status filters output = %q, error = %v", created.Output, err) + } + none, err := environment.Execute(context.Background(), "docker ps --filter status=running --filter health=none") + if err != nil || strings.Contains(none.Output, "web-") || !strings.Contains(none.Output, "CONTAINER ID") { + t.Fatalf("combined filters output = %q, error = %v", none.Output, err) + } + + inspected, err := environment.Execute(context.Background(), "docker inspect web-b") + if err != nil || !strings.Contains(inspected.Output, `"Health": {`) || !strings.Contains(inspected.Output, `"Status": "unhealthy"`) || !strings.Contains(inspected.Output, `"Name": "no"`) { + t.Fatalf("health inspection = %q, error = %v", inspected.Output, err) + } + inspected, err = environment.Execute(context.Background(), "docker inspect payments") + if err != nil || !strings.Contains(inspected.Output, `"RestartCount": 2`) || !strings.Contains(inspected.Output, `"Name": "on-failure"`) || !strings.Contains(inspected.Output, `"MaximumRetryCount": 50`) || !strings.Contains(inspected.Output, `"Restarting": true`) { + t.Fatalf("crash-loop inspection = %q, error = %v", inspected.Output, err) + } + if strings.Contains(inspected.Output, environment.byAlias["payments"].id) || strings.Contains(inspected.Output, "Labels") || strings.Contains(inspected.Output, "Created") { + t.Fatalf("inspection leaked engine metadata:\n%s", inspected.Output) + } + + logs, err := environment.Execute(context.Background(), "docker logs --tail 2 web-b") + if err != nil || logs.Output != "WARN pool 80%\nERROR pool exhausted\n" { + t.Fatalf("tail output = %q, error = %v", logs.Output, err) + } + if _, call := firstCall(t, commandRunner.snapshotCalls(), "container", "logs", "--tail"); !reflect.DeepEqual(call, []string{"container", "logs", "--tail", "2", environment.byAlias["web-b"].id}) { + t.Fatalf("tail transport = %q", call) + } + loopLogs, err := environment.Execute(context.Background(), "docker logs payments") + if err != nil || strings.Count(loopLogs.Output, "FATAL key missing") < 2 { + t.Fatalf("crash-loop logs = %q, error = %v", loopLogs.Output, err) + } + + stopped := mission.Condition{Type: mission.ConditionDockerContainerStopped, Container: "payments"} + if got, err := environment.Observe(context.Background(), stopped); err != nil || got { + t.Fatalf("restarting container reported stopped = %v, %v", got, err) + } + if _, err := environment.Execute(context.Background(), "docker stop payments"); err != nil { + t.Fatal(err) + } + if got, err := environment.Observe(context.Background(), stopped); err != nil || !got { + t.Fatalf("stopped crash loop = %v, %v", got, err) + } +} + +func TestFixtureReadinessTimesOutAndCleansUp(t *testing.T) { + commandRunner := newFakeDockerRunner() + item := testTriageDockerMission() + // A healthy probe on a fixture that never reports health cannot settle. + item.Docker.Containers = []mission.DockerContainerSpec{{Name: "web-a", Image: "fixture", State: mission.DockerStateRunning, Health: mission.DockerHealthHealthy}} + factory := testFactory(commandRunner) + factory.readyTimeout = 20 * time.Millisecond + commandRunner.stripHealth = true + if _, err := factory.Create(context.Background(), item); err == nil || !strings.Contains(err.Error(), "did not reach its teaching state") { + t.Fatalf("Create() error = %v", err) + } + if commandRunner.containerCount() != 0 { + t.Fatalf("containers remaining after failed readiness = %d", commandRunner.containerCount()) + } +} diff --git a/internal/game/environment.go b/internal/game/environment.go index 29a6df1..d008744 100644 --- a/internal/game/environment.go +++ b/internal/game/environment.go @@ -79,6 +79,15 @@ type AvailabilityChecker interface { Availability(context.Context, mission.Mission) Availability } +// ResourceJanitor is implemented by factories whose attempt resources can +// outlive an OpsQuest process that exits without cleanup. Orphans are only +// resources whose owning process is provably gone or whose lifetime has +// expired; live attempts are never reported or removed. +type ResourceJanitor interface { + OrphanedResources(context.Context) (int, error) + RemoveOrphanedResources(context.Context) (int, error) +} + // EnvironmentAvailability reports ready by default so simple test factories // and the simulated environment do not need boilerplate capability checks. func EnvironmentAvailability(ctx context.Context, factory Factory, item mission.Mission) Availability { diff --git a/internal/game/validator.go b/internal/game/validator.go index 391ef84..1b21f29 100644 --- a/internal/game/validator.go +++ b/internal/game/validator.go @@ -110,6 +110,8 @@ func describeCondition(condition mission.Condition) string { return fmt.Sprintf("Exactly %d mission containers exist", *condition.Count) } return "The required number of mission containers exist" + case mission.ConditionDockerContainerAbsent: + return fmt.Sprintf("Container %s has been removed", condition.Container) default: return fmt.Sprintf("Outcome condition %s is satisfied", condition.Type) } diff --git a/internal/mission/catalog.go b/internal/mission/catalog.go index 3ed8553..c44f151 100644 --- a/internal/mission/catalog.go +++ b/internal/mission/catalog.go @@ -212,7 +212,7 @@ func validateMission(item Mission) error { if err := validateCondition(condition, environment); err != nil { return fmt.Errorf("validation condition %d: %w", index+1, err) } - if (condition.Type == ConditionDockerContainerRunning || condition.Type == ConditionDockerContainerStopped) && !dockerSetupHasContainer(item.Docker, condition.Container) { + if condition.Container != "" && !dockerSetupHasContainer(item.Docker, condition.Container) { return fmt.Errorf("validation condition %d: unknown docker container %q", index+1, condition.Container) } } @@ -268,29 +268,64 @@ func ValidateDockerSetup(setup DockerSetup) error { default: return fmt.Errorf("docker container %q has unknown state %q", container.Name, container.State) } - hasDiagnostic := container.Log != "" || container.ExitCode != nil - if hasDiagnostic { - if container.Log == "" || container.ExitCode == nil { - return fmt.Errorf("docker container %q diagnostic fixture requires both log and exit_code", container.Name) - } - if len(container.Log) > maxDockerFixtureLogBytes { - return fmt.Errorf("docker container %q log exceeds the %d-byte limit", container.Name, maxDockerFixtureLogBytes) - } - if strings.ContainsRune(container.Log, 0) { - return fmt.Errorf("docker container %q log cannot contain NUL", container.Name) - } - if *container.ExitCode < 0 || *container.ExitCode > 255 { - return fmt.Errorf("docker container %q exit_code must be between 0 and 255", container.Name) - } - if container.State != DockerStateStopped { - return fmt.Errorf("docker container %q diagnostic fixture must use stopped state", container.Name) - } + if len(container.Log) > maxDockerFixtureLogBytes { + return fmt.Errorf("docker container %q log exceeds the %d-byte limit", container.Name, maxDockerFixtureLogBytes) + } + if strings.ContainsRune(container.Log, 0) { + return fmt.Errorf("docker container %q log cannot contain NUL", container.Name) + } + if err := validateDockerFixtureBehavior(container); err != nil { + return err } containers[container.Name] = true } return nil } +// validateDockerFixtureBehavior accepts only the fixed fixture behaviors the +// Docker adapter implements: a long-lived service with optional startup log +// and health probe, a one-shot diagnostic job, or a bounded crash loop. +func validateDockerFixtureBehavior(container DockerContainerSpec) error { + switch container.Health { + case "", DockerHealthHealthy, DockerHealthUnhealthy: + default: + return fmt.Errorf("docker container %q has unknown health %q", container.Name, container.Health) + } + switch container.Restart { + case "", DockerRestartOnFailure: + default: + return fmt.Errorf("docker container %q has unknown restart behavior %q", container.Name, container.Restart) + } + if container.ExitCode == nil { + if container.Restart != "" { + return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) + } + return nil + } + if container.Log == "" { + return fmt.Errorf("docker container %q exit_code requires a log", container.Name) + } + if *container.ExitCode < 0 || *container.ExitCode > 255 { + return fmt.Errorf("docker container %q exit_code must be between 0 and 255", container.Name) + } + if container.Health != "" { + return fmt.Errorf("docker container %q exiting fixture cannot declare health", container.Name) + } + if container.Restart == DockerRestartOnFailure { + if *container.ExitCode == 0 { + return fmt.Errorf("docker container %q restart behavior requires log and a non-zero exit_code", container.Name) + } + if container.State != DockerStateRunning { + return fmt.Errorf("docker container %q crash-loop fixture must use running state", container.Name) + } + return nil + } + if container.State != DockerStateStopped { + return fmt.Errorf("docker container %q diagnostic fixture must use stopped state", container.Name) + } + return nil +} + func dockerSetupHasContainer(setup *DockerSetup, name string) bool { if setup == nil { return false @@ -431,6 +466,7 @@ var allowedConditionFields = map[ConditionType]conditionFields{ ConditionDockerContainerRunning: conditionContainer, ConditionDockerContainerStopped: conditionContainer, ConditionDockerContainerCountEqual: conditionCount, + ConditionDockerContainerAbsent: conditionContainer, } var conditionFieldFlags = map[string]conditionFields{ @@ -526,7 +562,7 @@ func validateCondition(condition Condition, environment string) error { if !found || !variablePattern.MatchString(name) { return fmt.Errorf("value must be NAME=value") } - case ConditionDockerContainerRunning, ConditionDockerContainerStopped: + case ConditionDockerContainerRunning, ConditionDockerContainerStopped, ConditionDockerContainerAbsent: if environment != EnvironmentDocker { return fmt.Errorf("%s requires a docker environment", condition.Type) } diff --git a/internal/mission/catalog_test.go b/internal/mission/catalog_test.go index e4e539e..e43869d 100644 --- a/internal/mission/catalog_test.go +++ b/internal/mission/catalog_test.go @@ -276,19 +276,85 @@ func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { wantErr: "unknown state", }, { - name: "diagnostic log without exit code", + name: "diagnostic exit code without log", mutate: func(item *Mission) { - item.Docker.Containers[0].Log = "diagnostic" + exitCode := 1 + item.Docker.Containers[0].ExitCode = &exitCode }, - wantErr: "requires both log and exit_code", + wantErr: "exit_code requires a log", }, { - name: "diagnostic exit code without log", + name: "service log too large", + mutate: func(item *Mission) { + item.Docker.Containers[1].Log = strings.Repeat("x", maxDockerFixtureLogBytes+1) + }, + wantErr: "log exceeds", + }, + { + name: "unknown health", + mutate: func(item *Mission) { + item.Docker.Containers[1].Health = "starting" + }, + wantErr: "unknown health", + }, + { + name: "health on exiting fixture", + mutate: func(item *Mission) { + exitCode := 0 + item.Docker.Containers[0].Log = "complete" + item.Docker.Containers[0].ExitCode = &exitCode + item.Docker.Containers[0].Health = DockerHealthHealthy + }, + wantErr: "cannot declare health", + }, + { + name: "unknown restart behavior", + mutate: func(item *Mission) { + item.Docker.Containers[1].Restart = "always" + }, + wantErr: "unknown restart behavior", + }, + { + name: "restart without crash", + mutate: func(item *Mission) { + item.Docker.Containers[1].Restart = DockerRestartOnFailure + }, + wantErr: "requires log and a non-zero exit_code", + }, + { + name: "restart with successful exit", + mutate: func(item *Mission) { + exitCode := 0 + item.Docker.Containers[1].Log = "complete" + item.Docker.Containers[1].ExitCode = &exitCode + item.Docker.Containers[1].Restart = DockerRestartOnFailure + }, + wantErr: "requires log and a non-zero exit_code", + }, + { + name: "crash loop declared stopped", mutate: func(item *Mission) { exitCode := 1 + item.Docker.Containers[0].Log = "FATAL" item.Docker.Containers[0].ExitCode = &exitCode + item.Docker.Containers[0].Restart = DockerRestartOnFailure }, - wantErr: "requires both log and exit_code", + wantErr: "crash-loop fixture must use running state", + }, + { + name: "unknown absent validation container", + mutate: func(item *Mission) { + item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "missing"} + }, + wantErr: "unknown docker container", + }, + { + name: "absent condition with count", + mutate: func(item *Mission) { + count := 1 + item.Validation.All[0] = Condition{Type: ConditionDockerContainerAbsent, Container: "api", Count: &count} + }, + wantErr: "does not support count", }, { name: "diagnostic exit code outside range", @@ -389,15 +455,35 @@ func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { } } +func TestMissionValidationAcceptsDockerFixtureBehaviors(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + item, _ := catalog.Find("docker-container-census") + failure := 3 + item.Docker.Containers = []DockerContainerSpec{ + {Name: "api", Image: "fixture", State: DockerStateStopped, Log: "INFO ready", Health: DockerHealthHealthy}, + {Name: "metrics", Image: "fixture", State: DockerStateRunning, Health: DockerHealthUnhealthy}, + {Name: "payments", Image: "fixture", State: DockerStateRunning, Log: "FATAL key missing", ExitCode: &failure, Restart: DockerRestartOnFailure}, + } + item.Validation.All = append(item.Validation.All, Condition{Type: ConditionDockerContainerAbsent, Container: "payments"}) + if err := validateMission(item); err != nil { + t.Fatalf("validateMission() error = %v", err) + } +} + func TestMissionValidationRejectsDockerConditionOnSimulatedMission(t *testing.T) { catalog, err := LoadCatalog() if err != nil { t.Fatal(err) } item, _ := catalog.Find("linux-orientation") - item.Validation.All = []Condition{{Type: "docker_container_running", Container: "api"}} - if err := validateMission(item); err == nil || !strings.Contains(err.Error(), "requires a docker environment") { - t.Fatalf("validateMission() error = %v", err) + for _, conditionType := range []ConditionType{ConditionDockerContainerRunning, ConditionDockerContainerAbsent} { + item.Validation.All = []Condition{{Type: conditionType, Container: "api"}} + if err := validateMission(item); err == nil || !strings.Contains(err.Error(), "requires a docker environment") { + t.Fatalf("validateMission(%s) error = %v", conditionType, err) + } } } diff --git a/internal/mission/mission.go b/internal/mission/mission.go index 1ee8754..f3caaf9 100644 --- a/internal/mission/mission.go +++ b/internal/mission/mission.go @@ -18,6 +18,11 @@ const ( DockerStateRunning = "running" DockerStateStopped = "stopped" + + DockerHealthHealthy = "healthy" + DockerHealthUnhealthy = "unhealthy" + + DockerRestartOnFailure = "on-failure" ) // ConditionType identifies one observable outcome supported by a mission @@ -45,6 +50,7 @@ const ( ConditionDockerContainerRunning ConditionType = "docker_container_running" ConditionDockerContainerStopped ConditionType = "docker_container_stopped" ConditionDockerContainerCountEqual ConditionType = "docker_container_count_equals" + ConditionDockerContainerAbsent ConditionType = "docker_container_absent" ) // Mission is a declarative OpsQuest exercise. Setup describes the isolated @@ -137,12 +143,17 @@ type DockerImageSpec struct { Reference string `json:"reference"` } +// DockerContainerSpec is one logical fixture. Log, ExitCode, Health, and +// Restart select fixed fixture behaviors implemented by the Docker adapter; +// mission content never supplies an executable command. type DockerContainerSpec struct { Name string `json:"name"` Image string `json:"image"` State string `json:"state"` Log string `json:"log,omitempty"` ExitCode *int `json:"exit_code,omitempty"` + Health string `json:"health,omitempty"` + Restart string `json:"restart,omitempty"` } type Validation struct { From dba6db704a444a829966b383fa12523f322605d5 Mon Sep 17 00:00:00 2001 From: Aleksandergreg Date: Wed, 30 Sep 2026 18:22:00 +0200 Subject: [PATCH 4/4] feat(docker): add Container Triage world and orphaned lab cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Docker World 2, "Container Triage" (Missions 30–34), built on the rm, logs --tail, ps --filter, health-probe, and crash-loop support from the previous commit: - Janitor Duty: remove finished job containers, keep services running - Tail End: show only the final lines of a long job log - Running Isn't Healthy: swap an unhealthy replica for its standby - Crash Loop: stop a restart-policy loop and surface its fault - Postmortem Triage: combine health, exit codes, stop, and rm while keeping the evidence the postmortem needs Add `opsquest doctor --cleanup` to remove lab containers left behind by OpsQuest processes that exited without cleanup. A plain `opsquest doctor` now reports them without removing anything. Also: - start every fixture before waiting on health probes, so setup waits one probe interval instead of one per fixture - keep the original 8-column STATUS layout in `docker ps` - add real-engine integration tests for the triage fixtures and the orphan sweep (run with OPSQUEST_DOCKER_TEST=1) - update smoke assertions, mission counts (34 total, 11 Docker), the mission map, safety model, authoring guide, roadmap, and diagram Schema changes are additive and persisted profiles need no migration. --- .../references/mission-checklist.md | 4 +- AGENTS.md | 2 +- README.md | 9 +- docs/README.md | 2 +- .../play/diagrams/learning-journey.excalidraw | 4 +- docs/play/diagrams/learning-journey.svg | 10 +- docs/play/docker-foundations.md | 37 ++++- docs/play/mission-map.md | 17 ++- docs/roadmap/README.md | 14 +- docs/technical/mission-authoring.md | 16 ++- docs/technical/sandbox-and-safety.md | 22 ++- internal/cli/app.go | 50 ++++++- internal/cli/app_test.go | 130 ++++++++++++++++- internal/dockerlab/environment.go | 3 +- internal/dockerlab/factory.go | 21 +-- internal/dockerlab/integration_test.go | 128 +++++++++++++++++ internal/dockerlab/triage_test.go | 12 ++ internal/game/missions_test.go | 133 ++++++++++++++---- internal/mission/catalog_test.go | 52 ++++++- internal/mission/data/30-janitor-duty.json | 93 ++++++++++++ internal/mission/data/31-tail-end.json | 49 +++++++ .../mission/data/32-running-isnt-healthy.json | 75 ++++++++++ internal/mission/data/33-crash-loop.json | 67 +++++++++ .../mission/data/34-postmortem-triage.json | 98 +++++++++++++ internal/mission/world_test.go | 13 +- scripts/smoke-test.sh | 12 +- 26 files changed, 992 insertions(+), 81 deletions(-) create mode 100644 internal/mission/data/30-janitor-duty.json create mode 100644 internal/mission/data/31-tail-end.json create mode 100644 internal/mission/data/32-running-isnt-healthy.json create mode 100644 internal/mission/data/33-crash-loop.json create mode 100644 internal/mission/data/34-postmortem-triage.json diff --git a/.agents/skills/add-mission/references/mission-checklist.md b/.agents/skills/add-mission/references/mission-checklist.md index 51701b0..b86ecc0 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. 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`). 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` with a declared logical container alias and `docker_container_count_equals` with a non-negative count. +- 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. 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 35c47b9..b45d707 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 6 optional Docker Foundations labs. 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 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. ## Repository map diff --git a/README.md b/README.md index 09fcf08..fe01540 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 -- 6 optional, disposable Docker Foundations missions +- 11 optional, disposable Docker missions across two worlds: Foundations and Container Triage - 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 @@ -85,7 +85,10 @@ $ opsquest doctor $ opsquest play 20 ``` -OpsQuest never pulls the image automatically. See +OpsQuest never pulls the image automatically. If an OpsQuest process is +killed before it can clean up, the next Docker mission removes its leftover lab +containers; `opsquest doctor` reports them and `opsquest doctor --cleanup` +removes them on demand. See [Docker Foundations](docs/play/docker-foundations.md) for Docker Desktop, Docker Engine, and OrbStack setup, plus the teaching subset and isolation boundary. @@ -94,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. 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. 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 5bf1fa6..67b2f6c 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 -- 6 optional Docker Foundations missions +- 11 optional Docker missions across two 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 820a829..cdca9f3 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 WORLD 1\nIt Works on My Machine\nMissions 20–25 · lifecycle + diagnostics","textAlign":"left","verticalAlign":"top","containerId":null,"originalText":"DOCKER WORLD 1\nIt Works on My Machine\nMissions 20–25 · lifecycle + diagnostics","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–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-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\n6 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\n6 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\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":"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 57178cb..114efe1 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 one independent six-mission Docker Foundations world. + Two-lane curriculum overview showing four Linux worlds with twenty-three missions and two independent Docker worlds with eleven missions. @@ -74,16 +74,16 @@ DOCKER TRACK · OPTIONAL · INDEPENDENT WORLD NUMBERING - Docker World 1 - It Works on My Machine - Missions 20–25 · 6 beginner lifecycle and diagnostic stages + Docker Worlds 1–2 + Foundations → Container Triage + Missions 20–34 · 11 stages; Linux 26–29 sit between Separate readiness and progress Pinned local fixture · no automatic pull · Docker never blocks Linux - 6 bounded missions · typed list, logs, inspect, and lifecycle actions only. + 11 bounded missions · typed list, filter, logs, inspect, lifecycle, and rm only. OUTCOME-BASED VALIDATION diff --git a/docs/play/docker-foundations.md b/docs/play/docker-foundations.md index 314a2e2..ba9a520 100644 --- a/docs/play/docker-foundations.md +++ b/docs/play/docker-foundations.md @@ -1,5 +1,5 @@ --- -description: Prepare and play the optional, disposable OpsQuest Docker Foundations missions. +description: Prepare and play the optional, disposable OpsQuest Docker missions. audience: players status: current --- @@ -39,7 +39,7 @@ $ opsquest play --track docker ## Current lessons -The 6 beginner missions cover a deliberately narrow operational loop: +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: 1. **Container Census** — list containers and start an existing stopped service. 2. **Last Broadcast** — read bounded logs from an exited one-shot job. @@ -48,12 +48,43 @@ The 6 beginner missions cover a deliberately narrow operational loop: 5. **Recovery Pair** — restore two stopped services without replacing them. 6. **Shift Handoff** — combine `start` and `stop` while preserving supporting metrics. -The delivered teaching subset recognizes container listing plus `start`, `restart`, `stop`, `inspect`, `logs`, and `help` forms. It accepts exact logical aliases without forwarding arbitrary Docker CLI arguments or flags. +**World 2: Container Triage** adds health checks, restart policies, log tails, and removal: + +1. **Janitor Duty** — remove finished job containers while services keep running. +2. **Tail End** — show only the last lines of a long job log. +3. **Running Isn't Healthy** — pull a running but unhealthy replica from service and start its standby. +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. + +## Teaching subset + +| Command | Supported forms | +| --- | --- | +| List | `docker ps` or `docker container ls`, with `-a`/`--all` and up to four `--filter`/`-f` values: `status=created\|running\|restarting\|exited` or `health=starting\|healthy\|unhealthy\|none` | +| Lifecycle | `start`, `restart`, and `stop` with one alias | +| 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`) | + +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. ## 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. +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: + +```console +$ opsquest doctor # reports orphaned lab containers +$ 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. + 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. See [Sandbox and safety](../technical/sandbox-and-safety.md#docker-teaching-boundary) diff --git a/docs/play/mission-map.md b/docs/play/mission-map.md index 76b7c3d..28aa62c 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:** 6 optional Foundations missions in one beginner world. Docker readiness never blocks Linux play. +- **Docker:** 11 optional missions across two worlds: Foundations, then Container Triage. Docker readiness never blocks Linux play. -Display numbers are global across both tracks: Docker occupies Missions 20–25 and Linux World 4 resumes at Mission 26. 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, 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. The [learning philosophy](../game/learning-philosophy.md) explains the incident loop and feedback model. [Mission authoring](../technical/mission-authoring.md) @@ -30,7 +30,7 @@ describes how validators preserve equivalent solutions. ```console $ opsquest play # resume Linux progress $ opsquest map # see every world and stage -$ opsquest map --track docker # see the optional Docker world +$ opsquest map --track docker # see the optional Docker worlds $ opsquest play --world 3 # stay within Production Friday $ opsquest play 19 # begin at a global mission number $ opsquest play linux-find-logs # begin at a stable mission ID @@ -75,6 +75,11 @@ The “tools” column records suggested commands, not a mandatory solution. Out | 27 | Linux W4.2 | Beginner | Make a focused configuration edit with a modal editor | `cat`, `vi` | Content and mode equal | | 28 | Linux W4.3 | Intermediate | Repair, permit, and run a reusable report script | `cat`, `less`, `vi`, `sed`, `chmod`, `sh` | Report content/lines and script mode | | 29 | Linux W4.4 | Advanced | Reason about child-shell directory and environment scope | `cat`, `less`, `vi`, `sed`, `chmod`, `sh`, `pwd`, `env` | Parent scope preserved; report and script state correct | +| 30 | Docker W2.1 | Beginner | Remove finished job containers while services keep running | `docker` | Jobs absent; services running; count equals | +| 31 | Docker W2.2 | Beginner | Read only the end of a long job log | `docker` | Output equals final log lines | +| 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 | ## World progression @@ -98,9 +103,13 @@ The final Linux world moves from executing a supplied script to repairing reusab Docker Foundations progresses through listing, logs, sanitized exit status, targeted stop, multi-service recovery, and a mixed lifecycle handoff. Logical aliases hide real container names and IDs, keeping every lesson focused on observable state for exact attempt-owned resources. +### Docker World 2: Container Triage + +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. + ## Curriculum evolution -The expanded map fills the early file-reading, focused log-preview, simple counting, supplied-script, and beginner Docker lifecycle 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, 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. 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 b5f0c4c..52d1a34 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -11,17 +11,19 @@ behavior. Shipped behavior belongs in the player and technical guides. ## Docker Foundations -The current six-mission campaign intentionally supports only listing, logs, -sanitized inspection, and bounded lifecycle actions for attempt-owned -containers. +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. Possible later increments include: -- environment-aware container creation; +- environment-aware container creation through a limited `docker run`; - limited port publication; - bounded volumes and networking; -- Dockerfile or Compose concepts; -- more advanced outcome-based troubleshooting. +- Dockerfile or Compose concepts, possibly taught in the simulated shell; +- restart-to-recover health lessons, which need fixture state that survives a + restart. Each expansion would widen the external-engine boundary. It requires its own parser contract, threat model, resource ownership rules, cleanup behavior, and diff --git a/docs/technical/mission-authoring.md b/docs/technical/mission-authoring.md index 7bbc19c..eeab863 100644 --- a/docs/technical/mission-authoring.md +++ b/docs/technical/mission-authoring.md @@ -21,7 +21,7 @@ Good evidence includes: - a target process is stopped while a healthy process remains running; - output contains all relevant paths and excludes a distractor; - a report contains exactly the required logical lines; -- an attempt-owned Docker container is in the required state. +- an attempt-owned Docker container is in the required state, or has been removed. Avoid requiring one command name, argument order, or intermediate state when another supported solution demonstrates the same understanding. @@ -62,14 +62,24 @@ Linux remains the default track and `simulated` remains the default environment - missing narrative, guidance, setup, validation, or reward data; - unsafe, conflicting, or inconsistent virtual paths and state; - validators with missing, extra, or incompatible fields; -- unpinned Docker images, invalid aliases, oversized Docker setup, or incomplete diagnostic fixtures; +- unpinned Docker images, invalid aliases, oversized Docker setup, or inconsistent fixture behaviors; - a campaign that reappears as separate worlds in one track. Catalog access is indexed by ID and number. Returned missions and worlds are deep copies so adapters cannot mutate embedded content. ## Observable validation -Conditions cover output, working directory, path existence, file content and logical lines, modes, owners, process state, environment values, and bounded Docker container state. Docker fixtures may additionally declare a bounded log and exit code together for a stopped one-shot diagnostic job; container conditions can require either running or stopped state. The game layer compares output conditions; the active environment observes state conditions through the shared `Environment` contract. +Conditions cover output, working directory, path existence, file content and logical lines, modes, owners, process state, environment values, and bounded Docker container state. Docker fixtures select fixed behaviors implemented in Go rather than supplying commands: + +| Fixture fields | Behavior | Rules | +| --- | --- | --- | +| none | Long-lived service | `running` or `stopped` | +| `log` | Long-lived service that prints a startup log | Log at most 8 KiB, no NUL | +| `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 | + +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. 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 d89eca9..096f42e 100644 --- a/docs/technical/sandbox-and-safety.md +++ b/docs/technical/sandbox-and-safety.md @@ -122,14 +122,25 @@ 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 generates a random session ID and creates containers with generated names and five ownership labels. -4. Player text is parsed into `list`, `start`, `restart`, `stop`, `inspect`, `logs`, or `help`; flags and aliases must match the small grammar. +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. 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. +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. +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 fixtures use a fixed shell program; bounded log text and exit status are passed as data arguments rather than interpolated into that program. Containers do not receive host bind mounts, devices, privileged mode, host networking, or a Docker socket. +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. + +### 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: + +- 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. + +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. Docker-specific ceilings include: @@ -157,6 +168,9 @@ The Docker-compatible engine remains a powerful external dependency; OpsQuest re | Docker input becomes arbitrary CLI flags | Typed parser accepts only exact actions and validated logical aliases | `internal/dockerlab/parser.go` and tests | | Cleanup removes another container | Exact ID plus managed/schema/session/mission/alias label verification | `internal/dockerlab/environment.go` and tests | | Partial Docker setup leaks resources silently | Factory may return a partial environment; managed cleanup and retryable `Close` | `internal/dockerlab/factory.go`, environment contract tests | +| 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 | | 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 d859de5..53adf97 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -739,7 +739,8 @@ func (a *App) runShow(args []string) error { } func (a *App) runDoctor(args []string) error { - flags := a.newFlagSet("doctor", func() { fmt.Fprintln(a.errOut, "Usage: opsquest doctor") }) + flags := a.newFlagSet("doctor", func() { fmt.Fprintln(a.errOut, "Usage: opsquest doctor [--cleanup]") }) + cleanup := flags.Bool("cleanup", false, "remove Docker lab containers left behind by exited OpsQuest processes") if help, err := parseFlags(flags, args); help || err != nil { return err } @@ -756,14 +757,57 @@ func (a *App) runDoctor(args []string) error { fmt.Fprintf(a.out, " %s profile: version %d, %d completed missions\n", check, player.Version, len(player.Completed)) fmt.Fprintf(a.out, " %s profile path: %s\n", check, a.store.Path()) fmt.Fprintf(a.out, " %s Linux labs: in-memory; no host shell or filesystem access\n", check) + dockerReady := false if item, found := a.catalog.FirstInTrack(mission.TrackDocker); found { availability := game.EnvironmentAvailability(a.ctx, a.factory, item) + dockerReady = availability.Available if availability.Available { fmt.Fprintf(a.out, " %s docker labs: ready · %s\n", check, availability.Detail) } else { fmt.Fprintf(a.out, " %s docker labs: unavailable · %s\n", a.style.Warning("!"), availability.Detail) } } + return a.reportOrphanedLabs(*cleanup, dockerReady) +} + +// reportOrphanedLabs checks for, or with cleanup removes, lab resources left +// behind by OpsQuest processes that exited without closing their attempt. +// A plain doctor run stays read-only and only checks a reachable engine. +func (a *App) reportOrphanedLabs(cleanup, dockerReady bool) error { + janitor, supported := a.factory.(game.ResourceJanitor) + check := a.style.Success("✓") + warning := a.style.Warning("!") + if !supported { + if cleanup { + fmt.Fprintf(a.out, " %s docker cleanup: nothing to clean; Docker labs are not configured\n", check) + } + return nil + } + if cleanup { + removed, err := janitor.RemoveOrphanedResources(a.ctx) + if err != nil { + if removed > 0 { + fmt.Fprintf(a.out, " %s docker cleanup: removed %d, then failed · %v\n", warning, removed, err) + } else { + fmt.Fprintf(a.out, " %s docker cleanup: failed · %v\n", warning, err) + } + 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")) + return nil + } + if !dockerReady { + return nil + } + 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) + 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")) + default: + fmt.Fprintf(a.out, " %s docker cleanup: no orphaned lab containers\n", check) + } return nil } @@ -825,7 +869,7 @@ func (a *App) runHelp(args []string) error { case "show", "mission": fmt.Fprintln(a.out, "Usage: opsquest show [MISSION]") case "doctor": - fmt.Fprintln(a.out, "Usage: opsquest doctor") + fmt.Fprintln(a.out, "Usage: opsquest doctor [--cleanup]") case "reset": fmt.Fprintln(a.out, "Usage: opsquest reset [--yes]") case "version": @@ -864,7 +908,7 @@ func (a *App) printUsage() { opsquest commands Show commands practiced successfully opsquest achievements Show learning achievements opsquest show [MISSION] Preview a mission without starting it - opsquest doctor Check the catalog, profile, and safety mode + opsquest doctor Check the catalog, profile, safety mode, and lab cleanup opsquest reset [--yes] Reset local progress opsquest version Print the version diff --git a/internal/cli/app_test.go b/internal/cli/app_test.go index 6d5af61..a649829 100644 --- a/internal/cli/app_test.go +++ b/internal/cli/app_test.go @@ -912,7 +912,9 @@ func TestListDockerTrackAndRejectsUnknownTrack(t *testing.T) { "Container Census", "Shift Handoff", "docker-container-census", - "0/6 missions complete", + "WORLD 2/2 · Container Triage", + "Postmortem Triage", + "0/11 missions complete", "Continue: opsquest play --track docker", "Jump: opsquest play --track docker --world N", "IDs: opsquest map --track docker --ids", @@ -1085,7 +1087,7 @@ func TestProfileRenameShowAndDoctor(t *testing.T) { if err := app.Run([]string{"doctor"}); err != nil { t.Fatal(err) } - if !strings.Contains(out.String(), "29 missions (23 Linux, 6 Docker)") || !strings.Contains(out.String(), "Linux labs: in-memory; no host shell or filesystem access") { + 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") { t.Fatalf("doctor output = %s", out.String()) } } @@ -1163,7 +1165,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/29 missions complete") || strings.Contains(out.String(), "Production Friday") { + if strings.Contains(out.String(), "0/34 missions complete") || strings.Contains(out.String(), "Production Friday") { t.Fatalf("campaign filter used catalog-wide scope:\n%s", out.String()) } } @@ -1364,3 +1366,125 @@ func (e *cliDockerEnvironment) Close() error { e.closed = true return nil } + +type cliJanitorFactory struct { + cliDockerFactory + orphans int + checkErr error + removeErr error + checks int + removeCalls int +} + +func (f *cliJanitorFactory) OrphanedResources(context.Context) (int, error) { + f.checks++ + return f.orphans, f.checkErr +} + +func (f *cliJanitorFactory) RemoveOrphanedResources(context.Context) (int, error) { + f.removeCalls++ + if f.removeErr != nil { + return 1, f.removeErr + } + removed := f.orphans + f.orphans = 0 + return removed, nil +} + +func TestDoctorReportsAndCleansOrphanedDockerLabs(t *testing.T) { + run := func(t *testing.T, factory game.Factory, args ...string) (string, error) { + t.Helper() + store := profile.NewStore(filepath.Join(t.TempDir(), "profile.json"), "alex") + catalog, err := mission.LoadCatalog() + if err != nil { + t.Fatal(err) + } + out := &bytes.Buffer{} + app := New(Config{Out: out, ErrOut: &bytes.Buffer{}, Catalog: catalog, Store: store, Factory: factory}) + err = app.Run(append([]string{"doctor"}, args...)) + return out.String(), err + } + + 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'") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + if factory.checks != 1 || factory.removeCalls != 0 { + t.Fatalf("checks = %d, removals = %d", factory.checks, factory.removeCalls) + } + }) + + 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") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + 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") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("unavailable engine skips the check", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "Docker labs unavailable"}, orphans: 2} + output, err := run(t, factory) + if err != nil || strings.Contains(output, "docker cleanup") || factory.checks != 0 { + t.Fatalf("doctor output = %s, error = %v, checks = %d", output, err, factory.checks) + } + }) + + 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 { + t.Fatalf("doctor output = %s, error = %v, removals = %d", output, err, factory.removeCalls) + } + }) + + t.Run("cleanup failure fails the command", func(t *testing.T) { + factory := &cliJanitorFactory{cliDockerFactory: cliDockerFactory{available: true, detail: "ready"}, orphans: 2, removeErr: fmt.Errorf("daemon went away")} + output, err := run(t, factory, "--cleanup") + if err == nil || !strings.Contains(err.Error(), "docker cleanup failed") || !strings.Contains(output, "removed 1, then failed · daemon went away") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("cleanup failure before removal", func(t *testing.T) { + factory := &zeroRemovalJanitor{&cliJanitorFactory{cliDockerFactory: cliDockerFactory{detail: "unavailable"}, removeErr: fmt.Errorf("docker executable not found in PATH")}} + output, err := run(t, factory, "--cleanup") + if err == nil || !strings.Contains(output, "docker cleanup: failed · docker executable not found in PATH") || strings.Contains(output, "removed 0") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("cleanup without Docker labs", func(t *testing.T) { + output, err := run(t, game.SandboxFactory{}, "--cleanup") + if err != nil || !strings.Contains(output, "nothing to clean; Docker labs are not configured") { + t.Fatalf("doctor output = %s, error = %v", output, err) + } + }) + + t.Run("rejects positional arguments", func(t *testing.T) { + if _, err := run(t, game.SandboxFactory{}, "cleanup"); err == nil { + t.Fatal("doctor accepted a positional argument") + } + }) +} + +// zeroRemovalJanitor fails before removing anything, as when the Docker CLI +// is absent. +type zeroRemovalJanitor struct { + *cliJanitorFactory +} + +func (f *zeroRemovalJanitor) RemoveOrphanedResources(context.Context) (int, error) { + f.removeCalls++ + return 0, f.removeErr +} diff --git a/internal/dockerlab/environment.go b/internal/dockerlab/environment.go index a94be92..d79380a 100644 --- a/internal/dockerlab/environment.go +++ b/internal/dockerlab/environment.go @@ -491,7 +491,8 @@ func (e *environment) listContainers(ctx context.Context, all bool, filters []li alias string } rows := make([]row, 0, len(e.containers)) - statusWidth := len("STATUS") + // Eight columns preserves the original listing layout for plain states. + statusWidth := 8 for _, tracked := range e.snapshotContainers() { inspection, exists, err := e.inspect(ctx, tracked.id) if err != nil { diff --git a/internal/dockerlab/factory.go b/internal/dockerlab/factory.go index c5d69c9..095c684 100644 --- a/internal/dockerlab/factory.go +++ b/internal/dockerlab/factory.go @@ -176,14 +176,19 @@ func (f *Factory) Create(ctx context.Context, item mission.Mission) (game.Enviro if err := environment.startContainer(ctx, fixture.Name); err != nil { return environment, joinSetupCleanupError(fmt.Errorf("start Docker fixture %s: %w", fixture.Name, err), environment.Close()) } - if fixture.Health != "" { - expected := fixture.Health - settled := func(inspection containerInspection) bool { - return healthStatus(inspection) == expected - } - if err := environment.waitForFixture(ctx, fixture.Name, settled); err != nil { - return environment, joinSetupCleanupError(fmt.Errorf("wait for Docker fixture %s health: %w", fixture.Name, err), environment.Close()) - } + } + // Probes run concurrently once every fixture has started, so waiting + // after all starts costs one probe interval rather than one per fixture. + for _, fixture := range item.Docker.Containers { + if fixture.Health == "" || fixture.State != mission.DockerStateRunning { + continue + } + expected := fixture.Health + settled := func(inspection containerInspection) bool { + return healthStatus(inspection) == expected + } + if err := environment.waitForFixture(ctx, fixture.Name, settled); err != nil { + return environment, joinSetupCleanupError(fmt.Errorf("wait for Docker fixture %s health: %w", fixture.Name, err), environment.Close()) } } return environment, nil diff --git a/internal/dockerlab/integration_test.go b/internal/dockerlab/integration_test.go index ba4692d..57ab6f6 100644 --- a/internal/dockerlab/integration_test.go +++ b/internal/dockerlab/integration_test.go @@ -5,6 +5,7 @@ import ( "os" "strings" "testing" + "time" "github.com/aleksandergregersen/opsquest/internal/game" "github.com/aleksandergregersen/opsquest/internal/mission" @@ -137,3 +138,130 @@ func createRealDockerEnvironment(t *testing.T, factory *Factory, item mission.Mi }) return created } + +func TestIntegrationRealDockerContainerTriage(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 + } + assertOutcomes := func(t *testing.T, created game.Environment, item mission.Mission, lastOutput string) { + t.Helper() + for _, outcome := range item.Validation.All { + var satisfied bool + var err error + switch outcome.Type { + case mission.ConditionOutputEquals: + satisfied = strings.TrimSpace(lastOutput) == strings.TrimSpace(outcome.Value) + case mission.ConditionOutputContains: + satisfied = strings.Contains(lastOutput, outcome.Value) + default: + satisfied, err = created.Observe(context.Background(), outcome) + } + if err != nil || !satisfied { + t.Fatalf("outcome %#v = %v, %v; last output %q", outcome, satisfied, err, lastOutput) + } + } + } + + t.Run("health, exit codes, and rm", func(t *testing.T) { + item, _ := catalog.Find("docker-postmortem-triage") + created := createRealDockerEnvironment(t, factory, item) + listed := run(t, created, "docker ps") + if !strings.Contains(listed, "(healthy)") || !strings.Contains(listed, "(unhealthy)") { + t.Fatalf("health did not settle before play:\n%s", listed) + } + unhealthy := run(t, created, "docker ps --filter health=unhealthy") + if !strings.Contains(unhealthy, "api-canary") || strings.Contains(unhealthy, "api \n") { + t.Fatalf("health filter:\n%s", unhealthy) + } + if _, err := created.Execute(context.Background(), "docker rm api-canary"); err == nil || !strings.Contains(err.Error(), "stop it first") { + t.Fatalf("rm running error = %v", err) + } + run(t, created, "docker stop api-canary") + run(t, created, "docker rm sync-orders") + last := run(t, created, "docker rm sync-users") + if _, err := created.Execute(context.Background(), "docker logs sync-users"); err == nil || !strings.Contains(err.Error(), "has been removed") { + t.Fatalf("logs after rm error = %v", err) + } + assertOutcomes(t, created, item, last) + }) + + t.Run("crash loop", func(t *testing.T) { + item, _ := catalog.Find("docker-crash-loop") + created := createRealDockerEnvironment(t, factory, item) + inspected := run(t, created, "docker inspect payments") + if !strings.Contains(inspected, `"Name": "on-failure"`) || strings.Contains(inspected, `"RestartCount": 0`) || strings.Contains(inspected, `"RestartCount": 1,`) { + t.Fatalf("crash-loop inspection:\n%s", inspected) + } + run(t, created, "docker stop payments") + time.Sleep(2 * time.Second) + stopped := mission.Condition{Type: mission.ConditionDockerContainerStopped, Container: "payments"} + if satisfied, err := created.Observe(context.Background(), stopped); err != nil || !satisfied { + t.Fatalf("manually stopped crash loop restarted: %v, %v", satisfied, err) + } + logs := run(t, created, "docker logs payments") + if strings.Count(logs, "FATAL cannot read") < 2 { + t.Fatalf("crash-loop logs = %q", logs) + } + assertOutcomes(t, created, item, logs) + }) + + t.Run("tail", func(t *testing.T) { + item, _ := catalog.Find("docker-tail-end") + created := createRealDockerEnvironment(t, factory, item) + assertOutcomes(t, created, item, run(t, created, "docker logs --tail 3 ledger")) + }) +} + +func TestIntegrationRealDockerOrphanSweep(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-quiet-worker") + 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") + } + // Simulate an OpsQuest process that exited without closing its attempt. + crashed := NewFactory(game.SandboxFactory{}) + crashed.owner.pid = deadPID + abandoned := createRealDockerEnvironment(t, crashed, item).(*environment) + + janitor := NewFactory(game.SandboxFactory{}) + live := createRealDockerEnvironment(t, janitor, item).(*environment) + for _, tracked := range abandoned.snapshotContainers() { + if _, exists, err := abandoned.inspectReferenceUnchecked(context.Background(), tracked.id); err != nil || exists { + t.Fatalf("setup did not sweep abandoned fixture %s: exists = %v, error = %v", tracked.alias, exists, err) + } + } + if count, err := janitor.OrphanedResources(context.Background()); err != nil || count != 0 { + t.Fatalf("OrphanedResources() after sweep = %d, %v", count, err) + } + for _, tracked := range live.snapshotContainers() { + if _, exists, err := live.inspectReferenceUnchecked(context.Background(), tracked.id); err != nil || !exists { + t.Fatalf("sweep removed live fixture %s: exists = %v, error = %v", tracked.alias, exists, err) + } + } +} diff --git a/internal/dockerlab/triage_test.go b/internal/dockerlab/triage_test.go index e1f4e41..5aa4063 100644 --- a/internal/dockerlab/triage_test.go +++ b/internal/dockerlab/triage_test.go @@ -317,3 +317,15 @@ func TestFixtureReadinessTimesOutAndCleansUp(t *testing.T) { t.Fatalf("containers remaining after failed readiness = %d", commandRunner.containerCount()) } } + +func TestPlainListingKeepsOriginalLayout(t *testing.T) { + environment := createTestEnvironment(t, newFakeDockerRunner()) + listed, err := environment.Execute(context.Background(), "docker ps") + if err != nil { + t.Fatal(err) + } + want := "CONTAINER ID IMAGE STATUS NAMES\nlab-02 fixture running metrics\n" + if listed.Output != want { + t.Fatalf("docker ps output:\n%q\nwant:\n%q", listed.Output, want) + } +} diff --git a/internal/game/missions_test.go b/internal/game/missions_test.go index 182cfeb..fa3e635 100644 --- a/internal/game/missions_test.go +++ b/internal/game/missions_test.go @@ -3,6 +3,7 @@ package game import ( "context" "fmt" + "strconv" "strings" "testing" @@ -40,13 +41,23 @@ func TestEveryMissionHasAWorkingOutcome(t *testing.T) { "chmod 750 /deploy/app/deploy.sh", "kill 9001", }, - "docker-container-census": {"docker ps -a", "docker start api"}, - "docker-last-broadcast": {"docker logs checkout"}, - "docker-exit-code-detective": {"docker inspect seed", "docker inspect migrate"}, - "docker-quiet-worker": {"docker ps", "docker stop worker"}, - "docker-recovery-pair": {"docker ps -a", "docker start frontend", "docker start backend"}, - "docker-shift-handoff": {"docker ps -a", "docker start standby", "docker stop retiring"}, - "linux-runbook-runner": {"sh publish-health.sh"}, + "docker-container-census": {"docker ps -a", "docker start api"}, + "docker-last-broadcast": {"docker logs checkout"}, + "docker-exit-code-detective": {"docker inspect seed", "docker inspect migrate"}, + "docker-quiet-worker": {"docker ps", "docker stop worker"}, + "docker-recovery-pair": {"docker ps -a", "docker start frontend", "docker start backend"}, + "docker-shift-handoff": {"docker ps -a", "docker start standby", "docker stop retiring"}, + "docker-janitor-duty": {"docker ps --filter status=exited", "docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly"}, + "docker-tail-end": {"docker logs --tail 3 ledger"}, + "docker-running-isnt-healthy": {"docker ps", "docker stop web-b", "docker start web-c"}, + "docker-crash-loop": {"docker ps", "docker stop payments", "docker logs payments"}, + "docker-postmortem-triage": { + "docker ps -a", + "docker stop api-canary", + "docker rm sync-orders", + "docker rm sync-users", + }, + "linux-runbook-runner": {"sh publish-health.sh"}, "linux-vi-first-aid": { `printf 'SERVICE=checkout\nLOG_LEVEL=info\n' > release.env`, }, @@ -136,18 +147,24 @@ func canonicalMissionEnvironment(item mission.Mission) (Environment, error) { type missionDockerEnvironment struct { aliases []string running map[string]bool + removed map[string]bool logs map[string]string exitCodes map[string]int oneShot map[string]bool + crashLoop map[string]bool + health map[string]string count int } func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment { environment := &missionDockerEnvironment{ running: make(map[string]bool), + removed: make(map[string]bool), logs: make(map[string]string), exitCodes: make(map[string]int), oneShot: make(map[string]bool), + crashLoop: make(map[string]bool), + health: make(map[string]string), } if item.Docker == nil { return environment @@ -156,10 +173,16 @@ func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment 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 if container.ExitCode != nil { environment.exitCodes[container.Name] = *container.ExitCode environment.oneShot[container.Name] = true } + if container.Restart == mission.DockerRestartOnFailure { + environment.crashLoop[container.Name] = true + // Setup waits for a visible loop before play begins. + environment.logs[container.Name] = strings.Repeat(container.Log+"\n", 3) + } } environment.count = len(item.Docker.Containers) return environment @@ -167,6 +190,19 @@ func newMissionDockerEnvironment(item mission.Mission) *missionDockerEnvironment func (e *missionDockerEnvironment) PromptLabel() string { return "docker" } +func (e *missionDockerEnvironment) status(alias string) string { + switch { + case e.running[alias] && e.crashLoop[alias]: + return "restarting" + case e.running[alias] && e.health[alias] != "": + return "running (" + e.health[alias] + ")" + case e.running[alias]: + return "running" + default: + return "exited" + } +} + func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Execution, error) { fields := strings.Fields(line) if len(fields) < 2 || fields[0] != "docker" { @@ -178,24 +214,51 @@ func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Exec } result := Execution{PracticedCommands: []string{"docker"}, PipelineWidth: 1} if len(fields) >= 1 && (fields[0] == "ps" || fields[0] == "ls") { - all := len(fields) == 2 && (fields[1] == "-a" || fields[1] == "--all") - if len(fields) > 2 || len(fields) == 2 && !all { - return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + all := false + filters := make(map[string]string) + for index := 1; index < len(fields); index++ { + switch fields[index] { + case "-a", "--all": + all = true + case "-f", "--filter": + if index+1 >= len(fields) { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + index++ + key, value, _ := strings.Cut(fields[index], "=") + filters[key] = value + default: + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } } var output strings.Builder for _, alias := range e.aliases { - if !all && !e.running[alias] { + if e.removed[alias] { + continue + } + if want, filtered := filters["status"]; filtered && !strings.HasPrefix(e.status(alias), want) { continue } - status := "stopped" - if e.running[alias] { - status = "running" + if want, filtered := filters["health"]; filtered && (!e.running[alias] || e.health[alias] != want) { + continue } - fmt.Fprintf(&output, "%s %s\n", alias, status) + if !all && filters["status"] == "" && !e.running[alias] { + continue + } + fmt.Fprintf(&output, "%s %s\n", alias, e.status(alias)) } result.Output = output.String() return result, nil } + tail := -1 + if len(fields) == 4 && fields[0] == "logs" && (fields[1] == "--tail" || fields[1] == "-n") { + parsed, err := strconv.Atoi(fields[2]) + if err != nil { + return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) + } + tail = parsed + fields = []string{"logs", fields[3]} + } if len(fields) != 2 { return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) } @@ -203,21 +266,31 @@ func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Exec if _, exists := e.running[alias]; !exists { return Execution{}, fmt.Errorf("unknown fake Docker alias %q", alias) } + if e.removed[alias] { + return Execution{}, fmt.Errorf("docker: container %q has been removed", alias) + } switch action { case "start", "restart": - e.running[alias] = !e.oneShot[alias] + e.running[alias] = !e.oneShot[alias] || e.crashLoop[alias] result.Output = alias + "\n" case "stop": e.running[alias] = false result.Output = alias + "\n" - case "logs": - result.Output = e.logs[alias] + "\n" - case "inspect": - status := "exited" + case "rm": if e.running[alias] { - status = "running" + return Execution{}, fmt.Errorf("docker: cannot remove running container %q; stop it first", alias) + } + e.removed[alias] = true + e.count-- + result.Output = alias + "\n" + case "logs": + lines := strings.Split(strings.TrimSuffix(e.logs[alias], "\n"), "\n") + if tail >= 0 && tail < len(lines) { + lines = lines[len(lines)-tail:] } - result.Output = fmt.Sprintf("{\n \"Name\": %q,\n \"State\": {\n \"Running\": %t,\n \"Status\": %q,\n \"ExitCode\": %d\n }\n}\n", alias, e.running[alias], status, e.exitCodes[alias]) + result.Output = strings.Join(lines, "\n") + "\n" + case "inspect": + result.Output = fmt.Sprintf("{\n \"Name\": %q,\n \"State\": {\n \"Running\": %t,\n \"Status\": %q,\n \"ExitCode\": %d\n }\n}\n", alias, e.running[alias], e.status(alias), e.exitCodes[alias]) default: return Execution{}, fmt.Errorf("unsupported fake Docker command %q", line) } @@ -227,10 +300,12 @@ func (e *missionDockerEnvironment) Execute(_ context.Context, line string) (Exec func (e *missionDockerEnvironment) Observe(_ context.Context, condition mission.Condition) (bool, error) { switch condition.Type { case mission.ConditionDockerContainerRunning: - return e.running[condition.Container], nil + return !e.removed[condition.Container] && e.running[condition.Container], nil case mission.ConditionDockerContainerStopped: _, exists := e.running[condition.Container] - return exists && !e.running[condition.Container], nil + return exists && !e.removed[condition.Container] && !e.running[condition.Container], nil + case mission.ConditionDockerContainerAbsent: + return e.removed[condition.Container], nil case mission.ConditionDockerContainerCountEqual: return condition.Count != nil && e.count == *condition.Count, nil default: @@ -302,6 +377,16 @@ func TestNewDockerMissionsAcceptAlternativesAndRejectIncompleteOutcomes(t *testi {id: "docker-quiet-worker", alternative: []string{"docker container stop worker"}, incomplete: []string{"docker stop metrics"}}, {id: "docker-recovery-pair", alternative: []string{"docker container start backend", "docker container start frontend"}, incomplete: []string{"docker start frontend"}}, {id: "docker-shift-handoff", alternative: []string{"docker container stop retiring", "docker container start standby"}, incomplete: []string{"docker start standby"}}, + {id: "docker-janitor-duty", alternative: []string{"docker container rm report-weekly", "docker container rm backup-0915", "docker container rm backup-0914"}, incomplete: []string{"docker rm backup-0914", "docker rm backup-0915"}}, + {id: "docker-janitor-duty", alternative: []string{"docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly", "docker ps -a"}, incomplete: []string{"docker stop worker", "docker rm worker", "docker rm backup-0914", "docker rm backup-0915", "docker rm report-weekly"}}, + {id: "docker-tail-end", alternative: []string{"docker container logs -n 3 ledger"}, incomplete: []string{"docker logs ledger"}}, + {id: "docker-tail-end", alternative: []string{"docker logs ledger", "docker logs --tail 3 ledger"}, incomplete: []string{"docker logs --tail 4 ledger"}}, + {id: "docker-running-isnt-healthy", alternative: []string{"docker ps --filter health=unhealthy", "docker container start web-c", "docker container stop web-b"}, incomplete: []string{"docker stop web-a", "docker start web-c"}}, + {id: "docker-running-isnt-healthy", alternative: []string{"docker stop web-b", "docker start web-c", "docker ps"}, incomplete: []string{"docker stop web-b", "docker rm web-b", "docker start web-c"}}, + {id: "docker-crash-loop", alternative: []string{"docker inspect payments", "docker container stop payments", "docker logs --tail 1 payments"}, incomplete: []string{"docker logs payments"}}, + {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"}}, } for _, test := range tests { item, found := catalog.Find(test.id) diff --git a/internal/mission/catalog_test.go b/internal/mission/catalog_test.go index e43869d..dc6615e 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) != 29 { - t.Fatalf("len(All()) = %d, want 29", len(items)) + if len(items) != 34 { + t.Fatalf("len(All()) = %d, want 34", 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) != 6 { + if len(linux) != 23 || len(docker) != 11 { t.Fatalf("track sizes = linux %d, docker %d", len(linux), len(docker)) } if docker[0].ID != "docker-container-census" || docker[0].Number != 20 { @@ -220,6 +220,44 @@ func TestExpandedDockerCurriculumMetadata(t *testing.T) { } } +func TestContainerTriageCurriculum(t *testing.T) { + catalog, err := LoadCatalog() + if err != nil { + t.Fatal(err) + } + wants := []struct { + id string + number int + difficulty string + hints int + }{ + {id: "docker-janitor-duty", number: 30, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-tail-end", number: 31, difficulty: DifficultyBeginner, hints: 3}, + {id: "docker-running-isnt-healthy", number: 32, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-crash-loop", number: 33, difficulty: DifficultyIntermediate, hints: 3}, + {id: "docker-postmortem-triage", number: 34, 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 != "Container Triage" || 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)) + } + } + 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)) + } + crashLoop, _ := catalog.Find("docker-crash-loop") + payments := crashLoop.Docker.Containers[0] + if payments.Restart != DockerRestartOnFailure || payments.State != DockerStateRunning || payments.ExitCode == nil || *payments.ExitCode == 0 { + t.Fatalf("crash-loop fixture = %#v", payments) + } +} + func TestMissionValidationRejectsInvalidDockerDefinitions(t *testing.T) { tests := []struct { name string @@ -710,6 +748,14 @@ func TestCatalogTrackBoundariesAndAdjacency(t *testing.T) { if !found || last.Number != 29 { t.Fatalf("LastInTrack(linux) = %#v, %v", last, found) } + lastDocker, found := catalog.LastInTrack(TrackDocker) + if !found || lastDocker.ID != "docker-postmortem-triage" || lastDocker.Number != 34 { + t.Fatalf("LastInTrack(docker) = %#v, %v", lastDocker, found) + } + crossWorld, found := catalog.AdjacentInTrack("docker-shift-handoff", 1) + if !found || crossWorld.ID != "docker-janitor-duty" { + t.Fatalf("AdjacentInTrack(after Docker 25) = %#v, %v", crossWorld, found) + } next, found := catalog.AdjacentInTrack("linux-production-friday", 1) if !found || next.ID != "linux-runbook-runner" { t.Fatalf("AdjacentInTrack(after 19) = %#v, %v", next, found) diff --git a/internal/mission/data/30-janitor-duty.json b/internal/mission/data/30-janitor-duty.json new file mode 100644 index 0000000..84be45e --- /dev/null +++ b/internal/mission/data/30-janitor-duty.json @@ -0,0 +1,93 @@ +{ + "id": "docker-janitor-duty", + "number": 30, + "track": "docker", + "environment": "docker", + "title": "Janitor Duty", + "campaign": "Container Triage", + "difficulty": "beginner", + "story": "Last week's one-off backup and report jobs finished long ago, but their exited containers still clutter every listing. The api and worker services are healthy and must keep running.", + "objective": "Remove the three finished one-off job containers, keep api and worker running, and leave exactly two mission containers.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "Finished jobs are exited containers; docker ps -a shows them beside the running services.", + "docker ps --filter status=exited lists only the exited containers.", + "docker rm removes one stopped container per command, for example docker rm backup-0914." + ], + "explanation": "Stopping a container keeps its filesystem, logs, and metadata. Removing it deletes that record. Clearing finished job containers keeps listings readable, and docker rm refuses running containers, so services stay safe unless you stop them first.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "containers": [ + { + "name": "api", + "image": "fixture", + "state": "running" + }, + { + "name": "worker", + "image": "fixture", + "state": "running" + }, + { + "name": "backup-0914", + "image": "fixture", + "state": "stopped", + "log": "backup 2026-09-14 complete: 412 MB written", + "exit_code": 0 + }, + { + "name": "backup-0915", + "image": "fixture", + "state": "stopped", + "log": "backup 2026-09-15 complete: 415 MB written", + "exit_code": 0 + }, + { + "name": "report-weekly", + "image": "fixture", + "state": "stopped", + "log": "weekly report delivered to ops-leads", + "exit_code": 0 + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_container_absent", + "container": "backup-0914" + }, + { + "type": "docker_container_absent", + "container": "backup-0915" + }, + { + "type": "docker_container_absent", + "container": "report-weekly" + }, + { + "type": "docker_container_running", + "container": "api" + }, + { + "type": "docker_container_running", + "container": "worker" + }, + { + "type": "docker_container_count_equals", + "count": 2 + } + ] + }, + "rewards": { + "xp": 75, + "hint_penalty": 10 + } +} diff --git a/internal/mission/data/31-tail-end.json b/internal/mission/data/31-tail-end.json new file mode 100644 index 0000000..0a05648 --- /dev/null +++ b/internal/mission/data/31-tail-end.json @@ -0,0 +1,49 @@ +{ + "id": "docker-tail-end", + "number": 31, + "track": "docker", + "environment": "docker", + "title": "Tail End", + "campaign": "Container Triage", + "difficulty": "beginner", + "story": "The nightly ledger reconciliation failed after dozens of routine progress lines. The incident channel only wants the lines that explain how it ended.", + "objective": "Display only the final three log lines of the ledger container.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "The reason a job stopped is usually at the end of its log.", + "docker logs accepts an option that limits output to the most recent lines.", + "docker logs --tail 3 ledger prints only the last three lines." + ], + "explanation": "docker logs --tail N reads only the newest N lines, the same idea as tail -n on a file. With long logs, that brings the ending into view without scrolling through the whole history.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "containers": [ + { + "name": "ledger", + "image": "fixture", + "state": "stopped", + "log": "INFO ledger reconciliation starting run=2026-09-30\nINFO batch 001 reconciled\nINFO batch 002 reconciled\nINFO batch 003 reconciled\nINFO batch 004 reconciled\nINFO batch 005 reconciled\nINFO batch 006 reconciled\nINFO batch 007 reconciled\nINFO batch 008 reconciled\nINFO batch 009 reconciled\nINFO batch 010 reconciled\nINFO batch 011 reconciled\nINFO batch 012 reconciled\nINFO batch 013 reconciled\nINFO batch 014 reconciled\nINFO batch 015 reconciled\nINFO batch 016 reconciled\nWARN batch 017 retried after lock timeout\nINFO batch 017 reconciled\nINFO batch 018 reconciled\nINFO batch 019 reconciled\nINFO batch 020 reconciled\nINFO batch 021 reconciled\nINFO batch 022 reconciled\nINFO batch 023 reconciled\nINFO batch 024 reconciled\nINFO batch 025 reconciled\nINFO batch 026 reconciled\nINFO batch 027 reconciled\nINFO batch 028 reconciled\nINFO batch 029 reconciled\nINFO batch 030 reconciled\nERROR batch 031 checksum mismatch account=4471\nERROR reconciliation aborted after 30 clean batches\nINFO ledger exiting with status 1", + "exit_code": 1 + } + ] + }, + "validation": { + "all": [ + { + "type": "output_equals", + "value": "ERROR batch 031 checksum mismatch account=4471\nERROR reconciliation aborted after 30 clean batches\nINFO ledger exiting with status 1" + } + ] + }, + "rewards": { + "xp": 80, + "hint_penalty": 10 + } +} diff --git a/internal/mission/data/32-running-isnt-healthy.json b/internal/mission/data/32-running-isnt-healthy.json new file mode 100644 index 0000000..a694adb --- /dev/null +++ b/internal/mission/data/32-running-isnt-healthy.json @@ -0,0 +1,75 @@ +{ + "id": "docker-running-isnt-healthy", + "number": 32, + "track": "docker", + "environment": "docker", + "title": "Running Isn't Healthy", + "campaign": "Container Triage", + "difficulty": "intermediate", + "story": "All the web replicas report as up, yet one in three requests fails. Each replica has a health probe, and one of them has been failing it quietly. A standby replica is ready to take its place.", + "objective": "Take the unhealthy web replica out of service without deleting it, bring the standby replica online, keep the healthy replica running, and leave exactly three mission containers.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "Running means the process exists; the health probe says whether it can do its job. docker ps shows health beside each running container.", + "docker ps --filter health=unhealthy isolates the failing replica, and docker inspect shows State.Health.", + "Stop the unhealthy replica instead of removing it so its logs stay available, then start web-c." + ], + "explanation": "A health check is a probe Docker runs inside the container. A container can be running while its probe fails, so check health as well as state. Stopping the bad replica keeps its logs and metadata for debugging, and starting the standby restores capacity.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "containers": [ + { + "name": "web-a", + "image": "fixture", + "state": "running", + "health": "healthy", + "log": "INFO web-a listening on :8080" + }, + { + "name": "web-b", + "image": "fixture", + "state": "running", + "health": "unhealthy", + "log": "INFO web-b listening on :8080\nERROR upstream pool exhausted; health probe failing" + }, + { + "name": "web-c", + "image": "fixture", + "state": "stopped", + "health": "healthy", + "log": "INFO web-c listening on :8080" + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_container_stopped", + "container": "web-b" + }, + { + "type": "docker_container_running", + "container": "web-a" + }, + { + "type": "docker_container_running", + "container": "web-c" + }, + { + "type": "docker_container_count_equals", + "count": 3 + } + ] + }, + "rewards": { + "xp": 90, + "hint_penalty": 15 + } +} diff --git a/internal/mission/data/33-crash-loop.json b/internal/mission/data/33-crash-loop.json new file mode 100644 index 0000000..51d625d --- /dev/null +++ b/internal/mission/data/33-crash-loop.json @@ -0,0 +1,67 @@ +{ + "id": "docker-crash-loop", + "number": 33, + "track": "docker", + "environment": "docker", + "title": "Crash Loop", + "campaign": "Container Triage", + "difficulty": "intermediate", + "story": "The payments container keeps crashing, and its restart policy keeps launching it again. Every restart pages the on-call engineer. The api container is unaffected.", + "objective": "Stop the crash-looping payments container without removing it, keep api running, and finish by displaying payments' logs so the fault is on screen for the incident channel.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "A restarting status and a climbing RestartCount in docker inspect mean a restart policy keeps relaunching a failing process.", + "Docker does not restart a container you stopped yourself, even when it has an on-failure restart policy.", + "Run docker stop payments, then docker logs payments." + ], + "explanation": "A restart policy relaunches a container whose process exits with an error. When the error is permanent, such as a missing secret, the policy produces a crash loop. docker stop ends the loop and keeps the container, and its logs show the same fatal line once per attempt.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "containers": [ + { + "name": "payments", + "image": "fixture", + "state": "running", + "log": "FATAL cannot read /run/secrets/payments-key: no such file", + "exit_code": 78, + "restart": "on-failure" + }, + { + "name": "api", + "image": "fixture", + "state": "running" + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_container_stopped", + "container": "payments" + }, + { + "type": "docker_container_running", + "container": "api" + }, + { + "type": "docker_container_count_equals", + "count": 2 + }, + { + "type": "output_contains", + "value": "FATAL cannot read /run/secrets/payments-key" + } + ] + }, + "rewards": { + "xp": 95, + "hint_penalty": 15 + } +} diff --git a/internal/mission/data/34-postmortem-triage.json b/internal/mission/data/34-postmortem-triage.json new file mode 100644 index 0000000..f796bf8 --- /dev/null +++ b/internal/mission/data/34-postmortem-triage.json @@ -0,0 +1,98 @@ +{ + "id": "docker-postmortem-triage", + "number": 34, + "track": "docker", + "environment": "docker", + "title": "Postmortem Triage", + "campaign": "Container Triage", + "difficulty": "advanced", + "story": "The morning after a rough release: a canary replica is failing its health probe, and last night's sync jobs have finished. Some succeeded and one did not. The postmortem needs the evidence, and the listings need cleaning up.", + "objective": "Take the unhealthy canary out of service but keep it for debugging, remove the sync jobs that succeeded, keep the failed sync job for the postmortem, keep api serving, and leave exactly three mission containers.", + "suggested_commands": [ + "docker" + ], + "hints": [ + "Look before changing anything. docker ps -a shows every service and job.", + "Health appears beside running containers; docker ps --filter health=unhealthy isolates the failing replica.", + "docker ps --filter status=exited lists the finished jobs, and docker inspect shows each job's State.ExitCode.", + "docker rm removes only stopped containers, and a removed container's logs are gone. Remove only the jobs that exited with code 0.", + "docker stop api-canary, docker rm sync-orders, and docker rm sync-users; leave sync-invoices in place." + ], + "explanation": "Triage separates evidence from clutter. Health shows which running replica is failing, and exit codes show which finished jobs succeeded. Stopping keeps a container's evidence and removing deletes it, so remove only what you will never need to investigate.", + "docker": { + "images": [ + { + "alias": "fixture", + "reference": "docker.io/library/busybox@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662" + } + ], + "containers": [ + { + "name": "api", + "image": "fixture", + "state": "running", + "health": "healthy" + }, + { + "name": "api-canary", + "image": "fixture", + "state": "running", + "health": "unhealthy", + "log": "INFO canary build 2026.09.30 started\nERROR GET /checkout returned HTTP 500" + }, + { + "name": "sync-orders", + "image": "fixture", + "state": "stopped", + "log": "sync-orders: 1204 rows synced", + "exit_code": 0 + }, + { + "name": "sync-users", + "image": "fixture", + "state": "stopped", + "log": "sync-users: 88 rows synced", + "exit_code": 0 + }, + { + "name": "sync-invoices", + "image": "fixture", + "state": "stopped", + "log": "ERROR invoice sync failed: upstream timeout after 30s", + "exit_code": 3 + } + ] + }, + "validation": { + "all": [ + { + "type": "docker_container_running", + "container": "api" + }, + { + "type": "docker_container_stopped", + "container": "api-canary" + }, + { + "type": "docker_container_absent", + "container": "sync-orders" + }, + { + "type": "docker_container_absent", + "container": "sync-users" + }, + { + "type": "docker_container_stopped", + "container": "sync-invoices" + }, + { + "type": "docker_container_count_equals", + "count": 3 + } + ] + }, + "rewards": { + "xp": 130, + "hint_penalty": 20 + } +} diff --git a/internal/mission/world_test.go b/internal/mission/world_test.go index 17556d3..f388237 100644 --- a/internal/mission/world_test.go +++ b/internal/mission/world_test.go @@ -28,8 +28,11 @@ func TestCatalogWorldsAreOrderedWithinEachTrack(t *testing.T) { } docker := catalog.Worlds(TrackDocker) - if len(docker) != 1 || 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) = %#v", docker) + 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" { + 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 worlds := catalog.Worlds("missing"); worlds != nil { t.Fatalf("Worlds(missing) = %#v, want nil", worlds) @@ -81,7 +84,11 @@ func TestCatalogWorldLookupAndPlacement(t *testing.T) { }, { missionID: "docker-container-census", - want: Placement{Track: TrackDocker, WorldNumber: 1, WorldTotal: 1, WorldName: "It Works on My Machine", StageNumber: 1, StageTotal: 6}, + want: Placement{Track: TrackDocker, WorldNumber: 1, WorldTotal: 2, 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}, }, } for _, test := range tests { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 311d57b..1d181ee 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -126,7 +126,8 @@ docker_list_output="$(run_opsquest list --track docker)" 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}" "0/6 missions complete" +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}" "Continue: opsquest play --track docker" assert_contains "docker list" "${docker_list_output}" "Jump: opsquest play --track docker --world N" @@ -152,11 +153,18 @@ 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: 29 missions" +assert_contains "doctor" "${doctor_output}" "embedded catalog: 34 missions (23 Linux, 11 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:" assert_contains "doctor" "${doctor_output}" "docker executable not found in PATH" +assert_not_contains "doctor" "${doctor_output}" "docker cleanup:" + +cleanup_output="$(run_opsquest doctor --cleanup 2>&1 || true)" +assert_contains "doctor cleanup" "${cleanup_output}" "docker cleanup failed: docker executable not found in PATH" + +doctor_help_output="$(run_opsquest help doctor)" +assert_contains "doctor help" "${doctor_help_output}" "Usage: opsquest doctor [--cleanup]" play_output="$(printf 'pwd\nopsquest list --completed\nplay 4\nquit\n' | run_opsquest play)" assert_no_ansi "scripted mission" "${play_output}"