Add Coolify deployment workflow
Deploy / build-and-deploy (push) Failing after 11m57s

This commit is contained in:
Raffael Sheikh committed 2026-05-24 22:13:55 +02:00
1 parent 49bb59a4ab
commit a9dbc23d7e
5 files changed
+230 -11

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.git
.gitea
.DS_Store
node_modules
*.log
leads
gamejam
public/gamejam-games
+62
View File
@@ -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"
+34
View File
@@ -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"]
+30
View File
@@ -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=<git-sha>
```
`/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`.
+96 -11
View File
@@ -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