# Fiskmås Docker Contract (v1)

An image is **acceptable** to run if it meets all of these. If it does not,
`deploy_project` returns `status: failed` (or `unhealthy`), not "running".

## Required
- Listens on **`0.0.0.0:$PORT`** (Fiskmås injects `PORT=8080`). Must bind all
  interfaces, not `127.0.0.1`.
- Serves **HTTP** (a non-HTTP container is `failed`).
- Has a **health path**, default `/health` (or `/`). Runner probes it before cutover.
- **`linux/amd64`** only. Other arches are rejected before pull.
- Exits ready to serve quickly; the runner waits a bounded time for the first 200.

## Hard limits
- **Non-root** if possible (`USER` set in the Dockerfile).
- **No privileged mode**, **no `docker.sock`**.
- **No published host ports** — on k3s the app runs as a Deployment behind a
  per-project Ingress (`<slug>-app.fiskmas.dev`); in local dev it attaches to
  the `fiskmas_apps` network by its slug alias (`<slug>:<port>`). Shared
  MariaDB is reachable as `shared-mariadb:3306` (see `DATABASE_URL`).
- **Memory cap**: plan-driven — **256 MB** (Free) / **512 MB** (Plus) per
  project. The per-account ceiling is a Kubernetes ResourceQuota sized to the
  plan (`docs/network-policy.md`); the per-project value is stamped on the
  deploy. OOM → `failed`.
- **CPU cap**: **0.2 CPU** (Free) / **1 CPU** (Plus) per project (Docker `NanoCpus`).
- **Image size cap**: 1 GB (enforced at push).
- **Filesystem is read-only** (enforced). The container root is mounted `ro`
  (plus `runAsNonRoot`/`runAsUser 1000`, all caps dropped): **writes anywhere
  except `/tmp` fail with `EROFS`** — a `touch /data` inside a hosted app
  errors "Read-only file system". The only writable path is **`/tmp`, a 64 Mi
  `tmpfs` (RAM, no swap)**: scratch space for temp files, gone on restart, and
  it counts against the memory cap, not disk. Persist data in the shared
  MariaDB (`DATABASE_URL` / `MYSQL_*`); there are no volumes and no way for an
  app to fill the node disk. (Kubelet-rotated logs, ~10 Mi × 5 files, are the
  only node-disk footprint — not writable by the app itself.)
- **Env vars only** — never bake secrets into the image. `set_env` changes apply on the next `deploy_project`; a restart does not re-apply them.

## Dockerfile template
```dockerfile
FROM node:20-slim
WORKDIR /app
ENV PORT=8080
EXPOSE 8080
# create a non-root user
USER node
CMD ["node", "server.js"]
```

## Health check example
```js
// /health returns 200 when ready
app.get('/health', (req, res) => res.status(200).send('ok'));
```

## Deploy flow (runner)

The runner is backend-agnostic (`AppRuntime`): **k3s** (production) runs the
app as a Deployment + Service + Ingress in a per-account `cust-<account>`
namespace; **Docker** (local dev) runs one container on the host daemon.

1. Pull the image (verify `image_digest`).
2. Start the workload:
   - k3s: upsert the Deployment (readiness probe on the health path), Service,
     and Ingress; wait for the rollout to become ready.
   - Docker: `docker run` with `--cpu`, `--memory`, `--env PORT=<port>`,
     attached to the `fiskmas_apps` network under the `<slug>` alias.
3. Health-gate: k3s uses the Deployment readiness probe; Docker probes the
   container's network IP at `<ip>:<port>/health` (fallback `/`).
4. If healthy → cutover: k3s keeps the new Ingress live; Docker's
   `publishRoutes()` rebuilds the proxy routing table from the `projects`
   table (`status='running'`). Route
   `Host <slug>-app.fiskmas.dev` → the app.
5. If unhealthy → keep the previous workload if any, mark `failed` (roll back
   unless `force`).
6. On `stop`/`restart`/`delete` the slug leaves the `running` set, so the next
   route reconciliation drops its route; `delete` also removes the workload.
