diff --git a/docs/COOLIFY.md b/docs/COOLIFY.md new file mode 100644 index 0000000..865deca --- /dev/null +++ b/docs/COOLIFY.md @@ -0,0 +1,229 @@ +# Coolify Deployment + +**Last Updated**: 2026-08-28 + +This guide describes an alternative deployment path for Linear Void using +[Coolify](https://github.com/coollabsio/coolify/tree/v4.3.14) (v4.3.14) — an +open-source, self-hostable PaaS that lets you manage servers, applications, and +databases over SSH without vendor lock-in. + +Coolify is a drop-in alternative to the two existing deployment flows described +in [SETUP.md](./SETUP.md): + +1. **Local development** (`docker-compose.yml`) — builds from source on your + workstation. +2. **Test-lab / production** (`docker-compose.prod.yml`) — pulls prebuilt images + from the Gitea registry and is driven by the Gitea Actions CD workflow. + +With Coolify, the same Docker Compose stack is deployed through a web UI and +Coolify's own reverse proxy (Traefik), giving you automatic HTTPS, domain +routing, and one-click redeploys without managing `DOCKER_HOST` manually. + +## Why use Coolify? + +- **Single control plane**: manage the playout, chat, nginx, redis, and db + services (plus exporters and monitoring) from one dashboard. +- **Automatic TLS**: Coolify provisions and renews Let's Encrypt certificates + per application/domain, replacing the manual Cloudflare + nginx origin TLS + dance for the app hostname. +- **No vendor lock-in**: all configuration lives on your server. If you stop + using Coolify, the running containers keep working; you only lose the + automation. +- **Simpler cutover**: when the Hetzner VPS is provisioned, point Coolify at the + new server and redeploy instead of editing CD `DOCKER_HOST` values. + +> **Note**: Coolify replaces the _deployment orchestration_ layer only. Cloudflare +> DNS, the R2 media bucket, cache rules, and the edge Worker described in +> [SETUP.md](./SETUP.md) still apply and are unchanged. + +## Prerequisites + +- A Linux server running **Docker Engine** (any distro Coolify supports). +- **SSH access** (root or a user with passwordless `sudo`) from the machine + where you run the Coolify install — Coolify uses SSH to provision the target + server. +- At least **2 GB RAM / 20 GB disk** for the Coolify instance itself (plus + additional resources for the Linear Void stack). +- A domain name pointed at the server for HTTPS (e.g. `linearvoid.tv`). +- Ports `80` and `443` reachable on the server for Traefik. +- The Gitea registry credentials (`CI_REGISTRY_USER` / `CI_REGISTRY_TOKEN`) if + you intend to pull the prebuilt `playout`, `chat`, and `nginx` images. + +## Installation + +Install Coolify on a dedicated server (or alongside your stack — but a dedicated +Coolify server is the recommended layout): + +```bash +curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash +``` + +The installer boots a Docker-based Coolify instance and prints the URL and +default credentials. Open the dashboard in your browser and complete first-run +setup. + +For non-interactive or offline installs, see the +[installation docs](https://coolify.io/docs/installation) and the +[install script source](https://github.com/coollabsio/coolify/blob/v4.3.14/scripts/install.sh). + +## Add the Linear Void server + +1. In the Coolify dashboard, go to **Servers → Add**. +2. Choose your provider (or **Localhost** if Coolify runs on the same machine + as the stack). +3. Provide the SSH details. For a remote host, Coolify validates the connection + and installs its agent. +4. Verify the server shows as **Reachable** before creating resources. + +### Current topology (verified 2026-08-30) + +The active test-lab setup is **two-tier**: + +| Component | Host | Environment | Exposes | Docker TCP | Notes | +| --------------------- | --------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Coolify dashboard** | `192.168.88.18` | Coolify; docker container; coolify dashboard at `8000` | `https://coolify.allucanget.biz` via Cloudflare tunnel (`coolify-cloudflared`) | No | Coolify manages target servers over SSH | +| **Deployment target** | `192.168.88.40` | Only Linear Void deploys here | `coolify-proxy` = Traefik on 80/443, Port 8000 on this host is bound by Portainer, not Coolify. | Yes | Contains Coolify's per-server helpers (`coolify-proxy`, `coolify-sentinel`, `coolify-cloudflared`) plus the stack via `docker-compose.prod.yml` (the `linearvoid` compose project, 11 containers). | + +The main `coolify` app container does **not** run on `192.168.88.40`; it lives on +the dashboard host `192.168.88.18`. When a Coolify deployment creates a Compose +resource, the app services land on the target server `192.168.88.40`. + +Useful checks: + +```powershell +# Environment / deployment health on the target 192.168.88.40 (containers, helpers, health) +./scripts/check-env.ps1 + +# Credential / token access (Gitea registry, Cloudflare, R2, admin login) +./scripts/check-tokens.ps1 +``` + +The Coolify dashboard runs on the `192.168.88.18` container host (port `8000`, +publicly `https://coolify.allucanget.biz`) and exposes no Docker TCP socket, so +the environment/token scripts are intended to run against the deployment target +`192.168.88.40`, not `192.168.88.18`. + +## Deploy the stack + +Linear Void is a multi-service Docker Compose stack, so deploy it as a +**Compose** (Docker Compose) resource rather than individual applications. + +1. **Projects → Add → Docker Compose**. +2. Select the target server. +3. In **Build Settings**, set the compose file source: + - **Git Repository** — point Coolify at + `https://git.allucanget.biz/zwitschi/linearvoid.git` and use + `docker-compose.prod.yml` as the compose file. Coolify pulls on each + redeploy. + - **Raw Compose** — paste the contents of `docker-compose.prod.yml` directly + if you prefer to manage it manually. +4. If using the Git source, add the Gitea registry login as a **Deployment Key / + environment secret** so the `playout`, `chat`, and `nginx` images can be + pulled. Alternatively, switch those services to `build:` blocks (as in + `docker-compose.yml`) so Coolify builds from source on the server. + +### Environment variables + +Coolify injects a secret store into the compose resource. Map the Linear Void +environment to Coolify's **Environment Variables** tab (these correspond to the +values normally placed in `.env`): + +| Variable | Purpose | +| ---------------------------------------------- | --------------------------------------------------------------------- | +| `DATABASE_URL` | `postgres://linearvoid:@db:5432/linearvoid?sslmode=disable` | +| `REDIS_URL` | `redis://redis:6379/0` | +| `POSTGRES_PASSWORD` | Database password (used by the `db` service) | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | Admin panel credentials | +| `ADMIN_SESSION_TTL_HOURS` | Admin session lifetime | +| `R2_ACCESS_KEY_ID` / `R2_SECRET_ACCESS_KEY` | Cloudflare R2 credentials | +| `R2_BUCKET_NAME` / `R2_ENDPOINT` / `R2_REGION` | R2 bucket config (`R2_REGION=auto`) | +| `MEDIA_DOMAIN` | `media.linearvoid.tv` | +| `VITE_PUBLIC_APP_URL` | `https://linearvoid.tv` | +| `VITE_PUBLIC_WS_URL` | `wss://linearvoid.tv/ws` | +| `VITE_PUBLIC_HLS_URL` | `https://media.linearvoid.tv/hls/playlist.m3u8` | + +Keep internal service URLs on compose hostnames (`redis`, `db`) exactly as in +`docker-compose.prod.yml`. + +### Persistent storage + +The compose file uses named volumes (`redis-data`, `postgres-data`) and bind +mounts under `./data` (playlist, media, hls, chat, slate). Configure these under +**Persistent Storage** so data survives redeploys: + +| Mount | Purpose | +| --------------- | ----------------------------------------------- | +| `data/playlist` | Playout playlist file | +| `data/media` | Normalized source videos (read-only to playout) | +| `data/hls` | Generated HLS manifests and segments | +| `data/chat` | Chat service working data | +| `data/slate` | Slate media | +| `redis-data` | Redis append-only persistence | +| `postgres-data` | PostgreSQL data directory | + +Create the `data` subdirectories before the first deploy if Coolify does not +auto-create them (`mkdir -p data/playlist data/media data/hls data/chat`). + +### Domains and proxy + +1. Open the compose resource and assign the **nginx** service a public domain + (e.g. `linearvoid.tv`). Coolify's Traefik reverse proxy then terminates TLS + and forwards to the `nginx` container. +2. Set the **Ports Exposes** mapping so Traefik targets the nginx service port + (`80` inside the container, mapped from `${NGINX_PORT:-8080}:80`). +3. Do **not** also expose the playout (`8081`) or chat (`3000`) health ports + publicly — keep those internal to the compose network and let Traefik route + through nginx. +4. For the media domain (`media.linearvoid.tv`), keep serving HLS from the R2 + custom domain (per SETUP.md) rather than routing it through Coolify, so + segments stay on Cloudflare's edge cache. + +## Post-deploy verification + +After the first deploy, confirm: + +- `docker compose ps` (on the server) shows all services healthy. +- Playout health: `https:///health` via nginx (internal `:8081/health`). +- Chat health: internal `:3000/health`. +- HLS is written to `data/hls` and served from `media.linearvoid.tv`. +- Chat WebSocket connects over `wss://linearvoid.tv/ws`. +- Admin login works at `/admin` with the configured credentials. + +## Monitoring + +Coolify exposes container logs and basic metrics per resource. The stack also +ships Prometheus + Grafana (`monitoring/`) and nginx/redis exporters. If you +want those reachable through Coolify, expose Grafana on a restricted subdomain +(e.g. `grafana.linearvoid.tv`) and keep Prometheus internal, mirroring the +access guidance in SETUP.md. + +## Cutover from Gitea CD + +Coolify can fully replace the Gitea Actions CD step. To switch: + +1. Deploy the stack as a Coolify compose resource (above). +2. Point Cloudflare DNS for `linearvoid.tv` / `www` at the server IP (proxied). +3. Disable (or keep as a fallback) the `cd` job in the Gitea workflow so the two + orchestrators don't fight over the same `DOCKER_HOST`. +4. Keep the Gitea **CI** (build + push images) job so Coolify continues pulling + `:latest` images from the registry. + +## Troubleshooting + +- **Image pull fails**: ensure the Gitea registry credentials are set in the + compose resource's secret store, or switch the services to `build:` blocks. +- **Healthchecks fail**: confirm `curl` is available in the built images (the + compose healthchecks rely on `curl`). +- **TLS errors**: check the domain's DNS points at the Coolify server and ports + `80`/`443` are open. +- **HLS not updating**: verify `data/hls` is writable and the playout service + restarts cleanly; see the normalization troubleshooting in SETUP.md. +- **Ports conflict**: Coolify's Traefik binds `80`/`443`; do not also map those + to the host in the compose file. + +## Further reading + +- Coolify repo (v4.3.14): +- Coolify docs: +- Linear Void deployment: [DEPLOYMENT.md](./DEPLOYMENT.md) +- Linear Void setup: [SETUP.md](./SETUP.md) diff --git a/docs/SETUP.md b/docs/SETUP.md index 9a63024..a52518e 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -453,6 +453,38 @@ To allow the Gitea Actions runner (or your local machine) to deploy to the remot unset DOCKER_HOST ``` +### Coolify Deployment + +Coolify is an open-source, self-hostable PaaS (Heroku/Netlify/Vercel +alternative) that can deploy the Linear Void stack through a web UI with +automatic HTTPS and one-click redeploys. It replaces the deployment +orchestration layer (the Gitea CD `DOCKER_HOST` flow) while leaving Cloudflare +DNS, R2 media, and cache rules unchanged. + +To deploy via Coolify: + +1. **Install Coolify** on a dedicated server: + + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` + +2. **Add the target server** in the dashboard via SSH (or use Localhost if + Coolify runs on the same machine as the stack). +3. **Create a Docker Compose resource** pointing at either the Git repository + (`docker-compose.prod.yml`) or the compose file contents directly. +4. **Configure environment variables** in Coolify's secret store (the values + normally held in `.env`), keeping internal URLs on compose hostnames + (`redis`, `db`). +5. **Map persistent storage** for `data/` mounts and the `redis-data` / + `postgres-data` named volumes so data survives redeploys. +6. **Assign a domain** to the nginx service so Coolify's Traefik proxy + terminates TLS for `linearvoid.tv`, while `media.linearvoid.tv` continues to + serve HLS from Cloudflare R2. + +For the full walkthrough, environment-variable table, verification steps, and +cutover notes, see [COOLIFY.md](./COOLIFY.md). + ### Production Setup Guide 1. **Prepare the target host**: diff --git a/scripts/check-env.ps1 b/scripts/check-env.ps1 new file mode 100644 index 0000000..bc0e707 --- /dev/null +++ b/scripts/check-env.ps1 @@ -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 diff --git a/scripts/check-tokens.ps1 b/scripts/check-tokens.ps1 new file mode 100644 index 0000000..b36efd8 --- /dev/null +++ b/scripts/check-tokens.ps1 @@ -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