docs: image verification (enforce default) + full service manifest reference

The repo's markdown manuals are being consolidated into this wiki so the
repo keeps only README.md. Adds the complete manifest/compose/accounts/
backup/egress reference (ported from docs/service-developer-guide.md,
corrected: HTTP account dispatch is implemented, egress uses Connectivity
v2 named instances, images are digest-pinned + signed). Documents the
image_verification setting now that enforce is the default. Cleans stale
CI-runner and test-count notes in Dev-Testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:13:13 -04:00
parent 0d207c17f4
commit eb887c7a2c
5 changed files with 511 additions and 4 deletions
+12
@@ -43,6 +43,18 @@ DHCP was removed from PIC. The `cell-dns` container runs CoreDNS only.
4. The service card shows a progress state while installation runs. Reload the page to check status.
5. Once installed, the service appears in the sidebar navigation.
### Image signature verification
Every store image is digest-pinned and signed by the PIC build pipeline. Before starting a container, the cell verifies the image signature with cosign against the bundled public key. The mode is the `image_verification` setting in `config/api/cell_config.json` (changing it requires an API restart):
| Mode | Behaviour |
|---|---|
| `enforce` (default) | Installation is refused if the image is not digest-pinned, unsigned, or the signature does not verify. |
| `warn` | Verification failures are logged but installation proceeds. |
| `off` | No verification. |
Leave it on `enforce` unless you are developing your own service images — see [Building Services](Dev-Building-Services).
---
## Using installed services
+6 -2
@@ -1,6 +1,6 @@
# Building a Service
This page is a summary. The full reference is at `docs/service-developer-guide.md` in the PIC repository.
This page is a summary. The full reference is the [Service Manifest Reference](Dev-Service-Manifest-Reference).
---
@@ -175,4 +175,8 @@ The review checks:
Increment `version` in `manifest.json` with every submission. PIC does not auto-update installed services.
See `docs/service-developer-guide.md` in the PIC repo for the complete field reference.
## Signing and verification
Merged services are built by the two-stage pipeline (untrusted kaniko build → trivy scan → cosign sign → push), and the manifest's `image` is rewritten to a `@sha256:` digest. Cells verify the signature with cosign before starting any store container, and the default `image_verification` mode is **`enforce`** — an unsigned or undigested image will not install. While developing locally, set `image_verification.mode` to `warn` or `off` in `config/api/cell_config.json` (then restart the API container) to run images that have not been through the pipeline yet.
See the [Service Manifest Reference](Dev-Service-Manifest-Reference) for the complete field reference, compose template variables, the account-provisioning HTTP interface, and backup/egress integration details.
+490
@@ -0,0 +1,490 @@
# Service Manifest Reference
The complete field-by-field reference for the PIC service manifest (`schema_version: 3`), the compose template variables, the account-provisioning HTTP interface, backup integration, and egress routing. For the narrative walkthrough and submission process, see [Building a Service](Dev-Building-Services).
**Prerequisites:** Docker, Docker Compose, basic Linux networking. You do not need to know Python to build a store service.
---
## 1. What a PIC service is
A PIC service is a Docker container (or a set of containers) that plugs into the PIC ecosystem through a single JSON file called the **manifest**. The manifest tells PIC everything it needs to know:
- How to route HTTPS traffic to the service through Caddy
- What subdomains to expose
- Which users get accounts on the service and what credentials they receive
- Which paths to include in automated backups
- Which outbound network interfaces the service is allowed to use
All PIC services are **store services** — optional packages installed by the cell admin from the `pic-services` catalog. PIC downloads the manifest, renders a per-service Docker Compose file, verifies the image signature with cosign, and starts the containers. The core PIC stack (DNS, NTP, WireGuard, Caddy, API, WebUI) runs independently of any installed services.
The email, calendar, and files services (in `pic-services/services/`) are the reference implementations and show the full feature set. The `ServiceRegistry` in `api/service_registry.py` is the single source of truth for all installed services. `CaddyManager`, the backup system, and the peer services endpoint all read from it rather than from hardcoded lists.
---
## 2. Manifest reference
The manifest is a JSON file with `"schema_version": 3`. The `email`, `calendar`, and `files` manifests in `pic-services/services/` are the canonical reference examples.
### Top-level identity fields
| Field | Type | Required | Description |
|---|---|---|---|
| `schema_version` | integer | yes | Must be `3`. |
| `id` | string | yes | Unique service identifier, lowercase, no spaces (e.g. `"notes"`). Must match the store index entry. |
| `name` | string | yes | Human-readable display name (e.g. `"Notes"`). |
| `description` | string | yes | One-sentence description shown in the UI. |
| `version` | string | yes | Semver string for the service package itself (e.g. `"1.0.0"`). |
| `author` | string | yes | Your name or organisation. |
| `kind` | string | yes | Must be `"store"`. |
| `min_pic_version` | string | no | Minimum PIC version required (e.g. `"1.0"`). |
```json
{
"schema_version": 3,
"id": "notes",
"name": "Notes",
"description": "Self-hosted Markdown notes with full-text search",
"version": "1.0.0",
"author": "acme",
"kind": "store",
"min_pic_version": "1.0"
}
```
### `capabilities`
A set of boolean flags that tell PIC which integrations to activate for your service.
| Field | Type | Default | Description |
|---|---|---|---|
| `has_subdomain` | bool | `false` | The service gets a subdomain and a Caddy reverse-proxy route. Requires `subdomain` and `backend`. |
| `has_accounts` | bool | `false` | The service provisions per-peer accounts. Requires `accounts`. |
| `has_admin_config` | bool | `false` | The service has admin-configurable fields. Requires `config_schema`. |
| `has_storage` | bool | `false` | The service has data worth backing up. Requires `backup`. |
| `has_egress` | bool | `false` | The admin can choose which outbound connection this service uses. Requires `egress`. |
| `has_api_hooks` | bool | `false` | Reserved for future use; set `false`. |
### `subdomain`, `extra_subdomains`, `backend`, `extra_backends`
These fields are only read when `has_subdomain` is `true`.
| Field | Type | Required | Description |
|---|---|---|---|
| `subdomain` | string | yes (if `has_subdomain`) | The primary subdomain (e.g. `"notes"`). Results in `notes.<cell-domain>`. Must not collide with reserved names: `api`, `webui`, `admin`, `www`, `ns1`, `ns2`, `git`, `registry`, `install`. |
| `extra_subdomains` | array of strings | no | Additional subdomains that point to the same backend (e.g. `["webmail"]`). |
| `backend` | string | yes (if `has_subdomain`) | The container-name:port combination that Caddy proxies to (e.g. `"cell-notes:8080"`). Uses Docker DNS on the `cell-network`. |
| `extra_backends` | object | no | Maps extra subdomain names to separate backends. Key is the subdomain string; value is the backend string. The email service uses this to send `webdav.*` to a different container than `files.*`. |
**Validation at runtime:** `ServiceRegistry.get_caddy_routes()` validates all subdomain and backend values before passing them to CaddyManager or NetworkManager. Any entry whose `subdomain` does not match `^[a-z][a-z0-9-]{0,30}$`, whose `backend` does not match `^[A-Za-z0-9._-]+:\d{1,5}$`, or whose `subdomain` appears in the reserved list is silently skipped with a warning log. The same validation applies to `extra_subdomains` and `extra_backends` keys/values. For store services, this validation is also performed during installation by `ServiceStoreManager._validate_manifest()`.
### `containers`
Array of container names that belong to this service. Used by the UI and log viewer.
```json
"containers": ["cell-notes"]
```
### `config_schema`
Defines admin-configurable fields for this service. When `has_admin_config` is `true`, the UI renders a settings form from this schema. PIC stores admin-saved values in `cell_config.json` and merges them with your `default` values at runtime. The merged result is available as the `config` key when `ServiceRegistry.get()` returns your service.
Each field is an object:
| Key | Type | Required | Description |
|---|---|---|---|
| `type` | string | yes | One of `"string"`, `"integer"`, `"boolean"`. |
| `label` | string | yes | Human-readable label for the settings form. |
| `required` | bool | no | Whether the field must have a value before the service starts. |
| `default` | any | no | Default value used when the admin has not set one. |
| `min` / `max` | integer | no (integer only) | Inclusive bounds for integer fields. |
```json
"config_schema": {
"port": {
"type": "integer",
"label": "Internal HTTP port",
"default": 8080,
"min": 1,
"max": 65535
}
}
```
### `peer_config_template`
When a peer is provisioned on this service, PIC fills this template and returns the result to the peer as their connection info. Template substitution tokens:
| Token | Replaced with |
|---|---|
| `{domain}` | The cell's public domain (e.g. `alice.pic.ngo`) |
| `{peer.username}` | The peer's username |
| `{peer.service_credentials.<id>.<field>}` | A credential value; `<id>` is the service `id`, `<field>` matches a name in `accounts.credentials` |
| `{config.<key>}` | A value from the merged `config_schema` result |
```json
"peer_config_template": {
"url": "https://notes.{domain}/",
"username": "{peer.username}",
"password": "{peer.service_credentials.notes.password}"
}
```
### `accounts`
Required when `has_accounts` is `true`.
| Field | Type | Description |
|---|---|---|
| `manager` | string | Set to `"http"` for store services — PIC calls your container's HTTP API for account operations (see section 4). The reference services (`email`, `calendar`, `files`) use internal manager names (`"email_manager"`, `"calendar_manager"`, `"file_manager"`). |
| `credentials` | array of strings | Names of credential fields this service issues per peer. Most services use `["password"]`. The names appear in `peer_config_template` tokens. |
### `compose`
Unused at the manifest level. Compose configuration is provided via `compose-template.yml` in the service package (see section 3). Set to `null` in the manifest.
### `backup`
Required when `has_storage` is `true`. Tells PIC's backup system what to snapshot.
| Field | Type | Description |
|---|---|---|
| `volumes` | array of objects | Container paths to stream out via `docker exec tar`. Each entry has three string fields: `container` (container name), `path` (absolute path inside the container), and `name` (archive filename stem). |
| `config_paths` | array of strings | Paths relative to the PIC project root on the host that contain service configuration (not user data). Copied directly into the snapshot. |
Each entry in `volumes` produces an archive at `<name>.tar.gz` inside the snapshot. See section 5 for details.
### `egress`
Required when `has_egress` is `true`. Declares which outbound paths this service is permitted to use.
| Field | Type | Description |
|---|---|---|
| `default` | string | The path selected when the admin has not changed anything. |
| `allowed` | array of strings | The complete set of connection types the admin can choose from. |
Valid identifiers: `default`, `wireguard_ext`, `openvpn`, `tor`, `sshuttle`, `proxy`. See section 6.
### `storage`
Informational metadata used by the UI to show storage usage.
| Field | Type | Description |
|---|---|---|
| `primary_path` | string | The path (relative to project root) that holds the bulk of user data. |
| `quota_mb` | integer or null | Storage quota in megabytes; `null` means no limit. |
### Store-only manifest fields
These are validated by `ServiceStoreManager._validate_manifest()` before installation is permitted.
| Field | Type | Required | Description |
|---|---|---|---|
| `image` | string | yes | Docker image to pull. Must match the pattern `git.pic.ngo/roof/*`. Images from other registries are rejected. In the published store, the image reference is **digest-pinned** (`@sha256:...`) and **cosign-signed** by the build pipeline; under the default `enforce` verification mode, an undigested or unsigned image will not install. |
| `container_name` | string | yes | The name Docker gives the running container. Must match `^cell-[a-z0-9][a-z0-9-]{0,30}$`. |
| `volumes` | array | no | Named volumes to mount. Each entry must have `name` (the volume name) and `mount` (the absolute path inside the container). Mounts to `/`, `/etc`, `/var`, `/proc`, `/sys`, `/dev`, `/app`, `/run`, `/boot`, and paths that are a prefix of the PIC project root are forbidden. |
| `env` | array | no | Environment variables to pass. Each entry has `key` and `value`. Values must match `^[A-Za-z0-9._@:/+\-= ]*$`. |
| `iptables_rules` | array | no | FORWARD ACCEPT rules PIC should install in `cell-wireguard`. Each rule must have `type: "ACCEPT"`, `dest_ip: "${SERVICE_IP}"`, an integer `dest_port` (1–65535), and an optional `proto` (`"tcp"` or `"udp"`, default `"tcp"`). The literal string `${SERVICE_IP}` is replaced with the allocated container IP at install time. |
| `caddy_route` | object | no | If the service exposes a web UI, provide `subdomain` (must not be reserved; must match `^[a-z][a-z0-9-]{0,30}$`). PIC inserts the corresponding `reverse_proxy` directive into the Caddyfile. |
---
## 3. Compose template variables
When you ship a store service, you include a `compose-template.yml` alongside your `manifest.json`. `ServiceComposer.render_template()` substitutes the variables below before writing the per-service `docker-compose.yml`.
| Variable | Syntax | Value |
|---|---|---|
| `${PIC_CFG_<KEY>}` | uppercase `config_schema` key | The admin-saved value for that field, or the `default` from the schema if the admin has not set it. For example, `config_schema.port``${PIC_CFG_PORT}`. |
| `${PIC_SECRET_<NAME>}` | any name you choose | An auto-generated random secret produced by `secrets.token_urlsafe(24)` (~32 URL-safe base64 characters). Generated once on first install, then reused unchanged on every reconfigure. Stored per service in `data/service_secrets.json`. |
| `${PIC_DOMAIN}` | literal | Effective domain from `ConfigManager` (e.g. `alice.pic.ngo`). |
| `${PIC_CELL_NAME}` | literal | Cell name from the identity config (e.g. `alice`). |
| `${PIC_SERVICE_ID}` | literal | The `id` field from the service manifest (e.g. `notes`). |
| `${SERVICE_IP}` | literal | The static IP PIC allocated from the service pool. Always set automatically. |
| `${INSTANCE_ID}` | literal | For instanceable connectivity services only: the short connection-instance id, used to derive per-instance container names. |
**Volume mounts**: Because docker compose runs inside the API container but the Docker daemon runs on the host, relative volume paths in compose templates resolve relative to the compose file's directory as seen by the HOST filesystem. To avoid path resolution surprises, prefer **named volumes** for service data (Docker manages them independently).
Example `compose-template.yml`:
```yaml
services:
cell-notes:
image: git.pic.ngo/roof/pic-notes@sha256:<digest>
container_name: cell-notes
restart: unless-stopped
environment:
NOTES_PORT: "${PIC_CFG_PORT}"
NOTES_DOMAIN: "${PIC_DOMAIN}"
NOTES_DB_PASS: "${PIC_SECRET_DB_PASSWORD}"
volumes:
- notes-data:/data/notes
networks:
cell-network:
ipv4_address: "${SERVICE_IP}"
volumes:
notes-data:
networks:
cell-network:
external: true
```
---
## 4. Account provisioning interface
### How AccountManager works
`AccountManager` (`api/account_manager.py`) is the single entry point for all account operations across every service type. When a peer account is provisioned, it:
1. Looks up the service in `ServiceRegistry` and reads `accounts.manager` from the manifest.
2. Dispatches to the appropriate internal manager method (for the reference services) or to the service's HTTP API endpoint (when `manager` is `"http"`). Both dispatch paths are implemented.
3. Stores the returned credentials in `data/peer_service_credentials.json` with permissions `0o600`.
Credentials are stored in plaintext. This is intentional: the peer credentials endpoint needs to return them verbatim for one-time client configuration. The `0o600` permission matches the pattern used for WireGuard keys and `data/service_secrets.json`. Writes use a write-then-rename pattern with `os.fsync` to avoid partial-write corruption.
### Provision flow
```
POST /api/services/catalog/<service_id>/accounts
Content-Type: application/json
{ "username": "alice", "password": "optional" }
```
If `password` is omitted, `AccountManager` generates one with `secrets.token_urlsafe(16)`. The response on HTTP 201 is:
```json
{ "service_id": "email", "username": "alice", "provisioned": true }
```
The password is not echoed in the response. To retrieve stored credentials for a provisioned peer, call `GET /api/services/catalog/<id>/accounts/<username>/credentials`.
If the underlying manager call fails, `provision()` raises and the route returns HTTP 500. For email, the domain is read from the service's merged config (`svc['config']['domain']`); if absent, provisioning raises `ValueError` before calling the manager.
### Deprovision flow
```
DELETE /api/services/catalog/<service_id>/accounts/<username>
```
Removes the account on the service and the peer's entry from the credentials file. Returns HTTP 200 on success, HTTP 400 if the service does not exist or does not support accounts.
**Peer deletion** calls `AccountManager.deprovision_peer(peer_username)`, which iterates over every service the peer is provisioned on. Failures on individual services are logged and skipped rather than aborting the deletion.
### Admin API endpoints for account management
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/services/catalog/<service_id>/accounts` | List provisioned usernames. |
| `POST` | `/api/services/catalog/<service_id>/accounts` | Provision a peer account (HTTP 201). |
| `DELETE` | `/api/services/catalog/<service_id>/accounts/<username>` | Deprovision. |
| `GET` | `/api/services/catalog/<service_id>/accounts/<username>/credentials` | Stored credentials for one peer+service pair (404 if not provisioned). |
| `GET` | `/api/peers/<peer_name>/service-credentials` | Filled `peer_config_template` values for all services the peer is provisioned on. |
The Email, Calendar, and Files service pages each have an **Accounts** tab in the admin dashboard that calls these endpoints.
### How `peer_config_template` connects to stored credentials
`GET /api/peers/<peer_name>/service-credentials` is the endpoint a peer device calls during first-time setup to configure email, CalDAV, and file sync clients. For each service the peer is provisioned on, `ServiceRegistry.get_peer_service_info()` fills the `peer_config_template` tokens (`{domain}`, `{peer.username}` URL-encoded, `{peer.service_credentials.<id>.<field>}`, `{config.<key>}`) and returns the filled dict. If a service has no `peer_config_template`, the raw credential dict is returned as a fallback.
### Container lifecycle routes
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/services/catalog/<id>/status` | Container status (one entry per container). |
| `POST` | `/api/services/catalog/<id>/restart` | Restart the service containers via the service's own compose project. |
| `POST` | `/api/services/catalog/<id>/reconfigure` | Re-render the compose file from the template and re-apply with `up -d` (rolling update). Body must include `compose_template`. |
### Store service HTTP API
When `accounts.manager` is `"http"`, PIC calls your container's HTTP API for account operations. The base path is `/service-api/accounts` on your container's internal address. There is no authentication on this API — it is reachable only from within the `cell-network` Docker network.
**Create account**
```
POST /service-api/accounts
Content-Type: application/json
{ "username": "alice", "password": "auto-generated-by-pic" }
```
PIC generates the password and passes it to your service. Return HTTP 200 with `{"ok": true}` on success. Return HTTP 400 or 409 with `{"ok": false, "error": "..."}` for expected errors (duplicate username, invalid input). Return HTTP 500 for unexpected internal errors.
**Delete account**
```
DELETE /service-api/accounts/{username}
```
Return HTTP 200 with `{"ok": true}` on success, HTTP 404 with `{"ok": false, "error": "not found"}` if the account does not exist.
**List accounts**
```
GET /service-api/accounts
```
Return `{"accounts": ["alice", "bob"]}`.
---
## 5. Backup integration
Declare `has_storage: true` in `capabilities` and fill in the `backup` block. `ServiceRegistry.get_backup_plan()` returns the combined backup declarations for all installed services; the backup runner reads from that method.
### Why docker exec instead of bind mounts
The API container only has access to `data/api/` on the host filesystem. Service data (mailboxes, calendar collections, file trees) lives in other containers' volumes. Rather than mount every service volume into the API container, PIC streams data using `docker exec <container> tar czf - <path>`. This works for any container on the Docker host regardless of how its volumes are configured.
### `volumes` entries
| Field | Description |
|---|---|
| `container` | Name of the running container to exec into (e.g. `"cell-notes"`). |
| `path` | Absolute path inside that container to archive (e.g. `"/data/notes"`). |
| `name` | Archive filename stem. PIC saves the archive as `<name>.tar.gz` under `service_data/<service_id>/` in the backup directory. |
A service with multiple containers or multiple data directories lists one entry per directory.
**Security note:** The backup commands use `docker exec -- <container> tar -C <path> -czf - .` (note the `--` separator) to prevent option injection. The container name is validated against `^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$` before the command is run.
### `config_paths`
Paths relative to the PIC project root on the host, copied directly into the snapshot (no docker exec). Use this for configuration files the service reads at startup, not for user data.
### Restore
PIC restores each volume entry by piping the archive back via `docker exec -i -- <container> tar -C <path> -xzf -`. The `-C <path>` flag bounds extraction to the declared volume path. The target container must be running at restore time.
---
## 6. Egress routing
When `has_egress` is `true`, the cell admin can route your service's outbound traffic through a **named connection instance** (Connectivity v2): a WireGuard external tunnel, OpenVPN, Tor, an SSH tunnel (sshuttle), or an upstream HTTP/SOCKS5 proxy.
The manifest's `egress.allowed` lists the connection **types** the admin may choose from: `default`, `wireguard_ext`, `openvpn`, `tor`, `sshuttle`, `proxy`. At assignment time the admin picks a concrete connection instance of one of those types; PIC enforces the selection with per-instance fwmarks (allocated from `0x1000` upward) and policy-routing tables (from `1000` upward) inside the `cell-wireguard` container.
Always include `default` in `allowed` so the admin has a way to use the normal path. If the chosen connection is unhealthy, traffic is dropped by the kill-switch FORWARD rule (fail-closed) unless the assignment is configured fail-open. See [Connectivity](Admin-Connectivity).
---
## 7. Quick-start example
A minimal working example: a static website served from Nginx with no accounts, no backup, and no egress policy.
### `manifest.json`
```json
{
"schema_version": 3,
"id": "homepage",
"name": "Homepage",
"description": "A static homepage served from your cell",
"version": "1.0.0",
"author": "acme",
"kind": "store",
"min_pic_version": "1.0",
"capabilities": {
"has_subdomain": true,
"has_accounts": false,
"has_admin_config": false,
"has_storage": false,
"has_egress": false,
"has_api_hooks": false
},
"subdomain": "home",
"extra_subdomains": [],
"backend": "cell-homepage:80",
"containers": ["cell-homepage"],
"image": "git.pic.ngo/roof/pic-homepage@sha256:<digest>",
"container_name": "cell-homepage",
"volumes": [
{ "name": "homepage-html", "mount": "/usr/share/nginx/html" }
],
"env": [],
"iptables_rules": [
{
"type": "ACCEPT",
"dest_ip": "${SERVICE_IP}",
"dest_port": 80,
"proto": "tcp"
}
],
"caddy_route": {
"subdomain": "home"
},
"compose": null
}
```
### What PIC does on install
1. Downloads this manifest from the store index.
2. Validates every field (image allowlist, volume safety, reserved subdomains, iptables rule format).
3. Verifies the image is digest-pinned and its cosign signature checks out (default `enforce` mode).
4. Allocates a static IP from the service pool (`172.20.0.20``172.20.0.254`).
5. Writes a Docker Compose file that starts `cell-homepage` with the allocated IP on `cell-network`.
6. Runs `docker compose up -d cell-homepage`.
7. Applies the `iptables_rules` in `cell-wireguard` so peers can reach the container.
8. Regenerates the Caddyfile so `home.<cell-domain>` proxies to `cell-homepage:80`.
The result: any WireGuard peer can reach `https://home.alice.pic.ngo/` immediately after installation.
---
## 8. Reference implementations
The `email`, `calendar`, and `files` services in `pic-services/services/` are the canonical examples of a complete store service:
| Service | Notable features demonstrated |
|---|---|
| `email` | `has_accounts`, `has_egress`, multi-container (`cell-mail` + webmail), `extra_backends`, custom image baking defaults via Dockerfile |
| `calendar` | `has_accounts`, CalDAV `peer_config_template`, htpasswd account provisioning |
| `files` | `has_accounts`, `has_storage`, WebDAV + Filegator `extra_backends`, `backup.volumes` with multiple entries |
---
## Appendix: manifest field quick reference
| Field | Required | Notes |
|---|---|---|
| `schema_version` | yes | Must be `3` |
| `id` | yes | |
| `name` | yes | |
| `description` | yes | |
| `version` | yes | |
| `author` | yes | |
| `kind` | yes | Must be `"store"` |
| `min_pic_version` | no | |
| `capabilities.*` | yes | All six flags must be present |
| `subdomain` | if `has_subdomain` | |
| `extra_subdomains` | no | |
| `backend` | if `has_subdomain` | |
| `extra_backends` | no | |
| `containers` | no | Informational |
| `config_schema` | if `has_admin_config` | |
| `peer_config_template` | if `has_accounts` | |
| `accounts` | if `has_accounts` | |
| `compose` | no | Always `null` — compose config goes in `compose-template.yml` |
| `backup` | if `has_storage` | |
| `egress` | if `has_egress` | |
| `storage` | if `has_storage` | |
| `image` | yes | `git.pic.ngo/roof/*`; digest-pinned and signed in the published store |
| `container_name` | yes | Must match `^cell-[a-z0-9][a-z0-9-]{0,30}$` |
| `volumes` | no | |
| `env` | no | |
| `iptables_rules` | no | |
| `caddy_route` | no | |
+2 -2
@@ -28,7 +28,7 @@ The unit suite covers manager classes, route handlers, and utility functions. Te
Important rule: when testing write-failure paths, mock `builtins.open` with `side_effect=OSError`. Do not rely on unwritable host paths — CI may run as root and can create any path.
The test count grows as features are added. CLAUDE.md references "1500+" tests; the README references "3170 tests" in older documentation. Run `make test` to see the current count.
The test count grows as features are added (3200+ as of mid-2026). Run `make test` to see the current count.
### Integration tests
@@ -62,7 +62,7 @@ The Playwright tests drive a real browser against a running stack. `test-e2e-ui`
## CI
Gitea Actions runs the unit suite on every push using a self-hosted runner on `pic0` (192.168.31.51). The workflow file calls:
Gitea Actions runs the unit suite on every push using the self-hosted runner VMs (`gitea-action0` trusted, `gitea-action1` sacrificial for untrusted builds). The workflow file calls:
```bash
pytest tests/ --ignore=tests/e2e --ignore=tests/integration
+1
@@ -37,6 +37,7 @@
[Dev Guide](Dev-Guide)
[Architecture](Dev-Architecture)
[Building a Service](Dev-Building-Services)
[Service Manifest Reference](Dev-Service-Manifest-Reference)
[API Reference](Dev-API)
[Testing](Dev-Testing)
[Install Internals](Dev-Install-Internals)