# Appliance build & distribution (Sprint 1800)

How the Index Monitor appliance is packaged, hardened, and shipped to agencies.

## The stack (1801)

`docker-compose.appliance.yml` brings up four services:

| Service | Image | Role |
|---------|-------|------|
| `db` | `mariadb:10.11` | Persistent volume; healthchecked. |
| `app` | `Dockerfile.app` (nginx + php-fpm) | Serves the `gsc_appliance` profile. Mounts `/custom`. |
| `worker` | `Dockerfile.worker` (python + php) | Loops the Index Monitor + GSC Analytics workers. |
| `cron` | `Dockerfile.worker` (cron entrypoint) | Ticks `cron-tick.php` every 60s + the license renewal hourly. |

Boot is zero-touch: the app entrypoint waits for the DB, runs the **schema-drift
guard** (1802), migrates, seeds the admin + tiers, then starts php-fpm. All
config is env-driven (`.env` / `SC_*`, ticket 1603) — no `config.local.php` is
baked into the image, and `.dockerignore` keeps vendor/.venv/logs/secrets out.

```bash
cp .env.example .env    # fill in DB / URL / mail / (optional) license vars
docker compose -f docker-compose.appliance.yml up -d
```

## Release provenance and local development

The normal release path is the tag-triggered GitHub publish workflow. While
Actions is unavailable, a local release build must use the guarded wrapper from
a clean checkout of the matching tag:

```bash
scripts/build-appliance-release.sh v1.2.3
```

It refuses a dirty checkout, a missing `appliance-v1.2.3` tag, or a tag that
does not resolve to `HEAD`. It stages and obfuscates a temporary build context,
then produces local `gsc-appliance-app:v1.2.3` and
`gsc-appliance-worker:v1.2.3` images. Do not run bare `docker build` to create
a release-shaped tag.

For iterative setup/UI debugging, use the development overlay instead. It
bind-mounts this checkout into the app container, so the default
`http://localhost:8080` follows source edits rather than a stale baked image:

```bash
docker compose -f docker-compose.appliance.yml -f docker-compose.appliance.dev.yml up -d --build
```

The overlay is never a release rehearsal: release rehearsals use only the
guarded image build and the image-pinned rehearsal compose file.

## Raw SQL DB grant (2848)

The GSC Analytics Raw SQL tool is operator-only in the app, and it refuses to
run unless it can connect through a separate least-privilege MySQL account. Set
`SC_GSC_RAW_SQL_DB_USER` / `SC_GSC_RAW_SQL_DB_PASS` in `.env`; host, port,
database, charset, and socket default to the normal `SC_DB_*` values.

Create the account without global privileges, `FILE`, or access to `mysql.*`,
then grant `SELECT` only on the current `ga_*` tables. One practical way to
generate the exact grants:

```sql
CREATE USER 'portal_gsc_sql_ro'@'%' IDENTIFIED BY 'replace-with-random-password';

SELECT CONCAT(
  'GRANT SELECT ON `', TABLE_SCHEMA, '`.`', TABLE_NAME,
  '` TO ''portal_gsc_sql_ro''@''%'';'
)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'portal'
  AND TABLE_NAME REGEXP '^ga_';
```

Run the generated `GRANT` statements, rerun the generator after migrations that
add `ga_*` tables, and verify `SHOW GRANTS FOR 'portal_gsc_sql_ro'@'%';` shows
only global `USAGE` plus `SELECT` on individual `ga_*` tables in the appliance
database. The app also checks those grants at connection time and rejects
wildcard, cross-schema, `FILE`, and write-capable privileges.

## MCP operator integration (2808)

The appliance now ships an optional **read-only MCP server**. It is not part of
the default `up -d` stack; instead it lives behind the compose `mcp` profile so
an operator or local AI client can launch it on demand:

```bash
docker compose -f docker-compose.appliance.yml run --rm --no-deps mcp
```

The server exposes safe appliance facts (status, modules, migrations, offline
license state) by shelling into the app's own PHP CLI, rather than duplicating
logic in a second runtime. See [appliance-mcp-server.md](./appliance-mcp-server.md).

## Remote MCP (2816 recovery)

The appliance also ships a dedicated **remote SSE MCP sidecar** for customer AI
clients. It runs separately from `php-fpm` and is reverse-proxied at:

- `GET /api/v1/mcp/sse`
- `POST /api/v1/mcp/message?session_id=...`

That split is deliberate: long-lived SSE connections never consume app web
workers. Auth and tool execution still shell into the app's PHP runtime, and
the exposed Sentinel/GSC tool surface remains read-only.

## Upgrade safety (1802)

`check-schema-drift.php` compares the migrations the **image bundles** against
what the **DB has applied**. If the DB is ahead (someone pulled an older image),
the entrypoint exits and refuses to boot — old code never runs against a newer
schema. Upgrades are just `docker compose pull && up -d`; the entrypoint
re-migrates idempotently.

## Whitelabel (1804)

Agencies drop `logo.*`, `theme.css`, and/or `config.json` into the mounted
`/custom` volume — see [custom/README.md](../custom/README.md). No rebuild;
`Portal_SiteBranding` + nginx pick them up live.

## Licensing (1700 + appliance queue)

Buying an **appliance plan** (a plan carrying the `appliance_license` feature)
mints an install key via the Stripe webhook → provisioning bridge (1803) and
emails it. The appliance phones home (`license-renew.php`, run by the cron
sidecar), caches the signed token, and verifies it **offline** on every request
(`LicenseServer_LicenseClient`). Cancelling suspends the install → the cached
token expires → the appliance locks (revocation by omission). Blocking the
network can't unlock — it only stops renewals.

### Fingerprint + cache plumbing (2812)

The machine fingerprint hashes **hostname + license-cache dir + inode**, and
the license server **hard-binds it on first renewal** (later changes 403 until
the vendor rotates the install key). Two consequences, both wired into the
compose files:

- **All containers pin one hostname** (`APPLIANCE_HOSTNAME`, default
  `gsc-appliance`). Default container hostnames are container IDs, which
  change on every `docker compose pull && up -d` and would brick renewals.
- **The license cache lives on a shared named volume**
  (`appliance_license` → `/var/lib/appliance-license`, via
  `SC_LICENSE_CACHE_PATH`) so the app (verifies per request), the cron sidecar
  (renews), and the worker (verifies at startup) all see the same token and
  compute the same fingerprint. Without this the worker can never validate a
  license minted against the app container.

PHP and Python compute the fingerprint independently
(`LicenseClient::machineFingerprint` / `appliance_worker_bootstrap`); their
parity — and the parity of the two Ed25519 verifiers — is pinned by the shared
vectors in `api/bin/fixtures/license-token-vectors.json` (2815), replayed by
`test-license-token-vectors.php` (PHP) and
`test-appliance-worker-bootstrap.py` (Python) in CI.

### Worker-side tier enforcement (2812)

The appliance is **graceful, not a kill switch**: with no `SC_LICENSE_*` env
the workers still run — but at the **compiled Free-tier caps** (5 properties /
10k tracked URLs), enforced inside the Cython-built workers from the *signed
token*, not from the PHP layer. A partial license config or an invalid token
fails closed. Editing the (lightly obfuscated) PHP cap layer therefore cannot
lift worker processing; only a valid signed tier token does. The explicit
Indie/Agency/Studio fragments (plus Studio's `whitelabel` aliases) are **baked**
into the compiled worker. Legacy paid fragments (`pro`, `standard`,
`enterprise`, `appliance`) resolve to uncapped Agency for compatibility. The
`appliance_pro_tier_slugs` config override only affects the PHP surface, so new
vendor plan slugs must use the baked explicit names.

## CI/CD hardening + publish (1805 / 2804 / 2805 / 2806)

`.github/workflows/appliance-publish.yml` builds the app + worker images and
pushes them to GHCR on an `appliance-v*` tag (or manual dispatch).

**Build-time hardening now uses open tooling:**

| Encoder | Secret | Covers |
|---------|--------|--------|
| yakpro-po (PHP) | none | `modules/{index-monitor,gsc-analytics,license-server}`, `api/lib` |
| Cython (Python) | none | the GSC appliance workers |

The workflows still publish a working image from open-source tooling only. This
is deliberate: hardening is a **deterrent**, not a guarantee — code that runs on
someone else's box must exist in executable form there. The licensing
**signature** scheme is what makes the vendor server un-fakeable; hardening only
makes the embedded verify harder to rip out. The two cover each other's gap;
neither is sufficient alone.

### Publishing hardened images

1. The workflow clones `yakpro-po` + `PHP-Parser` and rewrites the selected PHP
   directories before building the app image.
2. The worker Docker build compiles the GSC workers into C-extension modules,
   writes an artifact-hash manifest, and removes the original worker entrypoint
   sources from the final image.
3. The worker runtime verifies both the compiled-artifact manifest and the
   cached Ed25519 appliance license token before executing any worker code.
4. Update the generator and checked-in customer docs from the authoritative
   release metadata: `python3 scripts/update-appliance-release.py v1.2.3`.
   Commit the resulting release-pin changes; CI can verify them with
   `python3 scripts/update-appliance-release.py --check`. The updater preserves
   the required vendor Ed25519 `license_public_key` and refuses malformed or
   missing key metadata.
5. Tag that commit: `git tag appliance-v1.2.3 && git push --tags`.

### Registry retraction policy (2814)

**Pre-hardening tags never stay on the registry.** `appliance-v1.0.0` was
published before the 2804/2805/2806 hardening lane — its worker image has no
license or integrity checks, and its app image carries un-minified PHP. As of
2026-07-02 both packages are **private** on GHCR (verified: anonymous pulls
403), which contains the exposure, but the versions must still be deleted when
the replacement publishes:

1. Publish the replacement tag first (2900 gate G2), so `:latest` moves.
2. Run `scripts/retract-appliance-v100-images.sh` (needs `gh` with
   `read:packages` + `delete:packages`; interlocks on the replacement tag
   existing in both packages).
3. Only THEN decide/flip package visibility for customer access (2900 gate
   G5). Flipping to public while v1.0.0 versions exist would expose the
   unhardened build. Pin install docs to explicit version tags, not `latest`,
   so a future emergency retraction never breaks installs silently.

## Related

- [deployment-profiles.md](./deployment-profiles.md) — the `gsc_appliance` profile.
- [index-monitor-byok-oauth.md](./index-monitor-byok-oauth.md) — BYOK Google OAuth.
- `.env.example` — the full `SC_*` env surface.
