From 062e02b4179b7286b234fc6b859c3fb8e54693ec Mon Sep 17 00:00:00 2001 From: Bjorn Blomberg Date: Sun, 28 Jun 2026 16:03:14 +0200 Subject: [PATCH] CI/CD + deploy: Gitea Actions -> registry -> Dockge -> NPM (rcai.brasse-pc.eu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - serverar byggd web-frontend statiskt + SPA-fallback (WEB_DIST) - host-mountad persistent katalog via DATA_DIR (/srv/docker/agent-helm -> /app/data), förberedd för framtida db/auth enligt övriga appars konvention - esbuild-bundle -> dist/server.cjs (en fil, inga node_modules i runtime) Web: - WS-URL defaultar till samma origin i prod (wss://rcai.brasse-pc.eu), localhost i dev Deploy-artefakter: - Dockerfile: multi-stage, bygger web + bundlar server; --filter utesluter daemon (ingen node-pty-kompilering); kör som uid 1000 (= brasse på host) - .gitea/workflows/build.yaml: push master -> bygg arm64 -> push localhost:5000 - deploy/act-runner.{compose,config}.yaml: Gitea Actions-runner i Docker (docker.sock, job-containrar får värdens docker for build/push) - deploy/agent-helm.compose.yaml: Dockge-stack (srv_default, expose 8787, data-mount, x-dockge.urls) - deploy/DEPLOY.md: full runbook (runner, bygge, stack, NPM, uppdatering, felsökning) Verifierat lokalt: typecheck rent, server-bundle serverar UI+health+assets+SPA-fallback, data-dir skapas. Docker-bygget validerat så långt daemonen är nere (filter bekräftat). --- .dockerignore | 11 +++ .gitea/workflows/build.yaml | 27 +++++++ Dockerfile | 40 ++++++++++ deploy/DEPLOY.md | 133 +++++++++++++++++++++++++++++++++ deploy/act-runner.compose.yaml | 28 +++++++ deploy/act-runner.config.yaml | 20 +++++ deploy/agent-helm.compose.yaml | 33 ++++++++ packages/server/package.json | 2 + packages/server/src/index.ts | 56 ++++++++++++++ packages/web/src/App.vue | 6 +- pnpm-lock.yaml | 3 + 11 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yaml create mode 100644 Dockerfile create mode 100644 deploy/DEPLOY.md create mode 100644 deploy/act-runner.compose.yaml create mode 100644 deploy/act-runner.config.yaml create mode 100644 deploy/agent-helm.compose.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2b27725 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +**/dist +.git +.gitea +.env +.env.* +!.env.example +*.log +deploy +README.md diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..80bc325 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,27 @@ +name: build-and-push + +# Bygger image:n på Pi5-runnern (arm64, nativt) och pushar till den lokala +# registryn. localhost:5000 trustas automatiskt av Docker (ingen insecure-config). +on: + push: + branches: [master, main] + workflow_dispatch: {} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: | + docker build \ + -t localhost:5000/agent-helm-server:latest \ + -t "localhost:5000/agent-helm-server:${GITHUB_SHA::12}" \ + . + + - name: Push image + run: | + docker push localhost:5000/agent-helm-server:latest + docker push "localhost:5000/agent-helm-server:${GITHUB_SHA::12}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..21ec4f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# +# agent-helm control-plane + web-UI i en enda image. +# Byggs av Gitea Actions PÅ Pi5 (arm64) och pushas till localhost:5000. +# Servern serverar den byggda Vue-frontenden statiskt + WebSocket på samma port. + +# ---------- builder ---------- +FROM node:22-slim AS builder +WORKDIR /app +RUN npm install -g pnpm@11.3.0 + +# Installera bara det server + web behöver. "..."-suffixet tar med workspace- +# beroenden (shared) men UTESLUTER daemon -> ingen node-pty-kompilering här. +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./ +COPY packages/shared/package.json ./packages/shared/ +COPY packages/server/package.json ./packages/server/ +COPY packages/web/package.json ./packages/web/ +COPY packages/daemon/package.json ./packages/daemon/ +RUN pnpm install --frozen-lockfile \ + --filter "@agent-helm/server..." \ + --filter "@agent-helm/web..." + +# Källkod + bygg (web -> dist, server -> en bundlad cjs-fil) +COPY packages/shared ./packages/shared +COPY packages/server ./packages/server +COPY packages/web ./packages/web +RUN pnpm --filter @agent-helm/web build \ + && pnpm --filter @agent-helm/server bundle + +# ---------- runtime ---------- +FROM node:22-slim AS runtime +WORKDIR /app +ENV NODE_ENV=production \ + PORT=8787 \ + WEB_DIST=/app/web +COPY --from=builder /app/packages/server/dist/server.cjs ./server.cjs +COPY --from=builder /app/packages/web/dist ./web +EXPOSE 8787 +USER node +CMD ["node", "server.cjs"] diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..9c08d2b --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,133 @@ +# Deploy: agent-helm på Pi5 via Gitea Actions → registry → Dockge → NPM + +Flödet: `git push master` → **Gitea Actions-runnern på Pi5** bygger arm64-image:n +→ pushar till `localhost:5000/agent-helm-server:latest` → **Dockge** kör stacken → +**NPM** exponerar `https://rcai.brasse-pc.eu`. Uppdatering = push + klicka *Update* i Dockge. + +``` +push master ──► gitea-act-runner (bygg arm64) ──► localhost:5000/agent-helm-server:latest + │ + Dockge: Update (pull + up -d) + ▼ + container "agent-helm" :8787 (srv_default) + ▲ + NPM: rcai.brasse-pc.eu ─┘ (Websockets ON, TLS) +``` + +Förutsättningar som redan är på plats: DNS `rcai.brasse-pc.eu → 155.4.72.51` (✓), +`srv_default`-nätet, `docker-registry` och `npm` körs (✓). Gitea 1.24 har Actions +påslaget som default. + +--- + +## Steg 1 — Registrera Gitea Actions-runnern (engång) + +1. **Hämta registrerings-token** i Gitea: + `Site Administration → Actions → Runners → Create new runner` → kopiera token. + (Eller repo-nivå: `brasse/agent-helm → Settings → Actions → Runners`.) + +2. **Skapa stacken i Dockge** (`dockge.brasse-pc.eu`): + - Ny stack, namn `act-runner`. + - Klistra in `deploy/act-runner.compose.yaml`. + - Lägg `deploy/act-runner.config.yaml` som filen `config.yaml` i stacken + (Dockge låter dig skapa extra filer i stack-mappen, dvs + `/srv/dockge-staks/act-runner/config.yaml`). + - I stackens **env**: `GITEA_RUNNER_REGISTRATION_TOKEN=`. + - Deploy. + +3. **Verifiera**: i Gitea `Site Administration → Actions → Runners` ska + `pi5-docker-runner` dyka upp som *Idle*. Loggen: `docker logs gitea-act-runner`. + +> Runnern monterar värdens `docker.sock` och bygger med Pi5:ans egen Docker → +> arm64 nativt, och `localhost:5000` (registryn) trustas automatiskt. + +--- + +## Steg 2 — Första bygget + +`.gitea/workflows/build.yaml` triggas på push till `master`/`main`. Trigga det: + +- Pusha en commit till `master`, **eller** +- Gitea repo → `Actions` → välj workflowen → `Run workflow` (manuell dispatch). + +Följ körningen under repo:ts **Actions**-flik. När den är grön finns +`localhost:5000/agent-helm-server:latest` i registryn. + +Snabbkoll (på Pi5, eller via `claude-docker`): +```bash +curl -s http://localhost:5000/v2/agent-helm-server/tags/list +# -> {"name":"agent-helm-server","tags":["latest",""]} +``` + +--- + +## Steg 3 — Kör stacken i Dockge + +1. **Skapa host-katalogen för config/data** (som `brasse`, uid 1000): + `mkdir -p /srv/docker/agent-helm` + Den bind-mountas till `/app/data` i containern (där framtida db/auth hamnar); + containern kör som uid 1000 = `brasse` så filerna blir korrekt ägda. +2. Ny stack i Dockge, namn `agent-helm`. +3. Klistra in `deploy/agent-helm.compose.yaml`. +4. I stackens **env**: `AGENT_HELM_TOKEN=` + (generera t.ex. `openssl rand -hex 32`). +5. Deploy. Kolla i Dockge att containern `agent-helm` är *running*. + +--- + +## Steg 4 — NPM proxy host (manuellt i NPM-UI:t) + +I `Proxy.brasse-pc.eu` → **Proxy Hosts → Add Proxy Host**: + +| Fält | Värde | +|---------------------|-----------------------------| +| Domain Names | `rcai.brasse-pc.eu` | +| Scheme | `http` | +| Forward Hostname | `agent-helm` | +| Forward Port | `8787` | +| **Websockets Support** | **PÅ** (viktigt — WS-kanalen) | +| Block Common Exploits | valfritt | + +Under **SSL**: Request a new SSL Certificate (Let's Encrypt) + Force SSL + HTTP/2. + +Spara. Öppna `https://rcai.brasse-pc.eu` → logga in med `AGENT_HELM_TOKEN`. + +--- + +## Steg 5 — Anslut en daemon (annars finns inga sessioner) + +Servern är bara control-plane. En **daemon** kör agenten och ansluter till servern. +Kör den var du vill (workstation, eller som egen container senare): + +```bash +# på workstationen, i agent-helm-repot +AGENT_HELM_TOKEN= \ +SERVER_URL=wss://rcai.brasse-pc.eu \ +AGENT_CMD=gemini \ +pnpm dev:daemon +``` + +Daemonen dyker upp som en session i web-UI:t. (Kräver att NPM:s Websockets är på.) + +--- + +## Uppdatera till senaste + +1. `git push master` → runnern bygger + pushar `:latest`. +2. Dockge → stacken `agent-helm` → **Update** (kör `docker compose pull && up -d`). + +Det var det — senaste image:n körs. + +--- + +## Felsökning + +| Symptom | Trolig orsak / åtgärd | +|---|---| +| Runnern syns inte i Gitea | Fel/förbrukad token. Skapa ny, uppdatera env, redeploy `act-runner`. | +| Workflow fastnar på `actions/checkout` | Runnern når inte github för att hämta action:en, eller inte `gitea-d:3000`. Kolla att runnern är på `srv_default`. | +| `docker: command not found` i jobbet | Fel job-image. Labeln ska peka på `catthehacker/ubuntu:act-latest`. | +| `push` nekas / `http: server gave HTTP response to HTTPS client` | Pusha till `localhost:5000` (inte `192.168.0.19:5000`) från Pi-runnern — localhost trustas som insecure automatiskt. | +| `rcai` ger 502 | `agent-helm`-containern nere, eller fel forward-namn/port i NPM. | +| WS ansluter inte (UI loggar in men sessioner laddar ej) | Websockets Support av i NPM-hosten. Slå på. | +| Bygget kompilerar node-pty och failar | Ska inte hända — Dockerfilen filtrerar bort `daemon`. Om det sker: kontrollera `--filter`-raderna. | diff --git a/deploy/act-runner.compose.yaml b/deploy/act-runner.compose.yaml new file mode 100644 index 0000000..057bcf7 --- /dev/null +++ b/deploy/act-runner.compose.yaml @@ -0,0 +1,28 @@ +# Gitea Actions-runner (Docker) för Pi5 — bygger arm64-images nativt. +# +# Engångsregistrering: hämta en registrerings-token i Gitea +# Site Administration -> Actions -> Runners -> "Create new runner" +# och lägg den i en .env bredvid denna fil (Dockge: stackens env-flik): +# GITEA_RUNNER_REGISTRATION_TOKEN=xxxxxxxx +# +# Lägg denna fil + config.yaml i /srv/dockge-staks/act-runner/ och starta i Dockge. +services: + act-runner: + image: gitea/act_runner:latest + container_name: gitea-act-runner + restart: unless-stopped + networks: + - srv_default + environment: + GITEA_INSTANCE_URL: http://gitea-d:3000 # intern adress på srv_default + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_NAME: pi5-docker-runner + CONFIG_FILE: /config.yaml + volumes: + - /srv/docker/act-runner:/data # runner-state (persistent) + - /var/run/docker.sock:/var/run/docker.sock # bygg via värdens Docker + - ./config.yaml:/config.yaml:ro + +networks: + srv_default: + external: true diff --git a/deploy/act-runner.config.yaml b/deploy/act-runner.config.yaml new file mode 100644 index 0000000..369b061 --- /dev/null +++ b/deploy/act-runner.config.yaml @@ -0,0 +1,20 @@ +# act_runner-config. Nyckeln: job-containrar får värdens docker.sock så att +# `docker build` / `docker push` i workflowen kör mot Pi5:ans Docker-daemon, +# och localhost:5000 (registryn) trustas automatiskt. +log: + level: info + +runner: + capacity: 1 + # Label -> image som jobben körs i. catthehacker-imagen har node + docker-CLI. + labels: + - "ubuntu-latest:docker://catthehacker/ubuntu:act-latest" + - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04" + +container: + # Montera värdens docker.sock i varje job-container (för docker build/push). + options: "-v /var/run/docker.sock:/var/run/docker.sock" + valid_volumes: + - /var/run/docker.sock + # Job-containrar på srv_default så checkout når gitea-d:3000 internt. + network: srv_default diff --git a/deploy/agent-helm.compose.yaml b/deploy/agent-helm.compose.yaml new file mode 100644 index 0000000..476c179 --- /dev/null +++ b/deploy/agent-helm.compose.yaml @@ -0,0 +1,33 @@ +# agent-helm control-plane + web-UI. +# Image byggs av Gitea Actions och hämtas från den lokala registryn. +# Uppdatera till senaste: Dockge -> denna stack -> "Update" (pull + recreate). +# +# Lägg i /srv/dockge-staks/agent-helm/ och starta i Dockge. +# Sätt en stark token i stackens .env (Dockge: env-flik): +# AGENT_HELM_TOKEN= +x-dockge: + urls: + - https://rcai.brasse-pc.eu + +services: + agent-helm: + image: localhost:5000/agent-helm-server:latest + container_name: agent-helm + restart: unless-stopped + expose: + - "8787" # NPM binder publikt; ingen host-port behövs + environment: + AGENT_HELM_TOKEN: ${AGENT_HELM_TOKEN} + PORT: "8787" + DATA_DIR: /app/data + volumes: + # Host-mountad config/data enligt övriga appars konvention (/srv/docker/). + # Containern kör som uid 1000 (node) = brasse på host, så filer blir brasse-ägda. + # Skapa katalogen först: mkdir -p /srv/docker/agent-helm + - /srv/docker/agent-helm:/app/data + networks: + - srv_default # så NPM kan nå containern på namn + +networks: + srv_default: + external: true diff --git a/packages/server/package.json b/packages/server/package.json index f0731ed..ba3a4fe 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,6 +7,7 @@ "dev": "tsx --env-file-if-exists=../../.env watch src/index.ts", "start": "tsx --env-file-if-exists=../../.env src/index.ts", "build": "tsc --noEmit -p tsconfig.json", + "bundle": "esbuild src/index.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { @@ -18,6 +19,7 @@ "devDependencies": { "@types/node": "^22.0.0", "@types/ws": "^8.5.12", + "esbuild": "^0.25.0", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1fa9444..d1990dd 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,9 @@ * - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter. */ import http from "node:http"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import fs from "node:fs"; +import path from "node:path"; import { WebSocketServer, WebSocket } from "ws"; // @xterm/headless och addon-serialize är CommonJS — default-importera värdet och // destrukturera; ta typen via `import type` (raderas i runtime). @@ -37,6 +40,11 @@ if (!TOKEN) { process.exit(1); } +// Persistent katalog (host-mountad på Pi5: /srv/docker/agent-helm -> /app/data). +// Här hamnar framtida db/auth-state. Skapas vid start om den är satt. +const DATA_DIR = process.env.DATA_DIR ?? ""; +if (DATA_DIR) fs.mkdirSync(DATA_DIR, { recursive: true }); + interface Session { meta: SessionMeta; daemon: WebSocket | null; @@ -180,12 +188,58 @@ function handleClose(ws: WebSocket): void { } } +const WEB_DIST = process.env.WEB_DIST ?? ""; + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".png": "image/png", + ".webmanifest": "application/manifest+json", + ".woff2": "font/woff2", + ".map": "application/json", +}; + +/** Serverar den byggda web-frontenden (om WEB_DIST är satt) med SPA-fallback. */ +function serveStatic(req: IncomingMessage, res: ServerResponse): boolean { + if (!WEB_DIST) return false; + const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]); + const indexHtml = path.join(WEB_DIST, "index.html"); + let filePath = path.normalize(path.join(WEB_DIST, urlPath)); + // Path-traversal-skydd + SPA: "/" och rutter utan filändelse -> index.html + if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) { + filePath = indexHtml; + } + fs.readFile(filePath, (err, buf) => { + if (err) { + fs.readFile(indexHtml, (err2, idx) => { + if (err2) { + res.writeHead(404); + res.end("not found"); + } else { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(idx); + } + }); + return; + } + res.writeHead(200, { "content-type": MIME[path.extname(filePath)] ?? "application/octet-stream" }); + res.end(buf); + }); + return true; +} + const server = http.createServer((req, res) => { if (req.url === "/health") { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, sessions: sessions.size })); return; } + if (serveStatic(req, res)) return; res.writeHead(404); res.end(); }); @@ -208,4 +262,6 @@ wss.on("connection", (ws) => { server.listen(PORT, () => { console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`); + if (WEB_DIST) console.log(`[server] serverar web-UI från ${WEB_DIST}`); + if (DATA_DIR) console.log(`[server] data-dir: ${DATA_DIR}`); }); diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index a5b751f..2718e9c 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -3,7 +3,11 @@ import { ref, computed } from "vue"; import { createHelmClient } from "./lib/helm"; import TerminalView from "./components/TerminalView.vue"; -const defaultUrl = import.meta.env.VITE_SERVER_WS ?? "ws://localhost:8787"; +// I dev: peka mot lokal server. I prod (serverad av servern bakom NPM): samma +// origin, så bygget funkar på vilken host som helst (t.ex. wss://rcai.brasse-pc.eu). +const sameOriginWs = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`; +const defaultUrl = + import.meta.env.VITE_SERVER_WS || (import.meta.env.DEV ? "ws://localhost:8787" : sameOriginWs); const defaultToken = import.meta.env.VITE_AGENT_HELM_TOKEN ?? ""; const url = ref(localStorage.getItem("helm.url") ?? defaultUrl); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cda3818..3dbd7bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@types/ws': specifier: ^8.5.12 version: 8.18.1 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 tsx: specifier: ^4.19.0 version: 4.22.4