Add Coolify deployment workflow
Deploy / build-and-deploy (push) Failing after 11m57s
Deploy / build-and-deploy (push) Failing after 11m57s
This commit is contained in:
1 parent
49bb59a4ab
commit
a9dbc23d7e
5 files changed
+230
-11
No files matched your search
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitea
|
||||
.DS_Store
|
||||
node_modules
|
||||
*.log
|
||||
leads
|
||||
gamejam
|
||||
public/gamejam-games
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user