From a9dbc23d7e8a4728cdb4c1b6cf7c0df7b2457e78 Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 24 May 2026 22:13:55 +0200 Subject: [PATCH] Add Coolify deployment workflow --- .dockerignore | 8 +++ .gitea/workflows/deploy.yml | 62 +++++++++++++++++++++ Dockerfile | 34 ++++++++++++ README.md | 30 ++++++++++ server.js | 107 ++++++++++++++++++++++++++++++++---- 5 files changed, 230 insertions(+), 11 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/deploy.yml create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f40a043 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitea +.DS_Store +node_modules +*.log +leads +gamejam +public/gamejam-games diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..eb49bdd --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,62 @@ +name: Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build-and-deploy: + runs-on: toolctrl-ci + steps: + - name: Setup hosts override and Docker CLI + run: | + echo "192.168.178.62 git.tool-ctrl.de" >> /etc/hosts + apt-get update -qq + apt-get install -qq -y --no-install-recommends ca-certificates curl git + install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/debian/gpg | tee /etc/apt/keyrings/docker.asc >/dev/null + chmod a+r /etc/apt/keyrings/docker.asc + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null + apt-get update -qq + apt-get install -qq -y --no-install-recommends docker-ce-cli docker-buildx-plugin + docker --version + docker buildx version + + - name: Checkout + run: | + mkdir -p /workspace/repo + cd /workspace/repo + git clone "https://${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PAT }}@git.tool-ctrl.de/${{ github.repository }}.git" . + git checkout "${{ github.sha }}" + + - name: Static checks + run: | + cd /workspace/repo + docker run --rm -v "$PWD":/app -w /app node:22-bookworm-slim node --check server.js + git diff --check + + - name: Login to Gitea Registry + run: echo "${{ secrets.REGISTRY_PAT }}" | docker login git.tool-ctrl.de --username "${{ secrets.REGISTRY_USER }}" --password-stdin + + - name: Build and push image + env: + IMAGE: git.tool-ctrl.de/${{ github.repository }} + SHA: ${{ github.sha }} + run: | + cd /workspace/repo + SHORT_SHA=${SHA:0:7} + IMAGE_LC=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]') + docker build \ + --build-arg APP_REVISION="$SHA" \ + -t "$IMAGE_LC:$SHORT_SHA" \ + -t "$IMAGE_LC:main" \ + . + docker push "$IMAGE_LC:$SHORT_SHA" + docker push "$IMAGE_LC:main" + + - name: Trigger Coolify deploy + run: | + curl -fsS -X POST \ + -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" \ + "https://saas-ctrl.com/api/v1/applications/ocwtt7rd2zijtlszgua005k9/start" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82bf796 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:22-bookworm-slim + +LABEL org.opencontainers.image.description="ToolCTRL Website" + +ARG APP_REVISION=local + +ENV NODE_ENV=production \ + PORT=3000 \ + HOST=0.0.0.0 \ + DATA_DIR=/app/data \ + APP_REVISION=${APP_REVISION} + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 1001 nodejs \ + && useradd --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin nodejs + +WORKDIR /app + +COPY --chown=nodejs:nodejs public ./public +COPY --chown=nodejs:nodejs server.js ./server.js + +RUN mkdir -p /app/data \ + && chown -R nodejs:nodejs /app/data + +USER nodejs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD curl -fsS http://127.0.0.1:3000/healthz >/dev/null || exit 1 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index a97a2e0..8543a7b 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,22 @@ Host/Port und An/Aus stehen in `server.js` im Objekt `CLAMAV`. Mit `enabled: false` läuft der Upload ohne Virenscan (nur für lokale Tests ohne ClamAV gedacht). +Per Environment kann der Runtime-Betrieb konfiguriert werden: + +```bash +PORT=3000 +HOST=0.0.0.0 +DATA_DIR=/app/data +CLAMAV_HOST=127.0.0.1 +CLAMAV_PORT=3310 +CLAMAV_ENABLED=true +APP_REVISION= +``` + +`/healthz` ist die leichte Liveness-Probe für Container und Coolify. +`/readyz` prüft zusätzlich Schreibzugriff auf die Datenverzeichnisse und, +falls aktiv, die Erreichbarkeit von ClamAV. + ### Funktion testen Eine Datei mit der EICAR-Testsignatur hochladen — `clamd` meldet dann @@ -93,3 +109,17 @@ Eine Datei mit der EICAR-Testsignatur hochladen — `clamd` meldet dann Für den QR-Code am Stand muss der Rechner im selben WLAN erreichbar sein. Soll die Seite öffentlich laufen, hinter einen Reverse-Proxy mit HTTPS setzen oder bei einem Hoster bereitstellen — `tool-ctrl.de`. + +## Deployment + +Deployment folgt dem Gitea-Infra-Standard aus `toolctrl-labs/infra`: + +1. Push auf `main` triggert `.gitea/workflows/deploy.yml`. +2. Gitea Actions baut das Docker-Image und pusht es nach + `git.tool-ctrl.de/toolctrl-labs/toolctrl-website`. +3. Tags: kurzer Commit-SHA und `main`. +4. Coolify-App `toolctrl-website` (`ocwtt7rd2zijtlszgua005k9`) zieht + `:main` auf der Marketing-VM und startet neu. + +Die Coolify-App läuft im Projekt `Marketing`, Environment `production`, Server +`marketing-vm`, Port `3000`, mit persistentem Storage auf `/app/data`. diff --git a/server.js b/server.js index 7812d6d..4241918 100644 --- a/server.js +++ b/server.js @@ -19,32 +19,51 @@ const net = require("net"); const ROOT = __dirname; const PUBLIC_DIR = path.join(ROOT, "public"); -const LEADS_DIR = path.join(ROOT, "leads"); +const DATA_DIR = process.env.DATA_DIR + ? path.resolve(process.env.DATA_DIR) + : ROOT; +const LEADS_DIR = path.join(DATA_DIR, "leads"); const LEADS_JSON = path.join(LEADS_DIR, "leads.ndjson"); const LEADS_CSV = path.join(LEADS_DIR, "leads.csv"); /* GameJam-Speicher */ -const GAMEJAM_DIR = path.join(ROOT, "gamejam"); +const GAMEJAM_DIR = path.join(DATA_DIR, "gamejam"); const SUBMISSIONS_JSON = path.join(GAMEJAM_DIR, "submissions.ndjson"); -const GAMES_DIR = path.join(PUBLIC_DIR, "gamejam-games"); +const GAMES_DIR = process.env.DATA_DIR + ? path.join(DATA_DIR, "gamejam-games") + : path.join(PUBLIC_DIR, "gamejam-games"); const MAX_UPLOAD = 5 * 1024 * 1024; /* 5 MB pro eingereichtem Spiel */ +const APP_REVISION = process.env.APP_REVISION || "local"; + +function envFlag(name, defaultValue) { + const raw = process.env[name]; + if (raw == null || raw === "") return defaultValue; + return !/^(0|false|no|off)$/i.test(raw); +} + +function envInt(name, defaultValue) { + const raw = Number.parseInt(process.env[name] || "", 10); + return Number.isFinite(raw) ? raw : defaultValue; +} /* Virenscan über ClamAV. Der Server spricht das clamd-Socket-Protokoll direkt — keine zusätzliche Abhängigkeit. Voraussetzung: ClamAV ist installiert und der Daemon "clamd" läuft (Einrichtung siehe README). enabled:false schaltet den Scan ab (z. B. lokal ohne ClamAV). */ const CLAMAV = { - enabled: true, - host: "127.0.0.1", - port: 3310, /* clamd TCPSocket-Standardport */ - timeoutMs: 15000 + enabled: envFlag("CLAMAV_ENABLED", true), + host: process.env.CLAMAV_HOST || "127.0.0.1", + port: envInt("CLAMAV_PORT", 3310), /* clamd TCPSocket-Standardport */ + timeoutMs: envInt("CLAMAV_TIMEOUT_MS", 15000) }; /* Bevorzugte Ports — der erste freie wird genutzt. */ -const PORT_CANDIDATES = [4317, 4318, 4417, 5317, 8317, 0]; +const PORT_CANDIDATES = process.env.PORT + ? [envInt("PORT", 3000)] + : [4317, 4318, 4417, 5317, 8317, 0]; /* 0.0.0.0 = auf allen Schnittstellen erreichbar, auch für andere Geräte im selben WLAN (z. B. Handys, die den QR-Code scannen). */ -const HOST = "0.0.0.0"; +const HOST = process.env.HOST || process.env.HOSTNAME || "0.0.0.0"; /* Erste IPv4-LAN-Adresse dieses Rechners ermitteln. */ function getLanIP() { @@ -88,6 +107,18 @@ function ensureGamejamStore() { if (!fs.existsSync(GAMES_DIR)) fs.mkdirSync(GAMES_DIR, { recursive: true }); } +function assertInside(baseDir, candidate) { + const relative = path.relative(baseDir, candidate); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function writeProbe(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, ".write-probe-" + process.pid + "-" + Date.now()); + fs.writeFileSync(file, "ok", "utf8"); + fs.unlinkSync(file); +} + function csvCell(value) { const s = String(value == null ? "" : value); return '"' + s.replace(/"/g, '""').replace(/\r?\n/g, " ") + '"'; @@ -116,8 +147,13 @@ function serveStatic(req, res) { if (urlPath === "/") urlPath = "/index.html"; /* Path-Traversal verhindern */ - const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath)); - if (!filePath.startsWith(PUBLIC_DIR)) { + let baseDir = PUBLIC_DIR; + let filePath = path.normalize(path.join(PUBLIC_DIR, urlPath)); + if (urlPath.startsWith("/gamejam-games/")) { + baseDir = GAMES_DIR; + filePath = path.normalize(path.join(GAMES_DIR, urlPath.replace(/^\/gamejam-games\//, ""))); + } + if (!assertInside(baseDir, filePath)) { res.writeHead(403).end("Forbidden"); return; } @@ -439,10 +475,57 @@ function sendJSON(res, code, obj) { res.end(JSON.stringify(obj)); } +function handleHealthz(req, res) { + if (req.method !== "GET" && req.method !== "HEAD") { + return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + } + sendJSON(res, 200, { + ok: true, + service: "toolctrl-website", + revision: APP_REVISION + }); +} + +async function handleReadyz(req, res) { + if (req.method !== "GET" && req.method !== "HEAD") { + return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); + } + + const checks = { + leadsWritable: false, + gamejamWritable: false, + gamesWritable: false, + clamav: !CLAMAV.enabled + }; + + try { + writeProbe(LEADS_DIR); + checks.leadsWritable = true; + writeProbe(GAMEJAM_DIR); + checks.gamejamWritable = true; + writeProbe(GAMES_DIR); + checks.gamesWritable = true; + if (CLAMAV.enabled) checks.clamav = await clamavPing(); + } catch (e) { + console.error("[readyz] Prüfung fehlgeschlagen:", e.message); + } + + const ok = Object.keys(checks).every((key) => checks[key] === true); + sendJSON(res, ok ? 200 : 503, { + ok: ok, + service: "toolctrl-website", + revision: APP_REVISION, + checks: checks + }); +} + /* ---------- Router ---------- */ const server = http.createServer((req, res) => { const route = req.url.split("?")[0]; + if (route === "/healthz") return handleHealthz(req, res); + if (route === "/readyz") return handleReadyz(req, res); + if (route === "/api/lead") { if (req.method !== "POST") { return sendJSON(res, 405, { error: "Methode nicht erlaubt." }); @@ -502,6 +585,8 @@ function listen(candidates) { } console.log(" Leads: " + LEADS_CSV); console.log(" GameJam-Einreichungen: " + SUBMISSIONS_JSON); + console.log(" Daten: " + DATA_DIR); + console.log(" Revision: " + APP_REVISION); if (CLAMAV.enabled) { clamavPing().then((ok) => { console.log(" Virenscan: " + (ok