Refactor load testing scripts to support Bash and PowerShell
- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts. - Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility. - Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality. - Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting. - Enhanced error handling and output formatting in summary scripts for better readability.
This commit is contained in:
1 parent
9f60087a4c
commit
33b6296c5e
82 files changed
+19682
-21133
No files matched your search
+68
-216
@@ -1,216 +1,101 @@
|
||||
# API Reference
|
||||
# Linear Void API Reference
|
||||
|
||||
This document describes the currently implemented API surfaces in Linear Void.
|
||||
Base URLs: Public/admin app `https://linearvoid.tv` · WebSocket `wss://linearvoid.tv/ws` · Local fallback `http://localhost` / `ws://localhost/ws`
|
||||
|
||||
## Base URLs
|
||||
## Authentication
|
||||
|
||||
- Public app and admin: <https://linearvoid.tv>
|
||||
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
||||
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
||||
Admin REST endpoints use a cookie session:
|
||||
|
||||
## Authentication Model
|
||||
|
||||
Admin REST endpoints use a cookie session.
|
||||
|
||||
- Cookie name: linearvoid_admin_session
|
||||
- Cookie attributes: HttpOnly, SameSite=Strict, Secure when request is HTTPS (or X-Forwarded-Proto=https)
|
||||
- Session TTL: controlled by ADMIN_SESSION_TTL_HOURS (default 12h)
|
||||
- Cookie: `linearvoid_admin_session` — `HttpOnly`, `SameSite=Strict`, `Secure` when request is HTTPS (or `X-Forwarded-Proto=https`)
|
||||
- Session TTL: `ADMIN_SESSION_TTL_HOURS` (default 12h)
|
||||
|
||||
## Chat WebSocket API
|
||||
|
||||
### Connect
|
||||
Connect: `GET /ws?username={name}&room={room}` — client/server exchange JSON messages.
|
||||
|
||||
Endpoint:
|
||||
| Message (client→server) | Field | Notes |
|
||||
| ----------------------- | -------------------------------------------------------------------------------- | ----- |
|
||||
| `type` | `public` for room chat, `private` for DM routing; `system` for moderation/events | |
|
||||
| `room` | `main`; private DMs use `dm:userA-userB` (usernames sorted) | |
|
||||
| `username` | sender | |
|
||||
| `text` | message body | |
|
||||
|
||||
- GET /ws?username={name}&room={room}
|
||||
|
||||
Client sends JSON messages over the socket.
|
||||
|
||||
### Message Envelope (client -> server)
|
||||
Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
|
||||
|
||||
```json
|
||||
{
|
||||
"room": "main",
|
||||
"username": "viewer123",
|
||||
"text": "hello world",
|
||||
"type": "public"
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- type is usually public for room chat and private for DM room routing.
|
||||
- Private room names are formatted like dm:userA-userB (sorted usernames).
|
||||
- Moderation/system events are sent as type=system messages.
|
||||
|
||||
### Message Envelope (server -> client)
|
||||
|
||||
```json
|
||||
{
|
||||
"room": "main",
|
||||
"username": "viewer123",
|
||||
"text": "hello world",
|
||||
"type": "public",
|
||||
"timestamp": "2026-08-04T12:34:56Z"
|
||||
}
|
||||
{ "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
|
||||
```
|
||||
|
||||
## Admin Session API
|
||||
|
||||
### Login
|
||||
| Method & Path | Auth | Description |
|
||||
| ------------------------ | ----------------------------------- | ----------------------------- |
|
||||
| `POST /api/admin/login` | none | Authenticate |
|
||||
| `POST /api/admin/logout` | optional (clears cookie either way) | Logout |
|
||||
| `GET /api/admin/session` | cookie required | Session check; 401 if invalid |
|
||||
|
||||
- POST /api/admin/login
|
||||
- Auth: none
|
||||
|
||||
Request:
|
||||
Login request:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "your-password"
|
||||
}
|
||||
{ "username": "admin", "password": "your-password" }
|
||||
```
|
||||
|
||||
Success response:
|
||||
Login/logout/session success response (session returns 401 on failure):
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"username": "admin"
|
||||
}
|
||||
{ "authenticated": true, "username": "admin" }
|
||||
```
|
||||
|
||||
### Logout
|
||||
Logout response: `{ "authenticated": false }`
|
||||
|
||||
- POST /api/admin/logout
|
||||
- Auth: optional (clears cookie either way)
|
||||
## Admin Data API (auth required)
|
||||
|
||||
Response:
|
||||
| Method & Path | Returns |
|
||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| `GET /api/admin/overview` | Aggregate counters + runtime/system snapshot |
|
||||
| `GET /api/admin/playlist` | Playlist |
|
||||
| `PUT /api/admin/playlist` | Set playlist (body: `{ "content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n" }`) |
|
||||
| `GET /api/admin/schedule` | Parsed playlist entries + matched media metadata/runtime |
|
||||
| `GET /api/admin/moderation/logs?limit=50` | Moderation logs |
|
||||
| `POST /api/admin/moderation/actions` | Perform moderation action |
|
||||
| `GET /api/admin/analytics` | Totals (messages, moderation actions, rooms) + top room summary |
|
||||
| `GET /api/admin/system` | Health snapshot (chat/playout/redis/database) + config metadata |
|
||||
| `GET /api/admin/users` | Presence users (room + username) when Redis available |
|
||||
|
||||
Moderation action request:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": false
|
||||
}
|
||||
{ "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
|
||||
```
|
||||
|
||||
### Session Check
|
||||
|
||||
- GET /api/admin/session
|
||||
- Auth: cookie required
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticated": true,
|
||||
"username": "admin"
|
||||
}
|
||||
```
|
||||
|
||||
Failure:
|
||||
|
||||
- 401 unauthorized
|
||||
|
||||
## Admin Data API
|
||||
|
||||
All endpoints below require admin authentication.
|
||||
|
||||
### Overview
|
||||
|
||||
- GET /api/admin/overview
|
||||
|
||||
Returns aggregate counters and runtime/system snapshot fields.
|
||||
|
||||
### Playlist
|
||||
|
||||
- GET /api/admin/playlist
|
||||
- PUT /api/admin/playlist
|
||||
|
||||
PUT request:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": "file '/app/media/video1.mp4'\nfile '/app/media/video2.mp4'\n"
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule
|
||||
|
||||
- GET /api/admin/schedule
|
||||
|
||||
Returns parsed playlist entries plus matched media metadata/runtime.
|
||||
|
||||
### Moderation
|
||||
|
||||
- GET /api/admin/moderation/logs?limit=50
|
||||
- POST /api/admin/moderation/actions
|
||||
|
||||
Action request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "mute",
|
||||
"target": "username-or-id",
|
||||
"moderator": "admin",
|
||||
"reason": "spam",
|
||||
"room": "main"
|
||||
}
|
||||
```
|
||||
|
||||
### Analytics
|
||||
|
||||
- GET /api/admin/analytics
|
||||
|
||||
Returns totals (messages, moderation actions, rooms) and top room summary.
|
||||
|
||||
### System
|
||||
|
||||
- GET /api/admin/system
|
||||
|
||||
Returns service health snapshot for chat/playout/redis/database and config metadata.
|
||||
|
||||
### Active Users
|
||||
|
||||
- GET /api/admin/users
|
||||
|
||||
Returns currently discovered presence users (room + username) when Redis is available.
|
||||
|
||||
## Media Library API
|
||||
|
||||
### Media Collection
|
||||
| Method & Path | Description |
|
||||
| ------------------------------ | ------------------------------------------------- |
|
||||
| `GET /api/admin/media` | List media |
|
||||
| `POST /api/admin/media` | Multipart upload (legacy), form field name `file` |
|
||||
| `GET /api/admin/media/{id}` | Get item |
|
||||
| `PUT /api/admin/media/{id}` | Update item |
|
||||
| `DELETE /api/admin/media/{id}` | Delete item |
|
||||
|
||||
- GET /api/admin/media
|
||||
- POST /api/admin/media (multipart upload, legacy path)
|
||||
|
||||
Multipart form field name: file
|
||||
|
||||
### Media Item
|
||||
|
||||
- GET /api/admin/media/{id}
|
||||
- PUT /api/admin/media/{id}
|
||||
- DELETE /api/admin/media/{id}
|
||||
|
||||
PUT request:
|
||||
PUT media body:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"title": "My Clip",
|
||||
"tags": "music,live"
|
||||
}
|
||||
{ "status": "ready", "title": "My Clip", "tags": "music,live" }
|
||||
```
|
||||
|
||||
## Resumable Upload API
|
||||
|
||||
Maximum file size: 2GB
|
||||
Max file size: **2GB** · Default chunk size: **8MB**
|
||||
|
||||
Default chunk size: 8MB
|
||||
| Method & Path | Description |
|
||||
| ------------------------------------------------------------------ | ----------------------------------------------------------------- |
|
||||
| `POST /api/admin/media/uploads` | Initialize or resume session |
|
||||
| `GET /api/admin/media/uploads/{uploadId}` | Check upload status |
|
||||
| `PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}` | Upload chunk (`Content-Type: application/octet-stream`) |
|
||||
| `POST /api/admin/media/uploads/{uploadId}/complete` | Complete upload → returns media listing (`files` + `generatedAt`) |
|
||||
|
||||
### Initialize or Resume Session
|
||||
|
||||
- POST /api/admin/media/uploads
|
||||
|
||||
Request:
|
||||
Initialize request:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -223,7 +108,7 @@ Request:
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
Status envelope (returned by init, status check, and chunk upload):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -237,61 +122,33 @@ Response:
|
||||
}
|
||||
```
|
||||
|
||||
### Check Upload Status
|
||||
## Metrics & Health
|
||||
|
||||
- GET /api/admin/media/uploads/{uploadId}
|
||||
|
||||
Returns same status envelope as initialize.
|
||||
|
||||
### Upload Chunk
|
||||
|
||||
- PUT /api/admin/media/uploads/{uploadId}/chunk?index={chunkIndex}
|
||||
- Content-Type: application/octet-stream
|
||||
|
||||
Response returns updated upload status envelope.
|
||||
|
||||
### Complete Upload
|
||||
|
||||
- POST /api/admin/media/uploads/{uploadId}/complete
|
||||
|
||||
On success returns admin media listing payload (files + generatedAt).
|
||||
|
||||
## Metrics and Health
|
||||
|
||||
### Chat metrics
|
||||
|
||||
- GET /metrics
|
||||
|
||||
Prometheus exposition includes connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, and build info.
|
||||
|
||||
### Health
|
||||
|
||||
- GET /health on chat service
|
||||
- GET /health on playout service
|
||||
- GET /health on nginx reverse proxy
|
||||
- `GET /metrics` (chat) — Prometheus: connected clients, stored messages, moderation actions, Redis indicators, active admin sessions, build info
|
||||
- `GET /health` — chat service, playout service, and nginx reverse proxy each expose it
|
||||
|
||||
## HTTP Status Conventions
|
||||
|
||||
| Code | Meaning | When |
|
||||
| ---- | --------------------- | ----------------------------------------------------------- |
|
||||
| 200 | OK | Successful GET, PUT, or action |
|
||||
| 200 | OK | Successful GET, PUT, action |
|
||||
| 201 | Created | Successful POST (upload init, override created) |
|
||||
| 204 | No Content | Successful DELETE, CORS preflight |
|
||||
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
||||
| 401 | Unauthorized | Missing or expired admin session |
|
||||
| 403 | Forbidden | Valid session but insufficient permissions |
|
||||
| 401 | Unauthorized | Missing/expired admin session |
|
||||
| 403 | Forbidden | Valid session, insufficient permissions |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 405 | Method Not Allowed | Wrong HTTP method for endpoint |
|
||||
| 405 | Method Not Allowed | Wrong method for endpoint |
|
||||
| 409 | Conflict | Schedule override overlap, upload ID mismatch |
|
||||
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
||||
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
|
||||
| 429 | Too Many Requests | Rate limit exceeded (chat messages, WS connections, login) |
|
||||
| 429 | Too Many Requests | Rate limit exceeded (chat, WS, login) |
|
||||
| 500 | Internal Server Error | Unexpected server failure |
|
||||
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
|
||||
Per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
|
||||
|
||||
| Resource | Limit | Window | Response |
|
||||
| --------------------- | ---------------------------------- | ---------------------------------------- | ------------------------------------------------------ |
|
||||
@@ -299,9 +156,4 @@ All limits are per-IP (from `X-Forwarded-For` or `RemoteAddr`) unless noted.
|
||||
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
|
||||
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
||||
|
||||
Rate limit headers on applicable responses:
|
||||
|
||||
- `X-RateLimit-Limit`: maximum requests per window
|
||||
- `Retry-After`: seconds until window resets
|
||||
- 413: payload too large
|
||||
- 500: internal server error
|
||||
Rate limit headers (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
|
||||
+79
-160
@@ -1,229 +1,148 @@
|
||||
# Coolify Deployment
|
||||
|
||||
**Last Updated**: 2026-08-28
|
||||
*Last Updated: 2026-08-28*
|
||||
|
||||
This guide describes an alternative deployment path for Linear Void using
|
||||
[Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14) — an
|
||||
open-source, self-hostable PaaS that lets you manage servers, applications, and
|
||||
databases over SSH without vendor lock-in.
|
||||
Alternative deployment path for Linear Void using [Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14), an open-source self-hostable PaaS that manages servers, apps, and databases over SSH. Drop-in alternative to the flows in [SETUP.md](./SETUP.md): local dev (`docker-compose.yml`) and test-lab/prod (`docker-compose.prod.yml` via Gitea Actions CD). Coolify deploys the same Compose stack through a web UI and its own Traefik reverse proxy for automatic HTTPS, domain routing, and one-click redeploys (no manual `DOCKER_HOST`).
|
||||
|
||||
Coolify is a drop-in alternative to the two existing deployment flows described
|
||||
in [SETUP.md](./SETUP.md):
|
||||
## Why Coolify
|
||||
|
||||
1. **Local development** (`docker-compose.yml`) — builds from source on your
|
||||
workstation.
|
||||
2. **Test-lab / production** (`docker-compose.prod.yml`) — pulls prebuilt images
|
||||
from the Gitea registry and is driven by the Gitea Actions CD workflow.
|
||||
- **Single control plane**: manage playout, chat, nginx, redis, db (plus exporters/monitoring) from one dashboard.
|
||||
- **Automatic TLS**: Let's Encrypt provisioning/renewal per app/domain, replacing the manual Cloudflare + nginx origin TLS setup for the app hostname.
|
||||
- **No vendor lock-in**: config lives on your server; containers keep running if you stop using Coolify — you only lose automation.
|
||||
- **Simpler cutover**: on Hetzner VPS provisioning, point Coolify at the new server and redeploy instead of editing CD `DOCKER_HOST`.
|
||||
|
||||
With Coolify, the same Docker Compose stack is deployed through a web UI and
|
||||
Coolify's own reverse proxy (Traefik), giving you automatic HTTPS, domain
|
||||
routing, and one-click redeploys without managing `DOCKER_HOST` manually.
|
||||
|
||||
## Why use Coolify?
|
||||
|
||||
- **Single control plane**: manage the playout, chat, nginx, redis, and db
|
||||
services (plus exporters and monitoring) from one dashboard.
|
||||
- **Automatic TLS**: Coolify provisions and renews Let's Encrypt certificates
|
||||
per application/domain, replacing the manual Cloudflare + nginx origin TLS
|
||||
dance for the app hostname.
|
||||
- **No vendor lock-in**: all configuration lives on your server. If you stop
|
||||
using Coolify, the running containers keep working; you only lose the
|
||||
automation.
|
||||
- **Simpler cutover**: when the Hetzner VPS is provisioned, point Coolify at the
|
||||
new server and redeploy instead of editing CD `DOCKER_HOST` values.
|
||||
|
||||
> **Note**: Coolify replaces the _deployment orchestration_ layer only. Cloudflare
|
||||
> DNS, the R2 media bucket, cache rules, and the edge Worker described in
|
||||
> [SETUP.md](./SETUP.md) still apply and are unchanged.
|
||||
> Replaces the *deployment orchestration* layer only. Cloudflare DNS, R2 media bucket, cache rules, and the edge Worker from [SETUP.md](./SETUP.md) are unchanged.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Linux server running **Docker Engine** (any distro Coolify supports).
|
||||
- **SSH access** (root or a user with passwordless `sudo`) from the machine
|
||||
where you run the Coolify install — Coolify uses SSH to provision the target
|
||||
server.
|
||||
- At least **2 GB RAM / 20 GB disk** for the Coolify instance itself (plus
|
||||
additional resources for the Linear Void stack).
|
||||
- A domain name pointed at the server for HTTPS (e.g. `linearvoid.tv`).
|
||||
- Ports `80` and `443` reachable on the server for Traefik.
|
||||
- The Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if
|
||||
you intend to pull the prebuilt `playout`, `chat`, and `nginx` images.
|
||||
- Linux server with **Docker Engine** (any supported distro).
|
||||
- **SSH access** (root or passwordless `sudo`) from the Coolify install machine — Coolify provisions the target over SSH.
|
||||
- **2 GB RAM / 20 GB disk** minimum for Coolify itself (plus resources for the stack).
|
||||
- A domain pointed at the server for HTTPS (e.g. `linearvoid.tv`).
|
||||
- Ports `80` and `443` reachable for Traefik.
|
||||
- Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if pulling prebuilt `playout`, `chat`, `nginx` images.
|
||||
|
||||
## Installation
|
||||
|
||||
Install Coolify on a dedicated server (or alongside your stack — but a dedicated
|
||||
Coolify server is the recommended layout):
|
||||
Recommended on a dedicated server:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
```
|
||||
|
||||
The installer boots a Docker-based Coolify instance and prints the URL and
|
||||
default credentials. Open the dashboard in your browser and complete first-run
|
||||
setup.
|
||||
|
||||
For non-interactive or offline installs, see the
|
||||
[installation docs](https://coolify.io/docs/installation) and the
|
||||
[install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
|
||||
The installer boots a Docker-based Coolify instance and prints the URL and default credentials. Complete first-run setup in the dashboard. For non-interactive/offline installs, see the [installation docs](https://coolify.io/docs/installation) and [install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh).
|
||||
|
||||
## Add the Linear Void server
|
||||
|
||||
1. In the Coolify dashboard, go to **Servers → Add**.
|
||||
2. Choose your provider (or **Localhost** if Coolify runs on the same machine
|
||||
as the stack).
|
||||
3. Provide the SSH details. For a remote host, Coolify validates the connection
|
||||
and installs its agent.
|
||||
4. Verify the server shows as **Reachable** before creating resources.
|
||||
1. **Servers → Add**.
|
||||
2. Choose your provider (or **Localhost** if Coolify runs on the same host as the stack).
|
||||
3. Provide SSH details; for a remote host Coolify validates and installs its agent.
|
||||
4. Confirm the server shows **Reachable** before creating resources.
|
||||
|
||||
### Current topology (verified 2026-08-30)
|
||||
|
||||
The active test-lab setup is **two-tier**:
|
||||
Active test-lab setup is **two-tier**:
|
||||
|
||||
| Component | Host | Environment | Exposes | Docker TCP | Notes |
|
||||
| --------------------- | --------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Coolify dashboard** | `192.168.88.18` | Coolify; docker container; coolify dashboard at `8000` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`) | No | Coolify manages target servers over SSH |
|
||||
| **Deployment target** | `192.168.88.40` | Only Linear Void deploys here | `coolify-proxy` = Traefik on 80/443, Port 8000 on this host is bound by Portainer, not Coolify. | Yes | Contains Coolify's per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) plus the stack via `docker-compose.prod.yml` (the `linearvoid` compose project, 11 containers). |
|
||||
| Component | Host | Exposes | Docker TCP | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Coolify dashboard** | `192.168.88.18` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`); dashboard at `8000` | No | Manages target servers over SSH |
|
||||
| **Deployment target** | `192.168.88.40` | `coolify-proxy` = Traefik on 80/443; port `8000` bound by Portainer, not Coolify | Yes | Per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) + the stack via `docker-compose.prod.yml` (`linearvoid` compose project, 11 containers) |
|
||||
|
||||
The main `coolify` app container does **not** run on `192.168.88.40`; it lives on
|
||||
the dashboard host `192.168.88.18`. When a Coolify deployment creates a Compose
|
||||
resource, the app services land on the target server `192.168.88.40`.
|
||||
The main `coolify` app container runs only on the dashboard host `192.168.88.18`; Compose-resource services land on the target `192.168.88.40`. The dashboard host exposes no Docker TCP socket, so run the checks below against the **target** `192.168.88.40`:
|
||||
|
||||
Useful checks:
|
||||
|
||||
```powershell
|
||||
# Environment / deployment health on the target 192.168.88.40 (containers, helpers, health)
|
||||
./scripts/check-env.ps1
|
||||
|
||||
# Credential / token access (Gitea registry, Cloudflare, R2, admin login)
|
||||
./scripts/check-tokens.ps1
|
||||
```bash
|
||||
./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
|
||||
./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
|
||||
```
|
||||
|
||||
The Coolify dashboard runs on the `192.168.88.18` container host (port `8000`,
|
||||
publicly `https://coolify.allucanget.biz`) and exposes no Docker TCP socket, so
|
||||
the environment/token scripts are intended to run against the deployment target
|
||||
`192.168.88.40`, not `192.168.88.18`.
|
||||
|
||||
## Deploy the stack
|
||||
|
||||
Linear Void is a multi-service Docker Compose stack, so deploy it as a
|
||||
**Compose** (Docker Compose) resource rather than individual applications.
|
||||
Deploy as a **Docker Compose** resource (not individual apps):
|
||||
|
||||
1. **Projects → Add → Docker Compose**.
|
||||
2. Select the target server.
|
||||
3. In **Build Settings**, set the compose file source:
|
||||
- **Git Repository** — point Coolify at
|
||||
`https://git.allucanget.biz/zwitschi/linearvoid.git` and use
|
||||
`docker-compose.prod.yml` as the compose file. Coolify pulls on each
|
||||
redeploy.
|
||||
- **Raw Compose** — paste the contents of `docker-compose.prod.yml` directly
|
||||
if you prefer to manage it manually.
|
||||
4. If using the Git source, add the Gitea registry login as a **Deployment Key /
|
||||
environment secret** so the `playout`, `chat`, and `nginx` images can be
|
||||
pulled. Alternatively, switch those services to `build:` blocks (as in
|
||||
`docker-compose.yml`) so Coolify builds from source on the server.
|
||||
- **Git Repository** → point at `https://git.allucanget.biz/zwitschi/linearvoid.git`, compose file `docker-compose.prod.yml` (Coolify pulls on each redeploy).
|
||||
- **Raw Compose** → paste `docker-compose.prod.yml` directly to manage manually.
|
||||
4. For the Git source, add the Gitea registry login as a **Deployment Key / environment secret** so `playout`, `chat`, `nginx` images can be pulled. Alternatively switch those services to `build:` blocks (as in `docker-compose.yml`) to build from source on the server.
|
||||
|
||||
### Environment variables
|
||||
|
||||
Coolify injects a secret store into the compose resource. Map the Linear Void
|
||||
environment to Coolify's **Environment Variables** tab (these correspond to the
|
||||
values normally placed in `.env`):
|
||||
Map Linear Void's `.env` values into Coolify's **Environment Variables** tab:
|
||||
|
||||
| Variable | Purpose |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` |
|
||||
| `POSTGRES_PASSWORD` | Database password (used by the `db` service) |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials |
|
||||
| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime |
|
||||
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials |
|
||||
| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) |
|
||||
| `MEDIA_DOMAIN` | `media.linearvoid.tv` |
|
||||
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `DATABASE_URL` | `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable` |
|
||||
| `REDIS_URL` | `redis://redis:6379/0` |
|
||||
| `POSTGRES_PASSWORD` | DB password (used by `db` service) |
|
||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials |
|
||||
| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime |
|
||||
| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials |
|
||||
| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) |
|
||||
| `MEDIA_DOMAIN` | `media.linearvoid.tv` |
|
||||
| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` |
|
||||
| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` |
|
||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||
|
||||
Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in
|
||||
`docker-compose.prod.yml`.
|
||||
Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in `docker-compose.prod.yml`.
|
||||
|
||||
### Persistent storage
|
||||
|
||||
The compose file uses named volumes (`redis-data`, `postgres-data`) and bind
|
||||
mounts under `./data` (playlist, media, hls, chat, slate). Configure these under
|
||||
**Persistent Storage** so data survives redeploys:
|
||||
Named volumes (`redis-data`, `postgres-data`) and bind mounts under `./data` must be configured under **Persistent Storage** to survive redeploys:
|
||||
|
||||
| Mount | Purpose |
|
||||
| --------------- | ----------------------------------------------- |
|
||||
| `data/playlist` | Playout playlist file |
|
||||
| `data/media` | Normalized source videos (read-only to playout) |
|
||||
| `data/hls` | Generated HLS manifests and segments |
|
||||
| `data/chat` | Chat service working data |
|
||||
| `data/slate` | Slate media |
|
||||
| `redis-data` | Redis append-only persistence |
|
||||
| `postgres-data` | PostgreSQL data directory |
|
||||
| Mount | Purpose |
|
||||
|---|---|
|
||||
| `data/playlist` | Playout playlist file |
|
||||
| `data/media` | Normalized source videos (read-only to playout) |
|
||||
| `data/hls` | Generated HLS manifests/segments |
|
||||
| `data/chat` | Chat service working data |
|
||||
| `data/slate` | Slate media |
|
||||
| `redis-data` | Redis append-only persistence |
|
||||
| `postgres-data` | PostgreSQL data directory |
|
||||
|
||||
Create the `data` subdirectories before the first deploy if Coolify does not
|
||||
auto-create them (`mkdir -p data/playlist data/media data/hls data/chat`).
|
||||
If Coolify doesn't auto-create the dirs, create them before first deploy:
|
||||
|
||||
```bash
|
||||
mkdir -p data/playlist data/media data/hls data/chat
|
||||
```
|
||||
|
||||
### Domains and proxy
|
||||
|
||||
1. Open the compose resource and assign the **nginx** service a public domain
|
||||
(e.g. `linearvoid.tv`). Coolify's Traefik reverse proxy then terminates TLS
|
||||
and forwards to the `nginx` container.
|
||||
2. Set the **Ports Exposes** mapping so Traefik targets the nginx service port
|
||||
(`80` inside the container, mapped from `${NGINX_PORT:-8080}:80`).
|
||||
3. Do **not** also expose the playout (`8081`) or chat (`3000`) health ports
|
||||
publicly — keep those internal to the compose network and let Traefik route
|
||||
through nginx.
|
||||
4. For the media domain (`media.linearvoid.tv`), keep serving HLS from the R2
|
||||
custom domain (per SETUP.md) rather than routing it through Coolify, so
|
||||
segments stay on Cloudflare's edge cache.
|
||||
1. Assign the **nginx** service a public domain (e.g. `linearvoid.tv`); Traefik terminates TLS and forwards to `nginx`.
|
||||
2. Set **Ports Exposes** so Traefik targets the nginx port (`80` inside container, mapped from `${NGINX_PORT:-8080}:80`).
|
||||
3. Do **not** publicly expose playout (`8081`) or chat (`3000`) health ports — keep them internal and route through nginx.
|
||||
4. For `media.linearvoid.tv`, keep serving HLS from the R2 custom domain (per SETUP.md) rather than routing through Coolify, so segments stay on Cloudflare's edge cache.
|
||||
|
||||
## Post-deploy verification
|
||||
|
||||
After the first deploy, confirm:
|
||||
|
||||
- `docker compose ps` (on the server) shows all services healthy.
|
||||
- `docker compose ps` on the server shows all services healthy.
|
||||
- Playout health: `https://<host>/health` via nginx (internal `:8081/health`).
|
||||
- Chat health: internal `:3000/health`.
|
||||
- HLS is written to `data/hls` and served from `media.linearvoid.tv`.
|
||||
- HLS written to `data/hls` and served from `media.linearvoid.tv`.
|
||||
- Chat WebSocket connects over `wss://linearvoid.tv/ws`.
|
||||
- Admin login works at `/admin` with the configured credentials.
|
||||
- Admin login works at `/admin` with configured credentials.
|
||||
|
||||
## Monitoring
|
||||
|
||||
Coolify exposes container logs and basic metrics per resource. The stack also
|
||||
ships Prometheus + Grafana (`monitoring/`) and nginx/redis exporters. If you
|
||||
want those reachable through Coolify, expose Grafana on a restricted subdomain
|
||||
(e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring the
|
||||
access guidance in SETUP.md.
|
||||
Coolify exposes container logs/basic metrics per resource. The stack ships Prometheus + Grafana (`monitoring/`) plus nginx/redis exporters. To reach them via Coolify, expose Grafana on a restricted subdomain (e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring SETUP.md access guidance.
|
||||
|
||||
## Cutover from Gitea CD
|
||||
|
||||
Coolify can fully replace the Gitea Actions CD step. To switch:
|
||||
|
||||
1. Deploy the stack as a Coolify compose resource (above).
|
||||
2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied).
|
||||
3. Disable (or keep as a fallback) the `cd` job in the Gitea workflow so the two
|
||||
orchestrators don't fight over the same `DOCKER_HOST`.
|
||||
4. Keep the Gitea **CI** (build + push images) job so Coolify continues pulling
|
||||
`:latest` images from the registry.
|
||||
3. Disable (or keep as fallback) the `cd` job in the Gitea workflow so the two orchestrators don't fight over the same `DOCKER_HOST`.
|
||||
4. Keep the Gitea **CI** (build + push images) job so Coolify keeps pulling `:latest` from the registry.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Image pull fails**: ensure the Gitea registry credentials are set in the
|
||||
compose resource's secret store, or switch the services to `build:` blocks.
|
||||
- **Healthchecks fail**: confirm `curl` is available in the built images (the
|
||||
compose healthchecks rely on `curl`).
|
||||
- **TLS errors**: check the domain's DNS points at the Coolify server and ports
|
||||
`80`/`443` are open.
|
||||
- **HLS not updating**: verify `data/hls` is writable and the playout service
|
||||
restarts cleanly; see the normalization troubleshooting in SETUP.md.
|
||||
- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those
|
||||
to the host in the compose file.
|
||||
- **Image pull fails**: set Gitea registry credentials in the compose secret store, or switch services to `build:` blocks.
|
||||
- **Healthchecks fail**: confirm `curl` exists in the built images (healthchecks rely on it).
|
||||
- **TLS errors**: check the domain's DNS points at the Coolify server and ports `80`/`443` are open.
|
||||
- **HLS not updating**: verify `data/hls` is writable and playout restarts cleanly; see SETUP.md normalization troubleshooting.
|
||||
- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those to the host in the compose file.
|
||||
|
||||
## Further reading
|
||||
|
||||
- Coolify repo (v4.3.14): <https://github.com/coollabsio/coolify/tree/v4.3.14>
|
||||
- Coolify docs: <https://coolify.io/docs>
|
||||
- Linear Void deployment: [DEPLOYMENT.md](./DEPLOYMENT.md)
|
||||
- Linear Void setup: [SETUP.md](./SETUP.md)
|
||||
- [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
|
||||
+72
-194
@@ -1,64 +1,26 @@
|
||||
# Deployment Guide
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Deployment Guide](#deployment-guide)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Environments](#environments)
|
||||
- [Production Target (Hetzner)](#production-target-hetzner)
|
||||
- [Planned Infrastructure](#planned-infrastructure)
|
||||
- [Cutover Plan](#cutover-plan)
|
||||
- [Production Preflight](#production-preflight)
|
||||
- [Firewall / Security Group](#firewall--security-group)
|
||||
- [Pipeline Readiness](#pipeline-readiness)
|
||||
- [Target Topology](#target-topology)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Environment File](#environment-file)
|
||||
- [Gitea Secrets](#gitea-secrets)
|
||||
- [Pull-and-Run Deployment](#pull-and-run-deployment)
|
||||
- [First-Time or Recovery Provisioning](#first-time-or-recovery-provisioning)
|
||||
- [Health Verification](#health-verification)
|
||||
- [Zero/Low-Downtime Update Pattern](#zerolow-downtime-update-pattern)
|
||||
- [Rollback](#rollback)
|
||||
- [Operational Tasks](#operational-tasks)
|
||||
- [View logs](#view-logs)
|
||||
- [Restart one service](#restart-one-service)
|
||||
- [Stop stack](#stop-stack)
|
||||
- [Keep volumes (recommended) vs remove volumes (destructive)](#keep-volumes-recommended-vs-remove-volumes-destructive)
|
||||
- [Nginx and Proxy Considerations](#nginx-and-proxy-considerations)
|
||||
- [Security Baseline](#security-baseline)
|
||||
- [Common Deployment Pitfalls](#common-deployment-pitfalls)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
## Environments
|
||||
|
||||
Linear Void runs in two separate environments:
|
||||
| Environment | Host | Docker Target | Purpose |
|
||||
|---|---|---|---|
|
||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
|
||||
|
||||
| Environment | Host | Docker Target | Purpose |
|
||||
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv serving real viewers |
|
||||
|
||||
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. The CD workflow (`cd.yml`) and `docker-compose.prod.yml` currently target the test lab. Switching to production requires only changing `DOCKER_HOST` — see [Production Deployment](#production-target-hetzner) below.
|
||||
**Current state (2026-08-06):** Test lab is active and receives automated CD deploys. Production Hetzner VPS is **not yet provisioned**. `cd.yml` and `docker-compose.prod.yml` target the test lab; switching to production only requires changing `DOCKER_HOST`.
|
||||
|
||||
## Production Target (Hetzner)
|
||||
|
||||
**Status: Not provisioned.** The production deployment plan is documented and the pipeline is ready — only the VPS needs to be created.
|
||||
**Status: Not provisioned.** Plan documented and pipeline ready; only the VPS needs creation.
|
||||
|
||||
### Planned Infrastructure
|
||||
|
||||
| Resource | Spec |
|
||||
| -------------- | ------------------------------------------------ |
|
||||
| Provider | Hetzner Cloud |
|
||||
| Region | Ashburn, VA (us-east) |
|
||||
| Instance | CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent |
|
||||
| OS | Ubuntu 24.04 LTS or Debian 12 |
|
||||
| Bandwidth | 20 TB included traffic/month |
|
||||
| Estimated cost | ~€7–10/month base |
|
||||
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
|
||||
- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
|
||||
- OS: Ubuntu 24.04 LTS or Debian 12
|
||||
- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
|
||||
|
||||
### Cutover Plan
|
||||
|
||||
1. Provision Hetzner VPS (see [Production Deployment](#production-target-hetzner) section)
|
||||
1. Provision Hetzner VPS
|
||||
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||
4. Trigger deploy → validate health checks
|
||||
@@ -66,44 +28,33 @@ Linear Void runs in two separate environments:
|
||||
6. Enable HTTPS, WAF, cache rules on production zone
|
||||
7. Decommission test lab as public target
|
||||
|
||||
### Production Preflight
|
||||
|
||||
Before the cutover in the plan above, treat the following as the minimum readiness gate:
|
||||
|
||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
|
||||
5. Run the production smoke path end to end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, and playout health.
|
||||
6. Verify rollback is still possible before the final DNS switch.
|
||||
|
||||
Readiness is not complete until the checklist above and the manual evidence tracker are both satisfied.
|
||||
### Production Preflight (minimum readiness gate)
|
||||
1. Verify Hetzner host reachable over SSH and Docker remote API TLS works for the Gitea runner
|
||||
2. Confirm all production secrets exist under `CI_PROD_*` prefix
|
||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
||||
4. Complete manual Cloudflare/UptimeRobot checklist (see `docs/operations/cloudflare-uptimerobot-checklist.md`)
|
||||
5. Run production smoke path end-to-end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, playout health
|
||||
6. Verify rollback possible before final DNS switch
|
||||
|
||||
### Firewall / Security Group
|
||||
|
||||
| Port | Source | Purpose |
|
||||
| ---- | --------------------- | ------------------------------ |
|
||||
| 22 | Admin IPs | SSH management |
|
||||
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
||||
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
||||
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
||||
| Port | Source | Purpose |
|
||||
|---|---|---|
|
||||
| 22 | Admin IPs | SSH management |
|
||||
| 80 | 0.0.0.0/0 | HTTP (redirect to HTTPS) |
|
||||
| 443 | 0.0.0.0/0 | HTTPS (nginx) |
|
||||
| 2375 | Gitea runner IP only | Docker remote API (TLS) |
|
||||
| 9090 | Admin IPs or internal | Prometheus (or tunnel via SSH) |
|
||||
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
||||
| 3001 | Admin IPs or internal | Grafana (or tunnel via SSH) |
|
||||
|
||||
### Pipeline Readiness
|
||||
| Workflow | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push images (`:latest`, `:main`) |
|
||||
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||
| `release.yml` | Tag push `*.*.*` | Build & push versioned images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers |
|
||||
|
||||
Three Gitea Actions workflows drive the full pipeline:
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
| -------------------- | ----------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `ci.yml` | Push to `main` | Lint, build frontend, vet Go, build & push Docker images (`:latest`, `:main`) |
|
||||
| `cd.yml` | Push to `main` | Deploy to test lab (`192.168.88.40:2375`) — runs **in parallel** with CI |
|
||||
| `release.yml` | Tag push `*.*.*` | Build & push versioned Docker images (`:X.Y.Z`, `:X.Y`, `:latest`), create Gitea Release |
|
||||
| `workers-deploy.yml` | After CI succeeds | Deploy frontend to Cloudflare Workers (see `docs/operations/gitea-cloudflare-workers.md`) |
|
||||
|
||||
CI and CD run simultaneously on every push to `main`. The deploy pulls whatever `:latest` images are available in the registry at that moment.
|
||||
|
||||
The CD workflow (`cd.yml`) uses `DOCKER_HOST` to target the deployment host. To switch targets:
|
||||
CI and CD run simultaneously on every push to `main`. Deploy pulls whatever `:latest` images are available at that moment. `cd.yml` uses `DOCKER_HOST` to target the host:
|
||||
|
||||
```bash
|
||||
# Test lab (current)
|
||||
@@ -113,10 +64,9 @@ DOCKER_HOST=tcp://192.168.88.40:2375
|
||||
DOCKER_HOST=tcp://<hetzner-public-ip>:2375
|
||||
```
|
||||
|
||||
No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deployment section for full checklist.
|
||||
No pipeline code changes needed to switch targets.
|
||||
|
||||
## Target Topology
|
||||
|
||||
- Host: Linux server with Docker Engine
|
||||
- Reverse proxy: nginx container (linearvoid-nginx)
|
||||
- Core services: playout, chat, redis, postgres
|
||||
@@ -124,16 +74,14 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
|
||||
- CDN/edge: Cloudflare
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine + Docker Compose plugin
|
||||
- Network access to git.allucanget.biz
|
||||
- A valid .env file with production values
|
||||
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
||||
- Valid `.env` file with production values
|
||||
- DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
|
||||
|
||||
## Environment File
|
||||
|
||||
Use `.env` with at least:
|
||||
|
||||
`.env` requires at minimum:
|
||||
- `DATABASE_URL` — PostgreSQL connection string
|
||||
- `REDIS_URL` — Redis connection string
|
||||
- `ADMIN_USERNAME` / `ADMIN_PASSWORD` — admin dashboard credentials
|
||||
@@ -141,186 +89,116 @@ Use `.env` with at least:
|
||||
- `VITE_PUBLIC_APP_URL` / `VITE_PUBLIC_WS_URL` / `VITE_PUBLIC_HLS_URL` — frontend URLs
|
||||
|
||||
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||
|
||||
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` — R2 write credentials
|
||||
- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
|
||||
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||
- `R2_REGION` — set to `auto`
|
||||
|
||||
**Cloudflare (optional, for Workers):**
|
||||
|
||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||
|
||||
See `.env.example` and `SETUP.md` for full variable reference.
|
||||
|
||||
### Gitea Secrets
|
||||
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure in the Gitea repository:
|
||||
|
||||
The CD workflow (`cd.yml`) injects sensitive values from Gitea secrets into the `.env` file at deploy time. The following secrets must be configured in the Gitea repository:
|
||||
|
||||
| Secret Name | Description |
|
||||
| ---------------------------- | --------------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `ADMIN_USERNAME` | Admin dashboard username |
|
||||
| `ADMIN_PASSWORD` | Admin dashboard password |
|
||||
| `R2_ACCESS_KEY_ID` | Cloudflare R2 access key |
|
||||
| `R2_SECRET_ACCESS_KEY` | Cloudflare R2 secret key |
|
||||
| `R2_BUCKET_NAME` | R2 bucket name |
|
||||
| `R2_ENDPOINT` | R2 S3-compatible endpoint URL |
|
||||
| `R2_ADMIN_ACCESS_KEY_ID` | R2 admin access key (optional) |
|
||||
| `R2_ADMIN_SECRET_ACCESS_KEY` | R2 admin secret key (optional) |
|
||||
| `R2_ADMIN_ENDPOINT` | R2 admin endpoint (optional) |
|
||||
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (optional) |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID (optional) |
|
||||
| `CI_REGISTRY_USER` | Gitea container registry username |
|
||||
| `CI_REGISTRY_TOKEN` | Gitea container registry token |
|
||||
`DATABASE_URL`, `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME`, `R2_ENDPOINT`, `R2_ADMIN_ACCESS_KEY_ID` (optional), `R2_ADMIN_SECRET_ACCESS_KEY` (optional), `R2_ADMIN_ENDPOINT` (optional), `CLOUDFLARE_API_TOKEN` (optional), `CLOUDFLARE_ACCOUNT_ID` (optional), `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN`
|
||||
|
||||
## Pull-and-Run Deployment
|
||||
|
||||
Images are built and pushed automatically by the CI and release workflows (see [CONTRIBUTING.md](../CONTRIBUTING.md#versioning-and-releases) for the release procedure).
|
||||
|
||||
Every push to `main` triggers CI (builds `:main` and `:latest` tags) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow which builds `:X.Y.Z`, `:X.Y`, and `:latest` tags and creates a Gitea Release.
|
||||
|
||||
From repository root:
|
||||
Images are built/pushed automatically by CI and release workflows. Every push to `main` triggers CI (`:main`, `:latest`) and CD (deploys `:latest` to test lab) in parallel. Semantic version tags (`*.*.*`, e.g. `1.0.9`) trigger the release workflow (`:X.Y.Z`, `:X.Y`, `:latest` + Gitea Release).
|
||||
|
||||
```bash
|
||||
docker login git.allucanget.biz
|
||||
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
## First-Time or Recovery Provisioning
|
||||
|
||||
1. Ensure host paths exist:
|
||||
|
||||
```bash
|
||||
mkdir -p data/chat data/playlist data/media data/hls
|
||||
```
|
||||
|
||||
1. Confirm volumes and networks:
|
||||
|
||||
```bash
|
||||
docker volume ls
|
||||
docker network ls
|
||||
```
|
||||
|
||||
1. Start stack:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
## Health Verification
|
||||
|
||||
After startup, check service status:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
```
|
||||
|
||||
Endpoint checks:
|
||||
|
||||
- App: <http://{host}:8080/>
|
||||
- Nginx health: <http://{host}:8080/health>
|
||||
- Chat health: <http://{host}:3000/health>
|
||||
- Playout health: <http://{host}:8081/health>
|
||||
- Prometheus: <http://{host}:9090>
|
||||
- Grafana: <http://{host}:3001>
|
||||
- App: `http://{host}:8080/`
|
||||
- Nginx health: `http://{host}:8080/health`
|
||||
- Chat health: `http://{host}:3000/health`
|
||||
- Playout health: `http://{host}:8081/health`
|
||||
- Prometheus: `http://{host}:9090`
|
||||
- Grafana: `http://{host}:3001`
|
||||
|
||||
Domain checks:
|
||||
|
||||
- <https://linearvoid.tv>
|
||||
- <https://linearvoid.tv/admin>
|
||||
- <https://linearvoid.tv/metrics>
|
||||
- `https://linearvoid.tv`
|
||||
- `https://linearvoid.tv/admin`
|
||||
- `https://linearvoid.tv/metrics`
|
||||
|
||||
## Zero/Low-Downtime Update Pattern
|
||||
|
||||
1. Pull newest images.
|
||||
1. Recreate changed services only:
|
||||
|
||||
2. Recreate changed services only:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||
```
|
||||
|
||||
1. Re-run health checks.
|
||||
3. Re-run health checks.
|
||||
|
||||
## Rollback
|
||||
|
||||
If new images are unhealthy:
|
||||
|
||||
1. Pin previous tags in docker-compose.prod.yml.
|
||||
1. Pin previous tags in `docker-compose.prod.yml`.
|
||||
2. Pull pinned tags.
|
||||
3. Restart impacted services.
|
||||
|
||||
3. Restart impacted services:
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
|
||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||
```
|
||||
|
||||
## Operational Tasks
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||
```
|
||||
|
||||
### Restart one service
|
||||
|
||||
```bash
|
||||
# Restart one service
|
||||
docker compose -f docker-compose.prod.yml restart chat
|
||||
```
|
||||
|
||||
### Stop stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
```
|
||||
|
||||
### Keep volumes (recommended) vs remove volumes (destructive)
|
||||
|
||||
```bash
|
||||
# keep data
|
||||
# Stop stack (keep data)
|
||||
docker compose -f docker-compose.prod.yml down
|
||||
|
||||
# destructive (remove data)
|
||||
# Stop stack — destructive (remove data)
|
||||
docker compose -f docker-compose.prod.yml down -v
|
||||
```
|
||||
|
||||
## Nginx and Proxy Considerations
|
||||
|
||||
- If an external proxy (for example Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx, see [nginx.conf](../infrastructure/nginx/nginx.conf).
|
||||
- For resumable admin uploads, ensure client_max_body_size supports chunk requests in both proxy layers.
|
||||
- If an external proxy (e.g. Nginx Proxy Manager) fronts the stack, align request body limits with internal nginx (see `infrastructure/nginx/nginx.conf`).
|
||||
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
||||
|
||||
## Security Baseline
|
||||
|
||||
- Use strong ADMIN_PASSWORD and rotate regularly.
|
||||
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
||||
- Keep Docker host access restricted.
|
||||
- Prefer HTTPS/WSS through Cloudflare.
|
||||
- Restrict exposed ports if only proxy access is needed.
|
||||
|
||||
## Common Deployment Pitfalls
|
||||
|
||||
- Missing frontend build artifacts in local-only compose workflows.
|
||||
- Wrong registry credentials or missing login.
|
||||
- Misaligned domain env vars (app/ws/hls) causing mixed-origin errors.
|
||||
|
||||
## Troubleshooting
|
||||
| Symptom | Check | Fix |
|
||||
|---|---|---|
|
||||
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
|
||||
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
|
||||
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
|
||||
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
|
||||
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
|
||||
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
|
||||
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
||||
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||
|
||||
| Symptom | Check | Fix |
|
||||
| -------------------------- | --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Service not starting | `docker logs linearvoid-<svc> --tail 50` | Check `.env` vars, volume mounts, port conflicts |
|
||||
| Playout stall / slate mode | `curl http://<host>:8081/health` — mode=slate | Check playlist file, media paths, disk space |
|
||||
| Chat not connecting | `curl http://<host>:3000/health` | Check Redis: `docker exec linearvoid-redis redis-cli ping` |
|
||||
| Admin 401 loop | Cookie not sent | Verify `X-Forwarded-Proto: https`, nginx auth_request headers |
|
||||
| Upload fails (413) | File too large | Check nginx `client_max_body_size` (should be 64m+) |
|
||||
| HLS not playing | `curl http://<host>/hls/playlist.m3u8` | Check playout health, HLS dir permissions |
|
||||
| Prometheus no targets | `curl http://<host>:9090/targets` | Verify container network, scrape config |
|
||||
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||
|
||||
- Proxy-layer body size limits returning 413 on upload.
|
||||
- Health checks failing before service warmup is complete.
|
||||
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
||||
+4
-12
@@ -52,7 +52,7 @@ Config: `monitoring/prometheus/prometheus.yml` (15s scrape interval)
|
||||
- **Linear Void app**: `monitoring/grafana/dashboard.json` — clients, messages, moderation, Redis, sessions, scrape health
|
||||
- **Prometheus ops**: `monitoring/grafana/dashboards/dashboard.json` — TSDB, CPU/memory, WAL, compactions, query latency
|
||||
|
||||
## Metrics checking
|
||||
## Metrics Checking
|
||||
|
||||
See detailed scripts for querying Prometheus scrape targets:
|
||||
|
||||
@@ -78,21 +78,13 @@ See detailed scripts for querying Prometheus scrape targets:
|
||||
| Prometheus targets down | Check container network: all must be on `linearvoid-net` |
|
||||
| Alert not firing | Check Prometheus → Alerts tab, verify `rule_files` in prometheus.yml |
|
||||
|
||||
### Grafana shows no data
|
||||
**Grafana no data**: ensure datasource points to `http://prometheus:9090`; confirm Prometheus is healthy; check dashboard query labels match active jobs.
|
||||
|
||||
- Ensure datasource points to <http://prometheus:9090>.
|
||||
- Confirm Prometheus is healthy.
|
||||
- Check dashboard query labels match active jobs.
|
||||
|
||||
### High cardinality or query slowness
|
||||
|
||||
- Reduce unneeded labels.
|
||||
- Limit expensive dashboard panels.
|
||||
- Tune retention and query windows.
|
||||
**High cardinality / slow queries**: reduce unneeded labels; limit expensive panels; tune retention and query windows.
|
||||
|
||||
## Operational Recommendations
|
||||
|
||||
- Keep alert thresholds tied to actual user impact.
|
||||
- Review dashboards after each release that changes endpoints or metrics names.
|
||||
- Periodically test alert routing and incident response flow.
|
||||
- Metrics retention is set to **30 days** time-based plus a **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
|
||||
- Retention: **30 days** time-based plus **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first.
|
||||
+55
-55
@@ -1,55 +1,55 @@
|
||||
# Docs Folder
|
||||
|
||||
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- Cloudflare
|
||||
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||
- Monitoring
|
||||
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||
|
||||
## API
|
||||
|
||||
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
|
||||
## Security
|
||||
|
||||
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
|
||||
## Setup
|
||||
|
||||
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
|
||||
## Streaming
|
||||
|
||||
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||
|
||||
## Other Documents
|
||||
|
||||
- [CHANGELOG](../CHANGELOG.md) - Release notes and version history.
|
||||
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct.
|
||||
- [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes.
|
||||
- [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks.
|
||||
- [Agents](../.github/AGENTS.md) - Instructions for agents and task management.
|
||||
- [Memory](../.github/MEMORY.md) - Session memory and development progress tracking.
|
||||
- [README](../README.md) - Project overview and high-level information.
|
||||
# Docs Folder
|
||||
|
||||
This folder contains the documentation for the Linear Void project. The documentation is organized into several markdown files, each covering different aspects of the project.
|
||||
|
||||
## Architecture
|
||||
|
||||
- [Architecture](architecture/README.md) - High-level system architecture and component interactions.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
- Cloudflare
|
||||
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers.
|
||||
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||
- Monitoring
|
||||
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
||||
|
||||
## API
|
||||
|
||||
- [API](API.md) - Implemented WebSocket and REST API reference.
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](DEPLOYMENT.md) - Production deployment and operational runbook.
|
||||
|
||||
## Monitoring
|
||||
|
||||
- [Monitoring](MONITORING.md) - Metrics, dashboards, and monitoring troubleshooting.
|
||||
|
||||
## Security
|
||||
|
||||
- [Security](SECURITY.md) - Security model, controls, and hardening baseline.
|
||||
|
||||
## Setup
|
||||
|
||||
- [Setup](SETUP.md) - Test lab setup, local development, and CI/CD workflow.
|
||||
|
||||
## Streaming
|
||||
|
||||
- [Streaming](STREAMING.md) - Streaming architecture, playout, and delivery details.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Marketing](marketing/README.md) - Marketing materials and resources.
|
||||
- [Operations](operations/README.md) - Operational procedures and checklists.
|
||||
- [Incident Runbook](incident-runbook.md) - Incident response and disaster recovery procedures.
|
||||
|
||||
## Other Documents
|
||||
|
||||
- [CHANGELOG](../CHANGELOG.md) - Release notes and version history.
|
||||
- [CONTRIBUTING](../CONTRIBUTING.md) - Contribution guidelines and code of conduct.
|
||||
- [TODO](../.github/instructions/TODO.md) - Active implementation plan with checkboxes.
|
||||
- [Backlog](../.github/instructions/BACKLOG.md) - Consolidated list of all development tasks.
|
||||
- [Agents](../.github/AGENTS.md) - Instructions for agents and task management.
|
||||
- [Memory](../.github/MEMORY.md) - Session memory and development progress tracking.
|
||||
- [README](../README.md) - Project overview and high-level information.
|
||||
+80
-503
@@ -2,572 +2,149 @@
|
||||
|
||||
**Last Updated**: 2026-08-03
|
||||
|
||||
This guide covers prerequisites, environment setup, running the application locally and in production, and troubleshooting. It references the [architecture documentation](./docs/architecture/README.md)(`docs/architecture/README.md`).
|
||||
Prerequisites, environment setup, and running the app locally and in production. Architecture: [`docs/architecture/README.md`](./architecture/README.md).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker & Docker Compose**: Required for running the full stack (playout, chat, nginx, redis, db, prometheus, grafana). Install from [docker.com](https://www.docker.com).
|
||||
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
||||
- **npm**: Installed with Node.js and used for the frontend dependency installation and Vite workflow.
|
||||
- **Go 1.25+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
||||
- **Git**: For cloning and version control.
|
||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
||||
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
|
||||
- Set up the required Actions secrets in Gitea at: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
||||
- Add the following secrets:
|
||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`)
|
||||
- `CI_REGISTRY_TOKEN`: A Gitea Personal Access Token with `write:packages` scope
|
||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`)
|
||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel
|
||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`)
|
||||
- **Hetzner Cloud Account** (optional for production): For the US-East VPS (Ashburn, VA recommended per ADR-001).
|
||||
- **Docker + Docker Compose** — runs the full stack (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||
- **Node.js 22+ / npm** — frontend dev and builds (`cd frontend && npm install && npm run build`).
|
||||
- **Go 1.25+** — working on the Go services outside Docker.
|
||||
- **FFmpeg** — playout HLS generation (`apt install ffmpeg`).
|
||||
- **Git**.
|
||||
- **Cloudflare account** — R2 storage + CDN.
|
||||
- **Gitea account** — image registry + CI/CD. Set Actions secrets at `https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets`: `CI_REGISTRY_USER`, `CI_REGISTRY_TOKEN` (scope `write:packages`), `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `DATABASE_URL`.
|
||||
- **Hetzner Cloud (optional, prod)** — US-East VPS (Ashburn, VA per ADR-001).
|
||||
|
||||
## Network Environments
|
||||
|
||||
Linear Void operates in two distinct environments. Know which one you're targeting.
|
||||
Two distinct targets; know which one you're using.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
||||
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
||||
end
|
||||
|
||||
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
||||
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
||||
end
|
||||
|
||||
subgraph Gitea["Gitea CI/CD"]
|
||||
CI["CI: build + push images"]
|
||||
CD["CD: deploy via DOCKER_HOST"]
|
||||
end
|
||||
|
||||
subgraph CF["Cloudflare"]
|
||||
DNS["linearvoid.tv"]
|
||||
R2["R2 Bucket"]
|
||||
end
|
||||
|
||||
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
||||
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375<br/>(when provisioned)"| PROD_HOST
|
||||
CD -.->|"DOCKER_HOST=tcp://<hetzner-ip>:2375 (when provisioned)"| PROD_HOST
|
||||
TEST_HOST -->|"test traffic"| DNS
|
||||
PROD_HOST -.->|"production traffic"| DNS
|
||||
TEST_HOST --> R2
|
||||
PROD_HOST -.-> R2
|
||||
```
|
||||
|
||||
**Test Lab (active):** Internal network `192.168.88.0/24`, host `192.168.88.40`. Receives automated CD deploys from Gitea Actions. Used for integration testing and staging validation.
|
||||
|
||||
**Production (pending):** Hetzner Cloud VPS, Ashburn VA. Not yet provisioned. CD workflow and compose files are ready — only `DOCKER_HOST` needs changing. See Production Deployment section in [DEPLOYMENT.md](./DEPLOYMENT.md) for cutover plan.
|
||||
|
||||
## Development Prerequisites
|
||||
|
||||
- **Frontend dependencies**: Run `cd frontend && npm install` before `npm run dev` or `npm run build`.
|
||||
- **Docker build support**: Local development expects Docker to be able to build the `chat`, `playout`, and `nginx` images referenced in `docker-compose.yml`.
|
||||
- **Data directories**: Create `data/playlist`, `data/media`, `data/hls`, and `data/chat` before the first run so bind mounts resolve cleanly.
|
||||
- **Database choice**: The stack defaults to PostgreSQL in `docker-compose.yml`. If you want SQLite for local-only development, update `DATABASE_URL` in `.env` accordingly.
|
||||
- **Redis availability**: Chat presence, rate limiting, and pub/sub require Redis. The compose stack provides it automatically.
|
||||
- **Video inputs**: Source videos should be normalized before playout. If the playlist contains invalid files, the playout container will fail its validation flow.
|
||||
- **Test Lab (active):** internal `192.168.88.0/24`, host `192.168.88.40`; receives automated CD deploys from Gitea Actions. Used for staging validation.
|
||||
- **Production (pending):** Hetzner CX22, Ashburn VA — not yet provisioned. Compose/CD are ready; only `DOCKER_HOST` needs changing. See [DEPLOYMENT.md](./DEPLOYMENT.md).
|
||||
|
||||
## Environment Setup
|
||||
|
||||
1. **Clone the Repository** (if not already done):
|
||||
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
```
|
||||
|
||||
2. **Copy Environment File**:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. **Configure `.env`** (copy from `.env.example` and replace placeholders)
|
||||
|
||||
4. **Check local value overrides for Docker Compose**:
|
||||
- `docker-compose.yml` builds images locally and maps the frontend build output from `frontend/dist` into nginx.
|
||||
- `docker-compose.prod.yml` pulls prebuilt images from the Gitea registry.
|
||||
- Internal container-to-container URLs should use compose hostnames, for example `REDIS_URL=redis://redis:6379/0` and `DATABASE_URL=postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`.
|
||||
- For production public routing, set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||
|
||||
5. **Create Data Directories**:
|
||||
|
||||
```bash
|
||||
mkdir -p data/playlist data/media data/hls data/chat
|
||||
```
|
||||
|
||||
6. **Prepare Sample Videos** (optional):
|
||||
- Place normalized MP4 files in `data/media/`.
|
||||
- Create a `data/playlist/playlist.txt` with paths (see ADR-001 for normalization requirements).
|
||||
1. `git clone https://git.allucanget.biz/zwitschi/linearvoid.git && cd linearvoid`
|
||||
2. `cp .env.example .env` then replace placeholders.
|
||||
3. Compose overrides: `docker-compose.yml` builds images locally and mounts `frontend/dist` into nginx; `docker-compose.prod.yml` pulls prebuilt images from Gitea.
|
||||
- Internal URLs use compose hostnames: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:<pw>@db:5432/linearvoid?sslmode=disable`.
|
||||
- Public routing: `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||
4. Create data dirs: `mkdir -p data/playlist data/media data/hls data/chat`
|
||||
5. Optional: place normalized MP4s in `data/media/` and create `data/playlist/playlist.txt` (see ADR-001 for normalization).
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
1. **Create or verify a Gitea user**:
|
||||
- The user should have access to the repository and permission to read from the container registry.
|
||||
|
||||
2. **Create an access token**:
|
||||
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
|
||||
- Store it as `CI_REGISTRY_TOKEN` in `.env`.
|
||||
|
||||
3. **Configure Gitea Actions Secrets**:
|
||||
- Go to the repository settings page: [https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets](https://git.allucanget.biz/zwitschi/linearvoid/settings/actions/secrets)
|
||||
- Add the following secrets to enable automated CI/CD and production deployment:
|
||||
- `CI_REGISTRY_USER`: Your Gitea username (e.g., `zwitschi`).
|
||||
- `CI_REGISTRY_TOKEN`: Your Gitea Personal Access Token with `write:packages` scope.
|
||||
- `ADMIN_USERNAME`: The administrator username for the production admin panel (e.g., `admin`).
|
||||
- `ADMIN_PASSWORD`: A strong password for the production admin panel.
|
||||
- `DATABASE_URL`: The production database connection string (e.g., `postgres://linearvoid:<password>@db:5432/linearvoid?sslmode=disable`).
|
||||
|
||||
4. **Configure registry variables**:
|
||||
- Set `CI_REGISTRY_URL` to the Gitea host.
|
||||
- Set `CI_REGISTRY_NAMESPACE` to the image namespace, for example `zwitschi/linearvoid`.
|
||||
- Set `CI_REGISTRY_USER` to the account that will push or pull images.
|
||||
|
||||
5. **Authenticate Docker to the registry**:
|
||||
|
||||
```bash
|
||||
docker login git.allucanget.biz
|
||||
```
|
||||
|
||||
6. **Build and publish images when needed**:
|
||||
- Local development uses `docker-compose.yml`, which builds from source.
|
||||
- Production uses `docker-compose.prod.yml`, which expects the `playout`, `chat`, and `nginx` images to exist in the configured Gitea registry namespace.
|
||||
|
||||
7. **Validate image availability**:
|
||||
- Before a production rollout, verify that the expected `:latest` tags can be pulled from the registry on the target server.
|
||||
1. User with repo + registry access; generate a token (scope `write:packages`) stored as `CI_REGISTRY_TOKEN`.
|
||||
2. Set Actions secrets (see Prerequisites) to enable automated CI/CD and deploys.
|
||||
3. Set registry vars: `CI_REGISTRY_URL`, `CI_REGISTRY_NAMESPACE` (e.g. `zwitschi/linearvoid`), `CI_REGISTRY_USER`.
|
||||
4. Auth: `docker login git.allucanget.biz`.
|
||||
5. Local dev builds from source; prod pulls `playout`, `chat`, `nginx` images from the registry namespace. Verify `:latest` tags exist before rollout.
|
||||
|
||||
## Cloudflare R2 Setup
|
||||
|
||||
1. **Create an R2 bucket**:
|
||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
||||
|
||||
2. **Generate R2 API credentials**:
|
||||
- Create an access key pair with read/write permissions for the bucket.
|
||||
- Set `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, and `R2_REGION=auto` in `.env`.
|
||||
|
||||
3. **Find the S3 endpoint**:
|
||||
- Use the account-scoped endpoint format `https://<account-id>.r2.cloudflarestorage.com`.
|
||||
- Store the account ID in both `R2_ENDPOINT` and `CLOUDFLARE_ACCOUNT_ID` where applicable.
|
||||
|
||||
4. **Bind a custom media domain**:
|
||||
- Attach a domain such as `media.linearvoid.tv` to the bucket.
|
||||
- Set `MEDIA_DOMAIN` to that hostname.
|
||||
|
||||
5. **Configure app-domain DNS and TLS**:
|
||||
- Point `linearvoid.tv` to the public entrypoint serving nginx.
|
||||
- Point `www.linearvoid.tv` to the same target and redirect it to `https://linearvoid.tv`.
|
||||
- Keep `media.linearvoid.tv` on the R2 custom domain path so HLS delivery stays split from the app origin.
|
||||
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
|
||||
|
||||
6. **Apply cache behavior**:
|
||||
- Configure the `.m4s` and `.m3u8` cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
- Enable Smart Tiered Cache for the R2-backed hostname.
|
||||
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
|
||||
|
||||
7. **Configure R2 CORS**:
|
||||
- Add CORS rules to allow the app domain to access the R2 bucket for HLS delivery.
|
||||
- Example CORS configuration:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://linearvoid.tv"],
|
||||
"AllowedMethods": ["GET"],
|
||||
"AllowedHeaders": ["*"],
|
||||
"ExposeHeaders": ["ETag", "Cache-Control"],
|
||||
"MaxAgeSeconds": 3600
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
8. **Verify access**:
|
||||
- Confirm uploads with an S3-compatible client such as `aws s3 ls --endpoint-url <R2_ENDPOINT>`.
|
||||
- After deployment, check Cloudflare analytics for cache hit ratio and origin request volume.
|
||||
|
||||
## Cloudflare
|
||||
|
||||
Use this section for full edge setup (DNS, Workers, cache, security, and monitoring).
|
||||
|
||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
|
||||
### 1. DNS Setup
|
||||
|
||||
1. Add apex record for `linearvoid.tv` pointing to the Cloudflare Worker.
|
||||
2. Add `www` CNAME to the Cloudflare Worker and configure redirect to `https://linearvoid.tv`.
|
||||
3. Add `media.linearvoid.tv` for split-domain HLS delivery.
|
||||
4. Keep all records proxied (orange cloud) unless troubleshooting requires DNS-only mode.
|
||||
|
||||
### 2. R2 Bucket
|
||||
|
||||
1. Create bucket `linearvoid-media`.
|
||||
2. Set location hint to `enam` (US-East).
|
||||
3. Attach custom domain `media.linearvoid.tv` to the bucket.
|
||||
4. Configure CORS rules to allow `https://linearvoid.tv` for HLS delivery.
|
||||
5. Verify object read works from the custom domain.
|
||||
|
||||
### 3. Cache Rules
|
||||
|
||||
Configure Cloudflare Cache Rules:
|
||||
|
||||
1. HLS segment files (`*.ts`, `*.m4s`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `31536000`
|
||||
- Browser TTL: long-lived
|
||||
- Cache key: include full path
|
||||
2. HLS manifests (`*.m3u8`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `2s`
|
||||
- Revalidation: enabled
|
||||
3. Static assets (`*.js`, `*.css`):
|
||||
- Cache: eligible
|
||||
- Edge TTL: `1y`
|
||||
- Browser TTL: long-lived
|
||||
- Cache key: include full path
|
||||
4. Validate behavior using response headers:
|
||||
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
|
||||
|
||||
Reference: [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
|
||||
### 4. Cloudflare Workers
|
||||
|
||||
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-workers.md).
|
||||
|
||||
Manual setup:
|
||||
|
||||
1. Create a Cloudflare Worker for the static SPA (Workers & Pages → Create → Worker).
|
||||
2. Build the frontend:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
3. Bind the build output (`frontend/dist`) as Worker static assets.
|
||||
4. Configure production domain `linearvoid.tv` on the Worker.
|
||||
|
||||
### 5. Tiered Cache
|
||||
|
||||
1. Enable Smart Tiered Cache in Cloudflare dashboard.
|
||||
2. Warm a segment and manifest path.
|
||||
3. Confirm tiered behavior through repeated requests and `Cf-Cache-Status: HIT`.
|
||||
|
||||
### 6. WAF and Bot Protection
|
||||
|
||||
1. Enable OWASP Managed Ruleset.
|
||||
2. Start in `Simulate` mode.
|
||||
3. Review events for 48 hours.
|
||||
4. Switch to `Block` once false positives are addressed.
|
||||
5. Enable Bot Fight Mode.
|
||||
|
||||
### 7. Rate Limiting
|
||||
|
||||
Create rules:
|
||||
|
||||
1. Path: `/api/admin/login`
|
||||
2. Path: `/api/admin/*`
|
||||
3. Threshold: `10 requests / 10 seconds / IP`
|
||||
4. Action: `Managed Challenge`
|
||||
|
||||
### 8. HTTPS and WSS
|
||||
|
||||
1. Enable `Always Use HTTPS`.
|
||||
2. Enable HSTS with:
|
||||
- `max-age=31536000`
|
||||
- `includeSubDomains`
|
||||
- `preload`
|
||||
3. Minimum TLS version: `1.2`.
|
||||
4. Keep SSL mode at `Full (strict)` when origin certs are valid.
|
||||
5. Enforce HTTPS redirect at both layers:
|
||||
- Cloudflare edge
|
||||
- nginx origin
|
||||
6. Validate websocket endpoint uses `wss://linearvoid.tv/ws`.
|
||||
|
||||
### 9. UptimeRobot Monitors
|
||||
|
||||
Create external monitors:
|
||||
|
||||
1. HTTP monitor:
|
||||
- URL: `https://linearvoid.tv/health`
|
||||
- Interval: 5 minutes
|
||||
2. Keyword monitor:
|
||||
- URL: `https://linearvoid.tv/hls/playlist.m3u8`
|
||||
- Keyword: `#EXTM3U`
|
||||
- Interval: 5 minutes
|
||||
3. Alert policy:
|
||||
- Trigger on 2 consecutive failures
|
||||
- Notify via email and Discord/Slack webhook
|
||||
|
||||
### 10. Verification Checklist
|
||||
|
||||
1. DNS resolves for `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv`.
|
||||
2. Workers serve frontend for apex and `www` redirects correctly.
|
||||
3. HLS segments are cacheable long-term, manifests refresh every ~2s.
|
||||
4. Admin paths are rate-limited and WAF events are visible.
|
||||
5. HTTPS and WSS both succeed with valid certificates.
|
||||
6. UptimeRobot alerts fire as expected when endpoints are intentionally made unavailable.
|
||||
1. Create R2 bucket (e.g. `linearvoid-media`, matching `R2_BUCKET_NAME`); generate an access key pair → `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_REGION=auto`.
|
||||
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
||||
3. Attach `media.linearvoid.tv` as custom media domain → `MEDIA_DOMAIN`; add CORS allowing `https://linearvoid.tv` GET for HLS delivery.
|
||||
4. Point `linearvoid.tv` (+ `www` redirect) at the app entrypoint; keep `media.*` on R2. Proxied DNS, TLS **Full (strict)**.
|
||||
5. Apply the `.m4s`/`.m3u8` cache rules from [`cache-rules.md`](./infrastructure/cloudflare/cache-rules.md); enable Smart Tiered Cache; optionally deploy `infrastructure/cloudflare/worker.js` for header normalization.
|
||||
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
||||
|
||||
## Running the Application
|
||||
|
||||
### Local Development
|
||||
### Local development
|
||||
|
||||
1. **Start All Services**:
|
||||
```bash
|
||||
docker compose up --build # start all services
|
||||
docker compose down # stop
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
Access: app/admin `https://linearvoid.tv` (via proxy) or `http://localhost`; WS `wss://linearvoid.tv/ws` / `ws://localhost/ws`. Health: playout `:8081/health`, chat `:3000/health`. Monitoring: Prometheus `:9090`, Grafana `:3001` (default `admin/admin`).
|
||||
|
||||
2. **Access the Application**:
|
||||
- Preferred local access with the documented proxy setup: <https://linearvoid.tv>
|
||||
- Admin Interface: <https://linearvoid.tv/admin> (redirects to `/admin/login/` until authenticated)
|
||||
- Chat WebSocket: `wss://linearvoid.tv/ws`
|
||||
- Direct-local fallback when bypassing Cloudflare/Nginx Proxy Manager: <http://localhost>, <http://localhost/admin>, and `ws://localhost/ws`
|
||||
- Health Checks:
|
||||
- Playout: <http://localhost:8081/health>
|
||||
- Chat: <http://localhost:3000/health>
|
||||
- Monitoring:
|
||||
- Prometheus: <http://localhost:9090>
|
||||
- Grafana: <http://localhost:3001> (default login: admin/admin)
|
||||
Frontend dev (optional): `cd frontend && npm install && npm run dev` (http://localhost:5173, proxies API). For nginx-backed local runs: `npm run build` (nginx serves `frontend/dist`).
|
||||
|
||||
3. **Frontend Development** (optional, for UI changes):
|
||||
### Test lab (remote Docker host)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
|
||||
|
||||
The dev server runs on <http://localhost:5173>. It proxies API calls to the backend services.
|
||||
Enable TCP on Alpine host — edit `/etc/conf.d/docker`:
|
||||
|
||||
4. **Build frontend assets for nginx-backed local runs**:
|
||||
```bash
|
||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
||||
```
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
|
||||
|
||||
The local nginx container serves `frontend/dist`, so rebuilding the frontend is required before validating production-like static output.
|
||||
Deploy from a machine with the Docker CLI:
|
||||
|
||||
5. **Stop Services**:
|
||||
```bash
|
||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||
docker info # confirm remote host
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans # registry images
|
||||
# or build from source: docker compose up -d --build
|
||||
unset DOCKER_HOST # back to local
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
|
||||
|
||||
### Local Test Environment Deployment (Remote Docker Host)
|
||||
### Coolify deployment
|
||||
|
||||
For testing deployments on a local network server without setting up a full production VPS, you can deploy directly to a remote Docker host (e.g., a home lab server or test machine) using Docker's TCP socket.
|
||||
Coolify replaces the Gitea CD `DOCKER_HOST` orchestration (Cloudflare DNS/R2/cache unchanged). Install (`curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash`), add the target server via SSH, create a Docker Compose resource from `docker-compose.prod.yml` (or the repo), configure env vars in Coolify's store, map persistent `data/` and named volumes, and assign `linearvoid.tv` to nginx for Traefik TLS. Full walkthrough: [`COOLIFY.md`](./COOLIFY.md).
|
||||
|
||||
#### Test Environment Prerequisites
|
||||
### Production setup
|
||||
|
||||
- A remote server running Docker at IP `192.168.88.40` (running Alpine Linux).
|
||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
||||
1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
|
||||
2. `git clone … && cd linearvoid && cp .env.example .env`.
|
||||
3. Set production `.env`: strong `POSTGRES_PASSWORD`/`GRAFANA_PASSWORD`, compose-hostname internal URLs, `MEDIA_DOMAIN=media.linearvoid.tv`, public `VITE_PUBLIC_*` URLs. For local-only testing bypassing the proxy, use `http://localhost`/`ws://localhost/ws`/`http://localhost/hls/playlist.m3u8`.
|
||||
4. `mkdir -p data/playlist data/media data/hls data/chat`.
|
||||
5. Ensure `playout`, `chat`, `nginx` images exist in the registry; `docker login` from the VPS.
|
||||
6. Deploy: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`.
|
||||
7. Verify: `docker compose -f docker-compose.prod.yml ps`, health endpoints, HLS written to `data/hls`, media domain serves via Cloudflare.
|
||||
8. Harden: HTTPS in front of nginx (Cloudflare, `www` → apex), restrict Grafana/Prometheus to trusted IPs, run under systemd for auto-start.
|
||||
|
||||
##### Enabling Docker TCP Socket on Alpine Linux
|
||||
### Production (Hetzner VPS + Cloudflare)
|
||||
|
||||
To allow the Gitea Actions runner (or your local machine) to deploy to the remote Alpine host, you must configure the Docker daemon to listen on the TCP socket:
|
||||
1. Provision CX22 in Ashburn, VA (ADR-001).
|
||||
2. Cloudflare: R2 bucket (`enam` hint), `media.linearvoid.tv` custom domain + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS, deploy `infrastructure/cloudflare/worker.js`.
|
||||
3. Deploy from the project: `docker compose -f docker-compose.prod.yml up -d` (or pull from Gitea registry); manage under systemd.
|
||||
4. Monitoring: Grafana dashboards from `monitoring/grafana/`.
|
||||
|
||||
1. Edit `/etc/conf.d/docker` on the Alpine host:
|
||||
|
||||
```bash
|
||||
vi /etc/conf.d/docker
|
||||
```
|
||||
|
||||
2. Update the `DOCKER_OPTS` line to include both the unix socket and the TCP socket:
|
||||
|
||||
```bash
|
||||
DOCKER_OPTS="-H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375"
|
||||
```
|
||||
|
||||
3. Restart the Docker service:
|
||||
|
||||
```bash
|
||||
rc-service docker restart
|
||||
```
|
||||
|
||||
4. Verify that Docker is listening on port `2375`:
|
||||
|
||||
```bash
|
||||
netstat -tlnp | grep 2375
|
||||
```
|
||||
|
||||
5. Ensure any firewall (like `awall` or `iptables`) on the Alpine host allows incoming traffic on port `2375` from your local network subnet (e.g., `192.168.88.0/24`).
|
||||
|
||||
#### Deployment Steps
|
||||
|
||||
1. **Configure Docker CLI to use the Remote Host**:
|
||||
You can point your local Docker CLI to the remote server by setting the `DOCKER_HOST` environment variable:
|
||||
- **PowerShell (Windows)**:
|
||||
|
||||
```powershell
|
||||
$env:DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||
```
|
||||
|
||||
- **Bash (Linux/macOS)**:
|
||||
|
||||
```bash
|
||||
export DOCKER_HOST="tcp://192.168.88.40:2375"
|
||||
```
|
||||
|
||||
2. **Verify Connection**:
|
||||
Run `docker info` to confirm your local CLI is communicating with the remote server at `192.168.88.40`:
|
||||
|
||||
```bash
|
||||
docker info
|
||||
```
|
||||
|
||||
The output should show the remote server's hostname and system details.
|
||||
|
||||
3. **Deploy the Stack**:
|
||||
With `DOCKER_HOST` set, any `docker` or `docker compose` commands you run locally will execute directly on the remote server.
|
||||
- **Deploy from Registry (Production-like)**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
```
|
||||
|
||||
- **Build and Deploy from Source**:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
4. **Access the Test Environment**:
|
||||
- Playout Health: `http://192.168.88.40:8081/health`
|
||||
- Chat Health: `http://192.168.88.40:3000/health`
|
||||
- Nginx Entrypoint: `http://192.168.88.40:8080` (or the port mapped to `NGINX_PORT` in `.env`)
|
||||
|
||||
5. **Reset to Local Docker Host**:
|
||||
Once testing is complete, clear the `DOCKER_HOST` variable to point back to your local machine:
|
||||
- **PowerShell**:
|
||||
|
||||
```powershell
|
||||
Remove-Item env:DOCKER_HOST
|
||||
```
|
||||
|
||||
- **Bash**:
|
||||
|
||||
```bash
|
||||
unset DOCKER_HOST
|
||||
```
|
||||
|
||||
### Coolify Deployment
|
||||
|
||||
Coolify is an open-source, self-hostable PaaS (Heroku/Netlify/Vercel
|
||||
alternative) that can deploy the Linear Void stack through a web UI with
|
||||
automatic HTTPS and one-click redeploys. It replaces the deployment
|
||||
orchestration layer (the Gitea CD `DOCKER_HOST` flow) while leaving Cloudflare
|
||||
DNS, R2 media, and cache rules unchanged.
|
||||
|
||||
To deploy via Coolify:
|
||||
|
||||
1. **Install Coolify** on a dedicated server:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
|
||||
```
|
||||
|
||||
2. **Add the target server** in the dashboard via SSH (or use Localhost if
|
||||
Coolify runs on the same machine as the stack).
|
||||
3. **Create a Docker Compose resource** pointing at either the Git repository
|
||||
(`docker-compose.prod.yml`) or the compose file contents directly.
|
||||
4. **Configure environment variables** in Coolify's secret store (the values
|
||||
normally held in `.env`), keeping internal URLs on compose hostnames
|
||||
(`redis`, `db`).
|
||||
5. **Map persistent storage** for `data/` mounts and the `redis-data` /
|
||||
`postgres-data` named volumes so data survives redeploys.
|
||||
6. **Assign a domain** to the nginx service so Coolify's Traefik proxy
|
||||
terminates TLS for `linearvoid.tv`, while `media.linearvoid.tv` continues to
|
||||
serve HLS from Cloudflare R2.
|
||||
|
||||
For the full walkthrough, environment-variable table, verification steps, and
|
||||
cutover notes, see [COOLIFY.md](./COOLIFY.md).
|
||||
|
||||
### Production Setup Guide
|
||||
|
||||
1. **Prepare the target host**:
|
||||
- Provision a Linux VPS with Docker Engine, Docker Compose, Git, and enough disk for media, HLS output, PostgreSQL, Redis persistence, and monitoring data.
|
||||
- Open the ports required for HTTP/HTTPS and any restricted monitoring access you intend to expose.
|
||||
|
||||
2. **Clone the repository and create the environment file**:
|
||||
|
||||
```bash
|
||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
||||
cd linearvoid
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
3. **Set production-safe `.env` values**:
|
||||
- Replace placeholder secrets.
|
||||
- Use compose-network hostnames for internal services, for example `redis` and `db`.
|
||||
- Set strong values for `POSTGRES_PASSWORD` and `GRAFANA_PASSWORD`.
|
||||
- Keep `MEDIA_DOMAIN`, R2 credentials, and Gitea registry values consistent with the external infrastructure.
|
||||
- Set `MEDIA_DOMAIN=media.linearvoid.tv`.
|
||||
- Set `VITE_PUBLIC_APP_URL=https://linearvoid.tv`, `VITE_PUBLIC_WS_URL=wss://linearvoid.tv/ws`, and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`.
|
||||
- If you intentionally bypass the public proxy for local-only testing, switch those values to `http://localhost`, `ws://localhost/ws`, and `http://localhost/hls/playlist.m3u8`.
|
||||
|
||||
4. **Create runtime directories on the server**:
|
||||
|
||||
```bash
|
||||
mkdir -p data/playlist data/media data/hls data/chat
|
||||
```
|
||||
|
||||
5. **Publish or verify required images in Gitea**:
|
||||
- Ensure the `playout`, `chat`, and `nginx` images exist under `${CI_REGISTRY_URL}/${CI_REGISTRY_NAMESPACE}`.
|
||||
- Log in to the registry from the VPS before pulling.
|
||||
|
||||
6. **Deploy the production stack**:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
7. **Run post-deploy verification**:
|
||||
- Check `docker compose -f docker-compose.prod.yml ps`.
|
||||
- Confirm nginx, chat, and playout health endpoints respond.
|
||||
- Verify that HLS output is being written to `data/hls` and that the media domain serves manifests and segments through Cloudflare.
|
||||
|
||||
8. **Harden and operate the deployment**:
|
||||
- Put HTTPS in front of nginx using Cloudflare or another reverse proxy, with `linearvoid.tv` as canonical app host and `www.linearvoid.tv` redirected.
|
||||
- Restrict Grafana and Prometheus exposure to trusted IPs or a private network.
|
||||
- Run the stack under systemd or another service manager if you need auto-start behavior outside Docker restart policies.
|
||||
|
||||
### Production Deployment (Hetzner VPS + Cloudflare)
|
||||
|
||||
1. **Provision VPS**: Use Hetzner Cloud CX22 in Ashburn, VA (per ADR-001).
|
||||
2. **Configure Cloudflare**:
|
||||
- Create R2 bucket with `enam` location hint.
|
||||
- Set up custom media domain (`media.linearvoid.tv`) with the cache rules from [cache-rules](../docs/infrastructure/cloudflare/cache-rules.md).
|
||||
- Point `linearvoid.tv` and `www.linearvoid.tv` at the app entrypoint, then redirect `www` to the apex domain.
|
||||
- Use Full (strict) TLS for both app and media hostnames.
|
||||
- Apply the Worker from `infrastructure/cloudflare/worker.js` for advanced header management.
|
||||
3. **Deploy**:
|
||||
- Copy the project to the VPS.
|
||||
- Use production compose: `docker compose -f docker-compose.prod.yml up -d`
|
||||
- Or pull from Gitea registry: `docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d`
|
||||
- Configure systemd to manage the Docker Compose stack.
|
||||
4. **Monitoring**: Access Grafana at the exposed port for dashboards (included in `monitoring/grafana/dashboard.json`).
|
||||
|
||||
See `docs/architecture/README.md` for the full arc42 documentation and the ADRs for architectural decisions.
|
||||
See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
||||
- **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
|
||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
||||
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
|
||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
||||
- **R2 upload fails** — check `.env` creds/bucket; `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
||||
- **Normalization errors** — run `backend/playout/start-playout.sh` to see ffprobe output. Video must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
|
||||
- **Chat won't connect** — check Redis and WS URL; `/health` should return 200.
|
||||
- **Cache not working** — verify `.m4s` long-TTL / `.m3u8` short-TTL cache rules and hit ratio.
|
||||
- **Docker issues** — `docker compose logs`; check port conflicts.
|
||||
- **Admin interface** — `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_TTL_HOURS` set; session-based login protects `/admin/` and `/api/admin/*`.
|
||||
- **Prod pull fails** — `docker login` to Gitea succeeded and image tags exist.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- Architecture: `docs/architecture/README.md` (arc42 template with diagrams).
|
||||
- Decisions: `docs/architecture/adr/` (ADR-001 to ADR-007).
|
||||
- Contributing: `CONTRIBUTING.md`.
|
||||
- Project Structure: Root `README.md`.
|
||||
|
||||
The system is ready for use. For production, review ADR-001 for cost estimates and scaling guidance.
|
||||
Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
|
||||
+67
-80
@@ -1,6 +1,6 @@
|
||||
# Streaming Pipeline
|
||||
|
||||
This document is the operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. General deployment, chat, and admin details remain in `DEPLOYMENT.md`, `SETUP.md`, and `API.md`.
|
||||
Operational contract for Linear Void video ingest, playout, publication, caching, and browser playback. Deployment, chat, admin details live in `DEPLOYMENT.md`, `SETUP.md`, `API.md`.
|
||||
|
||||
## Production flow
|
||||
|
||||
@@ -20,7 +20,7 @@ The playout service is the timeline authority. It reads the ordered playlist, ru
|
||||
https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
```
|
||||
|
||||
Nginx `/hls/` is a local/test fallback, not the production media origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||
Nginx `/hls/` is a local/test fallback, not the production origin. The SPA, admin API, and chat use `linearvoid.tv`; media uses `media.linearvoid.tv`.
|
||||
|
||||
## Source-media contract
|
||||
|
||||
@@ -30,12 +30,12 @@ Only validated files may enter the production playlist.
|
||||
| ---------- | ------------------------------------------------------------------------ |
|
||||
| Container | MP4 |
|
||||
| Video | H.264/AVC, `yuv420p` |
|
||||
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
|
||||
| Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
|
||||
| Frame rate | Constant 30 fps |
|
||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
||||
|
||||
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or incorrect keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID and object key.
|
||||
The ingest gate uses `ffprobe` to reject incompatible codecs, variable frame rate, missing audio, invalid dimensions, or wrong keyframe cadence. Assets are immutable after acceptance; corrections create a new asset ID/object key.
|
||||
|
||||
Reference normalization:
|
||||
|
||||
@@ -51,124 +51,111 @@ ffmpeg -i input.mov \
|
||||
|
||||
## Playlist and timeline
|
||||
|
||||
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
||||
- The service validates the complete playlist before activating a revision.
|
||||
- Playlist edits are written atomically and receive a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||
- All viewers follow the same live manifest; clients do not calculate the schedule independently.
|
||||
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
||||
- When the last item ends, playout returns to the first item unless an active schedule override says otherwise.
|
||||
- `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored.
|
||||
- Service validates the complete playlist before activating a revision.
|
||||
- Playlist edits are atomic and get a revision ID. The active FFmpeg run keeps its current item and adopts the new revision at the next item boundary.
|
||||
- All viewers follow the same live manifest; clients never calculate the schedule independently.
|
||||
- Schedule metadata derives from the same active revision/timeline so "now/next" agrees with the video.
|
||||
- When the last item ends, playout returns to the first unless an active schedule override says otherwise.
|
||||
|
||||
## Playout and HLS packaging
|
||||
|
||||
The playout service wraps FFmpeg, tracks the active item and playlist revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||
The playout service wraps FFmpeg, tracks the active item/revision, restarts failed processes, and exposes `/health` plus Prometheus metrics.
|
||||
|
||||
Baseline packaging:
|
||||
| Setting | Value |
|
||||
| -------------- | ---------------------------------------------------------------------------------- |
|
||||
| Protocol | Standard live HLS, not LL-HLS |
|
||||
| Segment format | fMP4 (`.m4s`) with init segment; `.ts` may be supported during migration |
|
||||
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), ~24 seconds |
|
||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||
| Expected latency | 15–35 seconds |
|
||||
|
||||
| Setting | Value |
|
||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
||||
| Protocol | Standard live HLS, not LL-HLS |
|
||||
| Segment format | fMP4 (`.m4s`) with an initialization segment; `.ts` may be supported during migration |
|
||||
| Segment target | 2 seconds (`HLS_SEGMENT_DURATION=2`) |
|
||||
| Live window | 12 segments (`HLS_WINDOW_SIZE=12`), about 24 seconds |
|
||||
| Codec path | Stream copy for conforming assets; no per-viewer transcoding |
|
||||
| Expected latency | 15–35 seconds |
|
||||
Segment boundaries must align with keyframes; timestamps must stay monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new init segment.
|
||||
|
||||
Segment boundaries must align with keyframes. Timestamps must remain monotonic across item boundaries. If FFmpeg cannot preserve a seamless timeline, insert `#EXT-X-DISCONTINUITY` and publish the corresponding new initialization segment.
|
||||
**Segment duration rationale**: 2s lowers latency and keeps the window small (12 × 2s = 24s) while staying above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60`) already matches the target, so segments cut cleanly on keyframes. Longer (4–6s) raises latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B ops with little gain.
|
||||
|
||||
**Segment duration rationale**: 2s segments were chosen to lower end-to-end latency and keep the live window small (12 × 2s = 24s) while staying well above the manifest TTL (1–2s). The 2s GOP (30fps, `-g 60 -keyint_min 60`) already matches the target, so segments cut cleanly on keyframes. Longer durations (4–6s) increase latency and window depth; shorter than 2s inflates segment/request overhead and R2 Class B operations with little latency gain.
|
||||
|
||||
Every segment name is globally unique and never reused, including after a restart or playlist loop. Use a run/session ID plus a monotonically increasing media sequence, for example:
|
||||
Every segment name is globally unique and never reused, including after restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence:
|
||||
|
||||
```text
|
||||
hls/r-20260807T170412Z/init-000001.mp4
|
||||
hls/r-20260807T170412Z/seg-000001234.m4s
|
||||
```
|
||||
|
||||
This naming rule is essential: a URL advertised as immutable must never later contain different bytes.
|
||||
A URL advertised as immutable must never later contain different bytes.
|
||||
|
||||
## Safe publication to R2
|
||||
|
||||
Publication order prevents a manifest from referencing an unavailable segment:
|
||||
Order prevents a manifest referencing an unavailable segment:
|
||||
|
||||
1. FFmpeg writes a segment to a temporary local name and closes it.
|
||||
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
||||
3. It verifies successful upload and records object size/checksum.
|
||||
4. It renders the next media manifest referencing only confirmed objects.
|
||||
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
||||
1. FFmpeg writes a segment to a temp local name and closes it.
|
||||
2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys.
|
||||
3. Verify upload success; record object size/checksum.
|
||||
4. Render the next manifest referencing only confirmed objects.
|
||||
5. Upload the manifest to a temp key, then replace `hls/playlist.m3u8` as the final commit step.
|
||||
|
||||
Never upload an actively written segment or publish the manifest first. Use a single publisher instance, or fencing/leader election, so two playout workers cannot update the live manifest concurrently.
|
||||
Never upload an actively written segment or publish the manifest first. Use a single publisher (or fencing/leader election) so two workers can't update the live manifest concurrently.
|
||||
|
||||
The local `data/hls/` directory is staging data and may be regenerated. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||
`data/hls/` is regenerable staging. Source media, playlist revisions, and schedule state are authoritative; generated HLS is not backed up as application state.
|
||||
|
||||
## Cache contract
|
||||
|
||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||
| -------------------------- | ---------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||
| Live `.m3u8` | Cache eligible; edge TTL 2 seconds | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
||||
| -------------------------- | --------------------------------- | --------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `.m4s`, `.ts`, init `.mp4` | Cache eligible; TTL 1 year | TTL 1 year | `Cache-Control: public, max-age=31536000, immutable`; full path in cache key |
|
||||
| Live `.m3u8` | Cache eligible; edge TTL 2s | Do not retain beyond freshness interval | `Cache-Control: public, max-age=1, s-maxage=2, must-revalidate` |
|
||||
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||
|
||||
Further rules:
|
||||
|
||||
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
||||
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
||||
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
||||
- Enable Smart Tiered Cache for `media.linearvoid.tv` to reduce R2 reads on regional misses.
|
||||
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
||||
- Retain expired-window segments in R2 for at least the maximum manifest TTL plus client retry/grace time. A practical minimum is one hour; lifecycle deletion may run later according to storage policy.
|
||||
- No query-string cache busters on segment URLs; uniqueness belongs in the path.
|
||||
- Cache keys preserve hostname and full path; ignore only explicitly approved tracking params.
|
||||
- HLS responses need `Access-Control-Allow-Origin: https://linearvoid.tv` (or reviewed allowlist), `GET`/`HEAD`/`OPTIONS`, and correct MIME types.
|
||||
- Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses.
|
||||
- Purge manifests when necessary, never immutable segments during normal operation.
|
||||
- Retain expired-window segments at least max manifest TTL + retry/grace time; practical minimum 1 hour; lifecycle deletion may run later per storage policy.
|
||||
|
||||
Expected validation:
|
||||
Validation:
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
||||
```
|
||||
|
||||
The manifest must refresh within a few seconds. Repeated segment requests should progress from `CF-Cache-Status: MISS` to `HIT`, retain the same ETag/content length, and carry the immutable cache header.
|
||||
The manifest must refresh within seconds; repeated segment requests should progress `CF-Cache-Status: MISS` → `HIT`, keep the same ETag/content length, and carry the immutable cache header.
|
||||
|
||||
## Player behavior
|
||||
|
||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
||||
|
||||
- Firefox has no native HLS, so it uses the hls.js/MSE path; fMP4 (`.m4s` + init segment) is supported. Autoplay requires `muted` + `playsinline`, which are already set on the `<video>` element.
|
||||
SPA uses native HLS where supported, hls.js elsewhere.
|
||||
|
||||
- Firefox has no native HLS → hls.js/MSE path; fMP4 (`.m4s` + init) is supported. Autoplay needs `muted` + `playsinline`, already set on the `<video>` element.
|
||||
- Start at the live edge with a conservative buffer; do not seek to playlist start.
|
||||
- Treat the manifest as the synchronization source and target roughly three segments behind the newest segment unless testing supports a smaller margin.
|
||||
- Recover transient network/media errors with bounded retries and exponential backoff.
|
||||
- On a fatal hls.js network error, reload the manifest; on a fatal media error, attempt media recovery once before recreating the player.
|
||||
- When tab suspension or drift leaves playback materially behind the target live position, seek forward to the target rather than playing the backlog.
|
||||
- Surface a clear reconnecting state. Never silently loop stale media.
|
||||
- Schedule/chat timestamps should account for the measured player latency rather than assuming wall-clock playout is already visible.
|
||||
- Treat the manifest as the sync source; target ~3 segments behind the newest unless testing supports a smaller margin.
|
||||
- Recover transient network/media errors with bounded retries + exponential backoff.
|
||||
- On fatal hls.js network error, reload the manifest; on fatal media error, attempt media recovery once before recreating the player.
|
||||
- If tab suspension/drift leaves playback materially behind the target, seek forward rather than play the backlog.
|
||||
- Surface a clear reconnecting state; never silently loop stale media.
|
||||
- Schedule/chat timestamps should account for measured player latency, not wall-clock playout.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
The service retries an item failure with a bounded backoff. After the configured failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`. It periodically retries normal playout and returns only after a segment and committed manifest have been published successfully.
|
||||
The service retries an item failure with bounded backoff. Past the failure threshold it switches to a pre-normalized looping slate and reports `mode=slate`, periodically retrying normal playout, returning only after a segment and committed manifest publish successfully.
|
||||
|
||||
On process or host restart:
|
||||
On process/host restart:
|
||||
|
||||
- create a new run/session ID and continue with globally unique segment paths;
|
||||
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
||||
- create a new run/session ID and continue with unique segment paths;
|
||||
- resume from schedule-derived current position when reliable state exists, else start the current playlist item;
|
||||
- never overwrite old segment objects;
|
||||
- keep the last valid manifest available until the replacement stream is playable;
|
||||
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
||||
- keep the last valid manifest available until the replacement is playable;
|
||||
- emit a discontinuity when timeline continuity isn't guaranteed.
|
||||
|
||||
Health is not merely “process running.” `/health` should report at least mode (`live`, `slate`, `starting`, `degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, and uptime. Mark the service unhealthy if the manifest or media sequence has not advanced for more than two segment durations.
|
||||
`/health` must report more than "process running": at least mode (`live`/`slate`/`starting`/`degraded`), active item, playlist revision, run ID, last segment sequence/time, last successful R2 publication, consecutive failures, uptime. Mark unhealthy if the manifest or media sequence hasn't advanced for more than two segment durations.
|
||||
|
||||
## Monitoring and acceptance checks
|
||||
## Monitoring and acceptance
|
||||
|
||||
Monitor and alert on:
|
||||
Monitor/alert on: manifest age and media-sequence advancement; time since last published segment; FFmpeg restarts and consecutive failures; live/slate mode; R2 upload latency/errors; segment 404/5xx and manifest/segment latency; Cloudflare cache-hit ratio, origin requests, R2 Class B ops; player startup, fatal errors, rebuffer ratio, live-edge drift; local staging disk usage.
|
||||
|
||||
- manifest age and media-sequence advancement;
|
||||
- time since last successfully published segment;
|
||||
- FFmpeg restarts and consecutive item failures;
|
||||
- live/slate mode;
|
||||
- R2 upload latency and errors;
|
||||
- segment 404/5xx rate and manifest/segment request latency;
|
||||
- Cloudflare cache-hit ratio, origin requests, and R2 Class B operations;
|
||||
- player startup time, fatal errors, rebuffer ratio, and live-edge drift;
|
||||
- local staging disk usage.
|
||||
|
||||
A release is streaming-ready only when one full playlist boundary has played without timestamp errors, the manifest never references a missing object, a second viewer joins the same timeline, cache headers match the table above, a forced FFmpeg failure activates the slate, and normal playout recovers without reusing an object URL.
|
||||
A release is streaming-ready only when: one full playlist boundary plays without timestamp errors; the manifest never references a missing object; a second viewer joins the same timeline; cache headers match the table; a forced FFmpeg failure activates slate; and normal playout recovers without reusing an object URL.
|
||||
|
||||
## Required configuration
|
||||
|
||||
@@ -179,14 +166,14 @@ A release is streaming-ready only when one full playlist boundary has played wit
|
||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||
| `R2_BUCKET_NAME` | Media bucket |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
||||
| `R2_KEEP_RUNS` | Number of run folders to retain (default `2`) |
|
||||
| `R2_MAX_RETENTION_BYTES` | Max bytes retained across run folders (10 GiB) |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
||||
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||
| `R2_MAX_RETENTION_BYTES` | Max bytes across run folders (10 GiB) |
|
||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||
|
||||
Production values are `MEDIA_DOMAIN=media.linearvoid.tv` and `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||
Production values: `MEDIA_DOMAIN=media.linearvoid.tv`, `VITE_PUBLIC_HLS_URL=https://media.linearvoid.tv/hls/playlist.m3u8`. Local fallback may use `http://localhost/hls/playlist.m3u8`.
|
||||
|
||||
## Explicitly deferred
|
||||
|
||||
Adaptive-bitrate ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any of them requires an ADR and an update to this document.
|
||||
ABR ladders, LL-HLS, DVR/catch-up, multi-origin playout, and regional chat affinity are outside the v1 contract. Adding any requires an ADR and an update to this document.
|
||||
+224
-693
File diff suppressed because it is too large.
Load diff
+57
-126
@@ -4,155 +4,101 @@ Triage order for Linear Void incidents. Work top-to-bottom — each tier depends
|
||||
|
||||
## Triage Order
|
||||
|
||||
1. Edge Reachability (Cloudflare)
|
||||
2. Origin Health (VPS/Docker)
|
||||
3. Playout Progression (HLS)
|
||||
4. Chat Connectivity (WebSocket)
|
||||
5. Data-Plane (Redis/DB)
|
||||
1. **Edge Reachability** (Cloudflare)
|
||||
2. **Origin Health** (VPS/Docker)
|
||||
3. **Playout Progression** (HLS)
|
||||
4. **Chat Connectivity** (WebSocket)
|
||||
5. **Data-Plane** (Redis/DB)
|
||||
|
||||
## 1. Edge Reachability (Cloudflare)
|
||||
|
||||
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
||||
|
||||
```bash
|
||||
# Check DNS
|
||||
dig linearvoid.tv
|
||||
dig www.linearvoid.tv
|
||||
|
||||
# Check Cloudflare status
|
||||
curl -I https://linearvoid.tv/health
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
**Common causes:** DNS misconfiguration (A record → wrong IP); Cloudflare proxy disabled (grey cloud exposes origin); SSL certificate expired (Cloudflare → SSL/TLS); Cloudflare outage ([cloudflarestatus.com](https://www.cloudflarestatus.com)).
|
||||
|
||||
- DNS misconfiguration (A record pointing to wrong IP)
|
||||
- Cloudflare proxy disabled (grey cloud → origin exposed directly)
|
||||
- SSL certificate expired (check Cloudflare dashboard → SSL/TLS)
|
||||
- Cloudflare outage → check [https://www.cloudflarestatus.com](https://www.cloudflarestatus.com)
|
||||
**Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
|
||||
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
|
||||
```bash
|
||||
curl -H "Host: linearvoid.tv" http://<origin-ip>/health
|
||||
```
|
||||
|
||||
## 2. Origin Health (VPS/Docker)
|
||||
|
||||
**Symptoms:** Cloudflare returns 502/504, containers down.
|
||||
**Symptoms:** Cloudflare 502/504, containers down.
|
||||
|
||||
```bash
|
||||
# Remote Docker (test lab)
|
||||
docker -H tcp://192.168.88.40:2375 ps
|
||||
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
||||
|
||||
# Direct health check
|
||||
curl http://192.168.88.40/health
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
|
||||
- Host out of disk space: `df -h`
|
||||
- Docker daemon stopped: `systemctl status docker`
|
||||
- Container restart loop: `docker ps -a --filter "status=exited"`
|
||||
- Resource exhaustion (OOM): `dmesg | grep -i oom`
|
||||
**Common causes:** Host disk full (`df -h`); Docker daemon stopped (`systemctl status docker`); container restart loop (`docker ps -a --filter "status=exited"`); OOM (`dmesg | grep -i oom`).
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||
- Free disk: `docker system prune -f`
|
||||
- Check logs per service: `docker logs linearvoid-<service> --tail 100`
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d # restart stack
|
||||
docker system prune -f # free disk
|
||||
docker logs linearvoid-<service> --tail 100 # per-service logs
|
||||
```
|
||||
|
||||
## 3. Playout Progression (HLS)
|
||||
|
||||
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
||||
|
||||
```bash
|
||||
# Check playout health
|
||||
curl http://192.168.88.40:8081/health
|
||||
# Response includes "mode": "live" or "slate"
|
||||
|
||||
# Check HLS output
|
||||
curl http://192.168.88.40:8081/health # "mode": "live" or "slate"
|
||||
curl -I http://192.168.88.40/hls/playlist.m3u8
|
||||
ls -la data/hls/
|
||||
|
||||
# Check playout logs
|
||||
docker logs linearvoid-playout --tail 100
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
**Common causes:** FFmpeg crash → slate fallback (health `degraded`); playlist empty/missing (`cat data/playlist/playlist.txt`); media file missing (`ls data/media/`); disk full → HLS segments can't write.
|
||||
|
||||
- FFmpeg crash → slate fallback active (health shows `degraded`)
|
||||
- Playlist file empty/missing: `cat data/playlist/playlist.txt`
|
||||
- Media file missing from playlist: `ls data/media/`
|
||||
- Disk full → HLS segments can't write
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- Verify playlist has valid entries
|
||||
- Check media files exist at paths in playlist
|
||||
- Restart playout: `docker compose restart playout`
|
||||
- Generate demo playlist if empty: see `backend/playout/start-playout.sh`
|
||||
**Mitigation:** Validate playlist entries and media paths; restart playout (`docker compose restart playout`); regenerate demo playlist if empty (see `backend/playout/start-playout.sh`).
|
||||
|
||||
## 4. Chat Connectivity (WebSocket)
|
||||
|
||||
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
||||
|
||||
```bash
|
||||
# Check chat health
|
||||
curl http://192.168.88.40:3000/health
|
||||
|
||||
# Test WebSocket
|
||||
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
||||
|
||||
# Check chat logs
|
||||
docker logs linearvoid-chat --tail 100
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
**Common causes:** Redis down → presence/rate-limiting degraded (chat still works); DB down → messages not persisting; port not exposed (`docker port linearvoid-chat`).
|
||||
|
||||
- Redis down → presence/rate-limiting degraded (chat still works)
|
||||
- Database down → messages not persisting
|
||||
- Port not exposed: `docker port linearvoid-chat`
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- Restart chat: `docker compose restart chat`
|
||||
- Check Redis: `docker exec linearvoid-redis redis-cli ping`
|
||||
- Check DB: `docker exec linearvoid-db pg_isready -U linearvoid`
|
||||
- Chat works without Redis (graceful degradation) — check logs for "Redis connection warning"
|
||||
**Mitigation:** Restart chat (`docker compose restart chat`); check Redis (`docker exec linearvoid-redis redis-cli ping`); check DB (`docker exec linearvoid-db pg_isready -U linearvoid`). Chat degrades gracefully without Redis — look for "Redis connection warning" in logs.
|
||||
|
||||
## 5. Data-Plane (Redis/DB)
|
||||
|
||||
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
||||
|
||||
```bash
|
||||
# Redis
|
||||
docker exec linearvoid-redis redis-cli ping
|
||||
docker exec linearvoid-redis redis-cli info memory
|
||||
|
||||
# PostgreSQL
|
||||
docker exec linearvoid-db pg_isready -U linearvoid
|
||||
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
||||
```
|
||||
|
||||
**Common causes:**
|
||||
**Common causes:** Redis OOM (check `maxmemory`); PostgreSQL connection limit; disk full on volume mount.
|
||||
|
||||
- Redis OOM (check `maxmemory` setting)
|
||||
- PostgreSQL connection limit reached
|
||||
- Disk full on volume mount
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- Redis: `docker compose restart redis` (AOF persist enabled)
|
||||
- PostgreSQL: `docker compose restart db`
|
||||
- Restore from backup: see `scripts/verify-backups.sh` for procedure
|
||||
**Mitigation:** Restart redis (`docker compose restart redis`, AOF persist enabled); restart db (`docker compose restart db`); restore from backup (see `scripts/verify-backups.sh`).
|
||||
|
||||
## Escalation
|
||||
|
||||
| Severity | When | Action |
|
||||
| -------- | ------------------------------------ | -------------------------------------------------- |
|
||||
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service |
|
||||
| Info | Single service blip | Monitor, no action unless recurring |
|
||||
| Severity | When | Action |
|
||||
| -------- | ------------------------------------ | ------------------------------------------- |
|
||||
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||
| Warning | Redis down, high CPU >30min | Investigate business hours, restart service |
|
||||
| Info | Single service blip | Monitor; no action unless recurring |
|
||||
|
||||
## Post-Incident
|
||||
|
||||
@@ -163,67 +109,52 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
||||
|
||||
## Quarterly Disaster Recovery Drill
|
||||
|
||||
Goal: validate recovery targets under controlled host-loss simulation.
|
||||
|
||||
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
||||
Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||
- Latest backups from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
|
||||
- Operator access to Docker host and DNS controls
|
||||
- Current deployment variables available (`.env`, compose file, image tags)
|
||||
- Current deployment variables (`.env`, compose file, image tags)
|
||||
|
||||
### Drill Scope
|
||||
### Scope
|
||||
|
||||
- Simulate complete origin loss (all app containers unavailable)
|
||||
- Rebuild stack on clean host
|
||||
- Restore DB and media/playlist mounts
|
||||
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||
|
||||
### Drill Steps
|
||||
### Steps
|
||||
|
||||
1. Record drill start timestamp and on-call participants.
|
||||
2. Capture pre-drill baseline:
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>:3000/health`
|
||||
- `curl -fsS http://<origin>:8081/health`
|
||||
3. Simulate origin outage:
|
||||
- `docker compose -f docker-compose.prod.yml down`
|
||||
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
||||
5. Restore required data paths:
|
||||
- `data/playlist/`
|
||||
- `data/media/`
|
||||
- `data/chat/`
|
||||
- `data/hls/` (optional warm cache)
|
||||
```bash
|
||||
curl -fsS http://<origin>/health
|
||||
curl -fsS http://<origin>:3000/health
|
||||
curl -fsS http://<origin>:8081/health
|
||||
```
|
||||
3. Simulate outage: `docker compose -f docker-compose.prod.yml down`
|
||||
4. Provision clean recovery host (or clean test-lab VM); copy deployment bundle.
|
||||
5. Restore data paths: `data/playlist/`, `data/media/`, `data/chat/`, `data/hls/` (optional warm cache).
|
||||
6. Restore database from latest backup dump.
|
||||
7. Start stack:
|
||||
- `docker compose -f docker-compose.prod.yml up -d`
|
||||
8. Validate service health and runtime:
|
||||
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
||||
- `curl -fsS http://<origin>/health`
|
||||
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
||||
- `curl -fsS http://<origin>:9090/-/ready`
|
||||
9. Validate app behavior:
|
||||
- Viewer SPA loads and HLS plays
|
||||
- WebSocket chat connects and message round-trip works
|
||||
- Admin login/session endpoint returns success
|
||||
10. Run backup integrity check:
|
||||
- `scripts/verify-backups.sh`
|
||||
11. Record drill end timestamp and compute measured RTO/RPO.
|
||||
7. Start stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||
8. Validate health and runtime:
|
||||
```bash
|
||||
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||
curl -fsS http://<origin>/health
|
||||
curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5
|
||||
curl -fsS http://<origin>:9090/-/ready
|
||||
```
|
||||
9. Validate app behavior: viewer SPA loads and HLS plays; WebSocket chat connects with message round-trip; admin login/session endpoint succeeds.
|
||||
10. Run backup integrity check: `scripts/verify-backups.sh`
|
||||
11. Record drill end timestamp; compute measured RTO/RPO.
|
||||
|
||||
### Pass/Fail Criteria
|
||||
|
||||
- Pass if all core services healthy and functional in <= 60 minutes.
|
||||
- Pass if latest recoverable data point is <= 24 hours old.
|
||||
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||
- **Pass:** all core services healthy and functional in ≤ 60 min.
|
||||
- **Pass:** latest recoverable data point ≤ 24 h old.
|
||||
- **Fail:** any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||
|
||||
### Evidence Template
|
||||
|
||||
Document each drill in incident tracker with:
|
||||
|
||||
- Date/time window
|
||||
- Environment (test-lab, staging, production)
|
||||
- Backup artifact IDs and timestamps
|
||||
- Measured RTO and RPO
|
||||
- Issues found and remediation owner
|
||||
- Follow-up ticket links
|
||||
Record in incident tracker: date/time window; environment (test-lab, staging, production); backup artifact IDs and timestamps; measured RTO and RPO; issues found and remediation owner; follow-up ticket links.
|
||||
@@ -6,19 +6,19 @@ Use `wrangler whoami` to verify the account ID and API token are valid, or query
|
||||
|
||||
## Validate Cloudflare account and API token
|
||||
|
||||
```powershell
|
||||
```bash
|
||||
# Validate Cloudflare account and API token in .env
|
||||
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
|
||||
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
|
||||
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
||||
cd /path/to/linearvoid
|
||||
grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
|
||||
|
||||
# Test API token with wrangler
|
||||
npx wrangler whoami
|
||||
|
||||
# Or verify with the Workers API directly
|
||||
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
||||
export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
|
||||
export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
|
||||
|
||||
# List all Workers for the account
|
||||
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/workers/scripts" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
|
||||
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||
```
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,345 +1,156 @@
|
||||
# Scaling and Performance Playbook
|
||||
|
||||
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
||||
|
||||
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
||||
Purpose: define a repeatable method to measure performance, capture baselines, and decide scaling actions for the "Linear Void" streaming app. Scope: planning/execution for the "Scaling & Performance (planning only)" TODO items.
|
||||
|
||||
## 1) Manifest TTL Tuning Plan
|
||||
|
||||
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
|
||||
Goal: tune the `.m3u8` TTL to ~`0.5x` segment duration while keeping playback stable.
|
||||
|
||||
Inputs:
|
||||
|
||||
- Segment duration (`HLS_SEGMENT_DURATION`)
|
||||
- Current manifest TTL (Cloudflare cache rule)
|
||||
- Cache outcome distribution (`Cf-Cache-Status`)
|
||||
Inputs: segment duration (`HLS_SEGMENT_DURATION`), current manifest TTL (Cloudflare cache rule), cache outcome distribution (`Cf-Cache-Status`).
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Start from current manifest TTL (2s).
|
||||
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||
4. Compare:
|
||||
- rebuffer events from player logs
|
||||
- origin request increase/decrease
|
||||
- HIT ratio changes
|
||||
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
||||
4. Compare rebuffer events (player logs), origin request increase/decrease, and HIT ratio changes.
|
||||
5. Select the lowest TTL that keeps playback stable and origin load acceptable.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No material increase in rebuffer rate.
|
||||
- Manifest staleness not observable by users.
|
||||
Acceptance: no material increase in rebuffer rate; manifest staleness not observable by users.
|
||||
|
||||
## 2) TTFB Baseline Plan
|
||||
|
||||
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||
|
||||
Suggested regions:
|
||||
|
||||
- US East (Virginia)
|
||||
- US Central
|
||||
- US West (California/Oregon)
|
||||
Regions: US East (Virginia), US Central, US West (California/Oregon).
|
||||
|
||||
Method:
|
||||
|
||||
1. Use WebPageTest with repeat view enabled.
|
||||
2. Run at least 5 samples per region.
|
||||
3. Record median and p95 TTFB.
|
||||
4. Store results in table (date, region, median, p95, test URL).
|
||||
4. Store results in a table (date, region, median, p95, test URL).
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Warm-cache median < 4s in all tested regions.
|
||||
- Outliers documented with likely cause.
|
||||
Acceptance: warm-cache median < 4s in all regions; outliers documented with likely cause.
|
||||
|
||||
### TTFB Baseline Worksheet
|
||||
Use one row per region; keep the raw WebPageTest result URL with the summary for comparison.
|
||||
|
||||
Use one row per region and keep the raw WebPageTest result URL with the summary so later runs can be compared against the same evidence.
|
||||
- US East (Virginia) / US Central / US West (California/Oregon): Date/time, Test URL `https://linearvoid.tv`, WebPageTest result URL, Median TTFB, p95 TTFB, Notes.
|
||||
|
||||
- US East (Virginia)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US Central
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
- US West (California/Oregon)
|
||||
- Date/time:
|
||||
- Test URL: <https://linearvoid.tv>
|
||||
- WebPageTest result URL:
|
||||
- Median TTFB:
|
||||
- p95 TTFB:
|
||||
- Notes:
|
||||
|
||||
Suggested capture notes:
|
||||
|
||||
- Use repeat view for the warm-cache measurement.
|
||||
- Keep the same browser profile and test URL across regions.
|
||||
- Note any CDN cache-rule changes or deploys that occurred between samples.
|
||||
Capture notes: use repeat view for warm-cache; keep the same browser profile and test URL across regions; note any CDN cache-rule changes or deploys between samples.
|
||||
|
||||
## 3) WebSocket Load-Test Plan
|
||||
|
||||
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
|
||||
Goal: characterize chat behavior across 10 → 100 → 500 → 1000 concurrent clients.
|
||||
|
||||
Tooling option A (Artillery):
|
||||
|
||||
```bash
|
||||
npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Tooling option B (custom Go harness):
|
||||
Tooling option B (custom Go harness): open N concurrent WS clients, send a message every X seconds, measure round-trip latency and failures.
|
||||
|
||||
- open N concurrent WebSocket clients
|
||||
- send message every X seconds
|
||||
- measure round-trip latency and failures
|
||||
Metrics: p50/p95 message latency; error rate (connect/send/receive); dropped connections; CPU/memory for `chat`, `redis`, `nginx`.
|
||||
|
||||
Metrics to capture:
|
||||
|
||||
- p50/p95 message latency
|
||||
- error rate (connect, send, receive)
|
||||
- dropped connections
|
||||
- CPU/memory for `chat`, `redis`, `nginx`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 chat latency <= 1s at target concurrency tier.
|
||||
- error rate <= 1% during steady state.
|
||||
Acceptance: p95 chat latency ≤ 1s at target concurrency tier; error rate ≤ 1% during steady state.
|
||||
|
||||
### WebSocket Load-Test Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency tiers reached:
|
||||
- p50 message latency:
|
||||
- p95 message latency:
|
||||
- Error rate:
|
||||
- Dropped connections:
|
||||
- CPU/memory notes for chat:
|
||||
- CPU/memory notes for redis:
|
||||
- CPU/memory notes for nginx:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency tiers reached, p50 message latency, p95 message latency, Error rate, Dropped connections, CPU/memory notes for chat/redis/nginx, Raw results location, Conclusion, Next action.
|
||||
|
||||
## 4) Admin API Benchmark Plan
|
||||
|
||||
Goal: measure admin API p95 and identify slow endpoints.
|
||||
|
||||
Tooling option A (`k6`):
|
||||
Endpoints: `/api/admin/overview`, `/api/admin/analytics`, `/api/admin/chat-log`, `/api/admin/users`.
|
||||
|
||||
Tooling option A (`k6`):
|
||||
```bash
|
||||
k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Tooling option B (`wrk`):
|
||||
|
||||
```bash
|
||||
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||
```
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `/api/admin/overview`
|
||||
- `/api/admin/analytics`
|
||||
- `/api/admin/chat-log`
|
||||
- `/api/admin/users`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- p95 < 1s for primary admin endpoints.
|
||||
- slow endpoint list with remediation notes.
|
||||
Acceptance: p95 < 1s for primary admin endpoints; slow endpoint list with remediation notes.
|
||||
|
||||
### Admin API Benchmark Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Test URL:
|
||||
- Tool used:
|
||||
- Scenario duration:
|
||||
- Concurrency / virtual users:
|
||||
- `/api/admin/overview` p95:
|
||||
- `/api/admin/analytics` p95:
|
||||
- `/api/admin/chat-log` p95:
|
||||
- `/api/admin/users` p95:
|
||||
- Error rate:
|
||||
- Slowest endpoint:
|
||||
- Cache or DB notes:
|
||||
- Raw results location:
|
||||
- Conclusion:
|
||||
- Next action:
|
||||
Date/time, Environment, Commit SHA/image tag, Test URL, Tool used, Scenario duration, Concurrency/virtual users, `/api/admin/overview|analytics|chat-log|users` p95, Error rate, Slowest endpoint, Cache or DB notes, Raw results location, Conclusion, Next action.
|
||||
|
||||
## 5) Cache Hit Ratio Tracking Plan
|
||||
|
||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||
|
||||
Implementation outline:
|
||||
|
||||
Implementation:
|
||||
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||
2. Export to Prometheus-compatible metric path.
|
||||
3. Add Grafana panel with 5m/1h/24h windows.
|
||||
2. Export to a Prometheus-compatible metric path.
|
||||
3. Add a Grafana panel with 5m/1h/24h windows.
|
||||
4. Alert when hit ratio < 90% for 15m.
|
||||
|
||||
Reference exporter:
|
||||
|
||||
- `scripts/cache-hit-ratio.js`
|
||||
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
||||
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
||||
- Windows example: `pwsh -File scripts/cache-hit-ratio.ps1`
|
||||
- `scripts/cache-hit-ratio.sh` for scheduled tasks / manual runs
|
||||
- Example run: `export CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv; node scripts/cache-hit-ratio.js`
|
||||
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||
|
||||
Metric target:
|
||||
|
||||
- `cache_hit_ratio >= 0.90`
|
||||
Metric target: `cache_hit_ratio >= 0.90`.
|
||||
|
||||
### Cache Hit Ratio Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Hostname:
|
||||
- Window minutes:
|
||||
- Cloudflare zone tag:
|
||||
- Export command:
|
||||
- Raw exporter output location:
|
||||
- Hit requests:
|
||||
- Total requests:
|
||||
- Cache hit ratio:
|
||||
- Threshold met:
|
||||
- Notes:
|
||||
Date/time, Environment, Commit SHA/image tag, Hostname, Window minutes, Cloudflare zone tag, Export command, Raw exporter output location, Hit requests, Total requests, Cache hit ratio, Threshold met, Notes.
|
||||
|
||||
## 6) Capacity Trigger Rules
|
||||
|
||||
Define operational triggers:
|
||||
|
||||
Operational triggers:
|
||||
- Cache miss ratio > 10% for 15m
|
||||
- Chat p95 latency > 1s for 5m
|
||||
- Host CPU > 80% for 10m
|
||||
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||
|
||||
Action mapping:
|
||||
|
||||
- Level 1: investigate + restart non-critical components
|
||||
- Level 2: scale service resources (CPU/RAM)
|
||||
- Level 3: activate standby planning path
|
||||
|
||||
### Capacity Trigger Review
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger hit:
|
||||
- Threshold value:
|
||||
- Observed value:
|
||||
- Duration above threshold:
|
||||
- Level selected:
|
||||
- Immediate action taken:
|
||||
- Services involved:
|
||||
- Follow-up owner:
|
||||
- Raw metrics / alert link:
|
||||
- Notes:
|
||||
Date/time, Environment, Commit SHA/image tag, Trigger hit, Threshold value, Observed value, Duration above threshold, Level selected, Immediate action taken, Services involved, Follow-up owner, Raw metrics/alert link, Notes.
|
||||
|
||||
## 7) Phase 2 Service Separation Plan
|
||||
|
||||
Objective: separate bottlenecked services while keeping simple ops.
|
||||
Objective: separate bottlenecked services while keeping ops simple.
|
||||
|
||||
Boundaries:
|
||||
|
||||
- `playout` isolated first (CPU-heavy)
|
||||
- `chat` + `redis` grouped for latency
|
||||
- `db` isolated for IO stability
|
||||
- monitoring stack optionally separated after sustained load
|
||||
|
||||
Initial sizing guidance:
|
||||
|
||||
Initial sizing:
|
||||
- playout host: 4 vCPU / 8 GB
|
||||
- chat host: 2-4 vCPU / 4-8 GB
|
||||
- chat host: 2–4 vCPU / 4–8 GB
|
||||
- db host: 2 vCPU / 4 GB + fast disk
|
||||
|
||||
Exit criteria for Phase 2:
|
||||
|
||||
- repeated trigger events over 7 days
|
||||
- capacity saturation despite tuning
|
||||
Exit criteria: repeated trigger events over 7 days; capacity saturation despite tuning.
|
||||
|
||||
### Phase 2 Boundary Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Trigger history summary:
|
||||
- Repeated trigger count over 7 days:
|
||||
- Primary bottleneck:
|
||||
- Proposed split:
|
||||
- `playout` target host size:
|
||||
- `chat` target host size:
|
||||
- `db` target host size:
|
||||
- Monitoring separation needed:
|
||||
- Data/state migration notes:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Next action:
|
||||
Date/time, Environment, Commit SHA/image tag, Trigger history summary, Repeated trigger count over 7 days, Primary bottleneck, Proposed split, `playout`/`chat`/`db` target host size, Monitoring separation needed, Data/state migration notes, Implementation owner, Review decision, Next action.
|
||||
|
||||
## 8) Phase 3 Warm Standby Plan
|
||||
|
||||
Objective: second-region resilience with DNS failover.
|
||||
|
||||
Candidate layout:
|
||||
|
||||
Layout:
|
||||
- Primary: Hetzner Ashburn (US-East)
|
||||
- Standby: Hetzner Hillsboro (US-West)
|
||||
|
||||
Design:
|
||||
|
||||
- Warm standby stack deployed and health-checked
|
||||
- Redis replication or fallback mode documented
|
||||
- Postgres replication strategy selected (streaming replica or periodic snapshot restore)
|
||||
- Cloudflare DNS failover policy defined (health-based or manual emergency switch)
|
||||
|
||||
Failover drill requirements:
|
||||
Design: warm standby stack deployed and health-checked; Redis replication or fallback mode documented; Postgres replication strategy selected (streaming replica or periodic snapshot restore); Cloudflare DNS failover policy defined (health-based or manual emergency switch).
|
||||
|
||||
### Phase 3 Warm Standby Worksheet
|
||||
|
||||
- Date/time:
|
||||
- Environment:
|
||||
- Commit SHA / image tag:
|
||||
- Primary region:
|
||||
- Standby region:
|
||||
- DNS failover mode:
|
||||
- Redis strategy:
|
||||
- Postgres strategy:
|
||||
- Health check endpoints:
|
||||
- Simulated cutover performed:
|
||||
- Measured RTO:
|
||||
- Measured RPO:
|
||||
- Open risks:
|
||||
- Implementation owner:
|
||||
- Review decision:
|
||||
- Notes:
|
||||
Date/time, Environment, Commit SHA/image tag, Primary region, Standby region, DNS failover mode, Redis strategy, Postgres strategy, Health check endpoints, Simulated cutover performed, Measured RTO, Measured RPO, Open risks, Implementation owner, Review decision, Notes.
|
||||
|
||||
## 9) Data Capture Template
|
||||
|
||||
For each benchmark run, capture:
|
||||
|
||||
- Date/time
|
||||
- Environment (test-lab/staging/production)
|
||||
- Commit SHA / image tag
|
||||
- Test scenario and duration
|
||||
- Raw metrics (latency, errors, resource usage)
|
||||
- Conclusion and next action
|
||||
For each benchmark run capture: Date/time, Environment (test-lab/staging/production), Commit SHA/image tag, Test scenario and duration, Raw metrics (latency, errors, resource usage), Conclusion and next action.
|
||||
|
||||
## 10) Execution Order
|
||||
|
||||
1. TTFB baseline
|
||||
2. WebSocket load profile
|
||||
3. Admin API benchmark
|
||||
@@ -349,14 +160,9 @@ For each benchmark run, capture:
|
||||
7. Phase 3 standby design review
|
||||
|
||||
## 11) Benchmark Run Sheet
|
||||
Use the same worksheet for every baseline/re-test so results stay comparable.
|
||||
|
||||
Use the same worksheet for every baseline or re-test so results stay comparable.
|
||||
|
||||
Preconditions:
|
||||
|
||||
- Confirm the target environment and commit SHA/image tag.
|
||||
- Confirm Cloudflare cache rules are active when testing TTFB or manifest TTL.
|
||||
- Confirm the chat, admin, and playout services are healthy before load testing.
|
||||
Preconditions: confirm target environment and commit SHA/image tag; confirm Cloudflare cache rules are active when testing TTFB or manifest TTL; confirm chat, admin, and playout services are healthy before load testing.
|
||||
|
||||
Run template:
|
||||
|
||||
@@ -373,70 +179,47 @@ Run template:
|
||||
| Next action | |
|
||||
|
||||
Suggested command mapping:
|
||||
|
||||
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
||||
- WebSocket profile: `npx artillery run scripts/loadtest/ws-chat.yml` or the Go harness referenced above.
|
||||
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||
|
||||
## 12) Scaling Decision Gate
|
||||
|
||||
Treat the following as the default review gate after any benchmark batch:
|
||||
|
||||
Default review gate after any benchmark batch:
|
||||
1. If warm-cache TTFB meets target but cache hit ratio falls below 90%, tune cache behavior before increasing capacity.
|
||||
2. If chat or admin latency exceeds target while CPU and memory remain low, prioritize query or handler optimization.
|
||||
3. If CPU, memory, or Redis saturation appears first, treat the service boundary guidance in Phase 2 as the next action.
|
||||
3. If CPU, memory, or Redis saturation appears first, treat the Phase 2 service boundary guidance as the next action.
|
||||
4. If the same threshold trips on multiple runs across 7 days, open the Phase 2 or Phase 3 planning path instead of tuning in place.
|
||||
|
||||
Decision note:
|
||||
|
||||
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||
Decision note: keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||
|
||||
## 13) Artifact-to-Worksheet Field Mapping
|
||||
|
||||
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||
Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||
|
||||
### WebSocket worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/ws-chat-report.json`
|
||||
- `artifacts/<timestamp>/ws-chat-summary.txt`
|
||||
Artifact sources: `artifacts/<timestamp>/ws-chat-report.json`, `artifacts/<timestamp>/ws-chat-summary.txt`.
|
||||
|
||||
Field mapping:
|
||||
- `Tool used` → `artillery` (or `scripts/loadtest/run-loadtests.sh -s ws`)
|
||||
- `Raw results location` → artifact folder path for this run
|
||||
- `Concurrency tiers reached` → `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||
- `Error rate` → `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||
- `Dropped connections` → derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||
- `p95 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `p95=...`
|
||||
- `p50 message latency` → first latency line under `summary.*` in `ws-chat-summary.txt` containing `median=...`
|
||||
|
||||
- Worksheet `Tool used` -> `artillery` (or `run-loadtests.ps1 -Scenario ws`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `Concurrency tiers reached` -> `ws-chat.yml` phases (`warmup-10`, `sustain-100`, `sustain-500`, `sustain-1000`)
|
||||
- Worksheet `Error rate` -> `vusers.failure_rate_percent` from `ws-chat-summary.txt`
|
||||
- Worksheet `Dropped connections` -> derive from `vusers.created - vusers.completed` (use `vusers.failed` as failure signal)
|
||||
- Worksheet `p95 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `p95=...`
|
||||
- Worksheet `p50 message latency` -> first available latency line under `summary.*` in `ws-chat-summary.txt` that includes `median=...`
|
||||
|
||||
Notes:
|
||||
|
||||
- The exact `summary.*` metric names in Artillery output can vary by engine/plugins.
|
||||
- If no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||
Notes: the exact `summary.*` metric names in Artillery output can vary by engine/plugins; if no latency summary field is emitted, record this explicitly and use server-side latency metrics as fallback evidence.
|
||||
|
||||
### Admin API worksheet mapping
|
||||
|
||||
Artifact sources:
|
||||
|
||||
- `artifacts/<timestamp>/admin-api-summary.json`
|
||||
- `artifacts/<timestamp>/admin-api-output.txt`
|
||||
- `artifacts/<timestamp>/admin-api-endpoint-summary.txt`
|
||||
Artifact sources: `artifacts/<timestamp>/admin-api-summary.json`, `artifacts/<timestamp>/admin-api-output.txt`, `artifacts/<timestamp>/admin-api-endpoint-summary.txt`.
|
||||
|
||||
Field mapping:
|
||||
- `Tool used` → `k6` (or `scripts/loadtest/run-loadtests.sh -s admin`)
|
||||
- `Raw results location` → artifact folder path for this run
|
||||
- `/api/admin/overview|analytics|chat-log|users` p95 → `overview|analytics|chat_log|users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- `Error rate` → `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||
- `Slowest endpoint` → endpoint row with highest `P95Ms`
|
||||
|
||||
- Worksheet `Tool used` -> `k6` (or `run-loadtests.ps1 -Scenario admin`)
|
||||
- Worksheet `Raw results location` -> artifact folder path for this run
|
||||
- Worksheet `/api/admin/overview` p95 -> `overview` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/analytics` p95 -> `analytics` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/chat-log` p95 -> `chat_log` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `/api/admin/users` p95 -> `users` row `P95Ms` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Error rate` -> `Global failure rate` in `admin-api-endpoint-summary.txt`
|
||||
- Worksheet `Slowest endpoint` -> endpoint row with highest `P95Ms`
|
||||
Notes: keep the raw `admin-api-summary.json` file for audits and re-parsing; if an endpoint row is missing, treat it as a test/data issue and re-run with full endpoint coverage.
|
||||
|
||||
Notes:
|
||||
|
||||
- Keep the raw `admin-api-summary.json` file for audits and re-parsing.
|
||||
- If an endpoint row is missing, treat as test/data issue and re-run with full endpoint coverage.
|
||||
Summary helper (bash): run `scripts/loadtest/summarize-artillery.sh <report.json>` after an Artillery WS run, and `scripts/loadtest/summarize-k6.sh <summary.json>` after a k6 admin run, to produce the artifact files used in the mappings above.
|
||||
Reference in new issue
Block a user