Web frontend and supporting services for lance.blue
headquarters docs launch-manifest.md
12 kB · 234 lines
Markdown
at main

The launch manifest #

The contract between headquarters (the control plane) and arena (a match container). Headquarters launches a task; arena reads exactly one input — a JSON manifest — and everything about the match comes from it.

One document rather than a spread of environment variables, because a match needs DIDs, per-player artifacts and several upload targets. Those do not fit env vars without inventing a naming scheme, and a signed document can be validated as a whole.

Status: implemented on both sides. arena reads and validates this schema; headquarters produces it for bot matches (the api's matches module, golden-tested against the example below). The pending contract notes in plan/complete/match-launch.md still stand.

How the container gets it #

Headquarters sets one of:

variable meaning
ARENA_MANIFEST_URL short-lived presigned HTTPS URL. The normal path.
ARENA_MANIFEST_FILE path to a manifest already in the container. Local dev and tests.

Optional overrides, both defaulted in the image:

variable default meaning
ARENA_PORT_WS 8080 HTTP port the proxy connects to
ARENA_PORT_MM 8850 MegaMek server port, container-internal only

Nothing else is read from the environment. A manifest URL is a bearer credential: anyone holding it can read the match setup, so it is fetched once at init into /run/arena/manifest.json (mode 0600) and never re-fetched.

Schema, version 1 #

{
  "version": 1,
  "matchId": "01J8ZQ2M0N4P6R8T",
  "expiresAt": "2026-07-31T22:00:00Z",

  "scenario": { "name": "TrainingScenarios/1-FirstRun.mms" },

  "players": [
    {
      "slot": "TraineeA",
      "control": "human",
      "did": "did:plc:6g2oekc75hx7swfxwvhyhfxf",
      "handle": "ravenous.bsky.social",
      "camo": { "url": "https://…presigned…", "cid": "bafy…", "sha256": "1a4b…" }
    },
    {
      "slot": "TraineeB",
      "control": "bot",
      "camo": { "url": "https://…presigned…", "sha256": "7c9e…" }
    }
  ],

  "observer": { "enabled": true },

  "upload": {
    "turnReports": { "signTemplate": "https://…/upload-urls/…/turns/{name}" },
    "result":      { "signUrl":      "https://…/upload-urls/…/result.json" },
    "screenshots": { "signTemplate": "https://…/upload-urls/…/shots/{name}" },
    "diagnostics": { "signTemplate": "https://…/upload-urls/…/diagnostics/{name}" }
  },

  "callback": { "url": "https://api.lance.blue/matches/01J8ZQ2M0N4P6R8T/events/<token>" },

  "limits": { "lingerSeconds": 60, "idleTimeoutSeconds": 3600, "autoReadySeconds": 0 }
}

Field rules #

  • version — arena refuses anything it does not know. Adding optional fields is a non-breaking change; changing the meaning of one is not.

  • scenario.name — a relative path into the scenario library baked into the arena image. arena rejects traversal and absolute paths.

  • scenario.content — the .mms itself, for a fight no image has: a daily challenge, or anything else generated rather than shipped. Exactly one of name and content; a manifest giving both is refused, because guessing which fight was meant costs somebody their match. A carried scenario still names boards that have to be in the image, which it finds out at load. Custom map files remain a later extension.

  • players[].slot — must name a faction that exists in the scenario. arena's match host fails loudly rather than silently reassigning, because a mis-mapped slot hands someone else's units to a player.

  • players[].control — human or bot. Every slot not listed as human gets Princess.

  • players[].handle — the name the seat fights under. For a human it is the handle verified at launch, omitted rather than guessed when verification failed. Every bot slot carries lance.blue — Princess plays as the site, special-cased in headquarters' manifest builder because there is no account to resolve. arena copies it into identity.json and uses it as the lobby display name.

  • players[].did — the ATProto identity that owns the slot. arena does not resolve DIDs or hold ATProto tokens; it records the DID alongside uploaded artifacts so headquarters can attribute them. Keeping token custody out of the match container is intentional — see LICENSING.md in the arena repo for the separate reason the container stays at arm's length from the control plane.

  • camo — fetched per match from the presigned URL, never bundled into the image, and present only on slots that have one. Any slot can have one, human or bot. sha256 is over the bytes at url and is the only thing arena checks.

    cid is optional. It is the content address the player published under, kept so an artifact can be traced back to the record, and it is over the published bytes rather than the ones at url, which are the re-encoded ones and therefore a different sequence. A bot's camo has no record behind it — the browser rolls one per match and nothing keeps it — so the field is absent rather than filled in with something invented.

    Camo is decoded by other players' browsers, so headquarters re-encodes it from a pixel buffer as it stages it here — arena treats it as untrusted bytes either way.

    The re-encode is at this step and not at upload. A player owns their repository and can write any bytes into blue.lance.camo with any client, so the only place the bytes can be made safe is the one place we hand them to someone else, which is this one. The bot's camo arrives from a browser and goes through the same gate for the same reason.

    A camo that cannot be read or cannot be made safe is left out and the match starts without it. Refusing to start over a cosmetic is a worse trade than playing in default colours.

  • upload.*.signUrl / upload.*.signTemplate — POST here with no body and the response is {"url", "method", "expiresIn"}: a presigned S3 PUT for that one key. In a template, {name} is substituted with the artifact filename. Send the bytes to the minted URL with PUT; the signature covers the method, so the manifest does not state it.

    This is the only upload target. A key with no signing endpoint has no target at all, and arena refuses such a manifest rather than dropping the artifacts it names.

    Two reasons it is the only one. Accepting bytes meant reading whole artifacts into api-host's memory to re-send them, and api-host is a t4g.nano holding the API and Caddy in 512MB — so a match token was enough to exhaust it, while a match's screenshots, summary GIF, profiles and client logs all arrive in the same few seconds at the end anyway. And a signature minted per upload is used immediately, so its life never has to cover the length of a match: a presigned URL cannot outlive the credentials that signed it, api-host signs with IMDS instance-profile credentials, and IMDS rotates those on its own schedule and will not issue a fresh set on request.

    There is no fallback, on purpose. A rarely-exercised second path stops being maintained and becomes the weakest door. A minting failure now fails the upload loudly, naming the key, instead of degrading to a slow route quietly.

    The minted URL is a bearer credential for that key — same discipline as the rest of the manifest, never logged.

  • upload.diagnostics — the per-layer latency table, profiles, and the MegaMek client's own logs. One key, because they are the same class of thing and nothing waits on any of them.

  • limits.lingerSeconds — how long the host stays up after victory so the final report and screenshots can be collected and a player can read the victory screen. Victory is not process exit.

Secrets discipline #

  • The manifest holds presigned URLs. Never log it verbatim; arena's container/lib/common.sh provides redact, and its init scripts log field names and digests, never values.
  • Credentials are never passed as JVM arguments — anything in argv is readable from /proc by any process in the container.
  • /run/arena is a tmpfs; nothing derived from the manifest is written to a layer or a volume.

Status reporting #

If callback.url is present, arena POSTs small JSON events to it. Each carries a detail string, which is free text for the log and is never interpreted. Every event arena emits, and where from:

event emitted by headquarters' status
starting container/init/10-manifest.sh —
running container/entrypoint.sh running, from starting
ready container/entrypoint.sh ready
error container/entrypoint.sh failed
idle-timeout container/watch/idle.sh —
game-over container/exit/finalize.sh over
exiting container/entrypoint.sh over

That is the whole list. emit in container/lib/common.sh is the only thing that posts here, so a name not in the table is a name nothing sends.

idle-timeout deliberately moves nothing: it fires when the match shows no sign of play for limits.idleTimeoutSeconds — a lobby nobody joined, a game everyone left or stopped playing — and nothing has failed. error would mark the match failed and would be wrong, so arena uses a name headquarters does not map, which falls through record_event's _ => None and is logged with its name. The reason ends up on the record, and the shutdown it triggers runs finalize, whose game-over ends the match a moment later the normal way.

arena reports readiness. It posts ready when both its ports are open; headquarters' own HTTP probe against the container's port stays as the fallback for a container that cannot reach us.

game-over and exiting both end the match, and ending it closes the upload window: after either one, POST /upload-urls/... answers 410 Gone for that match instead of signing. The match token in that path is the whole credential, and the minted URL is an S3 write, so the window has to shut at some point rather than staying open for as long as the row exists.

So game-over stays the last thing a container sends. Today it is: container/exit/finalize.sh uploads the turn reports, the result, the screenshots, the stats table, the profiles and the client logs, and emits game-over on its last line; container/entrypoint.sh emits exiting after finalize_once returns. Anything new that uploads has to go before that line, or it gets a 410 and the artifact is lost. The game-over file MatchHost writes mid-match is a different thing — a local flag for container/watch/turn-reports.sh — and does not close anything.

It is all best-effort — a failed callback is logged and never fails the match. Headquarters treats task exit as the authoritative signal and callbacks as an optimisation, and no callback can move a match backwards out of a finished state.

Not yet decided #

  • Signing. The manifest is currently trusted because the URL is presigned and the network path is private. A signature over the document (so arena can verify the control plane authored it, independent of transport) is the obvious hardening, and is tracked in plan/security-review.md.
  • Multi-human matches are expressible here, supported by the match host, and headquarters launches them from the challenge screen. arena renders one Suramadu app per human seat, keyed by DID, and headquarters hands each caller their own /match/{id}/<did>/ path. See TODO here and in arena for what multiplayer still waits on.