Compare commits
24
Commits
cef002862e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8cf853842 | ||
|
|
8c5e70959b | ||
|
|
5f1f796a20 | ||
|
|
11d48c8e0d | ||
|
|
242fc8da67 | ||
|
|
33b6296c5e | ||
|
|
9f60087a4c | ||
|
|
474da7418e | ||
|
|
d267173e30 | ||
|
|
e7bff8b9c0 | ||
|
|
9a0511b36b | ||
|
|
c2f9f9ed62 | ||
|
|
bde3dc3a46 | ||
|
|
21a70dcf0f | ||
|
|
4fe09a0a3d | ||
|
|
85454610f4 | ||
|
|
5585a6ecc3 | ||
|
|
53a0c3c825 | ||
|
|
9e56ff7859 | ||
|
|
9ac1a8b79d | ||
|
|
6950bba85d | ||
|
|
06b277e9c1 | ||
|
|
1cc44282bc | ||
|
|
b6705e6dbd |
No files matched your search
+5
-3
@@ -8,7 +8,8 @@ R2_SECRET_ACCESS_KEY=your-r2-secret-key
|
||||
R2_BUCKET_NAME=linearvoid-media
|
||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||
R2_REGION=auto
|
||||
R2_SEGMENT_RETENTION_HOURS=24
|
||||
R2_KEEP_RUNS=2
|
||||
R2_MAX_RETENTION_BYTES=10737418240
|
||||
|
||||
# Cloudflare (for custom domain, cache rules, Workers if used)
|
||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
||||
@@ -48,9 +49,10 @@ DATABASE_URL=postgres://linearvoid:linearvoid@db:5432/linearvoid?sslmode=disable
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# Service Ports (for local docker-compose)
|
||||
PLAYOUT_PORT=8080
|
||||
# Coolify env: 80/443 (Traefik proxy), 8000 (dashboard/Portainer), 8080 taken.
|
||||
PLAYOUT_PORT=8081
|
||||
CHAT_PORT=3000
|
||||
NGINX_PORT=80
|
||||
NGINX_PORT=8082
|
||||
|
||||
# Playout configuration
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
|
||||
@@ -39,8 +39,8 @@ jobs:
|
||||
|
||||
# Playout
|
||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||
HLS_SEGMENT_DURATION=6
|
||||
HLS_WINDOW_SIZE=24
|
||||
HLS_SEGMENT_DURATION=2
|
||||
HLS_WINDOW_SIZE=12
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Cloudflare R2 (required for HLS segment sync)
|
||||
@@ -69,7 +69,8 @@ jobs:
|
||||
ADMIN_SESSION_TTL_HOURS=12
|
||||
|
||||
# Nginx
|
||||
NGINX_PORT=80
|
||||
# Host port 80/443 (Coolify Traefik) and 8080 are taken; nginx publishes on 8082.
|
||||
NGINX_PORT=8082
|
||||
EOF
|
||||
|
||||
- name: Seed Prometheus config on remote host
|
||||
@@ -245,7 +246,7 @@ jobs:
|
||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||
run: |
|
||||
# Wait for services to stabilize
|
||||
sleep 20
|
||||
sleep 30
|
||||
|
||||
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||
echo "Checking chat health..."
|
||||
@@ -270,8 +271,8 @@ jobs:
|
||||
echo "Playout is healthy"
|
||||
break
|
||||
fi
|
||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
||||
sleep 5
|
||||
echo "Playout not ready yet (attempt $i/6), waiting 20s..."
|
||||
sleep 20
|
||||
done
|
||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||
echo "Playout failed to become healthy after 6 attempts"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Generate deployment manifest
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
echo "Form arguments: $form_args"
|
||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||
# Get short commit hash for deployment metadata
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null | head -1 || echo "Gitea Actions deploy")
|
||||
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-F "commit_hash=${COMMIT_HASH}" \
|
||||
-F "commit_message=${COMMIT_MSG}" \
|
||||
-F "commit_dirty=false" \
|
||||
$form_args
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend
|
||||
npx wrangler deploy
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Deploy Media Worker to Cloudflare
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Production deploy: only on pushes to main (via CI success) or manual main runs.
|
||||
if: ${{ (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd infrastructure/cloudflare && npm ci
|
||||
|
||||
- name: Test worker
|
||||
run: cd infrastructure/cloudflare && npm test
|
||||
|
||||
- name: Deploy to Cloudflare Workers (production)
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd infrastructure/cloudflare
|
||||
npx wrangler deploy --env production
|
||||
+2
-2
@@ -109,7 +109,7 @@
|
||||
- Total: 45 frontend tests passing (2 test files).
|
||||
- Added coverage reporting: `vitest.config.ts` with v8 provider, thresholds at 20/15/15/20 (statements/branches/functions/lines).
|
||||
- Added `test:coverage` script to `package.json`.
|
||||
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `pages-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||
- Fixed CI: bumped Node from 20 to 22 in `ci.yml` and `workers-deploy.yml` (jsdom@30 requires Node 22+). Added `engines` to `package.json`.
|
||||
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||
- All Go vet, Go tests, frontend test:coverage passing.
|
||||
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||
@@ -138,7 +138,7 @@
|
||||
## 2026-08-13
|
||||
|
||||
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
||||
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldSegments`, `R2_SEGMENT_RETENTION_HOURS`); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||
- **Playout**: automatic media normalization on upload (`media.NormalizeVideo`, `*.normalized.mp4`, statuses `ready`/`needs_normalization`/`normalizing`/`normalization_failed`, `POST /api/admin/media/{id}/normalize`); R2 segment retention (`cleanupOldRuns`, keep last `R2_KEEP_RUNS` runs, `R2_MAX_RETENTION_BYTES` cap); seamless looping (playlist SHA-256 fingerprint + `-start_number`); single `start-playout.sh` entrypoint + `-validate` mode; loop/cleanup metrics.
|
||||
- **Application configuration**: thread-safe JSON runtime config store (`backend/chat/src/config/runtime.go`) shared with playout via `/app/playlist/config.json`; `GET/PUT /api/admin/config`; admin UI config form.
|
||||
- **Matomo analytics**: public `GET /api/matomo` + viewer pageview snippet in `index.html`.
|
||||
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||
|
||||
@@ -2,12 +2,66 @@
|
||||
|
||||
> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here.
|
||||
|
||||
## Setup phases remaining
|
||||
|
||||
### Phase 5: complete manual Cloudflare and external-service setup
|
||||
|
||||
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
|
||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
|
||||
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
|
||||
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
|
||||
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
|
||||
### Phase 6: rehearse production deployment end to end
|
||||
|
||||
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||
- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
|
||||
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||
- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password)
|
||||
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS
|
||||
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||
|
||||
### Phase 7: baseline scaling and performance
|
||||
|
||||
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
|
||||
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
|
||||
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
|
||||
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
|
||||
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
|
||||
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
|
||||
|
||||
### Phase 8: close out remaining test and validation gaps
|
||||
|
||||
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
|
||||
- [ ] Frontend: coverage for all critical user + admin components and interactions
|
||||
- [ ] Frontend: document test cases and expected outcomes
|
||||
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
|
||||
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
|
||||
|
||||
### Playout
|
||||
|
||||
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
|
||||
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
|
||||
|
||||
## Cloudflare Configuration
|
||||
|
||||
### DNS & Pages
|
||||
### DNS & Workers
|
||||
|
||||
- [ ] Cloudflare Pages: link custom domain (manual)
|
||||
- [ ] Create Cloudflare Pages project and cut over frontend hosting (manual)
|
||||
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||
|
||||
### Security & TLS
|
||||
|
||||
@@ -27,28 +81,6 @@
|
||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword: `#EXTM3U`, 5min)
|
||||
- [ ] Alert on 2 consecutive failures, notify via email + Discord/Slack webhook
|
||||
|
||||
## Frontend Tests
|
||||
|
||||
### User Frontend
|
||||
|
||||
- [ ] Test coverage for all critical frontend components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
### Admin Frontend
|
||||
|
||||
- [ ] Test coverage for all critical admin UI components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
## Backend Integration Tests
|
||||
|
||||
### Cloudflare R2
|
||||
|
||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||
|
||||
## External Services
|
||||
|
||||
> Evidence tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
@@ -102,12 +134,3 @@
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||
- [ ] Plan Phase 2 service separation — document boundaries (playout CPU, chat latency, DB state, monitoring), estimate per-service resources
|
||||
- [ ] Plan Phase 3 multi-origin resilience — research second Hetzner location, design warm standby + DNS failover + DB/Redis replication
|
||||
|
||||
## Feature List (Future Enhancements)
|
||||
|
||||
- [ ] Admin backend
|
||||
- [ ] Analytics: implement a settings entry to set matomo url and page id
|
||||
- [ ] User frontend
|
||||
- [ ] Chat: add user registration and login flows
|
||||
- [ ] Chat: private DM history and search
|
||||
- [ ] Analytics: add javascript snippet to send pageview events to matomo
|
||||
+51
-1157
File diff suppressed because it is too large.
Load diff
@@ -2,66 +2,38 @@
|
||||
|
||||
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||
|
||||
## Chat improvements (remaining)
|
||||
## Playout / R2 manifest–segment layout mismatch
|
||||
|
||||
- [ ] Manual smoke: viewer join/leave shows a 3s flash (not a chat row); reconnect <5s shows no duplicate; admin chat-log table has no system rows
|
||||
**Chosen approach: candidate (a)** — rewrite the live manifest so its media URIs point into the current run-scoped folder before the atomic R2 publish. Go change in `backend/playout/src/playlist.go`. The `linearvoid-media` Worker stays a faithful path→R2 mapper (no serve-time rewrite).
|
||||
|
||||
## Playout improvements (remaining)
|
||||
### Remaining — Validation
|
||||
|
||||
- [ ] Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
||||
- [ ] Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
||||
- [ ] Manual: verify viewer latency and cache-hit behavior after the change
|
||||
- [ ] Run playout (local or test lab), then:
|
||||
- `curl https://media.linearvoid.tv/hls/playlist.m3u8` → 200, short TTL.
|
||||
- Every URI in the manifest resolves `200` with `Cache-Control: public, max-age=31536000, immutable` (via the `linearvoid-media` Worker).
|
||||
- Confirm manifest refresh + `CF-Cache-Status` progression and no 404 on segment fetch across a run loop.
|
||||
- [ ] Verify slate fallback still serves correctly after the rewrite change.
|
||||
|
||||
## Setup phases remaining
|
||||
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||
|
||||
### Phase 5: complete manual Cloudflare and external-service setup
|
||||
## Frontend Tests
|
||||
|
||||
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||
- [ ] Cloudflare Pages: link custom domain and cut over frontend hosting
|
||||
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
||||
- [ ] WAF: enable OWASP Managed Ruleset (Simulate → review 48h → Block), enable Bot Fight Mode
|
||||
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||
- [ ] HTTPS/WSS: Always Use HTTPS, HSTS max-age=31536000 includeSubDomains preload, min TLS 1.2; enforce at edge and origin
|
||||
- [ ] UptimeRobot: add HTTP monitor for `https://linearvoid.tv/health` (5min interval)
|
||||
- [ ] UptimeRobot: add keyword monitor for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5min)
|
||||
- [ ] UptimeRobot: alert on 2 consecutive failures via email + Discord/Slack webhook
|
||||
- [ ] Capture evidence in `docs/operations/cloudflare-uptimerobot-checklist.md`
|
||||
### User Frontend
|
||||
|
||||
### Phase 6: rehearse production deployment end to end
|
||||
- [ ] Test coverage for all critical frontend components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
- [ ] Provision Hetzner CX22 (2 vCPU, 4 GB, 40 GB) in Ashburn VA, Ubuntu 24.04
|
||||
- [ ] Install Docker Engine + Compose plugin; open firewall: 80/443 public, 2375 runner-only
|
||||
- [ ] Configure Docker daemon for remote TLS access (`-H tcp://0.0.0.0:2376` with certs)
|
||||
- [ ] Create `/srv/linearvoid/data/{playlist,media,hls,chat,slate}` directories
|
||||
- [ ] Add `CI_PROD_*` secrets to Gitea (DOCKER_HOST, R2 keys, admin password, DB password, Grafana password)
|
||||
- [ ] Trigger CD workflow with `DOCKER_HOST` pointing to Hetzner IP
|
||||
- [ ] Verify all 11 containers healthy: `/health`, `/hls/playlist.m3u8`, WebSocket
|
||||
- [ ] Update Cloudflare A record `linearvoid.tv` → Hetzner IP, SSL Full (strict); test HTTPS + WSS
|
||||
- [ ] Smoke test: viewer SPA loads, HLS plays, chat connects, admin login → upload → playlist → playout
|
||||
- [ ] Set up Prometheus alerting notifications + UptimeRobot on production URL
|
||||
- [ ] Run `scripts/backup-db.sh` + `scripts/verify-backups.sh` on production host
|
||||
- [ ] Document production IP, SSH access, rollback procedure, scaling thresholds
|
||||
### Admin Frontend
|
||||
|
||||
### Phase 7: baseline scaling and performance
|
||||
- [ ] Test coverage for all critical admin UI components and interactions
|
||||
- [ ] Add code coverage reporting and enforce minimum coverage threshold in CI/CD pipeline
|
||||
- [ ] Document test cases and expected outcomes
|
||||
|
||||
- [ ] Execute WebSocket load tests — ramp 10→100→500→1000 connections; measure p95 latency + errors
|
||||
- [ ] Execute admin API benchmarks — `wrk`/`k6` against `/api/admin/*`; target p95 <1s
|
||||
- [ ] Measure manifest TTFB and cache-hit distribution from multiple US regions (WebPageTest 3+ regions, <4s warm cache)
|
||||
- [ ] Tune manifest TTL to 0.5× segment duration; validate `Cf-Cache-Status` distribution
|
||||
- [ ] Track cache-hit ratio and R2 ops in production (Cloudflare GraphQL → Grafana panel, alert <90%)
|
||||
- [ ] Capacity triggers: Prometheus rules for cache-miss >10%/15min, CPU >80%/10min
|
||||
- [ ] Plan Phase 2 service separation (playout CPU, chat latency, DB state, monitoring)
|
||||
- [ ] Plan Phase 3 multi-origin resilience (second Hetzner location, warm standby, DNS failover, DB/Redis replication)
|
||||
## Backend Integration Tests
|
||||
|
||||
### Phase 8: close out remaining test and validation gaps
|
||||
### Cloudflare R2
|
||||
|
||||
- [ ] Admin frontend: user moderation tools tests (mute/ban/report, view moderation logs)
|
||||
- [ ] Frontend: coverage for all critical user + admin components and interactions
|
||||
- [ ] Frontend: document test cases and expected outcomes
|
||||
- [ ] Backend R2: test retrieval of segments/manifests via Cloudflare edge
|
||||
- [ ] Backend R2: validate edge cache hit/miss behavior with repeat requests (`scripts/validate-edge-cache.sh`)
|
||||
|
||||
### Playout
|
||||
|
||||
- [ ] Configure an R2 lifecycle rule on the `hls/` prefix as defense-in-depth (manual)
|
||||
- [ ] Full-loop E2E (2-file playlist, assert `Looping...` with no `CRASHED` across the boundary) — manual via `scripts/test-playout-e2e.sh`
|
||||
- [ ] Test retrieval of segments and manifests from R2 via Cloudflare edge
|
||||
- [ ] Validate cache hit/miss behavior and performance metrics
|
||||
- [ ] Validate edge cache hit/miss behavior with repeat requests — script `scripts/validate-edge-cache.sh` created
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"printWidth": 120,
|
||||
"endOfLine": "lf",
|
||||
"proseWrap": "preserve"
|
||||
}
|
||||
+20
-9
@@ -2,24 +2,35 @@
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
## [Unreleased]
|
||||
## [1.1.0] - 2026-09-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Transient presence flash**: Join/leave announcements now appear as a 3-second flash at the top of the chat panel (`#chat-flash`) with a fade-out, instead of permanent chat rows. Presence indicators still update from the same events.
|
||||
- **Cloudflare media edge worker (`linearvoid-media`)**: new self-contained project under `infrastructure/cloudflare/` — an R2-bound Worker owning `media.linearvoid.tv` that enforces immutable segment caching (`public, max-age=31536000, immutable`) and short-TTL live `.m3u8`, with HLS CORS/OPTIONS, byte-Range, and a `/health` probe. Includes Vitest unit tests + Miniflare/workerd R2 integration tests and production deploy via `.gitea/workflows/workers-media-deploy.yml`. Replaces the ad-hoc `worker.js`.
|
||||
- **Bash script ports**: Linux/macOS equivalents of the Windows ops/load-test scripts (`cache-hit-ratio`, `check-env`, `check-tokens`, loadtest run/summarize); `.ps1` files retained.
|
||||
- **Transient presence flash**: join/leave events show as a 3-second `#chat-flash` instead of permanent chat rows.
|
||||
|
||||
### Changed
|
||||
|
||||
- **System messages no longer persisted**: The chat backend no longer stores `system`-type messages; chat history and the admin chat-log table only contain real chat messages (moderation actions remain in the audit log).
|
||||
- **Reconnect grace period**: Disconnects schedule a delayed "left" announcement (5s); a reconnect within 5s cancels it and skips "joined", eliminating join/leave spam on brief reconnects.
|
||||
- **HLS segment duration 2s**: Default `HLS_SEGMENT_DURATION` changed from 6 to 2 and `HLS_WINDOW_SIZE` from 24 to 12 (2s × 12 = 24s live window), reducing end-to-end latency; the 2s GOP was already in place.
|
||||
- **Chat font sizing**: `.message-text` set to `0.95rem` (slightly smaller than the default, larger than the `0.83rem` system/flash text).
|
||||
- **Cloudflare Workers hosting**: frontend moved from Cloudflare Pages to the `linearvoid-frontend` static-assets Worker; docs now describe `linearvoid-frontend` (SPA) and `linearvoid-media` (edge HLS), correcting stale `worker.js` and R2 custom-domain wording.
|
||||
- **Docs de-Windows'd & condensed**: bash-first guides for `SETUP`, `DEPLOYMENT`, `API`, `MONITORING`, `STREAMING`, `COOLIFY`, incident runbook, scaling playbook, architecture README, and Prometheus metrics inventory.
|
||||
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||
- **Playout**: R2 retention now by run count + byte cap (`R2_KEEP_RUNS` default 2, `R2_MAX_RETENTION_BYTES` default 10 GiB, new gauges) replacing the 24h time-based cleanup; default `HLS_SEGMENT_DURATION` 2s / `HLS_WINDOW_SIZE` 12; deduplicated, concurrent startup validation.
|
||||
- **Chat/UX**: 5s reconnect grace window (no join/leave spam); `system` messages no longer persisted; smaller `.message-text`; sidebar cleanup (removed DM search + Users toggle, login/register now a modal).
|
||||
- **Auth**: viewer session cookie uses `SameSite=None; Secure` over HTTPS (cross-site SPA) and `Lax` over plain HTTP.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Run-scoped R2 manifest URIs (playout)**: `publishManifest` now rewrites bare relative media URIs to the current run folder (`r-{runID}/…`) via `rewriteManifestToRun` before the atomic `.tmp` → `CopyObject` publish, so the live `hls/playlist.m3u8` references resolve against the run-scoped segment keys on `media.linearvoid.tv`. Skipped in slate mode. (Live validation pending — see `TODO.md`.)
|
||||
- **Cross-origin auth**: login/register API calls now prefix `VITE_PUBLIC_APP_URL` so the Workers-served SPA reaches the app origin.
|
||||
- **Duplicate chat history**: `connectWebSocket` clears messages on each (re)connect and the initial connect waits for session resolution.
|
||||
|
||||
### Tests
|
||||
|
||||
- WebSocket: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`.
|
||||
- Handlers: `TestHandleAdminChatLog_ExcludesSystem`.
|
||||
- Frontend: `presence flash` test.
|
||||
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||
- Playout (run-scoped manifest rewrite): `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||
- Cloudflare media worker: Vitest unit (`cacheControlForPath`, `contentTypeForPath`, `parseByteRange`) + Miniflare R2 integration (immutable segment, short-TTL manifest, Range `206`, `404`, OPTIONS, method guard).
|
||||
|
||||
## [1.0.13] - 2026-08-13
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||
[](https://git.allucanget.biz)
|
||||
[](LICENSE)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||
[](CHANGELOG.md)
|
||||
|
||||
## Architecture
|
||||
@@ -187,7 +187,8 @@ frontend/
|
||||
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||
|
||||
- Cloudflare
|
||||
- [worker.js](infrastructure/cloudflare/worker.js) — adds immutable cache headers to HLS segments
|
||||
- [linearvoid-media worker](infrastructure/cloudflare/) — R2-bound edge Worker serving HLS on `media.linearvoid.tv` (immutable segment caching, CORS, Range)
|
||||
- [linearvoid-frontend worker](frontend/wrangler.toml) — serves the static SPA on `linearvoid.tv`
|
||||
- Nginx
|
||||
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
||||
@@ -213,11 +214,13 @@ One-off and CI-friendly scripts for video preparation, R2 sync, backups, and pla
|
||||
|
||||
- loadtest/
|
||||
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
|
||||
- [run-loadtests.ps1](scripts/loadtest/run-loadtests.ps1) — PowerShell script to run load tests
|
||||
- [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results
|
||||
- [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test results
|
||||
- [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
|
||||
- [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
|
||||
- [summarize-k6.sh](scripts/loadtest/summarize-k6.sh) — summarize k6 JSON results
|
||||
- [ws-chat.yml](scripts/loadtest/ws-chat.yml) — WebSocket chat load test configuration
|
||||
|
||||
PowerShell equivalents (`*.ps1`) of the three load-test scripts are kept alongside them.
|
||||
|
||||
## Getting Started
|
||||
|
||||
> **Note:** The `docker compose up` below targets local development. The test lab (192.168.88.40) receives automated CD deploys. Production Hetzner VPS is not yet provisioned — see `DEPLOYMENT.md`.
|
||||
@@ -262,8 +265,8 @@ See the [architecture docs](docs/architecture/README.md) for detailed architectu
|
||||
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
||||
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
||||
| `HLS_SEGMENT_DURATION` | `2` | HLS segment duration in seconds |
|
||||
| `HLS_WINDOW_SIZE` | `12` | HLS playlist window size |
|
||||
|
||||
Full reference: [`.env.example`](.env.example).
|
||||
|
||||
|
||||
@@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool {
|
||||
return r.TLS != nil
|
||||
}
|
||||
|
||||
// SetCookie writes the admin session cookie.
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
||||
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
|
||||
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
|
||||
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
|
||||
func SameSiteForUserCookie(r *http.Request) http.SameSite {
|
||||
if CookieSecure(r) {
|
||||
return http.SameSiteNoneMode
|
||||
}
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
|
||||
// SetCookie writes a session cookie with the given SameSite policy.
|
||||
// SameSite=None is only valid together with Secure, which is enforced here.
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
|
||||
secure := CookieSecure(r)
|
||||
if sameSite == http.SameSiteNoneMode {
|
||||
secure = true
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
SameSite: sameSite,
|
||||
Secure: secure,
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie removes the admin session cookie.
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
||||
// ClearCookie removes a session cookie with the given SameSite policy.
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
|
||||
secure := CookieSecure(r)
|
||||
if sameSite == http.SameSiteNoneMode {
|
||||
secure = true
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
SameSite: sameSite,
|
||||
Secure: secure,
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt)
|
||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
@@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
ClearCookie(w, req, "test_cookie")
|
||||
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
@@ -183,6 +183,38 @@ func TestClearCookie(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSiteForUserCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
|
||||
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
|
||||
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetCookie_NoneForcesSecure(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
w := httptest.NewRecorder()
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.SameSite != http.SameSiteNoneMode {
|
||||
t.Errorf("cookie SameSite = %v, want None", c.SameSite)
|
||||
}
|
||||
if !c.Secure {
|
||||
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenFromCookie(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||
|
||||
@@ -291,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
|
||||
|
||||
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
||||
deps.SessionStore.Add(token, expiresAt)
|
||||
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt)
|
||||
auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt, http.SameSiteStrictMode)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
|
||||
@@ -309,7 +309,7 @@ func HandleAdminLogout(deps *Dependencies) http.HandlerFunc {
|
||||
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
|
||||
deps.SessionStore.Remove(token)
|
||||
}
|
||||
auth.ClearCookie(w, r, config.AdminSessionCookieName)
|
||||
auth.ClearCookie(w, r, config.AdminSessionCookieName, http.SameSiteStrictMode)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
|
||||
@@ -368,7 +368,7 @@ func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||
deps.UserSessions.Add(token, username, expiresAt)
|
||||
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
|
||||
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt, auth.SameSiteForUserCookie(r))
|
||||
}
|
||||
|
||||
// HandleUserRegister creates a new chat user and signs them in.
|
||||
@@ -474,7 +474,7 @@ func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
|
||||
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||
deps.UserSessions.Remove(token)
|
||||
}
|
||||
auth.ClearCookie(w, r, userSessionCookieName)
|
||||
auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
+337
-73
@@ -11,8 +11,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
@@ -41,6 +44,9 @@ var (
|
||||
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||
return client.DeleteObjects(context.TODO(), input)
|
||||
}
|
||||
copyObject = func(client *s3.Client, input *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
return client.CopyObject(context.TODO(), input)
|
||||
}
|
||||
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||
playoutMode = "starting" // "live", "slate", "degraded"
|
||||
restartCount = 0
|
||||
@@ -66,6 +72,10 @@ var (
|
||||
// R2 retention cleanup counter
|
||||
r2CleanupObjectsTotal int64
|
||||
|
||||
// R2 retention gauges
|
||||
r2RunsRetained int64
|
||||
r2BytesRetained int64
|
||||
|
||||
// Continuous playout loop metrics
|
||||
loopsTotal int64
|
||||
lastRunDuration time.Duration
|
||||
@@ -78,6 +88,10 @@ const (
|
||||
// Cache-Control header values for R2 uploads.
|
||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
||||
|
||||
// R2 retention policy.
|
||||
defaultR2KeepRuns = 2
|
||||
defaultR2MaxRetentionBytes = int64(10 * 1024 * 1024 * 1024) // 10 GiB
|
||||
)
|
||||
|
||||
// envWithDefault returns the value of the environment variable or the default if not set.
|
||||
@@ -174,6 +188,8 @@ func watchAndPublishSegments(ctx context.Context, hlsOutputDir string) {
|
||||
}
|
||||
|
||||
// publishManifest uploads the manifest atomically: temp key first, then copy to final key (STREAMING.md contract).
|
||||
// Bare media URIs in the manifest are rewritten to the run-scoped R2 layout
|
||||
// (r-{runID}/…) so that every referenced segment/init resolves on the media host.
|
||||
func publishManifest(localManifestPath string) error {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
@@ -181,13 +197,33 @@ func publishManifest(localManifestPath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(localManifestPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read manifest failed: %w", err)
|
||||
}
|
||||
|
||||
// Rewrite bare relative media URIs to the current run folder so the manifest
|
||||
// matches the run-scoped segment keys uploaded by watchAndPublishSegments /
|
||||
// syncLatestToR2 (candidate (a)). Slate mode is skipped: its manifest references
|
||||
// slate assets that are not published under the live run prefix.
|
||||
if playoutMode != "slate" {
|
||||
data = rewriteManifestToRun(runID, data)
|
||||
}
|
||||
|
||||
// Stage the (rewritten) manifest locally so the existing SyncToR2 path is reused.
|
||||
staging := localManifestPath + ".r2staging"
|
||||
if err := os.WriteFile(staging, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write staging manifest failed: %w", err)
|
||||
}
|
||||
defer os.Remove(staging)
|
||||
|
||||
// 1. Upload to temp key
|
||||
if err := SyncToR2(localManifestPath, "hls/playlist.m3u8.tmp"); err != nil {
|
||||
if err := SyncToR2(staging, "hls/playlist.m3u8.tmp"); err != nil {
|
||||
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||
}
|
||||
|
||||
// 2. Atomic copy to final key
|
||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
||||
_, err = copyObject(r2Client, &s3.CopyObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||
Key: aws.String("hls/playlist.m3u8"),
|
||||
@@ -199,6 +235,55 @@ func publishManifest(localManifestPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isMediaURI reports whether s names an HLS media file (a segment or init file).
|
||||
func isMediaURI(s string) bool {
|
||||
name := s
|
||||
if i := strings.IndexByte(name, '?'); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".m4s", ".ts", ".mp4", ".m4a":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rewriteManifestToRun prefixes bare relative media URIs in an HLS manifest with the
|
||||
// run folder, e.g. "seg-000001.m4s" -> "r-20260903T…/seg-000001.m4s" and
|
||||
// #EXT-X-MAP:URI="init.mp4" -> URI="r-20260903T…/init.mp4". This keeps the published
|
||||
// manifest consistent with the run-scoped R2 layout (STREAMING.md contract). Directives,
|
||||
// comments, blank lines, and URIs that already contain a path are left unchanged.
|
||||
// Returns data unchanged when runID is empty.
|
||||
func rewriteManifestToRun(runID string, data []byte) []byte {
|
||||
if runID == "" {
|
||||
return data
|
||||
}
|
||||
prefix := runID + "/"
|
||||
lines := strings.Split(string(data), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "#EXT-X-MAP:URI="):
|
||||
if open := strings.Index(trimmed, `URI="`); open >= 0 {
|
||||
rest := trimmed[open+len(`URI="`):]
|
||||
if closeIdx := strings.IndexByte(rest, '"'); closeIdx > 0 {
|
||||
uri := rest[:closeIdx]
|
||||
if uri != "" && !strings.Contains(uri, "/") {
|
||||
line = strings.Replace(line, `URI="`+uri+`"`, `URI="`+prefix+uri+`"`, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
case trimmed != "" && !strings.HasPrefix(trimmed, "#") && !strings.Contains(trimmed, "/") && isMediaURI(trimmed):
|
||||
out = append(out, prefix+trimmed)
|
||||
default:
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return []byte(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
file, err := os.Open(playlistPath)
|
||||
@@ -229,16 +314,32 @@ func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
||||
func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
v := VideoFile{Path: videoPath}
|
||||
var errors []string
|
||||
|
||||
// Run ffprobe
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
v.Validation = "ffprobe failed: " + err.Error()
|
||||
return v, fmt.Errorf("ffprobe: %w", err)
|
||||
// Run the stream/format probe and the keyframe probe concurrently: they are
|
||||
// independent, and the keyframe probe (-show_frames) is the expensive one.
|
||||
var streamOut []byte
|
||||
var streamErr error
|
||||
var keyframeErr error
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
||||
streamOut, streamErr = cmd.Output()
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
keyframeErr = validateKeyframeInterval(videoPath)
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if streamErr != nil {
|
||||
v.Validation = "ffprobe failed: " + streamErr.Error()
|
||||
return v, fmt.Errorf("ffprobe: %w", streamErr)
|
||||
}
|
||||
|
||||
var errors []string
|
||||
|
||||
// Parse JSON
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
@@ -256,7 +357,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
Duration string `json:"duration"`
|
||||
} `json:"format"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &probe); err != nil {
|
||||
if err := json.Unmarshal(streamOut, &probe); err != nil {
|
||||
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
||||
return v, fmt.Errorf("ffprobe json: %w", err)
|
||||
}
|
||||
@@ -317,8 +418,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||
}
|
||||
|
||||
// Validate keyframe interval <=2s (60 frames at 30fps)
|
||||
keyframeErr := validateKeyframeInterval(videoPath)
|
||||
// Keyframe interval check (probe already completed concurrently above).
|
||||
if keyframeErr != nil {
|
||||
errors = append(errors, keyframeErr.Error())
|
||||
}
|
||||
@@ -412,21 +512,62 @@ func ValidateVideoStrict(videoPath string) map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidatePlaylist runs normalization validation on all entries
|
||||
// validateWorkers returns the number of concurrent validation workers.
|
||||
func validateWorkers() int {
|
||||
n := runtime.NumCPU()
|
||||
if v := os.Getenv("PLAYOUT_VALIDATE_WORKERS"); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ValidatePlaylist runs normalization validation on all entries in parallel,
|
||||
// deduplicating repeated paths so each unique file is probed once.
|
||||
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||
videos, err := LoadPlaylist(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range videos {
|
||||
validated, err := ValidateVideo(videos[i].Path)
|
||||
videos[i] = validated
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: %s - %s\n", videos[i].Path, err)
|
||||
// Collect unique paths in order of first appearance.
|
||||
var order []string
|
||||
seen := make(map[string]bool, len(videos))
|
||||
for _, v := range videos {
|
||||
if !seen[v.Path] {
|
||||
seen[v.Path] = true
|
||||
order = append(order, v.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate unique paths concurrently with a bounded worker pool.
|
||||
results := make(map[string]VideoFile, len(order))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, validateWorkers())
|
||||
for _, path := range order {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(p string) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
vf, err := ValidateVideo(p)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: %s - %s\n", p, err)
|
||||
}
|
||||
mu.Lock()
|
||||
results[p] = vf
|
||||
mu.Unlock()
|
||||
}(path)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Map results back onto the playlist (preserving order and duplicates).
|
||||
for i := range videos {
|
||||
videos[i] = results[videos[i].Path]
|
||||
}
|
||||
|
||||
return videos, nil
|
||||
}
|
||||
|
||||
@@ -623,11 +764,24 @@ func main() {
|
||||
initRunID()
|
||||
fmt.Println("Run ID:", runID)
|
||||
|
||||
// Validate playlist before starting (ADR-001 normalization enforcement)
|
||||
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
||||
// Validate playlist before starting (ADR-001 normalization enforcement).
|
||||
// Runs once here; start-playout.sh no longer pre-validates separately.
|
||||
videos, err := ValidatePlaylist(playlistPath)
|
||||
if err != nil {
|
||||
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
invalid := 0
|
||||
for _, v := range videos {
|
||||
if !v.Valid {
|
||||
fmt.Printf("ERROR: invalid media: %s — %s\n", v.Path, v.Validation)
|
||||
invalid++
|
||||
}
|
||||
}
|
||||
if invalid > 0 {
|
||||
fmt.Printf("ERROR: Playlist validation failed: %d invalid entries\n", invalid)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize R2 client (skip if not configured — local HLS via nginx still works)
|
||||
if err := initR2Client(); err != nil {
|
||||
@@ -658,6 +812,12 @@ func main() {
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_cleanup_objects_total Total R2 objects deleted by retention cleanup.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
|
||||
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_runs_retained Number of R2 run folders retained by cleanup.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_runs_retained gauge")
|
||||
fmt.Fprintf(w, "linearvoid_playout_r2_runs_retained %d\n", r2RunsRetained)
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_r2_bytes_retained Total bytes retained in R2 run folders.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_r2_bytes_retained gauge")
|
||||
fmt.Fprintf(w, "linearvoid_playout_r2_bytes_retained %d\n", r2BytesRetained)
|
||||
fmt.Fprintln(w, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
|
||||
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
|
||||
@@ -682,6 +842,15 @@ func main() {
|
||||
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||
initRunID()
|
||||
|
||||
// Remove run folders older than the retention policy (keep the last N runs,
|
||||
// cap total bytes) now that the next run has started. This never touches the
|
||||
// current or previous run folders.
|
||||
if r2Client != nil {
|
||||
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||
fmt.Println("R2 cleanup error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Reset HLS output only when the playlist changed, we are recovering from a
|
||||
// crash/slate, or this is the first run. Otherwise continue the segment
|
||||
// sequence so the loop is seamless for viewers (no gap on clean loops).
|
||||
@@ -834,68 +1003,51 @@ func syncLatestToR2(hlsDir string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// r2SegmentRetentionHours returns the R2 segment retention window in hours.
|
||||
func r2SegmentRetentionHours() time.Duration {
|
||||
h, err := strconv.Atoi(envWithDefault("R2_SEGMENT_RETENTION_HOURS", "24"))
|
||||
if err != nil || h <= 0 {
|
||||
h = 24
|
||||
// r2KeepRuns returns how many of the most recent run folders to keep in R2.
|
||||
func r2KeepRuns() int {
|
||||
n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
|
||||
if err != nil || n < 1 {
|
||||
return defaultR2KeepRuns
|
||||
}
|
||||
return time.Duration(h) * time.Hour
|
||||
return n
|
||||
}
|
||||
|
||||
// cleanupOldSegments deletes R2 objects under the hls/ prefix that are older than
|
||||
// the retention window, excluding the live manifest and any non-run keys.
|
||||
// It returns the number of objects deleted.
|
||||
func cleanupOldSegments(ctx context.Context) (int, error) {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
|
||||
func r2MaxRetentionBytes() int64 {
|
||||
n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return defaultR2MaxRetentionBytes
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-r2SegmentRetentionHours())
|
||||
|
||||
var stale []string
|
||||
var token *string
|
||||
for {
|
||||
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String("hls/"),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, obj := range output.Contents {
|
||||
key := aws.ToString(obj.Key)
|
||||
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
|
||||
continue
|
||||
}
|
||||
// Only consider segment/init objects under a run directory: hls/r-.../...
|
||||
if !strings.HasPrefix(key, "hls/r-") {
|
||||
continue
|
||||
}
|
||||
if obj.LastModified != nil && obj.LastModified.Before(cutoff) {
|
||||
stale = append(stale, key)
|
||||
}
|
||||
}
|
||||
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||
break
|
||||
}
|
||||
token = output.NextContinuationToken
|
||||
// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
|
||||
func r2RunFromKey(key string) (string, bool) {
|
||||
rest := strings.TrimPrefix(key, "hls/")
|
||||
if rest == key || !strings.HasPrefix(rest, "r-") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
if len(stale) == 0 {
|
||||
return 0, nil
|
||||
if idx := strings.IndexByte(rest, '/'); idx > 0 {
|
||||
return rest[:idx], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// runObjects groups the R2 object keys and total size for a single run folder.
|
||||
type runObjects struct {
|
||||
keys []string
|
||||
size int64
|
||||
}
|
||||
|
||||
// deleteR2Keys deletes the given R2 object keys in batches of up to 1000.
|
||||
func deleteR2Keys(keys []string) (int, error) {
|
||||
deleted := 0
|
||||
for i := 0; i < len(stale); i += 1000 {
|
||||
for i := 0; i < len(keys); i += 1000 {
|
||||
end := i + 1000
|
||||
if end > len(stale) {
|
||||
end = len(stale)
|
||||
if end > len(keys) {
|
||||
end = len(keys)
|
||||
}
|
||||
batch := stale[i:end]
|
||||
batch := keys[i:end]
|
||||
objects := make([]types.ObjectIdentifier, len(batch))
|
||||
for j, key := range batch {
|
||||
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
||||
@@ -911,6 +1063,118 @@ func cleanupOldSegments(ctx context.Context) (int, error) {
|
||||
}
|
||||
deleted += len(batch)
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// cleanupOldRuns lists run-scoped objects under hls/, keeps the most recent
|
||||
// R2_KEEP_RUNS runs, and deletes older runs. It also enforces the
|
||||
// R2_MAX_RETENTION_BYTES cap by removing oldest retained runs first, always
|
||||
// keeping at least the newest run. The live manifest keys are never deleted.
|
||||
func cleanupOldRuns(ctx context.Context) (int, error) {
|
||||
if r2Client == nil {
|
||||
if err := initR2Client(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
runs := map[string]*runObjects{}
|
||||
var token *string
|
||||
for {
|
||||
output, err := listObjects(r2Client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String("hls/"),
|
||||
ContinuationToken: token,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, obj := range output.Contents {
|
||||
key := aws.ToString(obj.Key)
|
||||
if key == "hls/playlist.m3u8" || key == "hls/playlist.m3u8.tmp" {
|
||||
continue
|
||||
}
|
||||
run, ok := r2RunFromKey(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
r, exists := runs[run]
|
||||
if !exists {
|
||||
r = &runObjects{}
|
||||
runs[run] = r
|
||||
}
|
||||
r.keys = append(r.keys, key)
|
||||
if obj.Size != nil {
|
||||
r.size += *obj.Size
|
||||
}
|
||||
}
|
||||
if output.IsTruncated == nil || !*output.IsTruncated {
|
||||
break
|
||||
}
|
||||
token = output.NextContinuationToken
|
||||
}
|
||||
|
||||
if len(runs) == 0 {
|
||||
r2RunsRetained = 0
|
||||
r2BytesRetained = 0
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Run IDs are ISO timestamps, so lexicographic order equals chronological order.
|
||||
sortedRuns := make([]string, 0, len(runs))
|
||||
for run := range runs {
|
||||
sortedRuns = append(sortedRuns, run)
|
||||
}
|
||||
sort.Strings(sortedRuns) // ascending: oldest -> newest
|
||||
|
||||
deleteRun := map[string]bool{}
|
||||
keepRuns := r2KeepRuns()
|
||||
|
||||
// Mark all but the newest keepRuns runs for deletion.
|
||||
if len(sortedRuns) > keepRuns {
|
||||
for _, run := range sortedRuns[:len(sortedRuns)-keepRuns] {
|
||||
deleteRun[run] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the total size cap on retained runs (oldest retained first), always
|
||||
// keeping the newest run even if it alone exceeds the cap.
|
||||
maxBytes := r2MaxRetentionBytes()
|
||||
retained := make([]string, 0, keepRuns)
|
||||
var retainedBytes int64
|
||||
for _, run := range sortedRuns {
|
||||
if !deleteRun[run] {
|
||||
retained = append(retained, run)
|
||||
retainedBytes += runs[run].size
|
||||
}
|
||||
}
|
||||
for i := 0; retainedBytes > maxBytes && i < len(retained)-1; i++ {
|
||||
run := retained[i]
|
||||
deleteRun[run] = true
|
||||
retainedBytes -= runs[run].size
|
||||
}
|
||||
|
||||
var toDelete []string
|
||||
for run, del := range deleteRun {
|
||||
if del {
|
||||
toDelete = append(toDelete, runs[run].keys...)
|
||||
}
|
||||
}
|
||||
|
||||
deleted, err := deleteR2Keys(toDelete)
|
||||
if err != nil {
|
||||
return deleted, err
|
||||
}
|
||||
|
||||
keptRuns := int64(0)
|
||||
keptBytes := int64(0)
|
||||
for run, r := range runs {
|
||||
if !deleteRun[run] {
|
||||
keptRuns++
|
||||
keptBytes += r.size
|
||||
}
|
||||
}
|
||||
r2RunsRetained = keptRuns
|
||||
r2BytesRetained = keptBytes
|
||||
|
||||
r2CleanupObjectsTotal += int64(deleted)
|
||||
return deleted, nil
|
||||
@@ -918,13 +1182,13 @@ func cleanupOldSegments(ctx context.Context) (int, error) {
|
||||
|
||||
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
|
||||
func runR2CleanupLoop() {
|
||||
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
||||
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||
fmt.Println("R2 cleanup error:", err)
|
||||
}
|
||||
ticker := time.NewTicker(time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
||||
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||
fmt.Println("R2 cleanup error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
)
|
||||
|
||||
const sampleManifest = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:2
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:2.000000,
|
||||
seg-000000.m4s
|
||||
#EXTINF:2.000000,
|
||||
seg-000001.m4s
|
||||
#EXT-X-ENDLIST
|
||||
`
|
||||
|
||||
func TestRewriteManifestToRun(t *testing.T) {
|
||||
runID := "r-20260903T120000Z"
|
||||
out := string(rewriteManifestToRun(runID, []byte(sampleManifest)))
|
||||
|
||||
wants := []string{
|
||||
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||
"r-20260903T120000Z/seg-000000.m4s",
|
||||
"r-20260903T120000Z/seg-000001.m4s",
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:7",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||
"#EXT-X-INDEPENDENT-SEGMENTS",
|
||||
"#EXT-X-ENDLIST",
|
||||
"#EXTINF:2.000000,",
|
||||
}
|
||||
for _, want := range wants {
|
||||
if !contains(out, want) {
|
||||
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
// Media URIs must be prefixed (no bare segments left).
|
||||
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n", `URI="init.mp4"`} {
|
||||
if contains(out, bare) {
|
||||
t.Errorf("output still contains bare %q\noutput:\n%s", bare, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifestToRun_AlreadyPrefixedUnchanged(t *testing.T) {
|
||||
manifest := "#EXTM3U\n#EXT-X-MAP:URI=\"r-old/init.mp4\"\nseg-000000.m4s\nr-old/seg-000001.m4s\n#EXT-X-ENDLIST\n"
|
||||
out := string(rewriteManifestToRun("r-new", []byte(manifest)))
|
||||
|
||||
if !contains(out, `#EXT-X-MAP:URI="r-old/init.mp4"`) {
|
||||
t.Errorf("EXT-X-MAP already-prefixed URI was rewritten: %s", out)
|
||||
}
|
||||
// A URI line that already contains a path is left alone.
|
||||
if !contains(out, "r-old/seg-000001.m4s") {
|
||||
t.Errorf("already-prefixed segment URI was changed:\n%s", out)
|
||||
}
|
||||
// Bare URI still gets the run prefix.
|
||||
if !contains(out, "r-new/seg-000000.m4s") {
|
||||
t.Errorf("bare segment not prefixed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifestToRun_HandlesTSAndComments(t *testing.T) {
|
||||
manifest := "#EXTM3U\n# a comment\nvideo-1.ts\ninit.mp4\n#EXT-X-ENDLIST\n"
|
||||
out := string(rewriteManifestToRun("r-x", []byte(manifest)))
|
||||
|
||||
for _, want := range []string{"r-x/video-1.ts", "r-x/init.mp4", "# a comment", "#EXTM3U", "#EXT-X-ENDLIST"} {
|
||||
if !contains(out, want) {
|
||||
t.Errorf("output missing %q\noutput:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifestToRun_EmptyRunID(t *testing.T) {
|
||||
out := rewriteManifestToRun("", []byte(sampleManifest))
|
||||
if string(out) != sampleManifest {
|
||||
t.Errorf("empty runID should return input unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMediaURI(t *testing.T) {
|
||||
for _, ok := range []string{"seg-000001.m4s", "init.mp4", "a.ts", "x.m4a"} {
|
||||
if !isMediaURI(ok) {
|
||||
t.Errorf("isMediaURI(%q) = false, want true", ok)
|
||||
}
|
||||
}
|
||||
for _, notOK := range []string{"playlist.m3u8", "readme.txt", "seg-000001.m4s?x=1"} {
|
||||
// Note: query strings are stripped; '?x=1' on m4s should still be media.
|
||||
if notOK == "seg-000001.m4s?x=1" {
|
||||
continue
|
||||
}
|
||||
if isMediaURI(notOK) {
|
||||
t.Errorf("isMediaURI(%q) = true, want false", notOK)
|
||||
}
|
||||
}
|
||||
if !isMediaURI("seg-000001.m4s?token=abc") {
|
||||
t.Errorf("isMediaURI with query should strip query and be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishManifest_RewritesToRunScopedURIs(t *testing.T) {
|
||||
prevClient, prevBucket := r2Client, bucket
|
||||
prevRunID, prevMode := runID, playoutMode
|
||||
prevPut, prevCopy := putObject, copyObject
|
||||
defer func() {
|
||||
r2Client, bucket = prevClient, prevBucket
|
||||
runID, playoutMode = prevRunID, prevMode
|
||||
putObject, copyObject = prevPut, prevCopy
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
runID = "r-20260903T120000Z"
|
||||
playoutMode = "live"
|
||||
r2Client = &s3.Client{}
|
||||
|
||||
var gotKey, gotCopySource, gotCopyKey string
|
||||
var gotBody []byte
|
||||
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
gotKey = aws.ToString(in.Key)
|
||||
b, err := io.ReadAll(in.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gotBody = b
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
copyObject = func(_ *s3.Client, in *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
gotCopySource = aws.ToString(in.CopySource)
|
||||
gotCopyKey = aws.ToString(in.Key)
|
||||
return &s3.CopyObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := publishManifest(mp); err != nil {
|
||||
t.Fatalf("publishManifest error: %v", err)
|
||||
}
|
||||
|
||||
if gotKey != "hls/playlist.m3u8.tmp" {
|
||||
t.Errorf("temp upload key = %q, want hls/playlist.m3u8.tmp", gotKey)
|
||||
}
|
||||
if gotCopySource != "linearvoid-media/hls/playlist.m3u8.tmp" {
|
||||
t.Errorf("CopySource = %q, want linearvoid-media/hls/playlist.m3u8.tmp", gotCopySource)
|
||||
}
|
||||
if gotCopyKey != "hls/playlist.m3u8" {
|
||||
t.Errorf("final key = %q, want hls/playlist.m3u8", gotCopyKey)
|
||||
}
|
||||
|
||||
body := string(gotBody)
|
||||
for _, want := range []string{
|
||||
"r-20260903T120000Z/seg-000000.m4s",
|
||||
"r-20260903T120000Z/seg-000001.m4s",
|
||||
`#EXT-X-MAP:URI="r-20260903T120000Z/init.mp4"`,
|
||||
} {
|
||||
if !contains(body, want) {
|
||||
t.Errorf("published manifest missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
for _, bare := range []string{"\nseg-000000.m4s\n", "\nseg-000001.m4s\n"} {
|
||||
if contains(body, bare) {
|
||||
t.Errorf("published manifest still contains bare %q:\n%s", bare, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishManifest_SlateNotRewritten(t *testing.T) {
|
||||
prevClient, prevBucket := r2Client, bucket
|
||||
prevRunID, prevMode := runID, playoutMode
|
||||
prevPut, prevCopy := putObject, copyObject
|
||||
defer func() {
|
||||
r2Client, bucket = prevClient, prevBucket
|
||||
runID, playoutMode = prevRunID, prevMode
|
||||
putObject, copyObject = prevPut, prevCopy
|
||||
}()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
mp := filepath.Join(tmpDir, "playlist.m3u8")
|
||||
if err := os.WriteFile(mp, []byte(sampleManifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
runID = "r-20260903T120000Z"
|
||||
playoutMode = "slate"
|
||||
r2Client = &s3.Client{}
|
||||
|
||||
var gotBody []byte
|
||||
putObject = func(_ *s3.Client, in *s3.PutObjectInput) (*s3.PutObjectOutput, error) {
|
||||
b, _ := io.ReadAll(in.Body)
|
||||
gotBody = b
|
||||
return &s3.PutObjectOutput{}, nil
|
||||
}
|
||||
copyObject = func(_ *s3.Client, _ *s3.CopyObjectInput) (*s3.CopyObjectOutput, error) {
|
||||
return &s3.CopyObjectOutput{}, nil
|
||||
}
|
||||
|
||||
if err := publishManifest(mp); err != nil {
|
||||
t.Fatalf("publishManifest error: %v", err)
|
||||
}
|
||||
|
||||
if string(gotBody) != sampleManifest {
|
||||
t.Errorf("slate manifest should be published unmodified; got:\n%s", string(gotBody))
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -465,6 +465,54 @@ func TestValidatePlaylist(t *testing.T) {
|
||||
t.Skip("requires ffprobe mock or test media fixtures")
|
||||
}
|
||||
|
||||
func TestValidateWorkersConfig(t *testing.T) {
|
||||
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
||||
if got := validateWorkers(); got < 1 {
|
||||
t.Errorf("validateWorkers() = %d, want >= 1", got)
|
||||
}
|
||||
|
||||
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "3")
|
||||
if got := validateWorkers(); got != 3 {
|
||||
t.Errorf("validateWorkers() = %d, want 3", got)
|
||||
}
|
||||
|
||||
os.Setenv("PLAYOUT_VALIDATE_WORKERS", "invalid")
|
||||
if got := validateWorkers(); got < 1 {
|
||||
t.Errorf("validateWorkers() = %d, want fallback >= 1", got)
|
||||
}
|
||||
|
||||
os.Unsetenv("PLAYOUT_VALIDATE_WORKERS")
|
||||
}
|
||||
|
||||
func TestValidatePlaylist_Dedup(t *testing.T) {
|
||||
ensureFFProbeInPath(t)
|
||||
|
||||
valid := filepath.Join("..", "testdata", "valid_720p30.mp4")
|
||||
playlistDir := t.TempDir()
|
||||
p := filepath.Join(playlistDir, "playlist.txt")
|
||||
// Same file listed three times to exercise dedup + order preservation.
|
||||
content := fmt.Sprintf("file '%s'\nfile '%s'\nfile '%s'\n", valid, valid, valid)
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write playlist: %v", err)
|
||||
}
|
||||
|
||||
videos, err := ValidatePlaylist(p)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePlaylist error: %v", err)
|
||||
}
|
||||
if len(videos) != 3 {
|
||||
t.Fatalf("len = %d, want 3", len(videos))
|
||||
}
|
||||
for i, v := range videos {
|
||||
if !v.Valid {
|
||||
t.Errorf("entry %d invalid: %s", i, v.Validation)
|
||||
}
|
||||
if v.Path != valid {
|
||||
t.Errorf("entry %d path = %q, want %q", i, v.Path, valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncToR2(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
@@ -593,54 +641,95 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestR2SegmentRetentionHours(t *testing.T) {
|
||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
|
||||
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||
t.Errorf("default = %v, want 24h", got)
|
||||
func TestR2KeepRuns(t *testing.T) {
|
||||
os.Setenv("R2_KEEP_RUNS", "")
|
||||
if got := r2KeepRuns(); got != 2 {
|
||||
t.Errorf("default = %d, want 2", got)
|
||||
}
|
||||
|
||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
|
||||
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
|
||||
t.Errorf("override = %v, want 48h", got)
|
||||
os.Setenv("R2_KEEP_RUNS", "5")
|
||||
if got := r2KeepRuns(); got != 5 {
|
||||
t.Errorf("override = %d, want 5", got)
|
||||
}
|
||||
|
||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
|
||||
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
||||
t.Errorf("invalid = %v, want 24h fallback", got)
|
||||
os.Setenv("R2_KEEP_RUNS", "0")
|
||||
if got := r2KeepRuns(); got != 2 {
|
||||
t.Errorf("invalid = %d, want 2 fallback", got)
|
||||
}
|
||||
|
||||
os.Unsetenv("R2_SEGMENT_RETENTION_HOURS")
|
||||
os.Unsetenv("R2_KEEP_RUNS")
|
||||
}
|
||||
|
||||
func TestCleanupOldSegments(t *testing.T) {
|
||||
func TestR2MaxRetentionBytes(t *testing.T) {
|
||||
os.Setenv("R2_MAX_RETENTION_BYTES", "")
|
||||
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||
t.Errorf("default = %d, want 10GiB", got)
|
||||
}
|
||||
|
||||
os.Setenv("R2_MAX_RETENTION_BYTES", "100")
|
||||
if got := r2MaxRetentionBytes(); got != 100 {
|
||||
t.Errorf("override = %d, want 100", got)
|
||||
}
|
||||
|
||||
os.Setenv("R2_MAX_RETENTION_BYTES", "invalid")
|
||||
if got := r2MaxRetentionBytes(); got != 10*1024*1024*1024 {
|
||||
t.Errorf("invalid = %d, want 10GiB fallback", got)
|
||||
}
|
||||
|
||||
os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||
}
|
||||
|
||||
func TestR2RunFromKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
key string
|
||||
run string
|
||||
ok bool
|
||||
}{
|
||||
{"hls/r-20260815T120000Z/seg-1.m4s", "r-20260815T120000Z", true},
|
||||
{"hls/r-20260815T120000Z/init.mp4", "r-20260815T120000Z", true},
|
||||
{"hls/playlist.m3u8", "", false},
|
||||
{"hls/playlist.m3u8.tmp", "", false},
|
||||
{"other/r-x/seg.m4s", "", false},
|
||||
{"hls/r-noslash", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
run, ok := r2RunFromKey(c.key)
|
||||
if run != c.run || ok != c.ok {
|
||||
t.Errorf("r2RunFromKey(%q) = (%q, %v), want (%q, %v)", c.key, run, ok, c.run, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldRuns(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevList := listObjects
|
||||
prevDelete := deleteObjects
|
||||
prevTotal := r2CleanupObjectsTotal
|
||||
prevRuns := r2RunsRetained
|
||||
prevBytes := r2BytesRetained
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
listObjects = prevList
|
||||
deleteObjects = prevDelete
|
||||
r2CleanupObjectsTotal = prevTotal
|
||||
r2RunsRetained = prevRuns
|
||||
r2BytesRetained = prevBytes
|
||||
}()
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
|
||||
now := time.Now().UTC()
|
||||
old := now.Add(-25 * time.Hour)
|
||||
recent := now.Add(-1 * time.Hour)
|
||||
|
||||
size := int64(1024)
|
||||
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||
return &s3.ListObjectsV2Output{
|
||||
Contents: []types.Object{
|
||||
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
|
||||
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
|
||||
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
|
||||
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
|
||||
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
|
||||
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
||||
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
||||
},
|
||||
IsTruncated: aws.Bool(false),
|
||||
}, nil
|
||||
@@ -654,23 +743,101 @@ func TestCleanupOldSegments(t *testing.T) {
|
||||
return &s3.DeleteObjectsOutput{}, nil
|
||||
}
|
||||
|
||||
n, err := cleanupOldSegments(context.Background())
|
||||
n, err := cleanupOldRuns(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupOldSegments error: %v", err)
|
||||
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("deleted = %d, want 1 (oldest run)", n)
|
||||
}
|
||||
|
||||
joined := strings.Join(deletedKeys, ",")
|
||||
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||
}
|
||||
if strings.Contains(joined, "playlist.m3u8") {
|
||||
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||
}
|
||||
if r2RunsRetained != 2 {
|
||||
t.Fatalf("r2RunsRetained = %d, want 2", r2RunsRetained)
|
||||
}
|
||||
if r2BytesRetained != 2*size {
|
||||
t.Fatalf("r2BytesRetained = %d, want %d", r2BytesRetained, 2*size)
|
||||
}
|
||||
if r2CleanupObjectsTotal != 1 {
|
||||
t.Fatalf("r2CleanupObjectsTotal = %d, want 1", r2CleanupObjectsTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupOldRuns_SizeCap(t *testing.T) {
|
||||
prevClient := r2Client
|
||||
prevBucket := bucket
|
||||
prevList := listObjects
|
||||
prevDelete := deleteObjects
|
||||
prevTotal := r2CleanupObjectsTotal
|
||||
prevRuns := r2RunsRetained
|
||||
prevBytes := r2BytesRetained
|
||||
defer func() {
|
||||
r2Client = prevClient
|
||||
bucket = prevBucket
|
||||
listObjects = prevList
|
||||
deleteObjects = prevDelete
|
||||
r2CleanupObjectsTotal = prevTotal
|
||||
r2RunsRetained = prevRuns
|
||||
r2BytesRetained = prevBytes
|
||||
}()
|
||||
|
||||
bucket = "linearvoid-media"
|
||||
r2Client = &s3.Client{}
|
||||
|
||||
// Force a small cap so the size policy trims even the last 2 runs.
|
||||
os.Setenv("R2_KEEP_RUNS", "2")
|
||||
os.Setenv("R2_MAX_RETENTION_BYTES", "1024")
|
||||
defer os.Unsetenv("R2_KEEP_RUNS")
|
||||
defer os.Unsetenv("R2_MAX_RETENTION_BYTES")
|
||||
|
||||
size := int64(1024)
|
||||
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||
return &s3.ListObjectsV2Output{
|
||||
Contents: []types.Object{
|
||||
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||
},
|
||||
IsTruncated: aws.Bool(false),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var deletedKeys []string
|
||||
deleteObjects = func(_ *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||
for _, obj := range input.Delete.Objects {
|
||||
deletedKeys = append(deletedKeys, aws.ToString(obj.Key))
|
||||
}
|
||||
return &s3.DeleteObjectsOutput{}, nil
|
||||
}
|
||||
|
||||
n, err := cleanupOldRuns(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupOldRuns error: %v", err)
|
||||
}
|
||||
// 3 runs -> keep 2 -> oldest deleted by count; remaining 2 bytes (2048) exceed
|
||||
// the 1024 cap -> the older of the kept two is deleted, leaving only the newest.
|
||||
if n != 2 {
|
||||
t.Fatalf("deleted = %d, want 2", n)
|
||||
}
|
||||
|
||||
joined := strings.Join(deletedKeys, ",")
|
||||
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
|
||||
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
|
||||
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||
}
|
||||
if strings.Contains(joined, "playlist.m3u8") {
|
||||
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
||||
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
||||
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
||||
}
|
||||
if r2CleanupObjectsTotal != 2 {
|
||||
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
|
||||
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
||||
t.Fatalf("newest run must be kept, got: %v", deletedKeys)
|
||||
}
|
||||
if r2RunsRetained != 1 {
|
||||
t.Fatalf("r2RunsRetained = %d, want 1", r2RunsRetained)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ set -e
|
||||
|
||||
echo "=== Linear Void Playout Service Starting ==="
|
||||
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-6}s"
|
||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-24}"
|
||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-2}s"
|
||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-12}"
|
||||
|
||||
# Ensure playlist directory and file exist
|
||||
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
||||
@@ -17,15 +17,7 @@ file '/app/media/demo2.mp4'
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Run normalization validation using the Go tool
|
||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
||||
if ! /app/playlist-manager -validate "$PLAYLIST_PATH"; then
|
||||
echo "ERROR: Playlist validation failed. Check logs."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Playlist validated successfully. Starting continuous HLS playout..."
|
||||
|
||||
# Single source of truth: the Go binary runs the full loop (HLS, R2 sync,
|
||||
# slate fallback, health server). The shell no longer duplicates the FFmpeg loop.
|
||||
# Single source of truth: the Go binary validates the playlist (ADR-001/ADR-003
|
||||
# normalization enforcement) and runs the full loop (HLS, R2 sync, slate
|
||||
# fallback, health server). Validation runs once inside the binary.
|
||||
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${PLAYOUT_PORT:-8081}:8081"
|
||||
volumes:
|
||||
- ./data/playlist:/app/playlist:rw
|
||||
- ./data/media:/app/media:ro
|
||||
@@ -56,7 +58,7 @@ services:
|
||||
container_name: linearvoid-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
- "${NGINX_PORT:-8082}:80"
|
||||
volumes:
|
||||
- ./data/hls:/srv/hls:ro
|
||||
networks:
|
||||
|
||||
+3
-1
@@ -13,6 +13,8 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${PLAYOUT_PORT:-8081}:8081"
|
||||
volumes:
|
||||
- ./data/playlist:/app/playlist:ro
|
||||
- ./data/media:/app/media:ro
|
||||
@@ -64,7 +66,7 @@ services:
|
||||
container_name: linearvoid-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-8080}:80"
|
||||
- "${NGINX_PORT:-8082}:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/srv/frontend:ro
|
||||
- ./data/hls:/srv/hls:ro
|
||||
|
||||
+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.
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# Coolify Deployment
|
||||
|
||||
*Last Updated: 2026-08-28*
|
||||
|
||||
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`).
|
||||
|
||||
## Why Coolify
|
||||
|
||||
- **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`.
|
||||
|
||||
> 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
|
||||
|
||||
- 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
|
||||
|
||||
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. 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. **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)
|
||||
|
||||
Active test-lab setup is **two-tier**:
|
||||
|
||||
| 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 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`:
|
||||
|
||||
```bash
|
||||
./scripts/check-env.sh # env/deployment health on target (containers, helpers, health)
|
||||
./scripts/check-tokens.sh # Gitea registry, Cloudflare, R2, admin credential access
|
||||
```
|
||||
|
||||
## Deploy the stack
|
||||
|
||||
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 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
|
||||
|
||||
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` | 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`.
|
||||
|
||||
### Persistent storage
|
||||
|
||||
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/segments |
|
||||
| `data/chat` | Chat service working data |
|
||||
| `data/slate` | Slate media |
|
||||
| `redis-data` | Redis append-only persistence |
|
||||
| `postgres-data` | PostgreSQL data directory |
|
||||
|
||||
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. 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:-8082}: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
|
||||
|
||||
- `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 written to `data/hls` and served from `media.linearvoid.tv`.
|
||||
- Chat WebSocket connects over `wss://linearvoid.tv/ws`.
|
||||
- Admin login works at `/admin` with configured credentials.
|
||||
|
||||
## Monitoring
|
||||
|
||||
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
|
||||
|
||||
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 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**: 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>
|
||||
- [DEPLOYMENT.md](./DEPLOYMENT.md) · [SETUP.md](./SETUP.md)
|
||||
+57
-151
@@ -1,63 +1,28 @@
|
||||
# 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)
|
||||
- [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 serving real viewers |
|
||||
| **Production** | Hetzner VPS (TBD) | `tcp://<hetzner-ip>:2375` | Public linearvoid.tv |
|
||||
|
||||
**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
|
||||
@@ -65,18 +30,14 @@ 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
|
||||
### Production Preflight (minimum readiness gate)
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -91,18 +52,14 @@ Readiness is not complete until the checklist above and the manual evidence trac
|
||||
|
||||
### Pipeline Readiness
|
||||
|
||||
Three Gitea Actions workflows drive the full pipeline:
|
||||
| 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 |
|
||||
|
||||
| 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 |
|
||||
| `pages-deploy.yml` | Push to `main` | Deploy frontend to Cloudflare Pages (see `docs/operations/gitea-cloudflare-pages.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)
|
||||
@@ -112,7 +69,7 @@ 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
|
||||
|
||||
@@ -126,12 +83,12 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
|
||||
|
||||
- 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
|
||||
@@ -141,12 +98,12 @@ Use `.env` with at least:
|
||||
|
||||
**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/Pages):**
|
||||
**Cloudflare (optional, for Workers):**
|
||||
|
||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
||||
@@ -155,149 +112,99 @@ See `.env.example` and `SETUP.md` for full variable reference.
|
||||
|
||||
### Gitea Secrets
|
||||
|
||||
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:
|
||||
`cd.yml` injects sensitive values from Gitea secrets into `.env` at deploy time. Configure 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
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml pull
|
||||
docker compose -f docker-compose.prod.yml up -d nginx chat playout
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -321,5 +228,4 @@ docker compose -f docker-compose.prod.yml down -v
|
||||
| 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.
|
||||
+1
-1
@@ -9,7 +9,7 @@ This folder contains the documentation for the Linear Void project. The document
|
||||
## Infrastructure
|
||||
|
||||
- Cloudflare
|
||||
- [Cache Rules](./infrastructure/cloudflare/cache-rules.md) - Cache rules for Cloudflare Workers and Pages.
|
||||
- [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.
|
||||
|
||||
+80
-476
@@ -2,545 +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, Pages, cache, security, and monitoring).
|
||||
|
||||
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
|
||||
|
||||
### 1. DNS Setup
|
||||
|
||||
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
|
||||
2. Add `www` CNAME to Cloudflare Pages 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 Pages
|
||||
|
||||
Also see [the automated Gitea Actions deployment guide](./operations/gitea-cloudflare-pages.md).
|
||||
|
||||
Manual setup:
|
||||
|
||||
1. Connect Gitea repository to Cloudflare Pages.
|
||||
2. Build command:
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
3. Output directory:
|
||||
|
||||
```text
|
||||
frontend/dist
|
||||
```
|
||||
|
||||
4. Configure production domain to `linearvoid.tv`.
|
||||
|
||||
### 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. Pages serves 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; deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` (replace the R2 bucket's direct custom-domain binding on that host) to enforce immutable segment caching + HLS CORS.
|
||||
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), deploy the [`linearvoid-media` worker](../infrastructure/cloudflare/) on `media.linearvoid.tv` + cache rules, `linearvoid.tv`/`www` at the app entrypoint with `www`→apex redirect, Full (strict) TLS.
|
||||
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
|
||||
```
|
||||
|
||||
### 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`.
|
||||
+93
-94
@@ -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,22 +20,22 @@ 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
|
||||
|
||||
Only validated files may enter the production playlist.
|
||||
|
||||
| Property | Required profile |
|
||||
| ---------- | ------------------------------------------------------------------------ |
|
||||
| Container | MP4 |
|
||||
| Video | H.264/AVC, `yuv420p` |
|
||||
| Resolution | 1280x720 or 1920x1080; avoid changing resolution 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 |
|
||||
| Property | Required profile |
|
||||
| ---------- | ----------------------------------------------------------------------- |
|
||||
| Container | MP4 |
|
||||
| Video | H.264/AVC, `yuv420p` |
|
||||
| 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,140 +51,139 @@ 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.
|
||||
**Run-scoped manifest URIs.** Segments and the init file are stored run-scoped under `hls/r-{runID}/…`, so the live `hls/playlist.m3u8` must reference them with that run prefix. Before the final commit step, the publisher rewrites each bare relative media URI (segments plus `#EXT-X-MAP:URI`) to `r-{runID}/<name>`. `publishManifest` in `backend/playout/src/playlist.go` does this via `rewriteManifestToRun`, using the same `runID` under which the segments were uploaded; the rewrite is skipped in slate mode (its manifest is served as-is). The published manifest therefore contains, e.g.:
|
||||
|
||||
```m3u8
|
||||
#EXT-X-MAP:URI="r-20260807T170412Z/init.mp4"
|
||||
#EXTINF:2.000000,
|
||||
r-20260807T170412Z/seg-000001234.m4s
|
||||
```
|
||||
|
||||
The `linearvoid-media` Worker on `media.linearvoid.tv` is a faithful path→R2 mapper and serves these run-scoped paths directly; it does **not** rewrite URIs at serve time.
|
||||
|
||||
`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
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------ | -------------------------------------------------- |
|
||||
| `PLAYLIST_PATH` | Active playlist file |
|
||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||
| `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 |
|
||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------ | ------------------------------------- |
|
||||
| `PLAYLIST_PATH` | Active playlist file |
|
||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||
| `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, 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.
|
||||
+240
-691
File diff suppressed because it is too large.
Load diff
@@ -69,7 +69,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Pages or same VPS/Nginx.
|
||||
- Static SPA (HTML + custom CSS + vanilla JS) hosted on Cloudflare Workers or same VPS/Nginx.
|
||||
- Video player: `hls.js` (Chromium/Firefox) or native HLS (Safari). Single live `master.m3u8`.
|
||||
- Embedded chat sidebar with room switcher, private DMs, now-playing overlay.
|
||||
|
||||
@@ -82,7 +82,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
|
||||
|
||||
### 5. Infrastructure Summary & Cost Model
|
||||
|
||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Pages.
|
||||
- **Primary**: Hetzner Ashburn VPS (playout + chat + Nginx) + Cloudflare R2 + Workers.
|
||||
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
||||
|
||||
+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.
|
||||
@@ -2,23 +2,23 @@
|
||||
|
||||
Reference for Cloudflare account and API token troubleshooting.
|
||||
|
||||
Use `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects` to verify the account ID and API token are valid.
|
||||
Use `wrangler whoami` to verify the account ID and API token are valid, or query the Workers API at `https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/workers/scripts`.
|
||||
|
||||
## 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 curl
|
||||
$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() })
|
||||
# Test API token with wrangler
|
||||
npx wrangler whoami
|
||||
|
||||
# List all Pages projects for the account
|
||||
curl -X GET "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json"
|
||||
# Or verify with the Workers API directly
|
||||
export CLOUDFLARE_API_TOKEN="$(grep '^CLOUDFLARE_API_TOKEN=' .env | cut -d= -f2-)"
|
||||
export CLOUDFLARE_ACCOUNT_ID="$(grep '^CLOUDFLARE_ACCOUNT_ID=' .env | cut -d= -f2-)"
|
||||
|
||||
# Create a new Pages project (if needed)
|
||||
# curl -X POST "https://api.cloudflare.com/client/v4/accounts/$env:CLOUDFLARE_ACCOUNT_ID/pages/projects" -H "Authorization: Bearer $env:CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" -d '{"name":"linearvoid","production_branch":"main"}'
|
||||
# List all Workers for the account
|
||||
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,69 +0,0 @@
|
||||
# Cloudflare Pages & Workers Setup — Linear Void
|
||||
|
||||
Linear Void uses two Cloudflare products:
|
||||
|
||||
| Product | Purpose | What |
|
||||
| ----------- | ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| **Pages** | Static SPA hosting | Vite-built frontend (`frontend/dist`) deployed via Git integration or Direct Upload |
|
||||
| **Workers** | Edge cache logic | `infrastructure/cloudflare/worker.js` — adds immutable cache headers to HLS segments |
|
||||
|
||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||
|
||||
## Dashboard URLs
|
||||
|
||||
| Resource | URL |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||
| Worker: linearvoid | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` |
|
||||
| Pages project | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` |
|
||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||
|
||||
## Worker: `linearvoid` (edge cache headers)
|
||||
|
||||
The Worker at `infrastructure/cloudflare/worker.js` runs on the `linearvoid` Workers service. It intercepts requests and adds immutable `Cache-Control` headers to `.m4s` segment files.
|
||||
|
||||
Deploy via Wrangler CLI:
|
||||
|
||||
```bash
|
||||
npx wrangler deploy infrastructure/cloudflare/worker.js
|
||||
```
|
||||
|
||||
Or paste into the Workers dashboard at the URL above (Quick Edit).
|
||||
|
||||
## Pages: Static frontend
|
||||
|
||||
### Direct Upload via Gitea Actions
|
||||
|
||||
Use `.gitea/workflows/pages-deploy.yml` to build in Gitea Actions and upload via the Cloudflare API.
|
||||
|
||||
See [gitea-cloudflare-pages.md](./gitea-cloudflare-pages.md) for the workflow.
|
||||
|
||||
### Variables and secrets
|
||||
|
||||
On Cloudflare pages, set the following environment variables (needed to run the frontend)
|
||||
|
||||
| Type | Name | Value |
|
||||
| --------- | -------------------------------------- | ----------------------------------------------- |
|
||||
| Secret | ADMIN_PASSWORD | `not-a-real-admin-password` |
|
||||
| Plaintext | ADMIN_SESSION_TTL_HOURS | `12` |
|
||||
| Plaintext | ADMIN_USERNAME | `boogers` |
|
||||
| Plaintext | HLS_SEGMENT_DURATION | `2` |
|
||||
| Plaintext | HLS_WINDOW_SIZE | `12` |
|
||||
| Plaintext | PLAYLIST_PATH | `/app/playlist/playlist.txt` |
|
||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR | `boogers` |
|
||||
| Plaintext | VITE_PUBLIC_ADMIN_DEFAULT_ROOM | `main` |
|
||||
| Plaintext | VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT | `50` |
|
||||
| Plaintext | VITE_PUBLIC_APP_URL | `https://linearvoid.tv` |
|
||||
| Plaintext | VITE_PUBLIC_HLS_URL | `https://media.linearvoid.tv/hls/playlist.m3u8` |
|
||||
| Plaintext | VITE_PUBLIC_WS_URL | `wss://linearvoid.tv/ws` |
|
||||
|
||||
## Custom domains
|
||||
|
||||
- `linearvoid.tv` → Pages project
|
||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||
- `media.linearvoid.tv` → R2 bucket (separate; not Pages)
|
||||
|
||||
## Related
|
||||
|
||||
- [Gitea to Cloudflare Pages deployment guide](./gitea-cloudflare-pages.md)
|
||||
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||
@@ -5,7 +5,7 @@ Purpose: execute and record all manual dashboard tasks that cannot be automated
|
||||
Recommended execution order:
|
||||
|
||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
||||
2. Enable Pages, Tiered Cache, and cache behavior for the media zone.
|
||||
2. Enable Workers, Tiered Cache, and cache behavior for the media zone.
|
||||
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||
5. Capture evidence and fill in the sign-off block.
|
||||
@@ -15,18 +15,18 @@ Recommended execution order:
|
||||
- [ ] Pending
|
||||
- [x] Done
|
||||
|
||||
## 1) Cloudflare Pages Link and Build
|
||||
## 1) Cloudflare Workers Deploy and Custom Domain
|
||||
|
||||
- [ ] Connect repo to Cloudflare Pages project
|
||||
- [ ] Create a Cloudflare Worker for the SPA and bind the build output
|
||||
- [ ] Build command set to: `cd frontend && npm run build`
|
||||
- [ ] Output directory set to: `frontend/dist`
|
||||
- [ ] Production domain attached to `linearvoid.tv`
|
||||
|
||||
Evidence to capture:
|
||||
|
||||
- Screenshot of Pages deployment settings
|
||||
- Screenshot of Worker deployment settings
|
||||
- Deployment URL and latest deployment timestamp
|
||||
- Confirmed production build output path in the Pages project
|
||||
- Confirmed production build output path in the Worker
|
||||
|
||||
## 2) Tiered Cache Enablement and Validation
|
||||
|
||||
@@ -125,7 +125,7 @@ Evidence to capture:
|
||||
|
||||
Completion summary:
|
||||
|
||||
- Pages build URL:
|
||||
- Worker build URL:
|
||||
- Cache validation result:
|
||||
- WAF/rate-limit status:
|
||||
- HTTPS/HSTS status:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# Cloudflare Workers Setup — Linear Void
|
||||
|
||||
Linear Void uses Cloudflare Workers for two purposes:
|
||||
|
||||
| Product | Purpose | What |
|
||||
| ----------- | ------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| **Workers** | Static SPA hosting | `linearvoid-frontend` (`frontend/wrangler.toml`) — Vite SPA (`frontend/dist`) static assets |
|
||||
| **Workers** | Edge HLS delivery | `linearvoid-media` (`infrastructure/cloudflare/`) — R2-bound Worker on `media.linearvoid.tv` |
|
||||
|
||||
Both are managed under **Workers & Pages** in the Cloudflare dashboard.
|
||||
|
||||
## Dashboard URLs
|
||||
|
||||
| Resource | URL |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Workers & Pages overview | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers-and-pages` |
|
||||
| Worker: linearvoid-media | `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid-media/production/settings` |
|
||||
| API tokens | <https://dash.cloudflare.com/profile/api-tokens> |
|
||||
|
||||
## Worker: `linearvoid-media` (edge HLS delivery)
|
||||
|
||||
The Worker in [`infrastructure/cloudflare/`](../../infrastructure/cloudflare/) runs on the `linearvoid-media` Workers service. It owns the `media.linearvoid.tv` host, serves HLS objects from the R2 bucket (bound as `MEDIA_BUCKET`), and stamps immutable `Cache-Control` on segments plus a short TTL on live manifests (CORS + Range supported). See its [README](../../infrastructure/cloudflare/README.md).
|
||||
|
||||
**Run-scoped layout.** R2 objects are stored run-scoped (`hls/r-{runID}/seg-*.m4s`, `…/init.mp4`), and the live `hls/playlist.m3u8` references them with the run prefix (the playout rewrites bare URIs before publishing — see [`docs/STREAMING.md`](../STREAMING.md)). The Worker is a faithful path→R2 mapper: it serves whatever key the request path resolves to (e.g. `/hls/r-…/seg-*.m4s`), adds the cache headers by file type, and does not rewrite manifest URIs at serve time.
|
||||
|
||||
Test / deploy:
|
||||
|
||||
```bash
|
||||
cd infrastructure/cloudflare
|
||||
npm install && npm test # unit + Miniflare R2 tests
|
||||
npx wrangler deploy # preview/test target
|
||||
npx wrangler deploy --env production # attach media.linearvoid.tv
|
||||
```
|
||||
|
||||
Attach the production custom domain only after removing `media.linearvoid.tv` from the R2 bucket's direct custom-domain binding. Automated production deploy: [`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml).
|
||||
|
||||
## Worker: static frontend
|
||||
|
||||
Serve the Vite-built SPA (`frontend/dist`) as Worker static assets:
|
||||
|
||||
1. Build the frontend: `cd frontend && npm run build`.
|
||||
2. Bind `frontend/dist` to the Worker as static assets (Workers Static Assets / `assets` in `wrangler.toml`).
|
||||
3. Attach the `linearvoid.tv` custom domain to the Worker.
|
||||
|
||||
See [gitea-cloudflare-workers.md](./gitea-cloudflare-workers.md) for the automated deployment workflow.
|
||||
|
||||
### Build-time variables
|
||||
|
||||
The `VITE_PUBLIC_*` values are baked into the bundle at build time by Vite (`vite.config.ts` uses `envDir: ".."` to read them from the repo root). They are supplied by the Gitea Actions build environment from Gitea secrets — not as Worker runtime variables. The static-assets Worker has no runtime JavaScript, so it reads no `env` at request time.
|
||||
|
||||
| Name | Value |
|
||||
| ---------------------------------------- | ----------------------------------------------- |
|
||||
| `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` |
|
||||
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | `boogers` |
|
||||
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | `main` |
|
||||
| `VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT` | `50` |
|
||||
|
||||
Backend services (playout, chat, nginx) read their own runtime variables (`ADMIN_*`, `HLS_*`, `PLAYLIST_PATH`, `R2_*`, `DATABASE_URL`, `REDIS_URL`) from the container environment — see [`.env.example`](../../.env.example).
|
||||
|
||||
## Custom domains
|
||||
|
||||
- `linearvoid.tv` → Worker (static SPA, `linearvoid-frontend`)
|
||||
- `www.linearvoid.tv` → redirect to `linearvoid.tv`
|
||||
- `media.linearvoid.tv` → Worker (`linearvoid-media`, R2-bound) serving the `linearvoid-media` bucket
|
||||
|
||||
## Related
|
||||
|
||||
- [Gitea to Cloudflare Workers deployment guide](./gitea-cloudflare-workers.md)
|
||||
- [Cloudflare & UptimeRobot checklist](./cloudflare-uptimerobot-checklist.md)
|
||||
@@ -1,144 +0,0 @@
|
||||
# Gitea to Cloudflare Pages — Linear Void
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Pages using Gitea Actions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> **Important:** The Pages project must exist before the API deploy call works. You'll get error 8000007 ("Project not found") if you skip this step.
|
||||
|
||||
1. **Cloudflare Pages project** created first:
|
||||
- Dashboard → Workers & Pages → Create → Pages → Direct Upload
|
||||
- Name it `linearvoid` (or a different name if `linearvoid` is already taken by a Worker — see note below)
|
||||
- See [Cloudflare Pages Setup](./cloudflare-pages-setup.md) for full details.
|
||||
2. **Project name**: note the exact name (e.g., `linearvoid`).
|
||||
3. **API token** with Pages permissions:
|
||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||
- Permission: `Account → Cloudflare Pages → Edit`
|
||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||
4. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||
|
||||
> **Naming conflict:** If a Worker already exists with the name `linearvoid`, the Pages project must use a different name (e.g., `linearvoid-site`). Update the `projectName` in the workflow accordingly. Workers and Pages share a namespace under "Workers & Pages".
|
||||
|
||||
## Project-specific values
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Framework | Vite + TypeScript |
|
||||
| Build command | `cd frontend && npm install && npm run build` |
|
||||
| Output directory | `frontend/dist` |
|
||||
| Node version | 20 |
|
||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||
|
||||
## Gitea Actions workflow
|
||||
|
||||
Create `.gitea/workflows/pages-deploy.yml`:
|
||||
|
||||
```yaml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: linearvoid
|
||||
directory: frontend/dist
|
||||
gitHubToken: ${{ secrets.GITEA_TOKEN }}
|
||||
```
|
||||
|
||||
If the `cloudflare/pages-action@v1` action is unavailable from Gitea, use the direct Cloudflare Pages API (multipart/form-data with manifest):
|
||||
|
||||
Snippet to generate a manifest in format `-F manifest='{"index.html": "abc123", "style.css": "def456"}'`
|
||||
|
||||
```bash
|
||||
# Build manifest form argument from all files in frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find frontend/dist -type f | sed 's|^frontend/dist/||'); do
|
||||
hash=$(sha256sum "frontend/dist/$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Generate deployment manifest
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="-F manifest={"
|
||||
for file in $(find . -type f ! -name manifest.json | sed 's|^\./||'); do
|
||||
hash=$(sha256sum "$file" | awk '{print $1}')
|
||||
form_args="$form_args\"$file\":\"$hash\","
|
||||
done
|
||||
form_args="${form_args%,}}" # remove trailing comma and close JSON
|
||||
echo "Form arguments: $form_args"
|
||||
echo "FORM_ARGS=$form_args" >> $GITHUB_ENV
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend/dist
|
||||
form_args="$FORM_ARGS" # use form arguments generated in previous step
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
curl -sS -X POST \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/pages/projects/linearvoid/deployments" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-F "commit_hash=${COMMIT_HASH}" \
|
||||
$form_args
|
||||
```
|
||||
|
||||
This matches the Cloudflare Pages Direct Upload API: `Content-Type: multipart/form-data`, each file as a separate form field, and a `manifest` JSON mapping filenames to SHA-256 hashes.
|
||||
|
||||
## Secrets to add in Gitea
|
||||
|
||||
| Secret | Value |
|
||||
| ----------------------- | ------------------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | API token with Pages:Edit permission |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||
|
||||
## Verify deployment
|
||||
|
||||
1. Push to `main` or trigger manually from Gitea Actions.
|
||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||
3. Check **Cloudflare Pages** → Deployments for the new deployment.
|
||||
4. Visit the `.pages.dev` preview URL, then promote to `linearvoid.tv` when ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- The existing `ci.yml` already builds the frontend; the Pages deploy workflow is additive and independent.
|
||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Cloudflare Pages serves them correctly.
|
||||
- For SPA fallback routing, add a `frontend/public/_redirects` file with `/* /index.html 200` before building.
|
||||
- If deployments fail with 403, verify the API token has `Account → Cloudflare Pages → Edit` permission.
|
||||
- If you get error **8000007** ("Project not found"), the Pages project hasn't been created yet. Create it via the Cloudflare dashboard (Workers & Pages → Create → Pages → Direct Upload) first, then re-run.
|
||||
- If `linearvoid` is already taken by a Worker, use a different project name like `linearvoid-site` and update the `projectName` and `PROJECT_NAME` values in the workflow.
|
||||
|
||||
## Related resources
|
||||
|
||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment
|
||||
- Cloudflare Pages project: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/pages/view/linearvoid` — deployment history and custom domain settings
|
||||
@@ -0,0 +1,107 @@
|
||||
# Gitea to Cloudflare Workers — Linear Void
|
||||
|
||||
## Goal
|
||||
|
||||
Deploy the Linear Void frontend (Vite + TypeScript SPA) from Gitea to Cloudflare Workers using Gitea Actions and Wrangler.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Cloudflare Worker** for the SPA:
|
||||
- Dashboard → Workers & Pages → Create → Worker (or Workers Static Assets).
|
||||
- Name it `linearvoid-frontend` (the edge-cache Worker already uses the `linearvoid` name).
|
||||
2. **API token** with Workers permissions:
|
||||
- Create at <https://dash.cloudflare.com/profile/api-tokens>
|
||||
- Permission: `Account → Workers Scripts → Edit` and `Account → Workers Routes → Edit`
|
||||
- Store in Gitea → Settings → Secrets as `CLOUDFLARE_API_TOKEN`
|
||||
3. **Account ID** from Cloudflare dashboard overview → store as `CLOUDFLARE_ACCOUNT_ID`
|
||||
4. **Wrangler config** (`frontend/wrangler.toml`) binding the `frontend/dist` output as static assets.
|
||||
|
||||
> **Naming:** Workers share a namespace under "Workers & Pages". The media edge Worker is `linearvoid-media` ([`infrastructure/cloudflare/`](../../infrastructure/cloudflare/wrangler.toml), deployed via `.gitea/workflows/workers-media-deploy.yml`) and the SPA Worker is `linearvoid-frontend` (`frontend/wrangler.toml`, deployed via `.gitea/workflows/workers-deploy.yml`).
|
||||
|
||||
## Project-specific values
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| Framework | Vite + TypeScript |
|
||||
| Build command | `cd frontend && npm install && npm run build` |
|
||||
| Output directory | `frontend/dist` |
|
||||
| Node version | 22 |
|
||||
| Multi-page entries | `index.html`, `admin/index.html`, `admin/login/index.html` |
|
||||
|
||||
## Gitea Actions workflow
|
||||
|
||||
Build the SPA and deploy with Wrangler:
|
||||
|
||||
```yaml
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
VITE_PUBLIC_APP_URL: ${{ secrets.VITE_PUBLIC_APP_URL }}
|
||||
VITE_PUBLIC_WS_URL: ${{ secrets.VITE_PUBLIC_WS_URL }}
|
||||
VITE_PUBLIC_HLS_URL: ${{ secrets.VITE_PUBLIC_HLS_URL }}
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR }}
|
||||
VITE_PUBLIC_ADMIN_DEFAULT_ROOM: ${{ secrets.VITE_PUBLIC_ADMIN_DEFAULT_ROOM }}
|
||||
VITE_PUBLIC_ADMIN_MODERATION_LOG_LIMIT: "50"
|
||||
run: cd frontend && npm run build
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
run: |
|
||||
cd frontend
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
## Secrets to add in Gitea
|
||||
|
||||
| Secret | Value |
|
||||
| ------------------------------------- | ------------------------------------ |
|
||||
| `CLOUDFLARE_API_TOKEN` | API token with Workers permissions |
|
||||
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare account ID from dashboard |
|
||||
| `VITE_PUBLIC_APP_URL` | Public viewer URL |
|
||||
| `VITE_PUBLIC_WS_URL` | WebSocket endpoint |
|
||||
| `VITE_PUBLIC_HLS_URL` | HLS playlist URL |
|
||||
| `VITE_PUBLIC_ADMIN_DEFAULT_MODERATOR` | Default moderator username |
|
||||
| `VITE_PUBLIC_ADMIN_DEFAULT_ROOM` | Default chat room |
|
||||
|
||||
## Verify deployment
|
||||
|
||||
1. Push to `main` or trigger manually from Gitea Actions.
|
||||
2. Check **Gitea Actions** → job logs for the deploy step.
|
||||
3. Check **Cloudflare Workers** → `linearvoid-frontend` → Deployments for the new version.
|
||||
4. Visit `https://linearvoid-frontend.zwitschi82.workers.dev`, then attach `linearvoid.tv` when ready.
|
||||
|
||||
## Notes
|
||||
|
||||
- The existing `ci.yml` already builds the frontend; the Workers deploy workflow is additive and independent.
|
||||
- Multi-page SPA (`vite.config.ts`) produces three entry points — Workers static assets serve them correctly.
|
||||
- For SPA fallback routing, set Workers static assets `not_found_handling` to `single-page-application` (serve `index.html` for unknown paths).
|
||||
- The deploy workflow lives in `.gitea/workflows/workers-deploy.yml` (triggered after CI succeeds).
|
||||
|
||||
## Related resources
|
||||
|
||||
- Cloudflare Workers dashboard: `https://dash.cloudflare.com/deb11491c54ec53a24772e98ac6d8128/workers/services/view/linearvoid/production/settings` — for managing the `infrastructure/cloudflare/worker.js` deployment and the SPA Worker
|
||||
@@ -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.
|
||||
+14
-13
@@ -44,29 +44,30 @@
|
||||
<div class="chat-header-actions">
|
||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="auth-panel" class="auth-panel" hidden>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
<div id="auth-panel" class="auth-modal" hidden>
|
||||
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||
<div class="auth-modal-content">
|
||||
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||
<h2 class="auth-modal-title">Sign in</h2>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||
|
||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||||
|
||||
<div id="dm-search-row" class="dm-search-row" hidden>
|
||||
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
|
||||
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
|
||||
</div>
|
||||
|
||||
<section id="presence-panel" class="presence-panel">
|
||||
<div id="presence-list" class="presence-list"></div>
|
||||
</section>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.11",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "1.0.11",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.13",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
+30
-22
@@ -31,6 +31,7 @@ const adminConfig = {
|
||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||
const API_URL = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
|
||||
const state = {
|
||||
mediaUploading: false,
|
||||
@@ -131,7 +132,7 @@ const pageCache: Record<string, string> = {};
|
||||
async function loadPageContent(tabName: string): Promise<string> {
|
||||
if (pageCache[tabName]) return pageCache[tabName];
|
||||
|
||||
const response = await fetch(`/admin/${tabName}.html`);
|
||||
const response = await fetch(`${API_URL}/admin/${tabName}.html`);
|
||||
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
||||
const html = await response.text();
|
||||
pageCache[tabName] = html;
|
||||
@@ -175,7 +176,7 @@ async function switchTab(tabName: string) {
|
||||
|
||||
export async function refreshOverview() {
|
||||
try {
|
||||
const response = await fetch("/api/admin/overview");
|
||||
const response = await fetch(`${API_URL}/api/admin/overview`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -202,7 +203,7 @@ export async function refreshOverview() {
|
||||
export async function refreshAnalytics() {
|
||||
setStatus("analytics-status", "Loading analytics...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/analytics");
|
||||
const response = await fetch(`${API_URL}/api/admin/analytics`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -240,7 +241,7 @@ export async function refreshAnalytics() {
|
||||
async function refreshPlaylist() {
|
||||
setStatus("playlist-status", "Loading playlist...");
|
||||
try {
|
||||
const response = await fetch("/api/admin/playlist");
|
||||
const response = await fetch(`${API_URL}/api/admin/playlist`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -522,7 +523,7 @@ async function submitOverride(e: Event) {
|
||||
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||
try {
|
||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
||||
const resp = await fetch(`${API_URL}/api/admin/schedule/overrides`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -551,7 +552,7 @@ async function submitOverride(e: Event) {
|
||||
|
||||
async function deleteOverride(id: number) {
|
||||
try {
|
||||
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||
await fetch(`${API_URL}/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||
refreshOverrides();
|
||||
} catch {
|
||||
/* ignore */
|
||||
@@ -561,17 +562,21 @@ async function deleteOverride(id: number) {
|
||||
async function renderServiceRow(service: any) {
|
||||
const svcDetail = service.detail || "";
|
||||
|
||||
// playout service returns json detail, all others return ISO formatted time string
|
||||
// playout service returns json detail, all others return `local process` or `ping ok`
|
||||
if (service.name === "playout" && svcDetail) {
|
||||
try {
|
||||
// create a list of key-value pairs for each property in the JSON object
|
||||
const detailObj = JSON.parse(svcDetail);
|
||||
service.detail = Object.entries(detailObj)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
// create a table of key-value pairs for each property in the JSON object
|
||||
const detailTable = Object.entries(detailObj)
|
||||
.map(([key, value]) => `<tr><td><strong>${key}</strong></td><td>${value}</td></tr>`)
|
||||
.join("");
|
||||
service.detail = `<p><table class="service-detail-table">${detailTable}</table></p>`;
|
||||
} catch {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
} else {
|
||||
service.detail = `<p>${service.detail}</p>`;
|
||||
}
|
||||
|
||||
const row = document.createElement("article");
|
||||
@@ -583,7 +588,7 @@ async function renderServiceRow(service: any) {
|
||||
</div>
|
||||
<div class="service-row-right">
|
||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||
<p>${service.detail}</p>
|
||||
${service.detail}
|
||||
</div>
|
||||
`;
|
||||
return row;
|
||||
@@ -749,9 +754,12 @@ async function uploadVideo(file: File) {
|
||||
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||
}
|
||||
|
||||
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
||||
method: "POST",
|
||||
});
|
||||
const completeResponse = await fetch(
|
||||
`${API_URL}/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(await readErrorMessage(completeResponse));
|
||||
@@ -791,7 +799,7 @@ async function editMedia(file: MediaEntry) {
|
||||
|
||||
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
||||
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, status, tags }),
|
||||
@@ -816,7 +824,7 @@ async function normalizeMedia(file: MediaEntry) {
|
||||
|
||||
setStatus("media-status", `Normalizing ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}/normalize`, { method: "POST" });
|
||||
const response = await fetch(`${API_URL}/api/admin/media/${file.id}/normalize`, { method: "POST" });
|
||||
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||
|
||||
setStatus("media-status", `Normalization started for ${file.name}.`);
|
||||
@@ -836,7 +844,7 @@ function pollNormalizationStatus(id: number, attempt: number) {
|
||||
}
|
||||
window.setTimeout(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${id}`);
|
||||
const response = await fetch(`${API_URL}/api/admin/media/${id}`);
|
||||
if (!response.ok) return;
|
||||
const file = (await response.json()) as MediaEntry;
|
||||
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
||||
@@ -869,7 +877,7 @@ async function deleteMedia(file: MediaEntry) {
|
||||
|
||||
setStatus("media-status", `Deleting ${file.name}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
||||
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -887,7 +895,7 @@ async function deleteMedia(file: MediaEntry) {
|
||||
async function refreshModerationLogs() {
|
||||
setStatus("moderation-status", "Loading moderation logs...");
|
||||
try {
|
||||
const response = await fetch(`/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
const response = await fetch(`${API_URL}/api/admin/moderation/logs?limit=${adminConfig.moderationLogLimit}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -988,7 +996,7 @@ async function refreshChatLog() {
|
||||
if (chatLogState.username) params.set("username", chatLogState.username);
|
||||
if (chatLogState.text) params.set("text", chatLogState.text);
|
||||
|
||||
const response = await fetch(`/api/admin/chat-log?${params}`);
|
||||
const response = await fetch(`${API_URL}/api/admin/chat-log?${params}`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const payload = await response.json();
|
||||
@@ -1118,7 +1126,7 @@ async function deleteChatEntry(id: number) {
|
||||
|
||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/chat-log/${id}`, {
|
||||
const response = await fetch(`${API_URL}/api/admin/chat-log/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
@@ -1268,7 +1276,7 @@ async function restartService(service: string) {
|
||||
const status = document.getElementById("restart-status");
|
||||
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/restart?service=${service}`, { method: "POST" });
|
||||
const resp = await fetch(`${API_URL}/api/admin/restart?service=${service}`, { method: "POST" });
|
||||
const data = await resp.json();
|
||||
if (status) status.textContent = data.message;
|
||||
} catch (err) {
|
||||
|
||||
+30
-36
@@ -124,19 +124,18 @@ beforeEach(() => {
|
||||
<div id="chat-user-label" hidden></div>
|
||||
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||||
<div id="auth-panel" hidden>
|
||||
<input id="auth-username" type="text" />
|
||||
<input id="auth-password" type="password" />
|
||||
<span id="auth-status"></span>
|
||||
<button id="auth-login-btn" type="button">Login</button>
|
||||
<button id="auth-register-btn" type="button">Register</button>
|
||||
<div id="auth-panel" class="auth-modal" hidden>
|
||||
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||
<div class="auth-modal-content">
|
||||
<button id="auth-close-btn" type="button">×</button>
|
||||
<input id="auth-username" type="text" />
|
||||
<input id="auth-password" type="password" />
|
||||
<span id="auth-status"></span>
|
||||
<button id="auth-login-btn" type="button">Login</button>
|
||||
<button id="auth-register-btn" type="button">Register</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
|
||||
<div id="typing-indicator" hidden></div>
|
||||
<div id="dm-search-row" hidden>
|
||||
<input id="dm-search-input" type="text" />
|
||||
<button id="dm-search-btn" type="button">Search</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Stub prompt for DM
|
||||
@@ -253,6 +252,25 @@ describe("WebSocket reconnect", () => {
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||
});
|
||||
|
||||
it("does not reconnect from a socket that was intentionally replaced", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
window.switchRoom("lobby");
|
||||
expect(websocketInstances).toHaveLength(1);
|
||||
const replaced = websocketInstances[0];
|
||||
|
||||
// A room switch intentionally replaces the socket; the old socket must not
|
||||
// schedule its own reconnect, otherwise history is re-pulled on a loop.
|
||||
window.switchRoom("main");
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
expect(replaced.onclose).toBeNull();
|
||||
|
||||
replaced.onclose?.();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL resolution", () => {
|
||||
@@ -437,7 +455,7 @@ describe("sendMessage over websocket", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth and user list wiring", () => {
|
||||
describe("auth wiring", () => {
|
||||
it("logs in and updates the auth UI", async () => {
|
||||
await import("../src/main");
|
||||
|
||||
@@ -467,30 +485,6 @@ describe("auth and user list wiring", () => {
|
||||
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
||||
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
|
||||
});
|
||||
|
||||
it("toggles the user list visibility", async () => {
|
||||
await import("../src/main");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ authenticated: false, entries: [] }),
|
||||
text: async () => "",
|
||||
}) as any;
|
||||
|
||||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||||
|
||||
const panel = document.getElementById("presence-panel")!;
|
||||
const btn = document.getElementById("users-toggle-btn")!;
|
||||
expect(panel.hidden).toBe(false);
|
||||
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(true);
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
btn.click();
|
||||
expect(panel.hidden).toBe(false);
|
||||
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("player error recovery", () => {
|
||||
|
||||
+67
-63
@@ -16,9 +16,7 @@ let ws: WebSocket | null = null;
|
||||
let currentRoom = "main";
|
||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||
let authenticated = false;
|
||||
let currentDMTarget = "";
|
||||
let chatVisible = true;
|
||||
let usersVisible = true;
|
||||
let typingUser = "";
|
||||
let lastTypingSent = 0;
|
||||
let typingTimeout: number | undefined;
|
||||
@@ -38,7 +36,6 @@ export function resetViewerStateForTests() {
|
||||
username = "viewer";
|
||||
authenticated = false;
|
||||
chatVisible = true;
|
||||
usersVisible = true;
|
||||
typingUser = "";
|
||||
lastTypingSent = 0;
|
||||
onlineUsers.clear();
|
||||
@@ -79,19 +76,32 @@ export function resolveManifestUrl() {
|
||||
}
|
||||
|
||||
function connectWebSocket(room: string) {
|
||||
if (ws) ws.close();
|
||||
// Clear previous messages so reconnects render a fresh history instead of
|
||||
// appending duplicates on top of the old log.
|
||||
clearMessages();
|
||||
|
||||
// Detach the old socket's close handler before closing it. Otherwise an
|
||||
// intentional reconnect (login/logout/room switch/session refresh) would
|
||||
// have the replaced socket schedule yet another reconnect, and each new
|
||||
// connection re-pulls the room's history — filling the panel with old
|
||||
// messages on a loop.
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
}
|
||||
|
||||
const socketUrl = new URL(resolveWebSocketBaseUrl());
|
||||
socketUrl.searchParams.set("username", username);
|
||||
socketUrl.searchParams.set("room", room);
|
||||
ws = new WebSocket(socketUrl);
|
||||
const socket = new WebSocket(socketUrl);
|
||||
ws = socket;
|
||||
|
||||
ws.onopen = () => {
|
||||
socket.onopen = () => {
|
||||
console.log(`Connected to chat room: ${room}`);
|
||||
addMessage("system", "Connected to " + room);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
socket.onmessage = (event) => {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "typing") {
|
||||
showTyping(msg.username);
|
||||
@@ -119,12 +129,20 @@ function connectWebSocket(room: string) {
|
||||
addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
socket.onclose = () => {
|
||||
// Ignore close events from sockets that were intentionally replaced.
|
||||
if (ws !== socket) return;
|
||||
ws = null;
|
||||
console.log("Chat disconnected. Reconnecting in 3s...");
|
||||
setTimeout(() => connectWebSocket(room), 3000);
|
||||
};
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
const messages = document.getElementById("messages");
|
||||
if (messages) messages.innerHTML = "";
|
||||
}
|
||||
|
||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||
const messages = document.getElementById("messages");
|
||||
if (!messages) return;
|
||||
@@ -177,7 +195,8 @@ function renderPresence() {
|
||||
|
||||
async function refreshSchedule() {
|
||||
try {
|
||||
const res = await fetch("/api/schedule");
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const res = await fetch(`${apiUrl}/api/schedule`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||
@@ -268,6 +287,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the user session first so we connect once with the correct username
|
||||
// (avoids pushing chat history twice on initial load).
|
||||
await sessionReady;
|
||||
connectWebSocket("main");
|
||||
|
||||
// Start schedule polling
|
||||
@@ -324,46 +346,34 @@ function sendMessage() {
|
||||
|
||||
function switchRoom(room: string) {
|
||||
currentRoom = room;
|
||||
const isDM = room.startsWith("dm:");
|
||||
document.getElementById("room-name")!.textContent =
|
||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
||||
addMessage("system", `Switched to ${room} room`);
|
||||
const searchRow = document.getElementById("dm-search-row");
|
||||
if (searchRow) searchRow.hidden = !isDM;
|
||||
connectWebSocket(room);
|
||||
}
|
||||
|
||||
function togglePrivateDM() {
|
||||
const target = prompt("Enter username to start private chat with:");
|
||||
if (target && target !== username) {
|
||||
currentDMTarget = target;
|
||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||
switchRoom(dmRoom);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchDM() {
|
||||
const input = document.getElementById("dm-search-input") as HTMLInputElement;
|
||||
const q = input.value.trim();
|
||||
if (!q || !currentDMTarget) return;
|
||||
try {
|
||||
const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`);
|
||||
if (!res.ok) return;
|
||||
const results = await res.json();
|
||||
addMessage("system", `Search results (${results.length}) for "${q}":`);
|
||||
for (const m of results) {
|
||||
addMessage(m.type || "private", m.text, m.username, m.timestamp);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
window.switchRoom = switchRoom;
|
||||
window.sendMessage = sendMessage;
|
||||
window.togglePrivateDM = togglePrivateDM;
|
||||
window.toggleChatPanel = toggleChatPanel;
|
||||
|
||||
function openAuthModal() {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = false;
|
||||
}
|
||||
|
||||
function closeAuthModal() {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
function updateAuthUI() {
|
||||
const label = document.getElementById("chat-user-label");
|
||||
const toggle = document.getElementById("auth-toggle-btn");
|
||||
@@ -378,24 +388,26 @@ function updateAuthUI() {
|
||||
if (panel) panel.hidden = true;
|
||||
}
|
||||
|
||||
async function refreshUserSession() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/session");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const wasAnonymous = username.startsWith("viewer");
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
updateAuthUI();
|
||||
if (wasAnonymous) connectWebSocket(currentRoom);
|
||||
} else {
|
||||
authenticated = false;
|
||||
let sessionReady: Promise<void> = Promise.resolve();
|
||||
|
||||
function refreshUserSession(): void {
|
||||
sessionReady = (async () => {
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.authenticated && data.username) {
|
||||
authenticated = true;
|
||||
username = data.username;
|
||||
} else {
|
||||
authenticated = false;
|
||||
}
|
||||
updateAuthUI();
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
} catch {
|
||||
/* non-critical */
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function submitAuth(path: string) {
|
||||
@@ -410,7 +422,8 @@ async function submitAuth(path: string) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(path, {
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
const res = await fetch(`${apiUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: u, password: p }),
|
||||
@@ -446,7 +459,8 @@ function register() {
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||
await fetch(`${apiUrl}/api/auth/logout`, { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -456,15 +470,6 @@ async function logout() {
|
||||
connectWebSocket(currentRoom);
|
||||
}
|
||||
|
||||
function toggleUserList() {
|
||||
const panel = document.getElementById("presence-panel");
|
||||
const btn = document.getElementById("users-toggle-btn");
|
||||
if (!panel) return;
|
||||
usersVisible = !usersVisible;
|
||||
panel.hidden = !usersVisible;
|
||||
if (btn) btn.setAttribute("aria-expanded", String(usersVisible));
|
||||
}
|
||||
|
||||
function showTyping(name: string) {
|
||||
if (name === username) return;
|
||||
const el = document.getElementById("typing-indicator");
|
||||
@@ -501,15 +506,14 @@ function sendTyping() {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void initPlayerAndChat();
|
||||
void refreshUserSession();
|
||||
refreshUserSession();
|
||||
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||
const panel = document.getElementById("auth-panel");
|
||||
if (panel) panel.hidden = !panel.hidden;
|
||||
if (!authenticated) openAuthModal();
|
||||
});
|
||||
document.getElementById("auth-close-btn")?.addEventListener("click", closeAuthModal);
|
||||
document.getElementById("auth-backdrop")?.addEventListener("click", closeAuthModal);
|
||||
document.getElementById("auth-login-btn")?.addEventListener("click", login);
|
||||
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
||||
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
||||
document.getElementById("dm-search-btn")?.addEventListener("click", searchDM);
|
||||
document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList);
|
||||
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||
});
|
||||
+48
-8
@@ -305,19 +305,53 @@ select {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
.auth-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.auth-modal[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.auth-modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.auth-modal-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
width: min(20rem, calc(100vw - 2rem));
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--bg-2);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--panel-2);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.dm-search-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--bg-2);
|
||||
.auth-close {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.auth-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-0);
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
@@ -1010,6 +1044,12 @@ select {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.service-detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.service-status {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
name = "linearvoid-frontend"
|
||||
compatibility_date = "2025-08-01"
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
not_found_handling = "single-page-application"
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -0,0 +1,65 @@
|
||||
# linearvoid-media Worker
|
||||
|
||||
Cloudflare Worker that owns **`media.linearvoid.tv`** and serves the HLS objects
|
||||
stored in the `linearvoid-media` R2 bucket, applying the edge cache policy in code:
|
||||
|
||||
| Object | Cache-Control |
|
||||
| ------------------------------------------- | ------------------------------------------------ |
|
||||
| `.m4s` / `.ts` / `.mp4` / `.m4a` (segments) | `public, max-age=31536000, immutable` |
|
||||
| `.m3u8` (live manifest) | `public, max-age=1, s-maxage=2, must-revalidate` |
|
||||
|
||||
It also adds HLS CORS (`Access-Control-Allow-Origin: *`), OPTIONS preflight, a
|
||||
`/health` probe, and Range-request support. This worker replaces direct R2
|
||||
custom-domain serving on the media host so cache headers/CORS are controlled in
|
||||
code (cache rules on the same host remain the origin baseline).
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
infrastructure/cloudflare/
|
||||
├── wrangler.toml # name/binding + production custom-domain route
|
||||
├── package.json
|
||||
├── src/worker.js # worker module (exports default.fetch + pure helpers)
|
||||
└── test/ # Vitest unit tests + Miniflare (workerd) R2 tests
|
||||
```
|
||||
|
||||
## Local dev / test
|
||||
|
||||
```bash
|
||||
cd infrastructure/cloudflare
|
||||
npm install
|
||||
npm test # unit + Miniflare R2 integration tests
|
||||
npx wrangler dev # run locally against your account's R2 bucket
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
**Preview / test target** (default environment → `*.workers.dev`):
|
||||
|
||||
```bash
|
||||
cd infrastructure/cloudflare
|
||||
npx wrangler deploy
|
||||
```
|
||||
|
||||
**Production** (attaches `media.linearvoid.tv` as a custom domain on this worker):
|
||||
|
||||
```bash
|
||||
cd infrastructure/cloudflare
|
||||
npx wrangler deploy --env production
|
||||
```
|
||||
|
||||
Both need `CLOUDFLARE_API_TOKEN` (Workers Scripts/Routes edit) and
|
||||
`CLOUDFLARE_ACCOUNT_ID` in the environment. The media host must first be removed
|
||||
from the R2 bucket's direct custom-domain binding, otherwise the route collides.
|
||||
|
||||
Automated production deploys run via
|
||||
[`.gitea/workflows/workers-media-deploy.yml`](../../.gitea/workflows/workers-media-deploy.yml)
|
||||
after CI succeeds on `main`.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8 # short TTL
|
||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s # immutable + CORS
|
||||
curl https://media.linearvoid.tv/health # {"status":"ok",...}
|
||||
```
|
||||
+2892
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "linearvoid-media-worker",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Linear Void media edge worker: serves HLS from R2 on media.linearvoid.tv with immutable segment caching.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"start": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"deploy:production": "wrangler deploy --env production",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"miniflare": "^4.0.0",
|
||||
"vitest": "^4.1.10",
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Linear Void media edge worker (linearvoid-media).
|
||||
//
|
||||
// Owns the media.linearvoid.tv host and serves HLS objects from the R2 bucket
|
||||
// bound as MEDIA_BUCKET. Its purpose (arc42 quality goal #1, cost efficiency) is
|
||||
// to guarantee immutable, long-lived caching of media segments at the edge while
|
||||
// keeping live manifests fresh — the caching baseline is also configured as
|
||||
// Cloudflare cache rules on the same host, but this worker enforces it in code.
|
||||
//
|
||||
// Cache policy (matches docs/infrastructure/cloudflare/cache-rules.md + STREAMING.md):
|
||||
// .m4s / .ts / .mp4 / .m4a -> public, max-age=31536000, immutable
|
||||
// .m3u8 (live manifest) -> public, max-age=1, s-maxage=2, must-revalidate
|
||||
|
||||
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
||||
const MANIFEST_CACHE = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||
const DEFAULT_CACHE = "public, max-age=60";
|
||||
|
||||
const IMMUTABLE_SUFFIXES = [".m4s", ".ts", ".mp4", ".m4a"];
|
||||
const MANIFEST_SUFFIXES = [".m3u8"];
|
||||
|
||||
const CONTENT_TYPES = {
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".m4s": "video/mp4",
|
||||
".mp4": "video/mp4",
|
||||
".ts": "video/MP2T",
|
||||
".m4a": "audio/mp4",
|
||||
};
|
||||
|
||||
/** Map a request path to its edge Cache-Control value. Pure + testable. */
|
||||
export function cacheControlForPath(pathname) {
|
||||
const lower = pathname.toLowerCase();
|
||||
for (const suffix of MANIFEST_SUFFIXES) {
|
||||
if (lower.endsWith(suffix)) return MANIFEST_CACHE;
|
||||
}
|
||||
for (const suffix of IMMUTABLE_SUFFIXES) {
|
||||
if (lower.endsWith(suffix)) return IMMUTABLE_CACHE;
|
||||
}
|
||||
return DEFAULT_CACHE;
|
||||
}
|
||||
|
||||
/** Map a request path to a browser-friendly Content-Type. Pure + testable. */
|
||||
export function contentTypeForPath(pathname) {
|
||||
const lower = pathname.toLowerCase();
|
||||
for (const [suffix, type] of Object.entries(CONTENT_TYPES)) {
|
||||
if (lower.endsWith(suffix)) return type;
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Range, If-None-Match, If-Modified-Since",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
};
|
||||
|
||||
function json(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
// Parse a single, closed byte range ("bytes=start-end") into an R2 range object.
|
||||
// Returns null for open/suffix/multi/invalid ranges — the caller then serves the
|
||||
// full object (HTTP 200), which is valid per RFC 7233 (a server may ignore Range).
|
||||
export function parseByteRange(header) {
|
||||
if (!header) return null;
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!m) return null;
|
||||
const start = m[1];
|
||||
const end = m[2];
|
||||
if (start === "" || end === "") return null; // open or suffix range
|
||||
const offset = Number(start);
|
||||
const length = Number(end) - offset + 1;
|
||||
if (!Number.isInteger(offset) || !Number.isInteger(length) || offset < 0 || length < 1) {
|
||||
return null;
|
||||
}
|
||||
return { offset, length };
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// CORS preflight for HLS media.
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
// Liveness probe used by uptime/monitoring (see cloudflare-uptimerobot-checklist).
|
||||
if (url.pathname === "/health") {
|
||||
return json({ status: "ok", service: "linearvoid-media" });
|
||||
}
|
||||
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
return json({ error: "method not allowed" }, 405);
|
||||
}
|
||||
|
||||
const key = decodeURIComponent(url.pathname).replace(/^\/+/, "");
|
||||
if (!key) {
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
if (!env.MEDIA_BUCKET) {
|
||||
return json({ error: "media bucket not configured" }, 500);
|
||||
}
|
||||
|
||||
// Serve from R2, honouring single closed Range requests (used by some hls.js
|
||||
// / player paths for byte-range fetches). Unsupported ranges fall back to a
|
||||
// full-body 200, which clients accept.
|
||||
const rangeHeader = request.headers.get("range");
|
||||
const parsedRange = parseByteRange(rangeHeader);
|
||||
const object = parsedRange
|
||||
? await env.MEDIA_BUCKET.get(key, { range: parsedRange })
|
||||
: await env.MEDIA_BUCKET.get(key);
|
||||
|
||||
if (object === null) {
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
object.writeHttpMetadata(headers);
|
||||
headers.set("Cache-Control", cacheControlForPath(url.pathname));
|
||||
headers.set("Content-Type", contentTypeForPath(url.pathname));
|
||||
headers.set("ETag", object.httpEtag);
|
||||
headers.set("Accept-Ranges", "bytes");
|
||||
headers.set("Access-Control-Allow-Origin", "*");
|
||||
|
||||
let status = 200;
|
||||
if (parsedRange && object.range) {
|
||||
status = 206;
|
||||
const { offset, length } = object.range;
|
||||
headers.set(
|
||||
"Content-Range",
|
||||
`bytes ${offset}-${offset + length - 1}/${object.size}`
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(request.method === "HEAD" ? null : object.body, {
|
||||
status,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Miniflare } from "miniflare";
|
||||
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||
|
||||
// Real-workerd integration test: boots the worker with an in-memory R2 bucket and
|
||||
// dispatches requests through the fetch handler.
|
||||
let mf;
|
||||
|
||||
beforeAll(async () => {
|
||||
mf = new Miniflare({
|
||||
modules: true,
|
||||
scriptPath: "src/worker.js",
|
||||
r2Buckets: { MEDIA_BUCKET: "linearvoid-media" },
|
||||
});
|
||||
const bucket = await mf.getR2Bucket("MEDIA_BUCKET");
|
||||
await bucket.put("hls/playlist.m3u8", "#EXTM3U\n#EXT-X-ENDLIST\n", {
|
||||
httpMetadata: { contentType: "application/vnd.apple.mpegurl" },
|
||||
});
|
||||
await bucket.put("hls/seg-000001.m4s", new Uint8Array([0, 1, 2, 3, 4]));
|
||||
await bucket.put("hls/init.mp4", new Uint8Array([9, 9, 9]));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mf?.dispose();
|
||||
});
|
||||
|
||||
const base = "http://media.linearvoid.tv";
|
||||
|
||||
describe("linearvoid-media worker (R2)", () => {
|
||||
it("serves a media segment with immutable cache headers", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("cache-control")).toBe(IMMUTABLE);
|
||||
expect(res.headers.get("content-type")).toBe("video/mp4");
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(await res.arrayBuffer()).toEqual(new Uint8Array([0, 1, 2, 3, 4]).buffer);
|
||||
});
|
||||
|
||||
it("serves a live manifest with a short TTL", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/playlist.m3u8`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("cache-control")).toBe(MANIFEST);
|
||||
expect(res.headers.get("content-type")).toBe("application/vnd.apple.mpegurl");
|
||||
});
|
||||
|
||||
it("honours Range requests with 206 + Content-Range", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||
headers: { Range: "bytes=0-2" },
|
||||
});
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get("content-range")).toBe("bytes 0-2/5");
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
expect([...bytes]).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("returns 404 for a missing object", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/does-not-exist.m4s`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("answers OPTIONS preflight with CORS headers", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||
method: "OPTIONS",
|
||||
});
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
});
|
||||
|
||||
it("rejects non-GET/HEAD methods", async () => {
|
||||
const res = await mf.dispatchFetch(`${base}/hls/seg-000001.m4s`, {
|
||||
method: "POST",
|
||||
});
|
||||
expect(res.status).toBe(405);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cacheControlForPath, contentTypeForPath, parseByteRange } from "../src/worker.js";
|
||||
|
||||
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||
const MANIFEST = "public, max-age=1, s-maxage=2, must-revalidate";
|
||||
const DEFAULT = "public, max-age=60";
|
||||
|
||||
describe("cacheControlForPath", () => {
|
||||
it("marks media segments as immutable", () => {
|
||||
expect(cacheControlForPath("/hls/seg-000001.m4s")).toBe(IMMUTABLE);
|
||||
expect(cacheControlForPath("/hls/init.mp4")).toBe(IMMUTABLE);
|
||||
expect(cacheControlForPath("/hls/seg-000001.ts")).toBe(IMMUTABLE);
|
||||
expect(cacheControlForPath("/media/audio.m4a")).toBe(IMMUTABLE);
|
||||
});
|
||||
|
||||
it("keeps live manifests short-lived", () => {
|
||||
expect(cacheControlForPath("/hls/playlist.m3u8")).toBe(MANIFEST);
|
||||
expect(cacheControlForPath("/hls/master.m3u8")).toBe(MANIFEST);
|
||||
});
|
||||
|
||||
it("falls back to a sane default for other paths", () => {
|
||||
expect(cacheControlForPath("/hls/unknown.txt")).toBe(DEFAULT);
|
||||
});
|
||||
});
|
||||
|
||||
describe("contentTypeForPath", () => {
|
||||
it("maps known media types", () => {
|
||||
expect(contentTypeForPath("/hls/playlist.m3u8")).toBe("application/vnd.apple.mpegurl");
|
||||
expect(contentTypeForPath("/hls/seg-000001.m4s")).toBe("video/mp4");
|
||||
expect(contentTypeForPath("/hls/init.mp4")).toBe("video/mp4");
|
||||
expect(contentTypeForPath("/hls/seg-000001.ts")).toBe("video/MP2T");
|
||||
expect(contentTypeForPath("/media/audio.m4a")).toBe("audio/mp4");
|
||||
});
|
||||
|
||||
it("falls back to octet-stream", () => {
|
||||
expect(contentTypeForPath("/hls/unknown.bin")).toBe("application/octet-stream");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseByteRange", () => {
|
||||
it("parses a closed byte range", () => {
|
||||
expect(parseByteRange("bytes=0-2")).toEqual({ offset: 0, length: 3 });
|
||||
expect(parseByteRange("bytes=10-19")).toEqual({ offset: 10, length: 10 });
|
||||
});
|
||||
|
||||
it("rejects open, suffix, multi, or malformed ranges", () => {
|
||||
expect(parseByteRange("bytes=0-")).toBeNull();
|
||||
expect(parseByteRange("bytes=-500")).toBeNull();
|
||||
expect(parseByteRange("bytes=0-1,3-4")).toBeNull();
|
||||
expect(parseByteRange("items=0-2")).toBeNull();
|
||||
expect(parseByteRange("")).toBeNull();
|
||||
expect(parseByteRange(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
// Cloudflare Worker for advanced cache rules and WAF (ADR-001)
|
||||
addEventListener("fetch", (event) => {
|
||||
event.respondWith(handleRequest(event.request));
|
||||
});
|
||||
|
||||
async function handleRequest(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Add immutable cache headers for .m4s segments
|
||||
if (url.pathname.endsWith(".m4s")) {
|
||||
const response = await fetch(request);
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
return fetch(request);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Cloudflare Worker: linearvoid-media (edge HLS delivery)
|
||||
# Serves HLS objects from the `linearvoid-media` R2 bucket on media.linearvoid.tv.
|
||||
# Applies immutable caching to media segments and a short TTL to live manifests.
|
||||
# This worker replaces direct R2 custom-domain serving on the media host so cache
|
||||
# headers/CORS are controlled in code (cache rules remain as the origin baseline).
|
||||
|
||||
name = "linearvoid-media"
|
||||
main = "src/worker.js"
|
||||
compatibility_date = "2025-08-01"
|
||||
|
||||
# R2 bucket holding the HLS objects. The request path is mirrored onto the bucket,
|
||||
# e.g. GET /hls/playlist.m3u8 -> object key `hls/playlist.m3u8`.
|
||||
[[r2_buckets]]
|
||||
binding = "MEDIA_BUCKET"
|
||||
bucket_name = "linearvoid-media"
|
||||
|
||||
# Default environment: deploy to the preview *.workers.dev URL (test target).
|
||||
# npx wrangler deploy
|
||||
#
|
||||
# Production environment: attach the media host as a custom domain on this worker.
|
||||
# Note: bindings are NOT inherited by environments, so r2_buckets is redeclared here.
|
||||
# npx wrangler deploy --env production
|
||||
[env.production]
|
||||
routes = [
|
||||
{ pattern = "media.linearvoid.tv", custom_domain = true }
|
||||
]
|
||||
|
||||
[[env.production.r2_buckets]]
|
||||
binding = "MEDIA_BUCKET"
|
||||
bucket_name = "linearvoid-media"
|
||||
|
||||
@@ -8,11 +8,21 @@ http {
|
||||
|
||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||
|
||||
map_hash_bucket_size 128;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
map $http_origin $cors_origin {
|
||||
default "";
|
||||
"https://linearvoid.tv" $http_origin;
|
||||
"https://www.linearvoid.tv" $http_origin;
|
||||
"https://linearvoid-frontend.zwitschi82.workers.dev" $http_origin;
|
||||
"~^https://[a-zA-Z0-9.-]+\.linearvoid\.tv$" $http_origin;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name www.linearvoid.tv;
|
||||
@@ -85,6 +95,14 @@ http {
|
||||
}
|
||||
|
||||
location = /api/admin/login {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $cors_origin;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
@@ -93,9 +111,19 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
}
|
||||
|
||||
location = /api/admin/logout {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $cors_origin;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
@@ -104,9 +132,19 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
}
|
||||
|
||||
location = /api/admin/session {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $cors_origin;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
@@ -115,9 +153,19 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
}
|
||||
|
||||
location /api/admin/ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $cors_origin;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
auth_request /_admin_auth;
|
||||
error_page 401 = @admin_api_unauthorized;
|
||||
set $chat_upstream http://chat:3000;
|
||||
@@ -128,9 +176,19 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header Access-Control-Allow-Origin $cors_origin;
|
||||
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
|
||||
add_header Access-Control-Allow-Credentials true;
|
||||
add_header Access-Control-Max-Age 86400;
|
||||
return 204;
|
||||
}
|
||||
set $chat_upstream http://chat:3000;
|
||||
proxy_pass $chat_upstream;
|
||||
proxy_http_version 1.1;
|
||||
@@ -138,6 +196,8 @@ http {
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
add_header Access-Control-Allow-Origin $cors_origin always;
|
||||
add_header Access-Control-Allow-Credentials true always;
|
||||
}
|
||||
|
||||
location = /metrics {
|
||||
|
||||
Generated
+1148
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
# cache-hit-ratio.sh — Thin wrapper that runs the Node.js cache-hit-ratio tool.
|
||||
# Mirrors scripts/cache-hit-ratio.ps1.
|
||||
set -euo pipefail
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec node "$script_dir/cache-hit-ratio.js" "$@"
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# check-env.ps1 — Verify the deployment environment for Linear Void.
|
||||
#
|
||||
# Checks, against the target Docker host (default test lab 192.168.88.40):
|
||||
# 1. Docker Engine reachability over the TCP socket (port 2375).
|
||||
# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared).
|
||||
# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||
# 4. Public endpoints (app, HLS manifest, admin).
|
||||
# 5. Health endpoints exposed by the services.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-env.ps1
|
||||
# ./scripts/check-env.ps1 -Target 192.168.88.40
|
||||
# ./scripts/check-env.ps1 -Target 192.168.88.40 -DockerPort 2375 -OnlyContainers
|
||||
#
|
||||
# Exit code is 0 when all required checks pass, 1 otherwise.
|
||||
|
||||
param(
|
||||
[string]$Target = '192.168.88.40',
|
||||
[int]$DockerPort = 2375,
|
||||
[switch]$OnlyContainers,
|
||||
[switch]$OnlyEndpoints,
|
||||
[int]$TimeoutSec = 10
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target = $Target
|
||||
$dockerHost = "tcp://${target}:${DockerPort}"
|
||||
|
||||
$failed = $false
|
||||
function Fail([string]$msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:failed = $true }
|
||||
function Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Warn([string]$msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
|
||||
Write-Host "== Linear Void environment check ==" -ForegroundColor Cyan
|
||||
Write-Host "Target Docker host : $dockerHost"
|
||||
Write-Host ("Date : " + (Get-Date -Format s))
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Docker Engine reachability
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not $OnlyEndpoints) {
|
||||
Write-Host "`n-- Docker Engine (${target}:${DockerPort}) --" -ForegroundColor Cyan
|
||||
$env:DOCKER_HOST = $dockerHost
|
||||
try {
|
||||
$ver = docker version --format '{{.Server.Version}}' 2>&1
|
||||
if ($LASTEXITCODE -eq 0 -and $ver -match '\d') {
|
||||
Ok "Docker Engine reachable (server version $ver)"
|
||||
} else {
|
||||
Fail "Docker Engine not reachable: $($ver -join ' ')"
|
||||
}
|
||||
} catch {
|
||||
Fail "Docker Engine error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 2. Coolify helper stack
|
||||
# -----------------------------------------------------------------------
|
||||
Write-Host "`n-- Coolify managed-server helpers --" -ForegroundColor Cyan
|
||||
foreach ($c in @('coolify-proxy', 'coolify-sentinel', 'coolify-cloudflared')) {
|
||||
try {
|
||||
$state = docker inspect $c --format '{{.State.Status}} ({{.State.Health.Status}})' 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
if ($state -match 'running.*(healthy|^running \(running\))') { Ok "coolify helper: $c -> $state" }
|
||||
else { Warn "coolify helper: $c -> $state" }
|
||||
} else {
|
||||
Warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
|
||||
}
|
||||
} catch { Warn "coolify helper: $c -> error $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 3. Linear Void containers
|
||||
# -----------------------------------------------------------------------
|
||||
Write-Host "`n-- Linear Void containers --" -ForegroundColor Cyan
|
||||
$required = @('linearvoid-playout', 'linearvoid-chat', 'linearvoid-nginx',
|
||||
'linearvoid-redis', 'linearvoid-db', 'linearvoid-prometheus', 'linearvoid-grafana')
|
||||
try {
|
||||
$running = @(docker ps --format '{{.Names}}' 2>&1)
|
||||
$all = @(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>&1)
|
||||
foreach ($name in $required) {
|
||||
if ($running -contains $name) {
|
||||
Ok "container: $name is running"
|
||||
} elseif (($all | Where-Object { $_ -match "^$name\s" })) {
|
||||
$status = ($all | Where-Object { $_ -match "^$name\s" } | Select-Object -First 1)
|
||||
Fail "container: $name EXISTS but not running -> $status"
|
||||
} else {
|
||||
Fail "container: $name MISSING"
|
||||
}
|
||||
}
|
||||
} catch { Fail "container listing error: $($_.Exception.Message)" }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Health endpoints (via Docker host)
|
||||
# ---------------------------------------------------------------------------
|
||||
if (-not $OnlyContainers) {
|
||||
Write-Host "`n-- Health endpoints --" -ForegroundColor Cyan
|
||||
$checks = @(
|
||||
@{ Name = 'chat /health'; Url = "http://${target}:3000/health" },
|
||||
@{ Name = 'nginx /health'; Url = "http://${target}:8081/health" },
|
||||
@{ Name = 'prometheus'; Url = "http://${target}:9090/-/healthy" },
|
||||
@{ Name = 'grafana /api/health'; Url = "http://${target}:3001/api/health" }
|
||||
)
|
||||
foreach ($c in $checks) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $c.Url -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop
|
||||
if ($r.StatusCode -eq 200) { Ok "$($c.Name) -> 200" } else { Warn "$($c.Name) -> $($r.StatusCode)" }
|
||||
} catch {
|
||||
Fail "$($c.Name) -> $($_.Exception.Response.StatusCode.value__)"
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# 5. Public endpoints (routed through the Docker host for reliable TLS)
|
||||
# -----------------------------------------------------------------------
|
||||
Write-Host "`n-- Public endpoints (via Docker host) --" -ForegroundColor Cyan
|
||||
$pub = @(
|
||||
@{ Name = 'linearvoid.tv app'; Url = 'https://linearvoid.tv/' },
|
||||
@{ Name = 'media HLS manifest'; Url = 'https://media.linearvoid.tv/hls/playlist.m3u8' },
|
||||
@{ Name = 'coolify dashboard'; Url = 'https://coolify.allucanget.biz/' }
|
||||
)
|
||||
foreach ($c in $pub) {
|
||||
try {
|
||||
$code = (& docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time $TimeoutSec -L $c.Url 2>&1 | Out-String).Trim()
|
||||
if ($code -match '^\d{3}$') { Ok "$($c.Name) -> HTTP $code" }
|
||||
else { Warn "$($c.Name) -> $code" }
|
||||
} catch {
|
||||
Warn "$($c.Name) -> error $($_.Exception.Message.Split("`n")[0])"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failed) { Write-Host "RESULT: FAILED" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "RESULT: OK" -ForegroundColor Green; exit 0
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-env.sh — Verify the deployment environment for Linear Void.
|
||||
# Mirrors scripts/check-env.ps1.
|
||||
#
|
||||
# Checks, against the target Docker host (default test lab 192.168.88.40):
|
||||
# 1. Docker Engine reachability over the TCP socket (port 2375).
|
||||
# 2. Coolify helper stack (proxy/Traefik, sentinel, cloudflared).
|
||||
# 3. Linear Void containers (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||
# 4. Public endpoints (app, HLS manifest, admin).
|
||||
# 5. Health endpoints exposed by the services.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-env.sh
|
||||
# ./scripts/check-env.sh -t 192.168.88.40
|
||||
# ./scripts/check-env.sh -t 192.168.88.40 -p 2375 --only-containers
|
||||
#
|
||||
# Exit code is 0 when all required checks pass, 1 otherwise.
|
||||
set -uo pipefail
|
||||
|
||||
TARGET=192.168.88.40
|
||||
DOCKER_PORT=2375
|
||||
ONLY_CONTAINERS=0
|
||||
ONLY_ENDPOINTS=0
|
||||
TIMEOUT=10
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-t|--target) TARGET="$2"; shift 2;;
|
||||
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
|
||||
--only-containers) ONLY_CONTAINERS=1; shift;;
|
||||
--only-endpoints) ONLY_ENDPOINTS=1; shift;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
|
||||
|
||||
FAILED=0
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
|
||||
ok() { printf ' [OK] %s\n' "$1"; }
|
||||
warn() { printf ' [WARN] %s\n' "$1"; }
|
||||
|
||||
echo "== Linear Void environment check =="
|
||||
echo "Target Docker host : $DOCKER_HOST"
|
||||
echo "Date : $(date -Iseconds)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Docker Engine reachability
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$ONLY_ENDPOINTS" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "-- Docker Engine (${TARGET}:${DOCKER_PORT}) --"
|
||||
if ver=$(docker version --format '{{.Server.Version}}' 2>&1) && [[ "$ver" =~ [0-9] ]]; then
|
||||
ok "Docker Engine reachable (server version $ver)"
|
||||
else
|
||||
fail "Docker Engine not reachable: $ver"
|
||||
fi
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 2. Coolify helper stack
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Coolify managed-server helpers --"
|
||||
for c in coolify-proxy coolify-sentinel coolify-cloudflared; do
|
||||
if state=$(docker inspect "$c" --format '{{.State.Status}} ({{.State.Health.Status}})' 2>/dev/null); then
|
||||
if [[ "$state" =~ running.*(healthy|running) ]]; then ok "coolify helper: $c -> $state"; else warn "coolify helper: $c -> $state"; fi
|
||||
else
|
||||
warn "coolify helper: $c -> NOT FOUND (is this a Coolify-managed server?)"
|
||||
fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 3. Linear Void containers
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Linear Void containers --"
|
||||
required=(linearvoid-playout linearvoid-chat linearvoid-nginx linearvoid-redis linearvoid-db linearvoid-prometheus linearvoid-grafana)
|
||||
running="$(docker ps --format '{{.Names}}' 2>/dev/null)"
|
||||
all="$(docker ps -a --format '{{.Names}}\t{{.Status}}' 2>/dev/null)"
|
||||
for name in "${required[@]}"; do
|
||||
if grep -qx "$name" <<<"$running"; then
|
||||
ok "container: $name is running"
|
||||
elif grep -q "^$name[[:space:]]" <<<"$all"; then
|
||||
status="$(grep "^$name[[:space:]]" <<<"$all" | head -1)"
|
||||
fail "container: $name EXISTS but not running -> $status"
|
||||
else
|
||||
fail "container: $name MISSING"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Health endpoints (via Docker host)
|
||||
# ---------------------------------------------------------------------------
|
||||
if [[ "$ONLY_CONTAINERS" -eq 0 ]]; then
|
||||
echo ""
|
||||
echo "-- Health endpoints --"
|
||||
checks=(
|
||||
"chat /health|http://${TARGET}:3000/health"
|
||||
"nginx /health|http://${TARGET}:8081/health"
|
||||
"prometheus|http://${TARGET}:9090/-/healthy"
|
||||
"grafana /api/health|http://${TARGET}:3001/api/health"
|
||||
)
|
||||
for entry in "${checks[@]}"; do
|
||||
name="${entry%%|*}"; url="${entry#*|}"
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" "$url" 2>/dev/null)
|
||||
if [[ "$code" == "200" ]]; then ok "$name -> 200"; elif [[ "$code" =~ ^[0-9]{3}$ ]]; then warn "$name -> $code"; else fail "$name -> unreachable"; fi
|
||||
done
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# 5. Public endpoints (routed through the Docker host for reliable TLS)
|
||||
# -------------------------------------------------------------------------
|
||||
echo ""
|
||||
echo "-- Public endpoints (via Docker host) --"
|
||||
pub=(
|
||||
"linearvoid.tv app|https://linearvoid.tv/"
|
||||
"media HLS manifest|https://media.linearvoid.tv/hls/playlist.m3u8"
|
||||
"coolify dashboard|https://coolify.allucanget.biz/"
|
||||
)
|
||||
for entry in "${pub[@]}"; do
|
||||
name="${entry%%|*}"; url="${entry#*|}"
|
||||
code=$(docker run --rm curlimages/curl:latest -sS -o /dev/null -w '%{http_code}' --max-time "$TIMEOUT" -L "$url" 2>/dev/null | tr -d '[:space:]')
|
||||
if [[ "$code" =~ ^[0-9]{3}$ ]]; then ok "$name -> HTTP $code"; else warn "$name -> $code"; fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
|
||||
echo "RESULT: OK"
|
||||
exit 0
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# check-tokens.ps1 — Verify that the credentials in the environment actually work.
|
||||
#
|
||||
# Reads .env (or a supplied file) and validates access against live services:
|
||||
# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER).
|
||||
# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token).
|
||||
# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets.
|
||||
# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin.
|
||||
#
|
||||
# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so
|
||||
# network + TLS work regardless of this machine's proxy/firewall. Uses the
|
||||
# curlimages/curl and amazon/aws-cli images, which are pulled on demand.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-tokens.ps1
|
||||
# ./scripts/check-tokens.ps1 -EnvFile .env
|
||||
# ./scripts/check-tokens.ps1 -Target 192.168.88.40
|
||||
#
|
||||
# Exit code 0 = all credentials valid; 1 = at least one check failed.
|
||||
|
||||
param(
|
||||
[string]$EnvFile = '',
|
||||
[string]$Target = '192.168.88.40',
|
||||
[int]$DockerPort = 2375,
|
||||
[int]$TimeoutSec = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target = $Target
|
||||
$dockerHost = "tcp://${target}:${DockerPort}"
|
||||
$env:DOCKER_HOST = $dockerHost
|
||||
|
||||
$failed = $false
|
||||
function Fail([string]$msg) { Write-Host " [FAIL] $msg" -ForegroundColor Red; $script:failed = $true }
|
||||
function Ok([string]$msg) { Write-Host " [OK] $msg" -ForegroundColor Green }
|
||||
function Warn([string]$msg) { Write-Host " [WARN] $msg" -ForegroundColor Yellow }
|
||||
|
||||
# Resolve env file: explicit, or $PWD/.env, or repo root.
|
||||
if (-not $EnvFile) {
|
||||
foreach ($cand in @((Join-Path $PWD '.env'), (Join-Path (Split-Path $PWD) '.env'))) {
|
||||
if (Test-Path $cand) { $EnvFile = $cand; break }
|
||||
}
|
||||
}
|
||||
if (-not $EnvFile -or -not (Test-Path $EnvFile)) {
|
||||
Write-Host "No .env file found. Pass one with -EnvFile." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse KEY=VALUE lines into a hashtable.
|
||||
$envs = @{}
|
||||
Get-Content $EnvFile | ForEach-Object {
|
||||
if ($_ -match '^\s*([A-Za-z0-9_]+)\s*=\s*(.*?)\s*$') {
|
||||
$envs[$Matches[1]] = $Matches[2]
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Val([string]$key) {
|
||||
if ($envs.ContainsKey($key)) { return $envs[$key] }
|
||||
return $null
|
||||
}
|
||||
|
||||
# Pull helper images once.
|
||||
Write-Host "Preparing helper images (curl, aws-cli) on $target..." -ForegroundColor Cyan
|
||||
docker pull curlimages/curl:latest 2>&1 | Out-Null
|
||||
docker pull amazon/aws-cli:latest 2>&1 | Out-Null
|
||||
|
||||
function Curl([string]$url, [string]$bearer, [string]$tokenHeader) {
|
||||
$a = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec")
|
||||
if ($bearer) { $a += @('-H', "Authorization: Bearer $bearer") }
|
||||
if ($tokenHeader) { $a += @('-H', "Authorization: token $tokenHeader") }
|
||||
$a += $url
|
||||
return (& docker @a 2>&1 | Out-String).Trim()
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host "== Linear Void credential / token check ==" -ForegroundColor Cyan
|
||||
Write-Host "Env file : $EnvFile"
|
||||
Write-Host "Docker host: $dockerHost"
|
||||
|
||||
# --- 1. Gitea registry token ---
|
||||
Write-Host "`n-- Gitea container registry --" -ForegroundColor Cyan
|
||||
$regUser = Get-Val 'CI_REGISTRY_USER'
|
||||
$regToken = Get-Val 'CI_REGISTRY_TOKEN'
|
||||
if ($regUser -and $regToken) {
|
||||
# Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
|
||||
$out = Curl 'https://git.allucanget.biz/api/v1/user' $null $regToken
|
||||
if ($out -match 'token does not have at least one of required scope' -or $out -match '"login"' -or $out -match 'invalid_grant') {
|
||||
if ($out -match 'invalid_grant|Unauthorized|invalid') {
|
||||
Fail "Gitea registry token rejected ($regUser): $out"
|
||||
} else {
|
||||
Ok "Gitea registry token valid for '$regUser' (scope: write:package/write:repository)"
|
||||
}
|
||||
} else {
|
||||
Warn "Gitea registry token response unexpected: $out"
|
||||
}
|
||||
} else {
|
||||
Warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
|
||||
}
|
||||
|
||||
# --- 2. Cloudflare API token ---
|
||||
Write-Host "`n-- Cloudflare API tokens --" -ForegroundColor Cyan
|
||||
$cfToken = Get-Val 'CLOUDFLARE_API_TOKEN'
|
||||
if ($cfToken) {
|
||||
$out = Curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' $cfToken $null
|
||||
if ($out -match '"success":true') {
|
||||
if ($out -match '"status":"active"') { Ok 'Cloudflare API token valid and ACTIVE' }
|
||||
else { Warn "Cloudflare API token valid but status: $out" }
|
||||
} else {
|
||||
Fail "Cloudflare API token rejected: $out"
|
||||
}
|
||||
} else { Warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check" }
|
||||
|
||||
# --- 3. Cloudflare R2 / S3 bucket access ---
|
||||
Write-Host "`n-- Cloudflare R2 (S3-compatible) --" -ForegroundColor Cyan
|
||||
$r2Key = Get-Val 'R2_ACCESS_KEY_ID'
|
||||
$r2Secret = Get-Val 'R2_SECRET_ACCESS_KEY'
|
||||
$r2Ep = Get-Val 'R2_ENDPOINT'
|
||||
$bucket = Get-Val 'R2_BUCKET_NAME'
|
||||
|
||||
# Use the admin key pair (which has ListBuckets) when present, else the regular key.
|
||||
$listKey = if ((Get-Val 'R2_ADMIN_ACCESS_KEY_ID') -and (Get-Val 'R2_ADMIN_SECRET_ACCESS_KEY')) {
|
||||
@{ k = Get-Val 'R2_ADMIN_ACCESS_KEY_ID'; s = Get-Val 'R2_ADMIN_SECRET_ACCESS_KEY' }
|
||||
} else { @{ k = $r2Key; s = $r2Secret } }
|
||||
|
||||
if ($listKey.k -and $listKey.s -and $r2Ep) {
|
||||
$script = @"
|
||||
export AWS_ACCESS_KEY_ID='$($listKey.k)'
|
||||
export AWS_SECRET_ACCESS_KEY='$($listKey.s)'
|
||||
aws s3 ls --endpoint-url '$r2Ep' --region auto 2>&1
|
||||
"@
|
||||
$out = (& docker run --rm --entrypoint sh amazon/aws-cli:latest -c $script 2>&1 | Out-String).Trim()
|
||||
if ($out -match $bucket) { Ok "R2 bucket listing OK, '$bucket' present" }
|
||||
elseif ($out -match 'AccessDenied') { Fail "R2 listing denied (no ListBuckets permission): $out" }
|
||||
elseif ($out -match '^\S+\s+') { Warn "R2 listing returned (unexpected): $out" }
|
||||
else { Fail "R2 access failed: $out" }
|
||||
} else { Warn "R2 credentials / endpoint not fully set; skipping R2 check" }
|
||||
|
||||
# Verify the specific application bucket is reachable with the primary key (read).
|
||||
if ($r2Key -and $r2Secret -and $r2Ep -and $bucket) {
|
||||
$script = @"
|
||||
export AWS_ACCESS_KEY_ID='$r2Key'
|
||||
export AWS_SECRET_ACCESS_KEY='$r2Secret'
|
||||
aws s3 ls "s3://$bucket/" --endpoint-url '$r2Ep' --region auto --max-keys 5 2>&1 | head -5
|
||||
"@
|
||||
$out = (& docker run --rm --entrypoint sh amazon/aws-cli:latest -c $script 2>&1 | Out-String).Trim()
|
||||
if ($out -match 'AccessDenied') { Fail "Bucket '$bucket' read denied with primary key" }
|
||||
elseif ($out -eq '') { Ok "Bucket '$bucket' reachable (empty listing, expected)" }
|
||||
elseif ($out -match 'NoSuchBucket') { Fail "Bucket '$bucket' NOT FOUND" }
|
||||
else { Ok "Bucket '$bucket' reachable" }
|
||||
} elseif (-not $bucket) { Warn "R2_BUCKET_NAME not set; skipping bucket-level check" }
|
||||
|
||||
# --- 4. Linear Void admin login ---
|
||||
Write-Host "`n-- Linear Void admin login --" -ForegroundColor Cyan
|
||||
$adminUser = Get-Val 'ADMIN_USERNAME'
|
||||
$adminPass = Get-Val 'ADMIN_PASSWORD'
|
||||
if ($adminUser -and $adminPass) {
|
||||
# Hit the chat backend directly (public origin is caught by nginx SPA fallback).
|
||||
$url = "http://${target}:3000/api/admin/login"
|
||||
$body = ('{"username":"' + $adminUser + '","password":"' + $adminPass + '"}')
|
||||
$wrongBody = ('{"username":"' + $adminUser + '","password":"__definitely_wrong__"}')
|
||||
$a = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec",
|
||||
'-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST',
|
||||
'-H', 'Content-Type: application/json', '-d', $body, $url)
|
||||
$okCode = (& docker @a 2>&1 | Out-String).Trim()
|
||||
$b = @('run', '--rm', 'curlimages/curl:latest', '-sS', '--max-time', "$TimeoutSec",
|
||||
'-o', '/dev/null', '-w', '%{http_code}', '-X', 'POST',
|
||||
'-H', 'Content-Type: application/json', '-d', $wrongBody, $url)
|
||||
$wrongCode = (& docker @b 2>&1 | Out-String).Trim()
|
||||
if ($okCode -eq '200' -and $wrongCode -eq '401') { Ok "Admin credentials valid (correct=200, wrong=401)" }
|
||||
else { Fail "Admin login unexpected (correct=$okCode, wrong=$wrongCode)" }
|
||||
} else { Warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check" }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failed) { Write-Host "RESULT: FAILED" -ForegroundColor Red; exit 1 }
|
||||
Write-Host "RESULT: OK (no failing checks)" -ForegroundColor Green; exit 0
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-tokens.sh — Verify that the credentials in the environment actually work.
|
||||
# Mirrors scripts/check-tokens.ps1.
|
||||
#
|
||||
# Reads .env (or a supplied file) and validates access against live services:
|
||||
# 1. Gitea container registry (CI_REGISTRY_TOKEN / CI_REGISTRY_USER).
|
||||
# 2. Cloudflare API token(s) (CLOUDFLARE_API_TOKEN, and any R2 admin token).
|
||||
# 3. R2/S3 bucket access (R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY) — lists buckets.
|
||||
# 4. Linear Void admin login (ADMIN_USERNAME / ADMIN_PASSWORD) against the app origin.
|
||||
#
|
||||
# NOTE: Runs the checks through the target Docker host (default 192.168.88.40) so
|
||||
# network + TLS work regardless of this machine's proxy/firewall. Uses the
|
||||
# curlimages/curl and amazon/aws-cli images, which are pulled on demand.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check-tokens.sh
|
||||
# ./scripts/check-tokens.sh -e .env
|
||||
# ./scripts/check-tokens.sh -t 192.168.88.40
|
||||
#
|
||||
# Exit code 0 = all credentials valid; 1 = at least one check failed.
|
||||
set -uo pipefail
|
||||
|
||||
ENV_FILE=""
|
||||
TARGET=192.168.88.40
|
||||
DOCKER_PORT=2375
|
||||
TIMEOUT=20
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-e|--env-file) ENV_FILE="$2"; shift 2;;
|
||||
-t|--target) TARGET="$2"; shift 2;;
|
||||
-p|--docker-port) DOCKER_PORT="$2"; shift 2;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
*) echo "Unknown option: $1" >&2; exit 2;;
|
||||
esac
|
||||
done
|
||||
|
||||
export DOCKER_HOST="tcp://${TARGET}:${DOCKER_PORT}"
|
||||
|
||||
FAILED=0
|
||||
fail() { printf ' [FAIL] %s\n' "$1"; FAILED=1; }
|
||||
ok() { printf ' [OK] %s\n' "$1"; }
|
||||
warn() { printf ' [WARN] %s\n' "$1"; }
|
||||
|
||||
# Resolve env file: explicit, or $PWD/.env, or repo root.
|
||||
if [[ -z "$ENV_FILE" ]]; then
|
||||
for cand in "$PWD/.env" "$(dirname "$PWD")/.env"; do
|
||||
if [[ -f "$cand" ]]; then ENV_FILE="$cand"; break; fi
|
||||
done
|
||||
fi
|
||||
if [[ -z "$ENV_FILE" || ! -f "$ENV_FILE" ]]; then
|
||||
echo "No .env file found. Pass one with -e." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse KEY=VALUE lines into an associative array.
|
||||
declare -A envs
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)[[:space:]]*$ ]]; then
|
||||
envs["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}"
|
||||
fi
|
||||
done < "$ENV_FILE"
|
||||
|
||||
get_val() { [[ -n "${envs[$1]+x}" ]] && echo "${envs[$1]}"; }
|
||||
|
||||
# Pull helper images once.
|
||||
echo "Preparing helper images (curl, aws-cli) on $TARGET..."
|
||||
docker pull curlimages/curl:latest >/dev/null 2>&1 || true
|
||||
docker pull amazon/aws-cli:latest >/dev/null 2>&1 || true
|
||||
|
||||
# dc_curl URL [bearer] [token] — run curl via the curlimages/curl container.
|
||||
dc_curl() {
|
||||
local url="$1" bearer="${2:-}" token="${3:-}"
|
||||
local -a args=(run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT")
|
||||
[[ -n "$bearer" ]] && args+=( -H "Authorization: Bearer $bearer" )
|
||||
[[ -n "$token" ]] && args+=( -H "Authorization: token $token" )
|
||||
args+=( "$url" )
|
||||
docker "${args[@]}" 2>&1 | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "== Linear Void credential / token check =="
|
||||
echo "Env file : $ENV_FILE"
|
||||
echo "Docker host: $DOCKER_HOST"
|
||||
|
||||
# --- 1. Gitea registry token ---
|
||||
echo ""
|
||||
echo "-- Gitea container registry --"
|
||||
reg_user="$(get_val CI_REGISTRY_USER)"
|
||||
reg_token="$(get_val CI_REGISTRY_TOKEN)"
|
||||
if [[ -n "$reg_user" && -n "$reg_token" ]]; then
|
||||
# Non-sensitive probe: /api/v1/user returns a scope message for token-based creds.
|
||||
out="$(dc_curl 'https://git.allucanget.biz/api/v1/user' "" "$reg_token")"
|
||||
if [[ "$out" =~ token.does.not.have.at.least.one.of.required.scope || "$out" =~ '"login"' || "$out" =~ invalid_grant ]]; then
|
||||
if [[ "$out" =~ invalid_grant|Unauthorized|invalid ]]; then
|
||||
fail "Gitea registry token rejected ($reg_user): $out"
|
||||
else
|
||||
ok "Gitea registry token valid for '$reg_user' (scope: write:package/write:repository)"
|
||||
fi
|
||||
else
|
||||
warn "Gitea registry token response unexpected: $out"
|
||||
fi
|
||||
else
|
||||
warn "CI_REGISTRY_USER / CI_REGISTRY_TOKEN not set; skipping Gitea registry check"
|
||||
fi
|
||||
|
||||
# --- 2. Cloudflare API token ---
|
||||
echo ""
|
||||
echo "-- Cloudflare API tokens --"
|
||||
cf_token="$(get_val CLOUDFLARE_API_TOKEN)"
|
||||
if [[ -n "$cf_token" ]]; then
|
||||
out="$(dc_curl 'https://api.cloudflare.com/client/v4/user/tokens/verify' "$cf_token")"
|
||||
if [[ "$out" =~ '"success":true' ]]; then
|
||||
if [[ "$out" =~ '"status":"active"' ]]; then ok 'Cloudflare API token valid and ACTIVE'; else warn "Cloudflare API token valid but status: $out"; fi
|
||||
else
|
||||
fail "Cloudflare API token rejected: $out"
|
||||
fi
|
||||
else
|
||||
warn "CLOUDFLARE_API_TOKEN not set; skipping Cloudflare check"
|
||||
fi
|
||||
|
||||
# --- 3. Cloudflare R2 / S3 bucket access ---
|
||||
echo ""
|
||||
echo "-- Cloudflare R2 (S3-compatible) --"
|
||||
r2_key="$(get_val R2_ACCESS_KEY_ID)"
|
||||
r2_secret="$(get_val R2_SECRET_ACCESS_KEY)"
|
||||
r2_ep="$(get_val R2_ENDPOINT)"
|
||||
bucket="$(get_val R2_BUCKET_NAME)"
|
||||
|
||||
# Use the admin key pair (which has ListBuckets) when present, else the regular key.
|
||||
list_k="$(get_val R2_ADMIN_ACCESS_KEY_ID)"; list_s="$(get_val R2_ADMIN_SECRET_ACCESS_KEY)"
|
||||
if [[ -z "$list_k" || -z "$list_s" ]]; then list_k="$r2_key"; list_s="$r2_secret"; fi
|
||||
|
||||
if [[ -n "$list_k" && -n "$list_s" && -n "$r2_ep" ]]; then
|
||||
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$list_k'; export AWS_SECRET_ACCESS_KEY='$list_s'; aws s3 ls --endpoint-url '$r2_ep' --region auto 2>&1" 2>&1)"
|
||||
if grep -q "$bucket" <<<"$out"; then ok "R2 bucket listing OK, '$bucket' present"
|
||||
elif grep -q 'AccessDenied' <<<"$out"; then fail "R2 listing denied (no ListBuckets permission): $out"
|
||||
elif grep -qE '^[^[:space:]]+[[:space:]]+' <<<"$out"; then warn "R2 listing returned (unexpected): $out"
|
||||
else fail "R2 access failed: $out"; fi
|
||||
else
|
||||
warn "R2 credentials / endpoint not fully set; skipping R2 check"
|
||||
fi
|
||||
|
||||
# Verify the specific application bucket is reachable with the primary key (read).
|
||||
if [[ -n "$r2_key" && -n "$r2_secret" && -n "$r2_ep" && -n "$bucket" ]]; then
|
||||
out="$(docker run --rm --entrypoint sh amazon/aws-cli:latest -c "export AWS_ACCESS_KEY_ID='$r2_key'; export AWS_SECRET_ACCESS_KEY='$r2_secret'; aws s3 ls s3://$bucket/ --endpoint-url '$r2_ep' --region auto --max-keys 5 2>&1 | head -5" 2>&1)"
|
||||
if grep -q 'AccessDenied' <<<"$out"; then fail "Bucket '$bucket' read denied with primary key"
|
||||
elif [[ -z "$out" ]]; then ok "Bucket '$bucket' reachable (empty listing, expected)"
|
||||
elif grep -q 'NoSuchBucket' <<<"$out"; then fail "Bucket '$bucket' NOT FOUND"
|
||||
else ok "Bucket '$bucket' reachable"; fi
|
||||
elif [[ -z "$bucket" ]]; then
|
||||
warn "R2_BUCKET_NAME not set; skipping bucket-level check"
|
||||
fi
|
||||
|
||||
# --- 4. Linear Void admin login ---
|
||||
echo ""
|
||||
echo "-- Linear Void admin login --"
|
||||
admin_user="$(get_val ADMIN_USERNAME)"
|
||||
admin_pass="$(get_val ADMIN_PASSWORD)"
|
||||
if [[ -n "$admin_user" && -n "$admin_pass" ]]; then
|
||||
# Hit the chat backend directly (public origin is caught by nginx SPA fallback).
|
||||
url="http://${TARGET}:3000/api/admin/login"
|
||||
body="{\"username\":\"$admin_user\",\"password\":\"$admin_pass\"}"
|
||||
wrong_body="{\"username\":\"$admin_user\",\"password\":\"__definitely_wrong__\"}"
|
||||
ok_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$body" "$url" 2>/dev/null | tr -d '[:space:]')"
|
||||
wrong_code="$(docker run --rm curlimages/curl:latest -sS --max-time "$TIMEOUT" -o /dev/null -w '%{http_code}' -X POST -H 'Content-Type: application/json' -d "$wrong_body" "$url" 2>/dev/null | tr -d '[:space:]')"
|
||||
if [[ "$ok_code" == "200" && "$wrong_code" == "401" ]]; then ok "Admin credentials valid (correct=200, wrong=401)"
|
||||
else fail "Admin login unexpected (correct=$ok_code, wrong=$wrong_code)"; fi
|
||||
else
|
||||
warn "ADMIN_USERNAME / ADMIN_PASSWORD not set; skipping admin check"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
echo ""
|
||||
if [[ "$FAILED" -ne 0 ]]; then echo "RESULT: FAILED"; exit 1; fi
|
||||
echo "RESULT: OK (no failing checks)"
|
||||
exit 0
|
||||
+20
-49
@@ -2,66 +2,42 @@
|
||||
|
||||
These files back the [scaling/performance playbook](../../docs/operations/scaling-performance-playbook.md).
|
||||
|
||||
## WebSocket chat
|
||||
## WebSocket chat (artillery)
|
||||
|
||||
Run with an explicit WebSocket URL, for example:
|
||||
Run with an explicit WebSocket URL, capturing output to an artifact file:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
||||
```
|
||||
|
||||
Capture output to an artifact file for the worksheet:
|
||||
|
||||
```bash
|
||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:WS_URL = 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
mkdir -p artifacts
|
||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
||||
```bash
|
||||
scripts/loadtest/summarize-artillery.sh artifacts/ws-chat-report.json
|
||||
```
|
||||
|
||||
## Admin API
|
||||
## Admin API (k6)
|
||||
|
||||
Run with a cookie or session header that can access admin endpoints:
|
||||
Run with a cookie or session header that can access admin endpoints, capturing machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
||||
```
|
||||
|
||||
Capture machine-readable metrics and a text summary:
|
||||
|
||||
```bash
|
||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js > artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
PowerShell equivalent:
|
||||
|
||||
```powershell
|
||||
$env:ADMIN_BASE_URL = 'https://linearvoid.tv'
|
||||
$env:ADMIN_SESSION_COOKIE = 'session=...'
|
||||
New-Item -ItemType Directory -Force artifacts | Out-Null
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | Tee-Object -FilePath artifacts/admin-api-output.txt
|
||||
export ADMIN_BASE_URL='https://linearvoid.tv'
|
||||
export ADMIN_SESSION_COOKIE='session=...'
|
||||
mkdir -p artifacts
|
||||
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
|
||||
```
|
||||
|
||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
||||
|
||||
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
||||
```bash
|
||||
scripts/loadtest/summarize-k6.sh artifacts/admin-api-summary.json
|
||||
```
|
||||
|
||||
## PowerShell helper
|
||||
## Runner helper
|
||||
|
||||
Use the helper to run one scenario or both and automatically write outputs into a dated artifact folder:
|
||||
|
||||
@@ -69,16 +45,11 @@ Use the helper to run one scenario or both and automatically write outputs into
|
||||
- WebSocket runs also emit `ws-chat-summary.txt` with connection/error counters and available summary percentiles.
|
||||
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario admin -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```
|
||||
|
||||
```powershell
|
||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario all -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main' -AdminBaseUrl 'https://linearvoid.tv' -AdminSessionCookie 'session=...'
|
||||
```bash
|
||||
scripts/loadtest/run-loadtests.sh -s ws -w 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||
scripts/loadtest/run-loadtests.sh -s admin -a 'https://linearvoid.tv' -c 'session=...'
|
||||
scripts/loadtest/run-loadtests.sh -s all -w 'wss://linearvoid.tv/ws?username=loadtest&room=main' \
|
||||
-a 'https://linearvoid.tv' -c 'session=...'
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-loadtests.sh — Run an artillery (ws) and/or k6 (admin) load-test scenario
|
||||
# and write outputs into a dated artifacts folder.
|
||||
# Mirrors scripts/loadtest/run-loadtests.ps1.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/loadtest/run-loadtests.sh -s ws -w 'wss://...'
|
||||
# ./scripts/loadtest/run-loadtests.sh -s admin -a 'https://...' -c 'session=...'
|
||||
# ./scripts/loadtest/run-loadtests.sh -s all -w 'wss://...' -a 'https://...' -c 'session=...'
|
||||
set -euo pipefail
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd "$script_dir/../.." && pwd)"
|
||||
|
||||
SCENARIO="all"
|
||||
ARTIFACT_ROOT="artifacts"
|
||||
WS_URL="${WS_URL:-}"
|
||||
ADMIN_BASE_URL="${ADMIN_BASE_URL:-}"
|
||||
ADMIN_SESSION_COOKIE="${ADMIN_SESSION_COOKIE:-}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [-s ws|admin|all] [-w ws_url] [-a admin_base_url] [-c admin_session_cookie] [-r artifact_root]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-s) SCENARIO="$2"; shift 2;;
|
||||
-w) WS_URL="$2"; shift 2;;
|
||||
-a) ADMIN_BASE_URL="$2"; shift 2;;
|
||||
-c) ADMIN_SESSION_COOKIE="$2"; shift 2;;
|
||||
-r) ARTIFACT_ROOT="$2"; shift 2;;
|
||||
-h|--help) usage;;
|
||||
*) echo "Unknown option: $1" >&2; usage;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$SCENARIO" in ws|admin|all) ;; *) echo "Scenario must be ws|admin|all" >&2; exit 2;; esac
|
||||
|
||||
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]] && ! command -v npx >/dev/null 2>&1; then
|
||||
echo "npx is required for ws scenario but was not found in PATH." >&2; exit 1
|
||||
fi
|
||||
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]] && ! command -v k6 >/dev/null 2>&1; then
|
||||
echo "k6 is required for admin scenario but was not found in PATH." >&2; exit 1
|
||||
fi
|
||||
|
||||
if [[ "$ARTIFACT_ROOT" = /* ]]; then artifact_base="$ARTIFACT_ROOT"; else artifact_base="$repo_root/$ARTIFACT_ROOT"; fi
|
||||
artifact_dir="$artifact_base/$(date +%Y-%m-%d_%H%M%S)"
|
||||
mkdir -p "$artifact_dir"
|
||||
|
||||
if [[ "$SCENARIO" == ws || "$SCENARIO" == all ]]; then
|
||||
[[ -n "$WS_URL" ]] || { echo "WS_URL is required for ws scenario. Pass -w or set \$WS_URL." >&2; exit 1; }
|
||||
ws_out="$artifact_dir/ws-chat-report.json"
|
||||
ws_sum="$artifact_dir/ws-chat-summary.txt"
|
||||
echo "== Running artillery ws scenario =="
|
||||
WS_URL="$WS_URL" npx artillery run "$script_dir/ws-chat.yml" --output "$ws_out"
|
||||
"$script_dir/summarize-artillery.sh" "$ws_out" | tee "$ws_sum"
|
||||
fi
|
||||
|
||||
if [[ "$SCENARIO" == admin || "$SCENARIO" == all ]]; then
|
||||
[[ -n "$ADMIN_BASE_URL" ]] || { echo "ADMIN_BASE_URL is required for admin scenario. Pass -a or set \$ADMIN_BASE_URL." >&2; exit 1; }
|
||||
[[ -n "$ADMIN_SESSION_COOKIE" ]] || { echo "ADMIN_SESSION_COOKIE is required for admin scenario. Pass -c or set \$ADMIN_SESSION_COOKIE." >&2; exit 1; }
|
||||
admin_sum="$artifact_dir/admin-api-summary.json"
|
||||
admin_out="$artifact_dir/admin-api-output.txt"
|
||||
admin_ep_sum="$artifact_dir/admin-api-endpoint-summary.txt"
|
||||
echo "== Running k6 admin scenario =="
|
||||
ADMIN_BASE_URL="$ADMIN_BASE_URL" ADMIN_SESSION_COOKIE="$ADMIN_SESSION_COOKIE" \
|
||||
k6 run --summary-export "$admin_sum" "$script_dir/admin-api.js" | tee "$admin_out"
|
||||
"$script_dir/summarize-k6.sh" "$admin_sum" | tee "$admin_ep_sum"
|
||||
fi
|
||||
|
||||
echo "Artifacts written to $artifact_dir"
|
||||
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
# summarize-artillery.sh — Extract counters, rates and percentile stats from an
|
||||
# Artillery JSON report. Mirrors scripts/loadtest/summarize-artillery.ps1.
|
||||
# Usage: ./scripts/loadtest/summarize-artillery.sh <artillery-report.json>
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <artillery-report.json>" >&2
|
||||
exit 2
|
||||
fi
|
||||
report_file="$1"
|
||||
[[ -f "$report_file" ]] || { echo "Report file not found: $report_file" >&2; exit 1; }
|
||||
|
||||
REPORT_FILE="$report_file" node <<'EOF'
|
||||
const fs = require('fs');
|
||||
const report = JSON.parse(fs.readFileSync(process.env.REPORT_FILE, 'utf8'));
|
||||
const aggregate = report.aggregate;
|
||||
if (!aggregate) throw new Error('Artillery report is missing aggregate data.');
|
||||
|
||||
const get = (obj, name) => (obj && obj[name] !== undefined) ? obj[name] : undefined;
|
||||
const counters = aggregate.counters || {};
|
||||
const rates = aggregate.rates || {};
|
||||
const summaries = aggregate.summaries || {};
|
||||
|
||||
const created = Number(get(counters, 'vusers.created') || 0);
|
||||
const failed = Number(get(counters, 'vusers.failed') || 0);
|
||||
const completed = Number(get(counters, 'vusers.completed') || 0);
|
||||
const errorRate = created > 0 ? +((failed / created) * 100).toFixed(2) : 0;
|
||||
|
||||
console.log('=== Artillery WebSocket Summary ===');
|
||||
console.log(`source: ${process.env.REPORT_FILE}`);
|
||||
if (report.timestamp) console.log(`timestamp: ${report.timestamp}`);
|
||||
console.log(`vusers.created: ${created}`);
|
||||
console.log(`vusers.completed: ${completed}`);
|
||||
console.log(`vusers.failed: ${failed}`);
|
||||
console.log(`vusers.failure_rate_percent: ${errorRate}`);
|
||||
|
||||
for (const metric of ['ws.messages_sent', 'ws.messages_received', 'ws.ping', 'plugins.metrics-by-endpoint.response_time']) {
|
||||
const v = get(counters, metric);
|
||||
if (v !== undefined) console.log(`${metric}: ${v}`);
|
||||
}
|
||||
for (const [k, v] of Object.entries(rates || {})) console.log(`rate.${k}: ${v}`);
|
||||
|
||||
const fmt = (prefix, stats) => {
|
||||
if (!stats) return;
|
||||
const parts = [];
|
||||
for (const name of ['count', 'min', 'max', 'mean', 'median', 'p95', 'p99']) {
|
||||
const v = get(stats, name);
|
||||
if (v !== undefined) parts.push(`${name}=${v}`);
|
||||
}
|
||||
if (parts.length) console.log(`${prefix}: ${parts.join(', ')}`);
|
||||
};
|
||||
for (const [k, v] of Object.entries(summaries)) fmt(`summary.${k}`, v);
|
||||
EOF
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# summarize-k6.sh — Extract per-endpoint p95 and failure rates from a k6 JSON
|
||||
# summary export. Mirrors scripts/loadtest/summarize-k6.ps1.
|
||||
# Usage: ./scripts/loadtest/summarize-k6.sh <k6-summary.json>
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <k6-summary.json>" >&2
|
||||
exit 2
|
||||
fi
|
||||
summary_file="$1"
|
||||
[[ -f "$summary_file" ]] || { echo "Summary file not found: $summary_file" >&2; exit 1; }
|
||||
|
||||
SUMMARY_FILE="$summary_file" node <<'EOF'
|
||||
const fs = require('fs');
|
||||
const summary = JSON.parse(fs.readFileSync(process.env.SUMMARY_FILE, 'utf8'));
|
||||
const metrics = summary.metrics;
|
||||
if (!metrics) throw new Error('Invalid k6 summary file: missing metrics object.');
|
||||
|
||||
const val = (m, key) => (m && m.values && m.values[key] !== undefined) ? Number(m.values[key]) : undefined;
|
||||
const globalDuration = val(metrics.http_req_duration, 'p(95)');
|
||||
const globalFailRate = val(metrics.http_req_failed, 'rate');
|
||||
|
||||
console.log(`k6 Summary: ${process.env.SUMMARY_FILE}`);
|
||||
if (globalDuration !== undefined) console.log(`Global p95 duration (ms): ${globalDuration.toFixed(2)}`);
|
||||
if (globalFailRate !== undefined) console.log(`Global failure rate: ${(globalFailRate * 100).toFixed(2)}%`);
|
||||
console.log('');
|
||||
|
||||
const endpoints = ['overview', 'analytics', 'chat_log', 'users'];
|
||||
const rows = endpoints.map((ep) => {
|
||||
const dP95 = val(metrics[`http_req_duration{endpoint:${ep}}`], 'p(95)');
|
||||
const fRate = val(metrics[`http_req_failed{endpoint:${ep}}`], 'rate');
|
||||
return {
|
||||
Endpoint: ep,
|
||||
P95Ms: dP95 !== undefined ? dP95.toFixed(2) : 'n/a',
|
||||
FailurePct: fRate !== undefined ? (fRate * 100).toFixed(4) : 'n/a',
|
||||
};
|
||||
});
|
||||
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
console.log(pad('ENDPOINT', 12) + pad('P95_MS', 10) + 'FAILURE_PCT');
|
||||
for (const r of rows) console.log(pad(r.Endpoint, 12) + pad(r.P95Ms, 10) + r.FailurePct);
|
||||
EOF
|
||||
Reference in new issue
Block a user