Compare commits
21
Commits
06b277e9c1
...
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 |
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_BUCKET_NAME=linearvoid-media
|
||||||
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
R2_ENDPOINT=https://your-account-id.r2.cloudflarestorage.com
|
||||||
R2_REGION=auto
|
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 (for custom domain, cache rules, Workers if used)
|
||||||
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
|
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
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
# Service Ports (for local docker-compose)
|
# 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
|
CHAT_PORT=3000
|
||||||
NGINX_PORT=80
|
NGINX_PORT=8082
|
||||||
|
|
||||||
# Playout configuration
|
# Playout configuration
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ jobs:
|
|||||||
|
|
||||||
# Playout
|
# Playout
|
||||||
PLAYLIST_PATH=/app/playlist/playlist.txt
|
PLAYLIST_PATH=/app/playlist/playlist.txt
|
||||||
HLS_SEGMENT_DURATION=6
|
HLS_SEGMENT_DURATION=2
|
||||||
HLS_WINDOW_SIZE=24
|
HLS_WINDOW_SIZE=12
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# Cloudflare R2 (required for HLS segment sync)
|
# Cloudflare R2 (required for HLS segment sync)
|
||||||
@@ -69,7 +69,8 @@ jobs:
|
|||||||
ADMIN_SESSION_TTL_HOURS=12
|
ADMIN_SESSION_TTL_HOURS=12
|
||||||
|
|
||||||
# Nginx
|
# Nginx
|
||||||
NGINX_PORT=80
|
# Host port 80/443 (Coolify Traefik) and 8080 are taken; nginx publishes on 8082.
|
||||||
|
NGINX_PORT=8082
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Seed Prometheus config on remote host
|
- name: Seed Prometheus config on remote host
|
||||||
@@ -245,7 +246,7 @@ jobs:
|
|||||||
DOCKER_HOST: tcp://192.168.88.40:2375
|
DOCKER_HOST: tcp://192.168.88.40:2375
|
||||||
run: |
|
run: |
|
||||||
# Wait for services to stabilize
|
# Wait for services to stabilize
|
||||||
sleep 20
|
sleep 30
|
||||||
|
|
||||||
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
# Check chat health (via docker exec — IP-agnostic, works on any DOCKER_HOST)
|
||||||
echo "Checking chat health..."
|
echo "Checking chat health..."
|
||||||
@@ -270,8 +271,8 @@ jobs:
|
|||||||
echo "Playout is healthy"
|
echo "Playout is healthy"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Playout not ready yet (attempt $i/6), waiting 5s..."
|
echo "Playout not ready yet (attempt $i/6), waiting 20s..."
|
||||||
sleep 5
|
sleep 20
|
||||||
done
|
done
|
||||||
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
if ! docker exec linearvoid-playout curl -sf http://localhost:8081/health; then
|
||||||
echo "Playout failed to become healthy after 6 attempts"
|
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).
|
- 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 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`.
|
- 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.
|
- Updated AGENTS.md: added Required tracking files table with 13 docs and update cadence.
|
||||||
- All Go vet, Go tests, frontend test:coverage passing.
|
- 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).
|
- CORS on media.linearvoid.tv remains the only streaming pipeline gap (Cloudflare R2 config, not code).
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
## 2026-08-13
|
## 2026-08-13
|
||||||
|
|
||||||
- Completed all remaining enhancement tasks (only manual/infrastructure phases left):
|
- 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.
|
- **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`.
|
- **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.
|
- **Chat user accounts**: register/login/logout/session endpoints, bcrypt hashing, `linearvoid_user_session` cookie, viewer sign-in UI.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
### Phase 5: complete manual Cloudflare and external-service setup
|
### Phase 5: complete manual Cloudflare and external-service setup
|
||||||
|
|
||||||
- [ ] Apply Cloudflare Pages, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
- [ ] Apply Cloudflare Workers, Tiered Cache, WAF, rate limiting, and HTTPS/HSTS settings in the dashboard
|
||||||
- [ ] Cloudflare Pages: link custom domain and cut over frontend hosting
|
- [ ] Cloudflare Workers: link custom domain and cut over frontend hosting
|
||||||
- [ ] Enable Smart Tiered Cache; validate `Cf-Cache-Status: HIT` from upper tier
|
- [ ] 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
|
- [ ] 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
|
- [ ] Rate limiting: `/api/admin/login` + `/api/admin/*` → 10 req/10s per IP, Challenge action
|
||||||
@@ -58,10 +58,10 @@
|
|||||||
|
|
||||||
## Cloudflare Configuration
|
## Cloudflare Configuration
|
||||||
|
|
||||||
### DNS & Pages
|
### DNS & Workers
|
||||||
|
|
||||||
- [ ] Cloudflare Pages: link custom domain (manual)
|
- [ ] Cloudflare Workers: link custom domain (manual)
|
||||||
- [ ] Create Cloudflare Pages project and cut over frontend hosting (manual)
|
- [ ] Create Cloudflare Worker and cut over frontend hosting (manual)
|
||||||
|
|
||||||
### Security & TLS
|
### Security & TLS
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
> Completed tasks from [TODO](./TODO.md). Product, feature, and release history lives in [CHANGELOG](./CHANGELOG.md); this file keeps only engineering and ops notes not captured there.
|
||||||
|
|
||||||
|
## Manifest run-scoped rewrite — candidate (a) (2026-09-03)
|
||||||
|
|
||||||
|
- Playout now publishes the live R2 manifest with run-scoped media URIs. `rewriteManifestToRun` in `backend/playout/src/playlist.go` prefixes each bare relative URI (`seg-*.m4s` / `init.mp4`, plus `#EXT-X-MAP:URI`) with `r-{runID}/` before `publishManifest`'s atomic `.tmp` → `CopyObject` commit; skipped in slate mode. Added a stubbable `copyObject` hook for tests (consistent with `putObject`/`listObjects`/`deleteObjects`).
|
||||||
|
- Tests: `playlist_rewrite_test.go` — `TestRewriteManifestToRun`, `TestRewriteManifestToRun_AlreadyPrefixedUnchanged`, `TestRewriteManifestToRun_HandlesTSAndComments`, `TestRewriteManifestToRun_EmptyRunID`, `TestIsMediaURI`, `TestPublishManifest_RewritesToRunScopedURIs`, `TestPublishManifest_SlateNotRewritten`.
|
||||||
|
- Docs updated: `STREAMING.md`, `operations/cloudflare-workers-setup.md`, `architecture/README.md` (incl. Level-2 diagram showing the R2-bound `linearvoid-media` Worker and run-scoped layout).
|
||||||
|
- **Still pending (in TODO): live Validation** — deploy the updated playout to the test lab and confirm manifest/segment URIs resolve via `media.linearvoid.tv`.
|
||||||
|
|
||||||
## Test infrastructure & refactors (2026-08-07)
|
## Test infrastructure & refactors (2026-08-07)
|
||||||
|
|
||||||
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
- Chat service `main.go` split into 8 packages (config, auth, db, handlers, ws, media, ratelimit, metrics); `db.RedisClient` + `db.PubSub` interfaces added for mocking.
|
||||||
@@ -37,3 +44,35 @@
|
|||||||
- Test in Firefox: live playback, rebuffer/recovery, chat sync; record quirks in `docs/STREAMING.md`
|
- 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
|
- Rebuild + run playout; confirm `playlist.m3u8` lists ~12 segments at 2s each
|
||||||
- Manual: verify viewer latency and cache-hit behavior after the change
|
- Manual: verify viewer latency and cache-hit behavior after the change
|
||||||
|
|
||||||
|
## Chat sidebar (2026-08-17)
|
||||||
|
|
||||||
|
- [x] remove search bar from chat sidebar (not needed for single-room)
|
||||||
|
- [x] remove "user" toggle button from chat sidebar (hide for now, add back when user interface is stable)
|
||||||
|
- [x] make login/registration form a modal and only toggle it when user is not logged in and clicks "Login" or "Register"
|
||||||
|
- [x] when user is logged in, do not show login/registration form
|
||||||
|
- [x] make sure each message is only rendered once in the chat log (no duplicates on reconnect or initial load)
|
||||||
|
|
||||||
|
### Plan
|
||||||
|
|
||||||
|
Files: `frontend/index.html`, `frontend/src/main.ts`, `frontend/src/style.css`.
|
||||||
|
|
||||||
|
1. **Remove search bar** (single-room)
|
||||||
|
- `index.html`: delete the `<div id="dm-search-row">` block.
|
||||||
|
- `main.ts`: remove `searchDM()` and the `dm-search-btn` listener; drop the `#dm-search-row` show/hide logic in `switchRoom()` (the `searchRow.hidden = !isDM` lines). Keep `currentDMTarget` only if the "New DM" flow stays.
|
||||||
|
|
||||||
|
2. **Remove "Users" toggle button**
|
||||||
|
- `index.html`: delete the `<button id="users-toggle-btn">` button.
|
||||||
|
- `main.ts`: remove `toggleUserList()` and the `users-toggle-btn` listener; drop the now-unused `usersVisible` variable.
|
||||||
|
|
||||||
|
3. **Login/registration form as a modal**
|
||||||
|
- `index.html`: wrap the `#auth-panel` form in a modal structure (`.auth-modal` + backdrop) with a close button.
|
||||||
|
- `style.css`: add `.auth-modal` / `.auth-modal-backdrop` styles.
|
||||||
|
- `main.ts`: add `openAuthModal()` / `closeAuthModal()`; change the `auth-toggle-btn` handler to open the modal (only when not authenticated) instead of toggling `#auth-panel` inline; wire a close/backdrop-click to `closeAuthModal()`.
|
||||||
|
|
||||||
|
4. **Hide form when logged in**
|
||||||
|
- `main.ts` `updateAuthUI()`: when `authenticated` → hide the "Sign in" button and call `closeAuthModal()`; show the user label + "Sign out" button. When not authenticated → show "Sign in" and hide "Sign out".
|
||||||
|
|
||||||
|
5. **No duplicate messages**
|
||||||
|
- `main.ts`: add `clearMessages()` (empties `#messages`) and call it at the top of `connectWebSocket()` so each (re)connect renders history fresh instead of appending.
|
||||||
|
- Fix the initial double-connect: make the initial `connectWebSocket("main")` wait for `refreshUserSession()` (or connect once with the resolved username) so history isn't pushed twice on load.
|
||||||
@@ -2,9 +2,19 @@
|
|||||||
|
|
||||||
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
> Completed tasks moved to [DONE](./DONE.md). Remaining tasks in [BACKLOG](./BACKLOG.md).
|
||||||
|
|
||||||
## R2 Data Retention
|
## Playout / R2 manifest–segment layout mismatch
|
||||||
|
|
||||||
- [ ] Adjust internal data retention for R2: remove folders /hls/r-{ISOTIME} after next run, keep only the last 2 runs or max. 10GB of data
|
**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).
|
||||||
|
|
||||||
|
### Remaining — Validation
|
||||||
|
|
||||||
|
- [ ] 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.
|
||||||
|
|
||||||
|
> Implementation (candidate a), Go unit tests, and documentation are complete — see [DONE.md](./DONE.md).
|
||||||
|
|
||||||
## Frontend Tests
|
## Frontend Tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"proseWrap": "preserve"
|
||||||
|
}
|
||||||
+20
-9
@@ -2,24 +2,35 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD024 -->
|
<!-- markdownlint-disable MD024 -->
|
||||||
|
|
||||||
## [Unreleased]
|
## [1.1.0] - 2026-09-03
|
||||||
|
|
||||||
### Added
|
### 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
|
### 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).
|
- **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.
|
||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **Compose port de-collision**: playout on host port `8081`, nginx moved to `8082` (`NGINX_PORT` default updated) to avoid Coolify-reserved ports.
|
||||||
- **Chat font sizing**: `.message-text` set to `0.95rem` (slightly smaller than the default, larger than the `0.83rem` system/flash text).
|
- **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
|
### Tests
|
||||||
|
|
||||||
- WebSocket: `TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`.
|
- WebSocket (`TestHub_Broadcast_SystemNotPersisted`, `TestHub_ReconnectGracePeriod`), handlers (`TestHandleAdminChatLog_ExcludesSystem`), frontend presence flash.
|
||||||
- Handlers: `TestHandleAdminChatLog_ExcludesSystem`.
|
- Playout: `TestCleanupOldRuns(_SizeCap)`, `TestR2KeepRuns`, `TestR2MaxRetentionBytes`, `TestR2RunFromKey`, `TestValidateWorkersConfig`, `TestValidatePlaylist_Dedup`.
|
||||||
- Frontend: `presence flash` test.
|
- 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
|
## [1.0.13] - 2026-08-13
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/actions)
|
||||||
[](https://git.allucanget.biz)
|
[](https://git.allucanget.biz)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
[](https://git.allucanget.biz/zwitschi/linearvoid/releases)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -187,7 +187,8 @@ frontend/
|
|||||||
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
Everything needed to deploy and configure the stack (Docker, Nginx, Cloudflare rules, future Terraform).
|
||||||
|
|
||||||
- Cloudflare
|
- 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
|
- Nginx
|
||||||
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
- [Dockerfile](infrastructure/nginx/Dockerfile) — builds Nginx with custom config
|
||||||
- [nginx.conf](infrastructure/nginx/nginx.conf) — reverse proxy, static SPA, HLS delivery
|
- [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/
|
- loadtest/
|
||||||
- [admin-api.js](scripts/loadtest/admin-api.js) — admin API load test script
|
- [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
|
- [run-loadtests.sh](scripts/loadtest/run-loadtests.sh) — bash script to run ws and/or admin load tests
|
||||||
- [summarize-artillery.ps1](scripts/loadtest/summarize-artillery.ps1) — PowerShell script to summarize Artillery load test results
|
- [summarize-artillery.sh](scripts/loadtest/summarize-artillery.sh) — summarize Artillery JSON results
|
||||||
- [summarize-k6.ps1](scripts/loadtest/summarize-k6.ps1) — PowerShell script to summarize k6 load test 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
|
- [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
|
## 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`.
|
> **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 |
|
| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | HLS playlist URL |
|
||||||
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
| `ADMIN_USERNAME` | `admin` | Admin panel username |
|
||||||
| `ADMIN_PASSWORD` | — | Admin panel password |
|
| `ADMIN_PASSWORD` | — | Admin panel password |
|
||||||
| `HLS_SEGMENT_DURATION` | `6` | HLS segment duration in seconds |
|
| `HLS_SEGMENT_DURATION` | `2` | HLS segment duration in seconds |
|
||||||
| `HLS_WINDOW_SIZE` | `24` | HLS playlist window size |
|
| `HLS_WINDOW_SIZE` | `12` | HLS playlist window size |
|
||||||
|
|
||||||
Full reference: [`.env.example`](.env.example).
|
Full reference: [`.env.example`](.env.example).
|
||||||
|
|
||||||
|
|||||||
@@ -88,29 +88,48 @@ func CookieSecure(r *http.Request) bool {
|
|||||||
return r.TLS != nil
|
return r.TLS != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCookie writes the admin session cookie.
|
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
|
||||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
// 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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearCookie removes the admin session cookie.
|
// ClearCookie removes a session cookie with the given SameSite policy.
|
||||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
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{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: cookieName,
|
Name: cookieName,
|
||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: sameSite,
|
||||||
Secure: CookieSecure(r),
|
Secure: secure,
|
||||||
Expires: time.Unix(0, 0),
|
Expires: time.Unix(0, 0),
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestSetCookie(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(1 * time.Hour)
|
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()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
if len(cookies) != 1 {
|
||||||
@@ -165,7 +165,7 @@ func TestClearCookie(t *testing.T) {
|
|||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
ClearCookie(w, req, "test_cookie")
|
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
cookies := w.Result().Cookies()
|
||||||
if len(cookies) != 1 {
|
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) {
|
func TestGetTokenFromCookie(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/", nil)
|
req := httptest.NewRequest("GET", "/", nil)
|
||||||
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ func HandleAdminLogin(deps *Dependencies) http.HandlerFunc {
|
|||||||
|
|
||||||
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
expiresAt := time.Now().Add(config.AdminSessionTTL())
|
||||||
deps.SessionStore.Add(token, expiresAt)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()})
|
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 {
|
if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok {
|
||||||
deps.SessionStore.Remove(token)
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false})
|
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())
|
expiresAt := time.Now().Add(config.UserSessionTTL())
|
||||||
deps.UserSessions.Add(token, username, expiresAt)
|
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.
|
// 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 {
|
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
|
||||||
deps.UserSessions.Remove(token)
|
deps.UserSessions.Remove(token)
|
||||||
}
|
}
|
||||||
auth.ClearCookie(w, r, userSessionCookieName)
|
auth.ClearCookie(w, r, userSessionCookieName, auth.SameSiteForUserCookie(r))
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||||
}
|
}
|
||||||
|
|||||||
+337
-73
@@ -11,8 +11,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/aws/aws-sdk-go-v2/aws"
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
@@ -41,6 +44,9 @@ var (
|
|||||||
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
deleteObjects = func(client *s3.Client, input *s3.DeleteObjectsInput) (*s3.DeleteObjectsOutput, error) {
|
||||||
return client.DeleteObjects(context.TODO(), input)
|
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")
|
bucket = os.Getenv("R2_BUCKET_NAME")
|
||||||
playoutMode = "starting" // "live", "slate", "degraded"
|
playoutMode = "starting" // "live", "slate", "degraded"
|
||||||
restartCount = 0
|
restartCount = 0
|
||||||
@@ -66,6 +72,10 @@ var (
|
|||||||
// R2 retention cleanup counter
|
// R2 retention cleanup counter
|
||||||
r2CleanupObjectsTotal int64
|
r2CleanupObjectsTotal int64
|
||||||
|
|
||||||
|
// R2 retention gauges
|
||||||
|
r2RunsRetained int64
|
||||||
|
r2BytesRetained int64
|
||||||
|
|
||||||
// Continuous playout loop metrics
|
// Continuous playout loop metrics
|
||||||
loopsTotal int64
|
loopsTotal int64
|
||||||
lastRunDuration time.Duration
|
lastRunDuration time.Duration
|
||||||
@@ -78,6 +88,10 @@ const (
|
|||||||
// Cache-Control header values for R2 uploads.
|
// Cache-Control header values for R2 uploads.
|
||||||
segmentCacheControl = "public, max-age=31536000, immutable"
|
segmentCacheControl = "public, max-age=31536000, immutable"
|
||||||
manifestCacheControl = "public, max-age=1, s-maxage=2, must-revalidate"
|
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.
|
// 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).
|
// 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 {
|
func publishManifest(localManifestPath string) error {
|
||||||
if r2Client == nil {
|
if r2Client == nil {
|
||||||
if err := initR2Client(); err != 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
|
// 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)
|
return fmt.Errorf("temp manifest upload failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Atomic copy to final key
|
// 2. Atomic copy to final key
|
||||||
_, err := r2Client.CopyObject(context.TODO(), &s3.CopyObjectInput{
|
_, err = copyObject(r2Client, &s3.CopyObjectInput{
|
||||||
Bucket: aws.String(bucket),
|
Bucket: aws.String(bucket),
|
||||||
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
CopySource: aws.String(bucket + "/hls/playlist.m3u8.tmp"),
|
||||||
Key: aws.String("hls/playlist.m3u8"),
|
Key: aws.String("hls/playlist.m3u8"),
|
||||||
@@ -199,6 +235,55 @@ func publishManifest(localManifestPath string) error {
|
|||||||
return nil
|
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
|
// LoadPlaylist reads a playlist file and returns a list of VideoFile entries
|
||||||
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
func LoadPlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
file, err := os.Open(playlistPath)
|
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)
|
// ValidateVideo uses ffprobe to enforce codec, fps, and keyframe interval (ADR-001)
|
||||||
func ValidateVideo(videoPath string) (VideoFile, error) {
|
func ValidateVideo(videoPath string) (VideoFile, error) {
|
||||||
v := VideoFile{Path: videoPath}
|
v := VideoFile{Path: videoPath}
|
||||||
var errors []string
|
|
||||||
|
|
||||||
// Run ffprobe
|
// Run the stream/format probe and the keyframe probe concurrently: they are
|
||||||
cmd := exec.Command("ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", videoPath)
|
// independent, and the keyframe probe (-show_frames) is the expensive one.
|
||||||
output, err := cmd.Output()
|
var streamOut []byte
|
||||||
if err != nil {
|
var streamErr error
|
||||||
v.Validation = "ffprobe failed: " + err.Error()
|
var keyframeErr error
|
||||||
return v, fmt.Errorf("ffprobe: %w", err)
|
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
|
// Parse JSON
|
||||||
var probe struct {
|
var probe struct {
|
||||||
Streams []struct {
|
Streams []struct {
|
||||||
@@ -256,7 +357,7 @@ func ValidateVideo(videoPath string) (VideoFile, error) {
|
|||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
} `json:"format"`
|
} `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()
|
v.Validation = "ffprobe JSON parse failed: " + err.Error()
|
||||||
return v, fmt.Errorf("ffprobe json: %w", err)
|
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)
|
return v, fmt.Errorf("validation failed: %s", v.Validation)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate keyframe interval <=2s (60 frames at 30fps)
|
// Keyframe interval check (probe already completed concurrently above).
|
||||||
keyframeErr := validateKeyframeInterval(videoPath)
|
|
||||||
if keyframeErr != nil {
|
if keyframeErr != nil {
|
||||||
errors = append(errors, keyframeErr.Error())
|
errors = append(errors, keyframeErr.Error())
|
||||||
}
|
}
|
||||||
@@ -412,21 +512,62 @@ func ValidateVideoStrict(videoPath string) map[string]any {
|
|||||||
return result
|
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) {
|
func ValidatePlaylist(playlistPath string) ([]VideoFile, error) {
|
||||||
videos, err := LoadPlaylist(playlistPath)
|
videos, err := LoadPlaylist(playlistPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range videos {
|
// Collect unique paths in order of first appearance.
|
||||||
validated, err := ValidateVideo(videos[i].Path)
|
var order []string
|
||||||
videos[i] = validated
|
seen := make(map[string]bool, len(videos))
|
||||||
if err != nil {
|
for _, v := range videos {
|
||||||
fmt.Printf("Warning: %s - %s\n", videos[i].Path, err)
|
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
|
return videos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,11 +764,24 @@ func main() {
|
|||||||
initRunID()
|
initRunID()
|
||||||
fmt.Println("Run ID:", runID)
|
fmt.Println("Run ID:", runID)
|
||||||
|
|
||||||
// Validate playlist before starting (ADR-001 normalization enforcement)
|
// Validate playlist before starting (ADR-001 normalization enforcement).
|
||||||
if _, err := ValidatePlaylist(playlistPath); err != nil {
|
// 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)
|
fmt.Println("ERROR: Playlist validation failed:", err)
|
||||||
os.Exit(1)
|
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)
|
// Initialize R2 client (skip if not configured — local HLS via nginx still works)
|
||||||
if err := initR2Client(); err != nil {
|
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, "# 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.Fprintln(w, "# TYPE linearvoid_playout_r2_cleanup_objects_total counter")
|
||||||
fmt.Fprintf(w, "linearvoid_playout_r2_cleanup_objects_total %d\n", r2CleanupObjectsTotal)
|
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, "# HELP linearvoid_playout_loops_total Total clean playlist completions (loops).")
|
||||||
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
|
fmt.Fprintln(w, "# TYPE linearvoid_playout_loops_total counter")
|
||||||
fmt.Fprintf(w, "linearvoid_playout_loops_total %d\n", loopsTotal)
|
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)
|
// New run ID on each restart for unique R2 segment keys (STREAMING.md)
|
||||||
initRunID()
|
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
|
// 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
|
// 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).
|
// sequence so the loop is seamless for viewers (no gap on clean loops).
|
||||||
@@ -834,68 +1003,51 @@ func syncLatestToR2(hlsDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// r2SegmentRetentionHours returns the R2 segment retention window in hours.
|
// r2KeepRuns returns how many of the most recent run folders to keep in R2.
|
||||||
func r2SegmentRetentionHours() time.Duration {
|
func r2KeepRuns() int {
|
||||||
h, err := strconv.Atoi(envWithDefault("R2_SEGMENT_RETENTION_HOURS", "24"))
|
n, err := strconv.Atoi(envWithDefault("R2_KEEP_RUNS", "2"))
|
||||||
if err != nil || h <= 0 {
|
if err != nil || n < 1 {
|
||||||
h = 24
|
return defaultR2KeepRuns
|
||||||
}
|
}
|
||||||
return time.Duration(h) * time.Hour
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupOldSegments deletes R2 objects under the hls/ prefix that are older than
|
// r2MaxRetentionBytes returns the maximum total bytes to retain across run folders.
|
||||||
// the retention window, excluding the live manifest and any non-run keys.
|
func r2MaxRetentionBytes() int64 {
|
||||||
// It returns the number of objects deleted.
|
n, err := strconv.ParseInt(envWithDefault("R2_MAX_RETENTION_BYTES", "10737418240"), 10, 64)
|
||||||
func cleanupOldSegments(ctx context.Context) (int, error) {
|
if err != nil || n <= 0 {
|
||||||
if r2Client == nil {
|
return defaultR2MaxRetentionBytes
|
||||||
if err := initR2Client(); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
cutoff := time.Now().UTC().Add(-r2SegmentRetentionHours())
|
// r2RunFromKey extracts the run folder name ("r-...") from an R2 object key.
|
||||||
|
func r2RunFromKey(key string) (string, bool) {
|
||||||
var stale []string
|
rest := strings.TrimPrefix(key, "hls/")
|
||||||
var token *string
|
if rest == key || !strings.HasPrefix(rest, "r-") {
|
||||||
for {
|
return "", false
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
if idx := strings.IndexByte(rest, '/'); idx > 0 {
|
||||||
if len(stale) == 0 {
|
return rest[:idx], true
|
||||||
return 0, nil
|
|
||||||
}
|
}
|
||||||
|
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
|
deleted := 0
|
||||||
for i := 0; i < len(stale); i += 1000 {
|
for i := 0; i < len(keys); i += 1000 {
|
||||||
end := i + 1000
|
end := i + 1000
|
||||||
if end > len(stale) {
|
if end > len(keys) {
|
||||||
end = len(stale)
|
end = len(keys)
|
||||||
}
|
}
|
||||||
batch := stale[i:end]
|
batch := keys[i:end]
|
||||||
objects := make([]types.ObjectIdentifier, len(batch))
|
objects := make([]types.ObjectIdentifier, len(batch))
|
||||||
for j, key := range batch {
|
for j, key := range batch {
|
||||||
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
objects[j] = types.ObjectIdentifier{Key: aws.String(key)}
|
||||||
@@ -911,6 +1063,118 @@ func cleanupOldSegments(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
deleted += len(batch)
|
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)
|
r2CleanupObjectsTotal += int64(deleted)
|
||||||
return deleted, nil
|
return deleted, nil
|
||||||
@@ -918,13 +1182,13 @@ func cleanupOldSegments(ctx context.Context) (int, error) {
|
|||||||
|
|
||||||
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
|
// runR2CleanupLoop runs retention cleanup once at startup, then hourly.
|
||||||
func runR2CleanupLoop() {
|
func runR2CleanupLoop() {
|
||||||
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||||
fmt.Println("R2 cleanup error:", err)
|
fmt.Println("R2 cleanup error:", err)
|
||||||
}
|
}
|
||||||
ticker := time.NewTicker(time.Hour)
|
ticker := time.NewTicker(time.Hour)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
for range ticker.C {
|
for range ticker.C {
|
||||||
if _, err := cleanupOldSegments(context.Background()); err != nil {
|
if _, err := cleanupOldRuns(context.Background()); err != nil {
|
||||||
fmt.Println("R2 cleanup error:", err)
|
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")
|
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) {
|
func TestSyncToR2(t *testing.T) {
|
||||||
prevClient := r2Client
|
prevClient := r2Client
|
||||||
prevBucket := bucket
|
prevBucket := bucket
|
||||||
@@ -593,54 +641,95 @@ func TestSyncToR2_NoCacheHeadersForUnknownExt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestR2SegmentRetentionHours(t *testing.T) {
|
func TestR2KeepRuns(t *testing.T) {
|
||||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "")
|
os.Setenv("R2_KEEP_RUNS", "")
|
||||||
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
if got := r2KeepRuns(); got != 2 {
|
||||||
t.Errorf("default = %v, want 24h", got)
|
t.Errorf("default = %d, want 2", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "48")
|
os.Setenv("R2_KEEP_RUNS", "5")
|
||||||
if got := r2SegmentRetentionHours(); got != 48*time.Hour {
|
if got := r2KeepRuns(); got != 5 {
|
||||||
t.Errorf("override = %v, want 48h", got)
|
t.Errorf("override = %d, want 5", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
os.Setenv("R2_SEGMENT_RETENTION_HOURS", "invalid")
|
os.Setenv("R2_KEEP_RUNS", "0")
|
||||||
if got := r2SegmentRetentionHours(); got != 24*time.Hour {
|
if got := r2KeepRuns(); got != 2 {
|
||||||
t.Errorf("invalid = %v, want 24h fallback", got)
|
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
|
prevClient := r2Client
|
||||||
prevBucket := bucket
|
prevBucket := bucket
|
||||||
prevList := listObjects
|
prevList := listObjects
|
||||||
prevDelete := deleteObjects
|
prevDelete := deleteObjects
|
||||||
prevTotal := r2CleanupObjectsTotal
|
prevTotal := r2CleanupObjectsTotal
|
||||||
|
prevRuns := r2RunsRetained
|
||||||
|
prevBytes := r2BytesRetained
|
||||||
defer func() {
|
defer func() {
|
||||||
r2Client = prevClient
|
r2Client = prevClient
|
||||||
bucket = prevBucket
|
bucket = prevBucket
|
||||||
listObjects = prevList
|
listObjects = prevList
|
||||||
deleteObjects = prevDelete
|
deleteObjects = prevDelete
|
||||||
r2CleanupObjectsTotal = prevTotal
|
r2CleanupObjectsTotal = prevTotal
|
||||||
|
r2RunsRetained = prevRuns
|
||||||
|
r2BytesRetained = prevBytes
|
||||||
}()
|
}()
|
||||||
|
|
||||||
bucket = "linearvoid-media"
|
bucket = "linearvoid-media"
|
||||||
r2Client = &s3.Client{}
|
r2Client = &s3.Client{}
|
||||||
|
|
||||||
now := time.Now().UTC()
|
size := int64(1024)
|
||||||
old := now.Add(-25 * time.Hour)
|
|
||||||
recent := now.Add(-1 * time.Hour)
|
|
||||||
|
|
||||||
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
listObjects = func(_ *s3.Client, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
|
||||||
return &s3.ListObjectsV2Output{
|
return &s3.ListObjectsV2Output{
|
||||||
Contents: []types.Object{
|
Contents: []types.Object{
|
||||||
{Key: aws.String("hls/r-old/seg-1.m4s"), LastModified: &old},
|
{Key: aws.String("hls/r-20260813T000000Z/seg-1.m4s"), Size: &size},
|
||||||
{Key: aws.String("hls/r-old/init.mp4"), LastModified: &old},
|
{Key: aws.String("hls/r-20260814T000000Z/seg-2.m4s"), Size: &size},
|
||||||
{Key: aws.String("hls/r-new/seg-2.m4s"), LastModified: &recent},
|
{Key: aws.String("hls/r-20260815T000000Z/seg-3.m4s"), Size: &size},
|
||||||
{Key: aws.String("hls/playlist.m3u8"), LastModified: &old},
|
{Key: aws.String("hls/playlist.m3u8"), Size: &size},
|
||||||
{Key: aws.String("hls/playlist.m3u8.tmp"), LastModified: &old},
|
{Key: aws.String("hls/playlist.m3u8.tmp"), Size: &size},
|
||||||
},
|
},
|
||||||
IsTruncated: aws.Bool(false),
|
IsTruncated: aws.Bool(false),
|
||||||
}, nil
|
}, nil
|
||||||
@@ -654,23 +743,101 @@ func TestCleanupOldSegments(t *testing.T) {
|
|||||||
return &s3.DeleteObjectsOutput{}, nil
|
return &s3.DeleteObjectsOutput{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
n, err := cleanupOldSegments(context.Background())
|
n, err := cleanupOldRuns(context.Background())
|
||||||
if err != nil {
|
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 {
|
if n != 2 {
|
||||||
t.Fatalf("deleted = %d, want 2", n)
|
t.Fatalf("deleted = %d, want 2", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
joined := strings.Join(deletedKeys, ",")
|
joined := strings.Join(deletedKeys, ",")
|
||||||
if !strings.Contains(joined, "hls/r-old/seg-1.m4s") || !strings.Contains(joined, "hls/r-old/init.mp4") {
|
if !strings.Contains(joined, "hls/r-20260813T000000Z/seg-1.m4s") {
|
||||||
t.Fatalf("unexpected deleted keys: %v", deletedKeys)
|
t.Fatalf("expected oldest run deleted, got: %v", deletedKeys)
|
||||||
}
|
}
|
||||||
if strings.Contains(joined, "playlist.m3u8") {
|
if !strings.Contains(joined, "hls/r-20260814T000000Z/seg-2.m4s") {
|
||||||
t.Fatalf("manifest must not be deleted: %v", deletedKeys)
|
t.Fatalf("expected second-oldest run deleted by size cap, got: %v", deletedKeys)
|
||||||
}
|
}
|
||||||
if r2CleanupObjectsTotal != 2 {
|
if strings.Contains(joined, "hls/r-20260815T000000Z/seg-3.m4s") {
|
||||||
t.Fatalf("r2CleanupObjectsTotal = %d, want 2", r2CleanupObjectsTotal)
|
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 "=== Linear Void Playout Service Starting ==="
|
||||||
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
echo "Playlist path: ${PLAYLIST_PATH:=/app/playlist/playlist.txt}"
|
||||||
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-6}s"
|
echo "HLS segment duration: ${HLS_SEGMENT_DURATION:-2}s"
|
||||||
echo "HLS window size: ${HLS_WINDOW_SIZE:-24}"
|
echo "HLS window size: ${HLS_WINDOW_SIZE:-12}"
|
||||||
|
|
||||||
# Ensure playlist directory and file exist
|
# Ensure playlist directory and file exist
|
||||||
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
mkdir -p "$(dirname "$PLAYLIST_PATH")"
|
||||||
@@ -17,15 +17,7 @@ file '/app/media/demo2.mp4'
|
|||||||
EOF
|
EOF
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Run normalization validation using the Go tool
|
# Single source of truth: the Go binary validates the playlist (ADR-001/ADR-003
|
||||||
echo "Validating playlist and enforcing normalization (ADR-001/ADR-003)..."
|
# normalization enforcement) and runs the full loop (HLS, R2 sync, slate
|
||||||
if ! /app/playlist-manager -validate "$PLAYLIST_PATH"; then
|
# fallback, health server). Validation runs once inside the binary.
|
||||||
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.
|
|
||||||
exec /app/playlist-manager "$PLAYLIST_PATH"
|
exec /app/playlist-manager "$PLAYLIST_PATH"
|
||||||
@@ -11,6 +11,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist:/app/playlist:rw
|
- ./data/playlist:/app/playlist:rw
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
@@ -56,7 +58,7 @@ services:
|
|||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/hls:/srv/hls:ro
|
- ./data/hls:/srv/hls:ro
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
+3
-1
@@ -13,6 +13,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "${PLAYOUT_PORT:-8081}:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/playlist:/app/playlist:ro
|
- ./data/playlist:/app/playlist:ro
|
||||||
- ./data/media:/app/media:ro
|
- ./data/media:/app/media:ro
|
||||||
@@ -64,7 +66,7 @@ services:
|
|||||||
container_name: linearvoid-nginx
|
container_name: linearvoid-nginx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${NGINX_PORT:-8080}:80"
|
- "${NGINX_PORT:-8082}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dist:/srv/frontend:ro
|
- ./frontend/dist:/srv/frontend:ro
|
||||||
- ./data/hls:/srv/hls: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>
|
Admin REST endpoints use a cookie session:
|
||||||
- WebSocket endpoint: <wss://linearvoid.tv/ws>
|
|
||||||
- Local fallback: <http://localhost> and <ws://localhost/ws>
|
|
||||||
|
|
||||||
## Authentication Model
|
- 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)
|
||||||
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)
|
|
||||||
|
|
||||||
## Chat WebSocket API
|
## 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}
|
Server→client envelope adds `"timestamp"` (ISO-8601, e.g. `2026-08-04T12:34:56Z`).
|
||||||
|
|
||||||
Client sends JSON messages over the socket.
|
|
||||||
|
|
||||||
### Message Envelope (client -> server)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "room": "main", "username": "viewer123", "text": "hello world", "type": "public" }
|
||||||
"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"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Admin Session API
|
## 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
|
Login request:
|
||||||
- Auth: none
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "username": "admin", "password": "your-password" }
|
||||||
"username": "admin",
|
|
||||||
"password": "your-password"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Success response:
|
Login/logout/session success response (session returns 401 on failure):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "authenticated": true, "username": "admin" }
|
||||||
"authenticated": true,
|
|
||||||
"username": "admin"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Logout
|
Logout response: `{ "authenticated": false }`
|
||||||
|
|
||||||
- POST /api/admin/logout
|
## Admin Data API (auth required)
|
||||||
- Auth: optional (clears cookie either way)
|
|
||||||
|
|
||||||
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
|
```json
|
||||||
{
|
{ "action": "mute", "target": "username-or-id", "moderator": "admin", "reason": "spam", "room": "main" }
|
||||||
"authenticated": false
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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 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
|
PUT media body:
|
||||||
- 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:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{ "status": "ready", "title": "My Clip", "tags": "music,live" }
|
||||||
"status": "ready",
|
|
||||||
"title": "My Clip",
|
|
||||||
"tags": "music,live"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Resumable Upload API
|
## 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
|
Initialize request:
|
||||||
|
|
||||||
- POST /api/admin/media/uploads
|
|
||||||
|
|
||||||
Request:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -223,7 +108,7 @@ Request:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Status envelope (returned by init, status check, and chunk upload):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -237,61 +122,33 @@ Response:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Upload Status
|
## Metrics & Health
|
||||||
|
|
||||||
- GET /api/admin/media/uploads/{uploadId}
|
- `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
|
||||||
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
|
|
||||||
|
|
||||||
## HTTP Status Conventions
|
## HTTP Status Conventions
|
||||||
|
|
||||||
| Code | Meaning | When |
|
| Code | Meaning | When |
|
||||||
| ---- | --------------------- | ----------------------------------------------------------- |
|
| ---- | --------------------- | ----------------------------------------------------------- |
|
||||||
| 200 | OK | Successful GET, PUT, or action |
|
| 200 | OK | Successful GET, PUT, action |
|
||||||
| 201 | Created | Successful POST (upload init, override created) |
|
| 201 | Created | Successful POST (upload init, override created) |
|
||||||
| 204 | No Content | Successful DELETE, CORS preflight |
|
| 204 | No Content | Successful DELETE, CORS preflight |
|
||||||
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
| 400 | Bad Request | Malformed JSON, missing required fields, invalid input |
|
||||||
| 401 | Unauthorized | Missing or expired admin session |
|
| 401 | Unauthorized | Missing/expired admin session |
|
||||||
| 403 | Forbidden | Valid session but insufficient permissions |
|
| 403 | Forbidden | Valid session, insufficient permissions |
|
||||||
| 404 | Not Found | Resource doesn't exist |
|
| 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 |
|
| 409 | Conflict | Schedule override overlap, upload ID mismatch |
|
||||||
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
| 413 | Payload Too Large | Upload exceeds 2 GB limit |
|
||||||
| 422 | Unprocessable | File fails normalization check (codec, fps, resolution) |
|
| 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 |
|
| 500 | Internal Server Error | Unexpected server failure |
|
||||||
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
| 503 | Service Unavailable | Redis unavailable for user actions, playout health degraded |
|
||||||
|
|
||||||
## Rate Limiting
|
## 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 |
|
| 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"` |
|
| WebSocket connections | 5 | 60s | 429 + `"too many connections from this IP"` |
|
||||||
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
| Admin login | 5 | 5min | 429 + `"too many login attempts, try again later"` |
|
||||||
|
|
||||||
Rate limit headers on applicable responses:
|
Rate limit headers (applicable responses): `X-RateLimit-Limit` (max requests/window), `Retry-After` (seconds until reset). 413 = payload too large; 500 = internal server error.
|
||||||
|
|
||||||
- `X-RateLimit-Limit`: maximum requests per window
|
|
||||||
- `Retry-After`: seconds until window resets
|
|
||||||
- 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
|
# 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
|
## Environments
|
||||||
|
|
||||||
Linear Void runs in two separate environments:
|
|
||||||
|
|
||||||
| Environment | Host | Docker Target | Purpose |
|
| Environment | Host | Docker Target | Purpose |
|
||||||
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
| -------------- | -------------------------- | -------------------------- | ---------------------------------------------- |
|
||||||
| **Test Lab** | `192.168.88.40` (internal) | `tcp://192.168.88.40:2375` | Integration testing, staging, CI/CD validation |
|
| **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)
|
## 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
|
### Planned Infrastructure
|
||||||
|
|
||||||
| Resource | Spec |
|
- Provider: Hetzner Cloud, Region: Ashburn, VA (us-east)
|
||||||
| -------------- | ------------------------------------------------ |
|
- Instance: CX22 (2 vCPU, 4 GB RAM, 40 GB SSD) or equivalent
|
||||||
| Provider | Hetzner Cloud |
|
- OS: Ubuntu 24.04 LTS or Debian 12
|
||||||
| Region | Ashburn, VA (us-east) |
|
- Bandwidth: 20 TB included traffic/month; Estimated cost: ~€7–10/month base
|
||||||
| 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
|
### 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)
|
2. Add production secrets to Gitea (`CI_PROD_*` prefix)
|
||||||
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
3. Update `DOCKER_HOST` in CD workflow to Hetzner public IP
|
||||||
4. Trigger deploy → validate health checks
|
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
|
6. Enable HTTPS, WAF, cache rules on production zone
|
||||||
7. Decommission test lab as public target
|
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 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
|
||||||
1. Verify the Hetzner host is reachable over SSH and Docker remote API TLS is working for the Gitea runner.
|
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, `media.linearvoid.tv` DNS targets match intended phase
|
||||||
2. Confirm all required production secrets are present under the `CI_PROD_*` prefix.
|
4. Complete manual Cloudflare/UptimeRobot checklist (see `docs/operations/cloudflare-uptimerobot-checklist.md`)
|
||||||
3. Confirm `linearvoid.tv`, `www.linearvoid.tv`, and `media.linearvoid.tv` DNS targets are correct for the intended phase.
|
5. Run production smoke path end-to-end: viewer SPA, HLS playback, chat connect, admin login, upload, playlist save, playout health
|
||||||
4. Complete the manual Cloudflare/UptimeRobot checklist and attach evidence in [Cloudflare Uptimerobot checklist](../docs/operations/cloudflare-uptimerobot-checklist.md).
|
6. Verify rollback possible before final DNS switch
|
||||||
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.
|
|
||||||
|
|
||||||
### Firewall / Security Group
|
### Firewall / Security Group
|
||||||
|
|
||||||
@@ -91,18 +52,14 @@ Readiness is not complete until the checklist above and the manual evidence trac
|
|||||||
|
|
||||||
### Pipeline Readiness
|
### 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 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:
|
||||||
| ------------------ | ---------------- | ---------------------------------------------------------------------------------------- |
|
|
||||||
| `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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test lab (current)
|
# Test lab (current)
|
||||||
@@ -112,7 +69,7 @@ DOCKER_HOST=tcp://192.168.88.40:2375
|
|||||||
DOCKER_HOST=tcp://<hetzner-public-ip>: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
|
## Target Topology
|
||||||
|
|
||||||
@@ -126,12 +83,12 @@ No code changes needed in the pipeline. See `DEPLOYMENT.md` Production Deploymen
|
|||||||
|
|
||||||
- Docker Engine + Docker Compose plugin
|
- Docker Engine + Docker Compose plugin
|
||||||
- Network access to git.allucanget.biz
|
- Network access to git.allucanget.biz
|
||||||
- A valid .env file with production values
|
- Valid `.env` file with production values
|
||||||
- DNS configured for linearvoid.tv (and media.linearvoid.tv if split media domain is used)
|
- DNS configured for `linearvoid.tv` (and `media.linearvoid.tv` if split media domain used)
|
||||||
|
|
||||||
## Environment File
|
## Environment File
|
||||||
|
|
||||||
Use `.env` with at least:
|
`.env` requires at minimum:
|
||||||
|
|
||||||
- `DATABASE_URL` — PostgreSQL connection string
|
- `DATABASE_URL` — PostgreSQL connection string
|
||||||
- `REDIS_URL` — Redis 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):**
|
**Cloudflare R2 (required for HLS segment sync to edge):**
|
||||||
|
|
||||||
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` — R2 write credentials
|
- `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY`
|
||||||
- `R2_BUCKET_NAME` — bucket name (e.g. `linearvoid-media`)
|
- `R2_BUCKET_NAME` (e.g. `linearvoid-media`)
|
||||||
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
- `R2_ENDPOINT` — S3-compatible endpoint URL
|
||||||
- `R2_REGION` — set to `auto`
|
- `R2_REGION` — set to `auto`
|
||||||
|
|
||||||
**Cloudflare (optional, for Workers/Pages):**
|
**Cloudflare (optional, for Workers):**
|
||||||
|
|
||||||
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
- `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID`
|
||||||
- `MEDIA_DOMAIN` — split media domain (e.g. `media.linearvoid.tv`)
|
- `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
|
### 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`, `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`
|
||||||
| ------------------------- | ------------------------------------ |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Pull-and-Run Deployment
|
## 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).
|
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).
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker login git.allucanget.biz
|
docker login git.allucanget.biz
|
||||||
|
|
||||||
docker compose -f docker-compose.prod.yml pull
|
docker compose -f docker-compose.prod.yml pull
|
||||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||||
```
|
```
|
||||||
|
|
||||||
## First-Time or Recovery Provisioning
|
## First-Time or Recovery Provisioning
|
||||||
|
|
||||||
1. Ensure host paths exist:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p data/chat data/playlist data/media data/hls
|
mkdir -p data/chat data/playlist data/media data/hls
|
||||||
```
|
|
||||||
|
|
||||||
1. Confirm volumes and networks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker volume ls
|
docker volume ls
|
||||||
docker network ls
|
docker network ls
|
||||||
```
|
|
||||||
|
|
||||||
1. Start stack:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml up -d
|
docker compose -f docker-compose.prod.yml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
## Health Verification
|
## Health Verification
|
||||||
|
|
||||||
After startup, check service status:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml ps
|
docker compose -f docker-compose.prod.yml ps
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoint checks:
|
Endpoint checks:
|
||||||
|
|
||||||
- App: <http://{host}:8080/>
|
- App: `http://{host}:8080/`
|
||||||
- Nginx health: <http://{host}:8080/health>
|
- Nginx health: `http://{host}:8080/health`
|
||||||
- Chat health: <http://{host}:3000/health>
|
- Chat health: `http://{host}:3000/health`
|
||||||
- Playout health: <http://{host}:8081/health>
|
- Playout health: `http://{host}:8081/health`
|
||||||
- Prometheus: <http://{host}:9090>
|
- Prometheus: `http://{host}:9090`
|
||||||
- Grafana: <http://{host}:3001>
|
- Grafana: `http://{host}:3001`
|
||||||
|
|
||||||
Domain checks:
|
Domain checks:
|
||||||
|
|
||||||
- <https://linearvoid.tv>
|
- `https://linearvoid.tv`
|
||||||
- <https://linearvoid.tv/admin>
|
- `https://linearvoid.tv/admin`
|
||||||
- <https://linearvoid.tv/metrics>
|
- `https://linearvoid.tv/metrics`
|
||||||
|
|
||||||
## Zero/Low-Downtime Update Pattern
|
## Zero/Low-Downtime Update Pattern
|
||||||
|
|
||||||
1. Pull newest images.
|
1. Pull newest images.
|
||||||
1. Recreate changed services only:
|
2. Recreate changed services only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml pull
|
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
|
3. Re-run health checks.
|
||||||
```
|
|
||||||
|
|
||||||
1. Re-run health checks.
|
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
If new images are unhealthy:
|
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.
|
2. Pull pinned tags.
|
||||||
3. Restart impacted services.
|
3. Restart impacted services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml pull
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Operational Tasks
|
## Operational Tasks
|
||||||
|
|
||||||
### View logs
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# View logs
|
||||||
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
docker compose -f docker-compose.prod.yml logs -f nginx chat playout
|
||||||
```
|
|
||||||
|
|
||||||
### Restart one service
|
# Restart one service
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml restart chat
|
docker compose -f docker-compose.prod.yml restart chat
|
||||||
```
|
|
||||||
|
|
||||||
### Stop stack
|
# Stop stack (keep data)
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.prod.yml down
|
|
||||||
```
|
|
||||||
|
|
||||||
### Keep volumes (recommended) vs remove volumes (destructive)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# keep data
|
|
||||||
docker compose -f docker-compose.prod.yml down
|
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
|
docker compose -f docker-compose.prod.yml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nginx and Proxy Considerations
|
## 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).
|
- 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.
|
- For resumable admin uploads, ensure `client_max_body_size` supports chunk requests in both proxy layers.
|
||||||
|
|
||||||
## Security Baseline
|
## Security Baseline
|
||||||
|
|
||||||
- Use strong ADMIN_PASSWORD and rotate regularly.
|
- Use a strong `ADMIN_PASSWORD` and rotate regularly.
|
||||||
- Keep Docker host access restricted.
|
- Keep Docker host access restricted.
|
||||||
- Prefer HTTPS/WSS through Cloudflare.
|
- Prefer HTTPS/WSS through Cloudflare.
|
||||||
- Restrict exposed ports if only proxy access is needed.
|
- 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 |
|
| 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 |
|
| Grafana no data | Check datasource | Verify Prometheus URL `http://prometheus:9090` provisioned |
|
||||||
|
|
||||||
- Proxy-layer body size limits returning 413 on upload.
|
Additional notes: proxy-layer body size limits can return 413 on upload; health checks may fail before service warmup completes.
|
||||||
- Health checks failing before service warmup is complete.
|
|
||||||
+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
|
- **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
|
- **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:
|
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` |
|
| 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 |
|
| 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>.
|
**High cardinality / slow queries**: reduce unneeded labels; limit expensive panels; tune retention and query windows.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Operational Recommendations
|
## Operational Recommendations
|
||||||
|
|
||||||
- Keep alert thresholds tied to actual user impact.
|
- Keep alert thresholds tied to actual user impact.
|
||||||
- Review dashboards after each release that changes endpoints or metrics names.
|
- Review dashboards after each release that changes endpoints or metrics names.
|
||||||
- Periodically test alert routing and incident response flow.
|
- 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
|
## Infrastructure
|
||||||
|
|
||||||
- Cloudflare
|
- 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.
|
- [Account Troubleshooting](./infrastructure/cloudflare/cloudflare-account-troubleshooting.md) - Troubleshooting Cloudflare account issues.
|
||||||
- Monitoring
|
- Monitoring
|
||||||
- [Prometheus CURL](./infrastructure/monitoring/prometheus_curl.md) - Prometheus metrics endpoint testing with curl and jq. Doocumentation of available metrics and their meanings.
|
- [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
|
**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
|
## 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).
|
- **Docker + Docker Compose** — runs the full stack (playout, chat, nginx, redis, db, prometheus, grafana).
|
||||||
- **Node.js 22+**: Required for frontend development and production builds (`cd frontend && npm install && npm run build`).
|
- **Node.js 22+ / npm** — frontend dev and 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+** — working on the Go services outside Docker.
|
||||||
- **Go 1.25+**: Recommended when working on the Go services outside Docker (`backend/chat`, `backend/playout`).
|
- **FFmpeg** — playout HLS generation (`apt install ffmpeg`).
|
||||||
- **FFmpeg**: Used by the playout service for HLS generation. Install via `apt install ffmpeg` (Linux), Homebrew (macOS), or Winget (Windows).
|
- **Git**.
|
||||||
- **Git**: For cloning and version control.
|
- **Cloudflare account** — R2 storage + CDN.
|
||||||
- **Cloudflare Account**: For R2 storage and CDN (free tier sufficient for most use cases).
|
- **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`.
|
||||||
- **Gitea Account**: Required if you want to push images to the self-hosted registry or use the repository's CI/CD flow.
|
- **Hetzner Cloud (optional, prod)** — US-East VPS (Ashburn, VA per ADR-001).
|
||||||
- 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).
|
|
||||||
|
|
||||||
## Network Environments
|
## 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
subgraph Test_Lab["Test Lab (192.168.88.0/24)"]
|
||||||
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
TEST_HOST["192.168.88.40<br/>Docker Engine :2375"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
subgraph Prod["Production (Hetzner — NOT provisioned)"]
|
||||||
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
PROD_HOST["Hetzner CX22 Ashburn, VA<br/>Public IP TBD"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Gitea["Gitea CI/CD"]
|
subgraph Gitea["Gitea CI/CD"]
|
||||||
CI["CI: build + push images"]
|
CI["CI: build + push images"]
|
||||||
CD["CD: deploy via DOCKER_HOST"]
|
CD["CD: deploy via DOCKER_HOST"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph CF["Cloudflare"]
|
subgraph CF["Cloudflare"]
|
||||||
DNS["linearvoid.tv"]
|
DNS["linearvoid.tv"]
|
||||||
R2["R2 Bucket"]
|
R2["R2 Bucket"]
|
||||||
end
|
end
|
||||||
|
|
||||||
CD -->|"DOCKER_HOST=tcp://192.168.88.40:2375"| TEST_HOST
|
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
|
TEST_HOST -->|"test traffic"| DNS
|
||||||
PROD_HOST -.->|"production traffic"| DNS
|
PROD_HOST -.->|"production traffic"| DNS
|
||||||
TEST_HOST --> R2
|
TEST_HOST --> R2
|
||||||
PROD_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.
|
- **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).
|
||||||
**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.
|
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
1. **Clone the Repository** (if not already done):
|
1. `git clone https://git.allucanget.biz/zwitschi/linearvoid.git && cd linearvoid`
|
||||||
|
2. `cp .env.example .env` then replace placeholders.
|
||||||
```bash
|
3. Compose overrides: `docker-compose.yml` builds images locally and mounts `frontend/dist` into nginx; `docker-compose.prod.yml` pulls prebuilt images from Gitea.
|
||||||
git clone https://git.allucanget.biz/zwitschi/linearvoid.git
|
- Internal URLs use compose hostnames: `REDIS_URL=redis://redis:6379/0`, `DATABASE_URL=postgres://linearvoid:<pw>@db:5432/linearvoid?sslmode=disable`.
|
||||||
cd linearvoid
|
- 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).
|
||||||
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).
|
|
||||||
|
|
||||||
## Gitea Setup
|
## Gitea Setup
|
||||||
|
|
||||||
1. **Create or verify a Gitea user**:
|
1. User with repo + registry access; generate a token (scope `write:packages`) stored as `CI_REGISTRY_TOKEN`.
|
||||||
- The user should have access to the repository and permission to read from the container registry.
|
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`.
|
||||||
2. **Create an access token**:
|
4. Auth: `docker login git.allucanget.biz`.
|
||||||
- Generate a token with the scopes needed for package/registry access and, if applicable, repository automation.
|
5. Local dev builds from source; prod pulls `playout`, `chat`, `nginx` images from the registry namespace. Verify `:latest` tags exist before rollout.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Cloudflare R2 Setup
|
## Cloudflare R2 Setup
|
||||||
|
|
||||||
1. **Create an R2 bucket**:
|
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`.
|
||||||
- Create a bucket such as `linearvoid-media` in Cloudflare R2.
|
2. S3 endpoint: `https://<account-id>.r2.cloudflarestorage.com` → `R2_ENDPOINT`.
|
||||||
- Keep the bucket name aligned with `R2_BUCKET_NAME` in `.env`.
|
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)**.
|
||||||
2. **Generate R2 API credentials**:
|
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.
|
||||||
- Create an access key pair with read/write permissions for the bucket.
|
6. Verify: `aws s3 ls --endpoint-url $R2_ENDPOINT`; check cache hit ratio after serving traffic.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Running the Application
|
## Running the Application
|
||||||
|
|
||||||
### Local Development
|
### Local development
|
||||||
|
|
||||||
1. **Start All Services**:
|
```bash
|
||||||
|
docker compose up --build # start all services
|
||||||
|
docker compose down # stop
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
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`).
|
||||||
docker compose up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Access the Application**:
|
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`).
|
||||||
- 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)
|
|
||||||
|
|
||||||
3. **Frontend Development** (optional, for UI changes):
|
### Test lab (remote Docker host)
|
||||||
|
|
||||||
```bash
|
Deploy to `192.168.88.40` over Docker's TCP socket (port `2375`).
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
Then `rc-service docker restart`; verify with `netstat -tlnp | grep 2375`; allow port `2375` from `192.168.88.0/24` in the firewall.
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
Health: `http://192.168.88.40:8081/health` (playout), `:3000/health` (chat), nginx entrypoint `:8080`.
|
||||||
docker compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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).
|
1. Provision a Linux VPS (Docker, Compose, Git); open HTTP/HTTPS ports.
|
||||||
- Docker TCP socket enabled and listening on port `2375` on the remote server.
|
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:
|
See `docs/architecture/README.md` (arc42) and the ADRs for decisions and cost/scaling guidance (ADR-001).
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
|
- **R2 upload fails** — check `.env` creds/bucket; `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).
|
- **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 Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
|
- **Chat won't connect** — check Redis and WS URL; `/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.
|
- **Cache not working** — verify `.m4s` long-TTL / `.m3u8` short-TTL cache rules and hit ratio.
|
||||||
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
|
- **Docker issues** — `docker compose logs`; check port conflicts.
|
||||||
- **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.
|
- **Admin interface** — `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `ADMIN_SESSION_TTL_HOURS` set; session-based login protects `/admin/` and `/api/admin/*`.
|
||||||
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
|
- **Prod pull fails** — `docker login` to Gitea succeeded and image tags exist.
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- Architecture: `docs/architecture/README.md` (arc42 template with diagrams).
|
Architecture: `docs/architecture/README.md` · Decisions: `docs/architecture/adr/` (ADR-001…007) · Contributing: `CONTRIBUTING.md` · Overview: root `README.md`.
|
||||||
- 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.
|
|
||||||
+93
-94
@@ -1,6 +1,6 @@
|
|||||||
# Streaming Pipeline
|
# 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
|
## 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
|
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
|
## Source-media contract
|
||||||
|
|
||||||
Only validated files may enter the production playlist.
|
Only validated files may enter the production playlist.
|
||||||
|
|
||||||
| Property | Required profile |
|
| Property | Required profile |
|
||||||
| ---------- | ------------------------------------------------------------------------ |
|
| ---------- | ----------------------------------------------------------------------- |
|
||||||
| Container | MP4 |
|
| Container | MP4 |
|
||||||
| Video | H.264/AVC, `yuv420p` |
|
| Video | H.264/AVC, `yuv420p` |
|
||||||
| Resolution | 1280x720 or 1920x1080; avoid changing resolution within a playlist block |
|
| Resolution | 1280x720 or 1920x1080; avoid resolution changes within a playlist block |
|
||||||
| Frame rate | Constant 30 fps |
|
| Frame rate | Constant 30 fps |
|
||||||
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
| GOP | Closed, fixed 2 seconds: `-g 60 -keyint_min 60 -sc_threshold 0` |
|
||||||
| Audio | AAC-LC, stereo, 48 kHz, 128–192 kbit/s |
|
| 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:
|
Reference normalization:
|
||||||
|
|
||||||
@@ -51,140 +51,139 @@ ffmpeg -i input.mov \
|
|||||||
|
|
||||||
## Playlist and timeline
|
## Playlist and timeline
|
||||||
|
|
||||||
- `data/playlist/playlist.txt` contains one local media path per line; blank lines and `#` comments are ignored.
|
- `data/playlist/playlist.txt`: one local media path per line; blank lines and `#` comments ignored.
|
||||||
- The service validates the complete playlist before activating a revision.
|
- 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.
|
- 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 do not calculate the schedule independently.
|
- All viewers follow the same live manifest; clients never calculate the schedule independently.
|
||||||
- Schedule metadata must be derived from the same active playlist revision and timeline so “now/next” agrees with the video.
|
- 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 item unless an active schedule override says otherwise.
|
- When the last item ends, playout returns to the first unless an active schedule override says otherwise.
|
||||||
|
|
||||||
## Playout and HLS packaging
|
## 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 |
|
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.
|
||||||
| ---------------- | ------------------------------------------------------------------------------------- |
|
|
||||||
| 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 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 restart or playlist loop — use a run/session ID plus a monotonically increasing media sequence:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
hls/r-20260807T170412Z/init-000001.mp4
|
hls/r-20260807T170412Z/init-000001.mp4
|
||||||
hls/r-20260807T170412Z/seg-000001234.m4s
|
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
|
## 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.
|
1. FFmpeg writes a segment to a temp local name and closes it.
|
||||||
2. The publisher uploads the initialization segment, if new, and media segment to their final immutable R2 keys.
|
2. Publisher uploads the init segment (if new) and media segment to final immutable R2 keys.
|
||||||
3. It verifies successful upload and records object size/checksum.
|
3. Verify upload success; record object size/checksum.
|
||||||
4. It renders the next media manifest referencing only confirmed objects.
|
4. Render the next manifest referencing only confirmed objects.
|
||||||
5. It uploads the manifest to a temporary key, then replaces `hls/playlist.m3u8` as the final commit step.
|
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
|
## Cache contract
|
||||||
|
|
||||||
| Objects | Edge policy | Browser policy | Required headers/behavior |
|
| 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 |
|
| `.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` |
|
| 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` |
|
| Versioned JS/CSS | Cache eligible; TTL 1 year | TTL 1 year | Content-hashed filenames and `immutable` |
|
||||||
|
|
||||||
Further rules:
|
Further rules:
|
||||||
|
|
||||||
- Do not add query-string cache busters to segment URLs; uniqueness belongs in the path.
|
- No query-string cache busters on segment URLs; uniqueness belongs in the path.
|
||||||
- Cache keys must preserve hostname and full path. Ignore only explicitly approved tracking parameters.
|
- Cache keys preserve hostname and full path; ignore only explicitly approved tracking params.
|
||||||
- HLS responses require `Access-Control-Allow-Origin: https://linearvoid.tv` (or the reviewed origin allowlist), `GET`, `HEAD`, and `OPTIONS` support, and correct MIME types.
|
- 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 reduce R2 reads on regional misses.
|
- Enable Smart Tiered Cache for `media.linearvoid.tv` to cut R2 reads on regional misses.
|
||||||
- Purge manifests when necessary, but never purge immutable segments during normal operation.
|
- Purge manifests when necessary, never 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.
|
- 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
|
```bash
|
||||||
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
|
||||||
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
|
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
|
## Player behavior
|
||||||
|
|
||||||
The SPA uses native HLS where supported and hls.js elsewhere.
|
SPA uses native HLS where supported, 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.
|
|
||||||
|
|
||||||
|
- 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.
|
- 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.
|
- 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 and exponential backoff.
|
- Recover transient network/media errors with bounded retries + 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.
|
- On fatal hls.js network error, reload the manifest; on 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.
|
- 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.
|
- 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.
|
- Schedule/chat timestamps should account for measured player latency, not wall-clock playout.
|
||||||
|
|
||||||
## Failure and recovery
|
## 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;
|
- create a new run/session ID and continue with unique segment paths;
|
||||||
- resume from the schedule-derived current position when reliable state exists, otherwise start the current playlist item;
|
- resume from schedule-derived current position when reliable state exists, else start the current playlist item;
|
||||||
- never overwrite old segment objects;
|
- never overwrite old segment objects;
|
||||||
- keep the last valid manifest available until the replacement stream is playable;
|
- keep the last valid manifest available until the replacement is playable;
|
||||||
- emit a discontinuity when timeline continuity cannot be guaranteed.
|
- 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;
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Required configuration
|
## Required configuration
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| ------------------------------------------ | -------------------------------------------------- |
|
| ------------------------------------------ | ------------------------------------- |
|
||||||
| `PLAYLIST_PATH` | Active playlist file |
|
| `PLAYLIST_PATH` | Active playlist file |
|
||||||
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
| `HLS_SEGMENT_DURATION` | Target segment duration; baseline `2` |
|
||||||
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
| `HLS_WINDOW_SIZE` | Manifest window; baseline `12` |
|
||||||
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
| `R2_ENDPOINT`, `R2_REGION` | S3-compatible publication endpoint |
|
||||||
| `R2_BUCKET_NAME` | Media bucket |
|
| `R2_BUCKET_NAME` | Media bucket |
|
||||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, available only to the publisher |
|
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY` | Write credentials, publisher only |
|
||||||
| `MEDIA_DOMAIN` | Public R2 custom domain |
|
| `R2_KEEP_RUNS` | Run folders to retain (default `2`) |
|
||||||
| `VITE_PUBLIC_HLS_URL` | Public live manifest URL |
|
| `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
|
## 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
|
### 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`.
|
- 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.
|
- 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
|
### 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.
|
- **Monitoring**: MediaMTX/SRS dashboard, UptimeRobot, systemd logs, Cloudflare analytics.
|
||||||
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
- **Backup**: Weekly R2/S3-compatible snapshots of playlist, DB, config.
|
||||||
- **Fallback**: Emergency slate/loop playlist, automated restart.
|
- **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
|
## Triage Order
|
||||||
|
|
||||||
1. Edge Reachability (Cloudflare)
|
1. **Edge Reachability** (Cloudflare)
|
||||||
2. Origin Health (VPS/Docker)
|
2. **Origin Health** (VPS/Docker)
|
||||||
3. Playout Progression (HLS)
|
3. **Playout Progression** (HLS)
|
||||||
4. Chat Connectivity (WebSocket)
|
4. **Chat Connectivity** (WebSocket)
|
||||||
5. Data-Plane (Redis/DB)
|
5. **Data-Plane** (Redis/DB)
|
||||||
|
|
||||||
## 1. Edge Reachability (Cloudflare)
|
## 1. Edge Reachability (Cloudflare)
|
||||||
|
|
||||||
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
**Symptoms:** `linearvoid.tv` not resolving, SSL errors, 5xx from Cloudflare.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check DNS
|
|
||||||
dig linearvoid.tv
|
dig linearvoid.tv
|
||||||
dig www.linearvoid.tv
|
dig www.linearvoid.tv
|
||||||
|
|
||||||
# Check Cloudflare status
|
|
||||||
curl -I https://linearvoid.tv/health
|
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)
|
**Mitigation:** Verify A record points `linearvoid.tv` → origin IP (proxied, orange cloud). Bypass Cloudflare temporarily:
|
||||||
- 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:**
|
```bash
|
||||||
|
curl -H "Host: linearvoid.tv" http://<origin-ip>/health
|
||||||
- Verify A record: `linearvoid.tv` → origin IP (proxied, orange cloud)
|
```
|
||||||
- Bypass Cloudflare temporarily: `curl -H "Host: linearvoid.tv" http://<origin-ip>/health`
|
|
||||||
|
|
||||||
## 2. Origin Health (VPS/Docker)
|
## 2. Origin Health (VPS/Docker)
|
||||||
|
|
||||||
**Symptoms:** Cloudflare returns 502/504, containers down.
|
**Symptoms:** Cloudflare 502/504, containers down.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remote Docker (test lab)
|
|
||||||
docker -H tcp://192.168.88.40:2375 ps
|
docker -H tcp://192.168.88.40:2375 ps
|
||||||
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
docker -H tcp://192.168.88.40:2375 logs linearvoid-nginx --tail 50
|
||||||
|
|
||||||
# Direct health check
|
|
||||||
curl http://192.168.88.40/health
|
curl http://192.168.88.40/health
|
||||||
```
|
```
|
||||||
|
|
||||||
**Common causes:**
|
**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`).
|
||||||
|
|
||||||
- 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`
|
|
||||||
|
|
||||||
**Mitigation:**
|
**Mitigation:**
|
||||||
|
|
||||||
- Restart stack: `docker compose -f docker-compose.prod.yml up -d`
|
```bash
|
||||||
- Free disk: `docker system prune -f`
|
docker compose -f docker-compose.prod.yml up -d # restart stack
|
||||||
- Check logs per service: `docker logs linearvoid-<service> --tail 100`
|
docker system prune -f # free disk
|
||||||
|
docker logs linearvoid-<service> --tail 100 # per-service logs
|
||||||
|
```
|
||||||
|
|
||||||
## 3. Playout Progression (HLS)
|
## 3. Playout Progression (HLS)
|
||||||
|
|
||||||
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
**Symptoms:** Viewer sees slate or buffering, playlist not advancing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check playout health
|
curl http://192.168.88.40:8081/health # "mode": "live" or "slate"
|
||||||
curl http://192.168.88.40:8081/health
|
|
||||||
# Response includes "mode": "live" or "slate"
|
|
||||||
|
|
||||||
# Check HLS output
|
|
||||||
curl -I http://192.168.88.40/hls/playlist.m3u8
|
curl -I http://192.168.88.40/hls/playlist.m3u8
|
||||||
ls -la data/hls/
|
ls -la data/hls/
|
||||||
|
|
||||||
# Check playout logs
|
|
||||||
docker logs linearvoid-playout --tail 100
|
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`)
|
**Mitigation:** Validate playlist entries and media paths; restart playout (`docker compose restart playout`); regenerate demo playlist if empty (see `backend/playout/start-playout.sh`).
|
||||||
- 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`
|
|
||||||
|
|
||||||
## 4. Chat Connectivity (WebSocket)
|
## 4. Chat Connectivity (WebSocket)
|
||||||
|
|
||||||
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
**Symptoms:** Chat not loading, messages not sending, presence broken.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check chat health
|
|
||||||
curl http://192.168.88.40:3000/health
|
curl http://192.168.88.40:3000/health
|
||||||
|
|
||||||
# Test WebSocket
|
|
||||||
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
wscat -c ws://192.168.88.40/ws?username=test&room=main
|
||||||
|
|
||||||
# Check chat logs
|
|
||||||
docker logs linearvoid-chat --tail 100
|
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)
|
**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.
|
||||||
- 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"
|
|
||||||
|
|
||||||
## 5. Data-Plane (Redis/DB)
|
## 5. Data-Plane (Redis/DB)
|
||||||
|
|
||||||
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
**Symptoms:** Chat works but no persistence/presence, admin stats empty.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Redis
|
|
||||||
docker exec linearvoid-redis redis-cli ping
|
docker exec linearvoid-redis redis-cli ping
|
||||||
docker exec linearvoid-redis redis-cli info memory
|
docker exec linearvoid-redis redis-cli info memory
|
||||||
|
|
||||||
# PostgreSQL
|
|
||||||
docker exec linearvoid-db pg_isready -U linearvoid
|
docker exec linearvoid-db pg_isready -U linearvoid
|
||||||
docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
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)
|
**Mitigation:** Restart redis (`docker compose restart redis`, AOF persist enabled); restart db (`docker compose restart db`); restore from backup (see `scripts/verify-backups.sh`).
|
||||||
- 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
|
|
||||||
|
|
||||||
## Escalation
|
## Escalation
|
||||||
|
|
||||||
| Severity | When | Action |
|
| Severity | When | Action |
|
||||||
| -------- | ------------------------------------ | -------------------------------------------------- |
|
| -------- | ------------------------------------ | ------------------------------------------- |
|
||||||
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
| Critical | Slate active >30min, complete outage | Trigger DR plan, provision fallback host |
|
||||||
| Warning | Redis down, high CPU >30min | Investigate during business hours, restart service |
|
| Warning | Redis down, high CPU >30min | Investigate business hours, restart service |
|
||||||
| Info | Single service blip | Monitor, no action unless recurring |
|
| Info | Single service blip | Monitor; no action unless recurring |
|
||||||
|
|
||||||
## Post-Incident
|
## Post-Incident
|
||||||
|
|
||||||
@@ -163,67 +109,52 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
|
|||||||
|
|
||||||
## Quarterly Disaster Recovery Drill
|
## Quarterly Disaster Recovery Drill
|
||||||
|
|
||||||
Goal: validate recovery targets under controlled host-loss simulation.
|
Validate recovery targets under controlled host-loss simulation: **RPO ≤ 24h, RTO ≤ 60min**.
|
||||||
|
|
||||||
**Recovery targets:** RPO <= 24h, RTO <= 60min.
|
|
||||||
|
|
||||||
### Prerequisites
|
### 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
|
- 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)
|
- Simulate complete origin loss (all app containers unavailable)
|
||||||
- Rebuild stack on clean host
|
- Rebuild stack on clean host
|
||||||
- Restore DB and media/playlist mounts
|
- Restore DB and media/playlist mounts
|
||||||
- Verify viewer playback, chat, admin auth, monitoring targets
|
- Verify viewer playback, chat, admin auth, monitoring targets
|
||||||
|
|
||||||
### Drill Steps
|
### Steps
|
||||||
|
|
||||||
1. Record drill start timestamp and on-call participants.
|
1. Record drill start timestamp and on-call participants.
|
||||||
2. Capture pre-drill baseline:
|
2. Capture pre-drill baseline:
|
||||||
- `curl -fsS http://<origin>/health`
|
```bash
|
||||||
- `curl -fsS http://<origin>:3000/health`
|
curl -fsS http://<origin>/health
|
||||||
- `curl -fsS http://<origin>:8081/health`
|
curl -fsS http://<origin>:3000/health
|
||||||
3. Simulate origin outage:
|
curl -fsS http://<origin>:8081/health
|
||||||
- `docker compose -f docker-compose.prod.yml down`
|
```
|
||||||
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
|
3. Simulate outage: `docker compose -f docker-compose.prod.yml down`
|
||||||
5. Restore required data paths:
|
4. Provision clean recovery host (or clean test-lab VM); copy deployment bundle.
|
||||||
- `data/playlist/`
|
5. Restore data paths: `data/playlist/`, `data/media/`, `data/chat/`, `data/hls/` (optional warm cache).
|
||||||
- `data/media/`
|
|
||||||
- `data/chat/`
|
|
||||||
- `data/hls/` (optional warm cache)
|
|
||||||
6. Restore database from latest backup dump.
|
6. Restore database from latest backup dump.
|
||||||
7. Start stack:
|
7. Start stack: `docker compose -f docker-compose.prod.yml up -d`
|
||||||
- `docker compose -f docker-compose.prod.yml up -d`
|
8. Validate health and runtime:
|
||||||
8. Validate service health and runtime:
|
```bash
|
||||||
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
|
docker ps --format "table {{.Names}}\t{{.Status}}"
|
||||||
- `curl -fsS http://<origin>/health`
|
curl -fsS http://<origin>/health
|
||||||
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
|
curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5
|
||||||
- `curl -fsS http://<origin>:9090/-/ready`
|
curl -fsS http://<origin>:9090/-/ready
|
||||||
9. Validate app behavior:
|
```
|
||||||
- Viewer SPA loads and HLS plays
|
9. Validate app behavior: viewer SPA loads and HLS plays; WebSocket chat connects with message round-trip; admin login/session endpoint succeeds.
|
||||||
- WebSocket chat connects and message round-trip works
|
10. Run backup integrity check: `scripts/verify-backups.sh`
|
||||||
- Admin login/session endpoint returns success
|
11. Record drill end timestamp; compute measured RTO/RPO.
|
||||||
10. Run backup integrity check:
|
|
||||||
- `scripts/verify-backups.sh`
|
|
||||||
11. Record drill end timestamp and compute measured RTO/RPO.
|
|
||||||
|
|
||||||
### Pass/Fail Criteria
|
### Pass/Fail Criteria
|
||||||
|
|
||||||
- Pass if all core services healthy and functional in <= 60 minutes.
|
- **Pass:** all core services healthy and functional in ≤ 60 min.
|
||||||
- Pass if latest recoverable data point is <= 24 hours old.
|
- **Pass:** latest recoverable data point ≤ 24 h old.
|
||||||
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
|
- **Fail:** any critical path remains degraded (playout, chat, admin auth, monitoring).
|
||||||
|
|
||||||
### Evidence Template
|
### Evidence Template
|
||||||
|
|
||||||
Document each drill in incident tracker with:
|
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.
|
||||||
|
|
||||||
- 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.
|
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
|
## Validate Cloudflare account and API token
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
# Validate Cloudflare account and API token in .env
|
# Validate Cloudflare account and API token in .env
|
||||||
Set-Location -Path "E:\Documents\02-Projects\07-Mentoring\Corey\linearvoid"
|
cd /path/to/linearvoid
|
||||||
Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN'
|
grep -E 'CLOUDFLARE_API_TOKEN|CLOUDFLARE_ACCOUNT_ID' .env
|
||||||
Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID'
|
|
||||||
|
|
||||||
# Test API token with curl
|
# Test API token with wrangler
|
||||||
$env:CLOUDFLARE_API_TOKEN = (Get-Content .env | Select-String 'CLOUDFLARE_API_TOKEN' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
npx wrangler whoami
|
||||||
$env:CLOUDFLARE_ACCOUNT_ID = (Get-Content .env | Select-String 'CLOUDFLARE_ACCOUNT_ID' | ForEach-Object { $_.Line.Split('=')[1].Trim() })
|
|
||||||
|
|
||||||
# List all Pages projects for the account
|
# Or verify with the Workers API directly
|
||||||
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"
|
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)
|
# List all Workers for the account
|
||||||
# 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"}'
|
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:
|
Recommended execution order:
|
||||||
|
|
||||||
1. Bind `media.linearvoid.tv` and confirm the HLS origin is separate from the app origin.
|
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.
|
3. Apply WAF, bot, rate limiting, HTTPS, and HSTS settings.
|
||||||
4. Verify monitor targets and alert delivery in UptimeRobot.
|
4. Verify monitor targets and alert delivery in UptimeRobot.
|
||||||
5. Capture evidence and fill in the sign-off block.
|
5. Capture evidence and fill in the sign-off block.
|
||||||
@@ -15,18 +15,18 @@ Recommended execution order:
|
|||||||
- [ ] Pending
|
- [ ] Pending
|
||||||
- [x] Done
|
- [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`
|
- [ ] Build command set to: `cd frontend && npm run build`
|
||||||
- [ ] Output directory set to: `frontend/dist`
|
- [ ] Output directory set to: `frontend/dist`
|
||||||
- [ ] Production domain attached to `linearvoid.tv`
|
- [ ] Production domain attached to `linearvoid.tv`
|
||||||
|
|
||||||
Evidence to capture:
|
Evidence to capture:
|
||||||
|
|
||||||
- Screenshot of Pages deployment settings
|
- Screenshot of Worker deployment settings
|
||||||
- Deployment URL and latest deployment timestamp
|
- 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
|
## 2) Tiered Cache Enablement and Validation
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ Evidence to capture:
|
|||||||
|
|
||||||
Completion summary:
|
Completion summary:
|
||||||
|
|
||||||
- Pages build URL:
|
- Worker build URL:
|
||||||
- Cache validation result:
|
- Cache validation result:
|
||||||
- WAF/rate-limit status:
|
- WAF/rate-limit status:
|
||||||
- HTTPS/HSTS 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
|
# Scaling and Performance Playbook
|
||||||
|
|
||||||
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
|
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.
|
||||||
|
|
||||||
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
|
|
||||||
|
|
||||||
## 1) Manifest TTL Tuning Plan
|
## 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:
|
Inputs: segment duration (`HLS_SEGMENT_DURATION`), current manifest TTL (Cloudflare cache rule), cache outcome distribution (`Cf-Cache-Status`).
|
||||||
|
|
||||||
- Segment duration (`HLS_SEGMENT_DURATION`)
|
|
||||||
- Current manifest TTL (Cloudflare cache rule)
|
|
||||||
- Cache outcome distribution (`Cf-Cache-Status`)
|
|
||||||
|
|
||||||
Procedure:
|
Procedure:
|
||||||
|
|
||||||
1. Start from current manifest TTL (2s).
|
1. Start from current manifest TTL (2s).
|
||||||
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
|
||||||
3. Test candidate TTLs: 1s, 2s, 3s.
|
3. Test candidate TTLs: 1s, 2s, 3s.
|
||||||
4. Compare:
|
4. Compare rebuffer events (player logs), origin request increase/decrease, and HIT ratio changes.
|
||||||
- rebuffer events from player logs
|
5. Select the lowest TTL that keeps playback stable and origin load acceptable.
|
||||||
- origin request increase/decrease
|
|
||||||
- HIT ratio changes
|
|
||||||
5. Select lowest TTL that keeps playback stable and origin load acceptable.
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance: no material increase in rebuffer rate; manifest staleness not observable by users.
|
||||||
|
|
||||||
- No material increase in rebuffer rate.
|
|
||||||
- Manifest staleness not observable by users.
|
|
||||||
|
|
||||||
## 2) TTFB Baseline Plan
|
## 2) TTFB Baseline Plan
|
||||||
|
|
||||||
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
|
||||||
|
|
||||||
Suggested regions:
|
Regions: US East (Virginia), US Central, US West (California/Oregon).
|
||||||
|
|
||||||
- US East (Virginia)
|
|
||||||
- US Central
|
|
||||||
- US West (California/Oregon)
|
|
||||||
|
|
||||||
Method:
|
Method:
|
||||||
|
|
||||||
1. Use WebPageTest with repeat view enabled.
|
1. Use WebPageTest with repeat view enabled.
|
||||||
2. Run at least 5 samples per region.
|
2. Run at least 5 samples per region.
|
||||||
3. Record median and p95 TTFB.
|
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:
|
Acceptance: warm-cache median < 4s in all regions; outliers documented with likely cause.
|
||||||
|
|
||||||
- Warm-cache median < 4s in all tested regions.
|
|
||||||
- Outliers documented with likely cause.
|
|
||||||
|
|
||||||
### TTFB Baseline Worksheet
|
### 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)
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 3) WebSocket Load-Test Plan
|
## 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):
|
Tooling option A (Artillery):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx artillery run scripts/loadtest/ws-chat.yml
|
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
|
Metrics: p50/p95 message latency; error rate (connect/send/receive); dropped connections; CPU/memory for `chat`, `redis`, `nginx`.
|
||||||
- send message every X seconds
|
|
||||||
- measure round-trip latency and failures
|
|
||||||
|
|
||||||
Metrics to capture:
|
Acceptance: p95 chat latency ≤ 1s at target concurrency tier; error rate ≤ 1% during steady state.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
### WebSocket Load-Test Worksheet
|
### 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/redis/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:
|
|
||||||
- CPU/memory notes for redis:
|
|
||||||
- CPU/memory notes for nginx:
|
|
||||||
- Raw results location:
|
|
||||||
- Conclusion:
|
|
||||||
- Next action:
|
|
||||||
|
|
||||||
## 4) Admin API Benchmark Plan
|
## 4) Admin API Benchmark Plan
|
||||||
|
|
||||||
Goal: measure admin API p95 and identify slow endpoints.
|
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
|
```bash
|
||||||
k6 run scripts/loadtest/admin-api.js
|
k6 run scripts/loadtest/admin-api.js
|
||||||
```
|
```
|
||||||
|
|
||||||
Tooling option B (`wrk`):
|
Tooling option B (`wrk`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints:
|
Acceptance: p95 < 1s for primary admin endpoints; slow endpoint list with remediation notes.
|
||||||
|
|
||||||
- `/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.
|
|
||||||
|
|
||||||
### Admin API Benchmark Worksheet
|
### Admin API Benchmark Worksheet
|
||||||
|
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.
|
||||||
- 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:
|
|
||||||
|
|
||||||
## 5) Cache Hit Ratio Tracking Plan
|
## 5) Cache Hit Ratio Tracking Plan
|
||||||
|
|
||||||
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
Goal: track Cloudflare cache hit ratio and alert when below threshold.
|
||||||
|
|
||||||
Implementation outline:
|
Implementation:
|
||||||
|
|
||||||
1. Pull Cloudflare GraphQL analytics on schedule.
|
1. Pull Cloudflare GraphQL analytics on schedule.
|
||||||
2. Export to Prometheus-compatible metric path.
|
2. Export to a Prometheus-compatible metric path.
|
||||||
3. Add Grafana panel with 5m/1h/24h windows.
|
3. Add a Grafana panel with 5m/1h/24h windows.
|
||||||
4. Alert when hit ratio < 90% for 15m.
|
4. Alert when hit ratio < 90% for 15m.
|
||||||
|
|
||||||
Reference exporter:
|
Reference exporter:
|
||||||
|
|
||||||
- `scripts/cache-hit-ratio.js`
|
- `scripts/cache-hit-ratio.js`
|
||||||
- `scripts/cache-hit-ratio.ps1` for Windows scheduled tasks / manual runs
|
- `scripts/cache-hit-ratio.sh` for scheduled tasks / manual runs
|
||||||
- Example run: `CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ZONE_TAG=... CLOUDFLARE_HOSTNAME=media.linearvoid.tv node scripts/cache-hit-ratio.js`
|
- Example run: `export 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`
|
|
||||||
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
- Intended output: Prometheus text format on stdout, suitable for cron, a Node wrapper, or a small scrape endpoint.
|
||||||
|
|
||||||
Metric target:
|
Metric target: `cache_hit_ratio >= 0.90`.
|
||||||
|
|
||||||
- `cache_hit_ratio >= 0.90`
|
|
||||||
|
|
||||||
### Cache Hit Ratio Worksheet
|
### 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
|
## 6) Capacity Trigger Rules
|
||||||
|
|
||||||
Define operational triggers:
|
Operational triggers:
|
||||||
|
|
||||||
- Cache miss ratio > 10% for 15m
|
- Cache miss ratio > 10% for 15m
|
||||||
- Chat p95 latency > 1s for 5m
|
- Chat p95 latency > 1s for 5m
|
||||||
- Host CPU > 80% for 10m
|
- Host CPU > 80% for 10m
|
||||||
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
- Redis memory usage ratio (`linearvoid_chat_redis_memory_usage_ratio`) > 0.85 for 10m
|
||||||
|
|
||||||
Action mapping:
|
Action mapping:
|
||||||
|
|
||||||
- Level 1: investigate + restart non-critical components
|
- Level 1: investigate + restart non-critical components
|
||||||
- Level 2: scale service resources (CPU/RAM)
|
- Level 2: scale service resources (CPU/RAM)
|
||||||
- Level 3: activate standby planning path
|
- Level 3: activate standby planning path
|
||||||
|
|
||||||
### Capacity Trigger Review
|
### 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
|
## 7) Phase 2 Service Separation Plan
|
||||||
|
|
||||||
Objective: separate bottlenecked services while keeping simple ops.
|
Objective: separate bottlenecked services while keeping ops simple.
|
||||||
|
|
||||||
Boundaries:
|
Boundaries:
|
||||||
|
|
||||||
- `playout` isolated first (CPU-heavy)
|
- `playout` isolated first (CPU-heavy)
|
||||||
- `chat` + `redis` grouped for latency
|
- `chat` + `redis` grouped for latency
|
||||||
- `db` isolated for IO stability
|
- `db` isolated for IO stability
|
||||||
- monitoring stack optionally separated after sustained load
|
- monitoring stack optionally separated after sustained load
|
||||||
|
|
||||||
Initial sizing guidance:
|
Initial sizing:
|
||||||
|
|
||||||
- playout host: 4 vCPU / 8 GB
|
- 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
|
- db host: 2 vCPU / 4 GB + fast disk
|
||||||
|
|
||||||
Exit criteria for Phase 2:
|
Exit criteria: repeated trigger events over 7 days; capacity saturation despite tuning.
|
||||||
|
|
||||||
- repeated trigger events over 7 days
|
|
||||||
- capacity saturation despite tuning
|
|
||||||
|
|
||||||
### Phase 2 Boundary Worksheet
|
### 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`/`chat`/`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` target host size:
|
|
||||||
- `chat` target host size:
|
|
||||||
- `db` target host size:
|
|
||||||
- Monitoring separation needed:
|
|
||||||
- Data/state migration notes:
|
|
||||||
- Implementation owner:
|
|
||||||
- Review decision:
|
|
||||||
- Next action:
|
|
||||||
|
|
||||||
## 8) Phase 3 Warm Standby Plan
|
## 8) Phase 3 Warm Standby Plan
|
||||||
|
|
||||||
Objective: second-region resilience with DNS failover.
|
Objective: second-region resilience with DNS failover.
|
||||||
|
|
||||||
Candidate layout:
|
Layout:
|
||||||
|
|
||||||
- Primary: Hetzner Ashburn (US-East)
|
- Primary: Hetzner Ashburn (US-East)
|
||||||
- Standby: Hetzner Hillsboro (US-West)
|
- Standby: Hetzner Hillsboro (US-West)
|
||||||
|
|
||||||
Design:
|
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).
|
||||||
|
|
||||||
- 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:
|
|
||||||
|
|
||||||
### Phase 3 Warm Standby Worksheet
|
### 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
|
## 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
|
## 10) Execution Order
|
||||||
|
|
||||||
1. TTFB baseline
|
1. TTFB baseline
|
||||||
2. WebSocket load profile
|
2. WebSocket load profile
|
||||||
3. Admin API benchmark
|
3. Admin API benchmark
|
||||||
@@ -349,14 +160,9 @@ For each benchmark run, capture:
|
|||||||
7. Phase 3 standby design review
|
7. Phase 3 standby design review
|
||||||
|
|
||||||
## 11) Benchmark Run Sheet
|
## 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 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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
Run template:
|
Run template:
|
||||||
|
|
||||||
@@ -373,70 +179,47 @@ Run template:
|
|||||||
| Next action | |
|
| Next action | |
|
||||||
|
|
||||||
Suggested command mapping:
|
Suggested command mapping:
|
||||||
|
|
||||||
- TTFB baseline: WebPageTest repeat-view run against `https://linearvoid.tv`.
|
- 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.
|
- 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`.
|
- Admin API benchmark: `k6 run scripts/loadtest/admin-api.js` or `wrk -t4 -c100 -d60s http://<host>/api/admin/overview`.
|
||||||
|
|
||||||
## 12) Scaling Decision Gate
|
## 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.
|
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.
|
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.
|
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:
|
Decision note: keep the smallest change that restores the target metric and record the reason in the run sheet.
|
||||||
|
|
||||||
- Keep the smallest change that restores the target metric and record the reason in the run sheet.
|
|
||||||
|
|
||||||
## 13) Artifact-to-Worksheet Field Mapping
|
## 13) Artifact-to-Worksheet Field Mapping
|
||||||
|
Use when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
||||||
Use this section when filling the WebSocket and Admin API worksheets from helper-generated artifacts.
|
|
||||||
|
|
||||||
### WebSocket worksheet mapping
|
### 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:
|
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`)
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Admin API worksheet mapping
|
### 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:
|
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`)
|
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.
|
||||||
- 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:
|
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.
|
||||||
|
|
||||||
- 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.
|
|
||||||
+14
-13
@@ -44,29 +44,30 @@
|
|||||||
<div class="chat-header-actions">
|
<div class="chat-header-actions">
|
||||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
<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-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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="auth-panel" class="auth-panel" hidden>
|
<div id="auth-panel" class="auth-modal" hidden>
|
||||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
<div class="auth-modal-content">
|
||||||
<div class="button-row">
|
<button id="auth-close-btn" type="button" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
<h2 class="auth-modal-title">Sign in</h2>
|
||||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
<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>
|
</div>
|
||||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
<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">
|
<section id="presence-panel" class="presence-panel">
|
||||||
<div id="presence-list" class="presence-list"></div>
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hls.js": "^1.6.16"
|
"hls.js": "^1.6.16"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.13",
|
"version": "1.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.0.0"
|
"node": ">=22.0.0"
|
||||||
|
|||||||
+30
-22
@@ -31,6 +31,7 @@ const adminConfig = {
|
|||||||
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
const UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024;
|
||||||
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
const UPLOAD_RESUME_KEY_PREFIX = "linearvoid-upload-resume:";
|
||||||
|
const API_URL = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
mediaUploading: false,
|
mediaUploading: false,
|
||||||
@@ -131,7 +132,7 @@ const pageCache: Record<string, string> = {};
|
|||||||
async function loadPageContent(tabName: string): Promise<string> {
|
async function loadPageContent(tabName: string): Promise<string> {
|
||||||
if (pageCache[tabName]) return pageCache[tabName];
|
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}`);
|
if (!response.ok) throw new Error(`Failed to load page: ${tabName}`);
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
pageCache[tabName] = html;
|
pageCache[tabName] = html;
|
||||||
@@ -175,7 +176,7 @@ async function switchTab(tabName: string) {
|
|||||||
|
|
||||||
export async function refreshOverview() {
|
export async function refreshOverview() {
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -202,7 +203,7 @@ export async function refreshOverview() {
|
|||||||
export async function refreshAnalytics() {
|
export async function refreshAnalytics() {
|
||||||
setStatus("analytics-status", "Loading analytics...");
|
setStatus("analytics-status", "Loading analytics...");
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -240,7 +241,7 @@ export async function refreshAnalytics() {
|
|||||||
async function refreshPlaylist() {
|
async function refreshPlaylist() {
|
||||||
setStatus("playlist-status", "Loading playlist...");
|
setStatus("playlist-status", "Loading playlist...");
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -522,7 +523,7 @@ async function submitOverride(e: Event) {
|
|||||||
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
const endTime = (document.getElementById("override-end") as HTMLInputElement).value;
|
||||||
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
const reason = (document.getElementById("override-reason") as HTMLInputElement).value;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/api/admin/schedule/overrides", {
|
const resp = await fetch(`${API_URL}/api/admin/schedule/overrides`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -551,7 +552,7 @@ async function submitOverride(e: Event) {
|
|||||||
|
|
||||||
async function deleteOverride(id: number) {
|
async function deleteOverride(id: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
await fetch(`${API_URL}/api/admin/schedule/overrides?id=${id}`, { method: "DELETE" });
|
||||||
refreshOverrides();
|
refreshOverrides();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -561,17 +562,21 @@ async function deleteOverride(id: number) {
|
|||||||
async function renderServiceRow(service: any) {
|
async function renderServiceRow(service: any) {
|
||||||
const svcDetail = service.detail || "";
|
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) {
|
if (service.name === "playout" && svcDetail) {
|
||||||
try {
|
try {
|
||||||
// create a list of key-value pairs for each property in the JSON object
|
// create a list of key-value pairs for each property in the JSON object
|
||||||
const detailObj = JSON.parse(svcDetail);
|
const detailObj = JSON.parse(svcDetail);
|
||||||
service.detail = Object.entries(detailObj)
|
// create a table of key-value pairs for each property in the JSON object
|
||||||
.map(([key, value]) => `${key}: ${value}`)
|
const detailTable = Object.entries(detailObj)
|
||||||
.join("\n");
|
.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 {
|
} catch {
|
||||||
// ignore JSON parse errors
|
// ignore JSON parse errors
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
service.detail = `<p>${service.detail}</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = document.createElement("article");
|
const row = document.createElement("article");
|
||||||
@@ -583,7 +588,7 @@ async function renderServiceRow(service: any) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="service-row-right">
|
<div class="service-row-right">
|
||||||
<p class="service-status service-status-${service.status}">${service.status}</p>
|
<p class="service-status service-status-${service.status}">${service.status}</p>
|
||||||
<p>${service.detail}</p>
|
${service.detail}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
return row;
|
return row;
|
||||||
@@ -749,9 +754,12 @@ async function uploadVideo(file: File) {
|
|||||||
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
setStatus("media-status", `Uploading ${file.name} (${chunkIndex + 1}/${effectiveTotalChunks} chunks)...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeResponse = await fetch(`/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`, {
|
const completeResponse = await fetch(
|
||||||
method: "POST",
|
`${API_URL}/api/admin/media/uploads/${encodeURIComponent(uploadId)}/complete`,
|
||||||
});
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (!completeResponse.ok) {
|
if (!completeResponse.ok) {
|
||||||
throw new Error(await readErrorMessage(completeResponse));
|
throw new Error(await readErrorMessage(completeResponse));
|
||||||
@@ -791,7 +799,7 @@ async function editMedia(file: MediaEntry) {
|
|||||||
|
|
||||||
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
setStatus("media-status", `Saving metadata for ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ title, status, tags }),
|
body: JSON.stringify({ title, status, tags }),
|
||||||
@@ -816,7 +824,7 @@ async function normalizeMedia(file: MediaEntry) {
|
|||||||
|
|
||||||
setStatus("media-status", `Normalizing ${file.name}...`);
|
setStatus("media-status", `Normalizing ${file.name}...`);
|
||||||
try {
|
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));
|
if (!response.ok) throw new Error(await readErrorMessage(response));
|
||||||
|
|
||||||
setStatus("media-status", `Normalization started for ${file.name}.`);
|
setStatus("media-status", `Normalization started for ${file.name}.`);
|
||||||
@@ -836,7 +844,7 @@ function pollNormalizationStatus(id: number, attempt: number) {
|
|||||||
}
|
}
|
||||||
window.setTimeout(async () => {
|
window.setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/media/${id}`);
|
const response = await fetch(`${API_URL}/api/admin/media/${id}`);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const file = (await response.json()) as MediaEntry;
|
const file = (await response.json()) as MediaEntry;
|
||||||
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
if (file.status === "normalizing" || file.status === "needs_normalization") {
|
||||||
@@ -869,7 +877,7 @@ async function deleteMedia(file: MediaEntry) {
|
|||||||
|
|
||||||
setStatus("media-status", `Deleting ${file.name}...`);
|
setStatus("media-status", `Deleting ${file.name}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/media/${file.id}`, {
|
const response = await fetch(`${API_URL}/api/admin/media/${file.id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -887,7 +895,7 @@ async function deleteMedia(file: MediaEntry) {
|
|||||||
async function refreshModerationLogs() {
|
async function refreshModerationLogs() {
|
||||||
setStatus("moderation-status", "Loading moderation logs...");
|
setStatus("moderation-status", "Loading moderation logs...");
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -988,7 +996,7 @@ async function refreshChatLog() {
|
|||||||
if (chatLogState.username) params.set("username", chatLogState.username);
|
if (chatLogState.username) params.set("username", chatLogState.username);
|
||||||
if (chatLogState.text) params.set("text", chatLogState.text);
|
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}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -1118,7 +1126,7 @@ async function deleteChatEntry(id: number) {
|
|||||||
|
|
||||||
setStatus("chat-log-status", `Deleting message #${id}...`);
|
setStatus("chat-log-status", `Deleting message #${id}...`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/chat-log/${id}`, {
|
const response = await fetch(`${API_URL}/api/admin/chat-log/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
@@ -1268,7 +1276,7 @@ async function restartService(service: string) {
|
|||||||
const status = document.getElementById("restart-status");
|
const status = document.getElementById("restart-status");
|
||||||
if (status) status.textContent = `Requesting restart of ${service}...`;
|
if (status) status.textContent = `Requesting restart of ${service}...`;
|
||||||
try {
|
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();
|
const data = await resp.json();
|
||||||
if (status) status.textContent = data.message;
|
if (status) status.textContent = data.message;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
+11
-36
@@ -124,19 +124,18 @@ beforeEach(() => {
|
|||||||
<div id="chat-user-label" hidden></div>
|
<div id="chat-user-label" hidden></div>
|
||||||
<button id="auth-toggle-btn" type="button">Sign in</button>
|
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||||||
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||||||
<div id="auth-panel" hidden>
|
<div id="auth-panel" class="auth-modal" hidden>
|
||||||
<input id="auth-username" type="text" />
|
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||||||
<input id="auth-password" type="password" />
|
<div class="auth-modal-content">
|
||||||
<span id="auth-status"></span>
|
<button id="auth-close-btn" type="button">×</button>
|
||||||
<button id="auth-login-btn" type="button">Login</button>
|
<input id="auth-username" type="text" />
|
||||||
<button id="auth-register-btn" type="button">Register</button>
|
<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>
|
</div>
|
||||||
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
|
|
||||||
<div id="typing-indicator" hidden></div>
|
<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
|
// Stub prompt for DM
|
||||||
@@ -456,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 () => {
|
it("logs in and updates the auth UI", async () => {
|
||||||
await import("../src/main");
|
await import("../src/main");
|
||||||
|
|
||||||
@@ -486,30 +485,6 @@ describe("auth and user list wiring", () => {
|
|||||||
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
||||||
expect(document.getElementById("chat-user-label")!.textContent).toBe("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", () => {
|
describe("player error recovery", () => {
|
||||||
|
|||||||
+50
-58
@@ -16,9 +16,7 @@ let ws: WebSocket | null = null;
|
|||||||
let currentRoom = "main";
|
let currentRoom = "main";
|
||||||
let username = "viewer" + Math.floor(Math.random() * 1000);
|
let username = "viewer" + Math.floor(Math.random() * 1000);
|
||||||
let authenticated = false;
|
let authenticated = false;
|
||||||
let currentDMTarget = "";
|
|
||||||
let chatVisible = true;
|
let chatVisible = true;
|
||||||
let usersVisible = true;
|
|
||||||
let typingUser = "";
|
let typingUser = "";
|
||||||
let lastTypingSent = 0;
|
let lastTypingSent = 0;
|
||||||
let typingTimeout: number | undefined;
|
let typingTimeout: number | undefined;
|
||||||
@@ -38,7 +36,6 @@ export function resetViewerStateForTests() {
|
|||||||
username = "viewer";
|
username = "viewer";
|
||||||
authenticated = false;
|
authenticated = false;
|
||||||
chatVisible = true;
|
chatVisible = true;
|
||||||
usersVisible = true;
|
|
||||||
typingUser = "";
|
typingUser = "";
|
||||||
lastTypingSent = 0;
|
lastTypingSent = 0;
|
||||||
onlineUsers.clear();
|
onlineUsers.clear();
|
||||||
@@ -79,6 +76,10 @@ export function resolveManifestUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connectWebSocket(room: string) {
|
function connectWebSocket(room: string) {
|
||||||
|
// 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
|
// Detach the old socket's close handler before closing it. Otherwise an
|
||||||
// intentional reconnect (login/logout/room switch/session refresh) would
|
// intentional reconnect (login/logout/room switch/session refresh) would
|
||||||
// have the replaced socket schedule yet another reconnect, and each new
|
// have the replaced socket schedule yet another reconnect, and each new
|
||||||
@@ -137,6 +138,11 @@ function connectWebSocket(room: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearMessages() {
|
||||||
|
const messages = document.getElementById("messages");
|
||||||
|
if (messages) messages.innerHTML = "";
|
||||||
|
}
|
||||||
|
|
||||||
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
function addMessage(type: string, text: string, sender = "", timestamp = new Date()) {
|
||||||
const messages = document.getElementById("messages");
|
const messages = document.getElementById("messages");
|
||||||
if (!messages) return;
|
if (!messages) return;
|
||||||
@@ -189,7 +195,8 @@ function renderPresence() {
|
|||||||
|
|
||||||
async function refreshSchedule() {
|
async function refreshSchedule() {
|
||||||
try {
|
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;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const entries = data.entries as Array<{ title: string; status: string }>;
|
const entries = data.entries as Array<{ title: string; status: string }>;
|
||||||
@@ -280,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");
|
connectWebSocket("main");
|
||||||
|
|
||||||
// Start schedule polling
|
// Start schedule polling
|
||||||
@@ -336,46 +346,34 @@ function sendMessage() {
|
|||||||
|
|
||||||
function switchRoom(room: string) {
|
function switchRoom(room: string) {
|
||||||
currentRoom = room;
|
currentRoom = room;
|
||||||
const isDM = room.startsWith("dm:");
|
|
||||||
document.getElementById("room-name")!.textContent =
|
document.getElementById("room-name")!.textContent =
|
||||||
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
|
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);
|
connectWebSocket(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePrivateDM() {
|
function togglePrivateDM() {
|
||||||
const target = prompt("Enter username to start private chat with:");
|
const target = prompt("Enter username to start private chat with:");
|
||||||
if (target && target !== username) {
|
if (target && target !== username) {
|
||||||
currentDMTarget = target;
|
|
||||||
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
const dmRoom = `dm:${[username, target].sort().join("-")}`;
|
||||||
switchRoom(dmRoom);
|
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.switchRoom = switchRoom;
|
||||||
window.sendMessage = sendMessage;
|
window.sendMessage = sendMessage;
|
||||||
window.togglePrivateDM = togglePrivateDM;
|
window.togglePrivateDM = togglePrivateDM;
|
||||||
window.toggleChatPanel = toggleChatPanel;
|
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() {
|
function updateAuthUI() {
|
||||||
const label = document.getElementById("chat-user-label");
|
const label = document.getElementById("chat-user-label");
|
||||||
const toggle = document.getElementById("auth-toggle-btn");
|
const toggle = document.getElementById("auth-toggle-btn");
|
||||||
@@ -390,24 +388,26 @@ function updateAuthUI() {
|
|||||||
if (panel) panel.hidden = true;
|
if (panel) panel.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUserSession() {
|
let sessionReady: Promise<void> = Promise.resolve();
|
||||||
try {
|
|
||||||
const res = await fetch("/api/auth/session");
|
function refreshUserSession(): void {
|
||||||
if (!res.ok) return;
|
sessionReady = (async () => {
|
||||||
const data = await res.json();
|
try {
|
||||||
const wasAnonymous = username.startsWith("viewer");
|
const apiUrl = import.meta.env.VITE_PUBLIC_APP_URL?.trim() || window.location.origin;
|
||||||
if (data.authenticated && data.username) {
|
const res = await fetch(`${apiUrl}/api/auth/session`, { credentials: "include" });
|
||||||
authenticated = true;
|
if (!res.ok) return;
|
||||||
username = data.username;
|
const data = await res.json();
|
||||||
updateAuthUI();
|
if (data.authenticated && data.username) {
|
||||||
if (wasAnonymous) connectWebSocket(currentRoom);
|
authenticated = true;
|
||||||
} else {
|
username = data.username;
|
||||||
authenticated = false;
|
} else {
|
||||||
|
authenticated = false;
|
||||||
|
}
|
||||||
updateAuthUI();
|
updateAuthUI();
|
||||||
|
} catch {
|
||||||
|
/* non-critical */
|
||||||
}
|
}
|
||||||
} catch {
|
})();
|
||||||
/* non-critical */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitAuth(path: string) {
|
async function submitAuth(path: string) {
|
||||||
@@ -422,7 +422,8 @@ async function submitAuth(path: string) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username: u, password: p }),
|
body: JSON.stringify({ username: u, password: p }),
|
||||||
@@ -458,7 +459,8 @@ function register() {
|
|||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -468,15 +470,6 @@ async function logout() {
|
|||||||
connectWebSocket(currentRoom);
|
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) {
|
function showTyping(name: string) {
|
||||||
if (name === username) return;
|
if (name === username) return;
|
||||||
const el = document.getElementById("typing-indicator");
|
const el = document.getElementById("typing-indicator");
|
||||||
@@ -513,15 +506,14 @@ function sendTyping() {
|
|||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
void initPlayerAndChat();
|
void initPlayerAndChat();
|
||||||
void refreshUserSession();
|
refreshUserSession();
|
||||||
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
document.getElementById("auth-toggle-btn")?.addEventListener("click", () => {
|
||||||
const panel = document.getElementById("auth-panel");
|
if (!authenticated) openAuthModal();
|
||||||
if (panel) panel.hidden = !panel.hidden;
|
|
||||||
});
|
});
|
||||||
|
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-login-btn")?.addEventListener("click", login);
|
||||||
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
document.getElementById("auth-register-btn")?.addEventListener("click", register);
|
||||||
document.getElementById("auth-logout-btn")?.addEventListener("click", logout);
|
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);
|
document.getElementById("message-input")?.addEventListener("input", sendTyping);
|
||||||
});
|
});
|
||||||
+48
-8
@@ -305,19 +305,53 @@ select {
|
|||||||
white-space: nowrap;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
width: min(20rem, calc(100vw - 2rem));
|
||||||
border-top: 1px solid var(--bg-2);
|
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 {
|
.auth-close {
|
||||||
display: flex;
|
position: absolute;
|
||||||
gap: 0.5rem;
|
top: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
right: 0.5rem;
|
||||||
border-top: 1px solid var(--bg-2);
|
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 {
|
.typing-indicator {
|
||||||
@@ -1010,6 +1044,12 @@ select {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
.service-status {
|
.service-status {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 700;
|
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;
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
map_hash_bucket_size 128;
|
||||||
|
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
'' close;
|
'' 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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name www.linearvoid.tv;
|
server_name www.linearvoid.tv;
|
||||||
@@ -85,6 +95,14 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /api/admin/login {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -93,9 +111,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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 {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -104,9 +132,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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 {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -115,9 +153,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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/ {
|
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;
|
auth_request /_admin_auth;
|
||||||
error_page 401 = @admin_api_unauthorized;
|
error_page 401 = @admin_api_unauthorized;
|
||||||
set $chat_upstream http://chat:3000;
|
set $chat_upstream http://chat:3000;
|
||||||
@@ -128,9 +176,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header Cookie $http_cookie;
|
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/ {
|
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;
|
set $chat_upstream http://chat:3000;
|
||||||
proxy_pass $chat_upstream;
|
proxy_pass $chat_upstream;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -138,6 +196,8 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Host $host;
|
proxy_set_header X-Forwarded-Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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 {
|
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).
|
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
|
```bash
|
||||||
WS_URL=wss://linearvoid.tv/ws?username=loadtest&room=main npx artillery run scripts/loadtest/ws-chat.yml
|
export WS_URL='wss://linearvoid.tv/ws?username=loadtest&room=main'
|
||||||
```
|
mkdir -p artifacts
|
||||||
|
|
||||||
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
|
|
||||||
npx artillery run scripts/loadtest/ws-chat.yml --output artifacts/ws-chat-report.json
|
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:
|
Summarize key counters, rates, and available percentile stats from a saved Artillery JSON report:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/summarize-artillery.ps1 -ReportFile artifacts/ws-chat-report.json
|
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
|
```bash
|
||||||
ADMIN_BASE_URL=https://linearvoid.tv ADMIN_SESSION_COOKIE="session=..." k6 run scripts/loadtest/admin-api.js
|
export ADMIN_BASE_URL='https://linearvoid.tv'
|
||||||
```
|
export ADMIN_SESSION_COOKIE='session=...'
|
||||||
|
mkdir -p artifacts
|
||||||
Capture machine-readable metrics and a text summary:
|
k6 run --summary-export artifacts/admin-api-summary.json scripts/loadtest/admin-api.js | tee artifacts/admin-api-output.txt
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The script emits global and per-endpoint thresholds (overview, analytics, chat-log, users) so p95 and error-rate regressions are visible per route.
|
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:
|
Summarize endpoint p95 and failure rates from a saved k6 JSON summary:
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/summarize-k6.ps1 -SummaryFile artifacts/admin-api-summary.json
|
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:
|
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.
|
- 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.
|
- Admin runs also emit `admin-api-endpoint-summary.txt` with per-endpoint p95/error values.
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
pwsh -File scripts/loadtest/run-loadtests.ps1 -Scenario ws -WsUrl 'wss://linearvoid.tv/ws?username=loadtest&room=main'
|
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' \
|
||||||
```powershell
|
-a 'https://linearvoid.tv' -c 'session=...'
|
||||||
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=...'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## 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