Compare commits
9
Commits
2de72ed506
...
v1.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e361d9a328 | ||
|
|
aa25bcc053 | ||
|
|
bda08b592e | ||
|
|
0e408974a0 | ||
|
|
f4b35dcc30 | ||
|
|
1c0d765182 | ||
|
|
2cd62208ac | ||
|
|
7557a6364a | ||
|
|
54c4b049a8 |
@@ -11,6 +11,7 @@ RUN npm install --production
|
||||
COPY *.js ./
|
||||
COPY src/ ./src/
|
||||
COPY routes/ ./routes/
|
||||
COPY dns-providers/ ./dns-providers/
|
||||
COPY openapi.yaml ./
|
||||
|
||||
# VERSION file holds the short git SHA the image was built from. Committed as
|
||||
|
||||
@@ -342,7 +342,8 @@ const APP_TEMPLATES = {
|
||||
volumes: [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"/opt/portainer/data:/data"
|
||||
]
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "portainer",
|
||||
defaultPort: 9000,
|
||||
@@ -393,7 +394,8 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "louislam/uptime-kuma:latest",
|
||||
ports: ["{{PORT}}:3001"],
|
||||
volumes: ["/opt/uptime-kuma:/app/data"]
|
||||
volumes: ["/opt/uptime-kuma:/app/data"],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "uptime",
|
||||
defaultPort: 3002,
|
||||
@@ -549,7 +551,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "dns2",
|
||||
defaultPort: 953,
|
||||
healthCheck: null,
|
||||
healthCheck: "tcp://localhost:53",
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: [
|
||||
"Configure zone files in /opt/bind9/config/",
|
||||
@@ -640,14 +642,14 @@ const APP_TEMPLATES = {
|
||||
],
|
||||
docker: {
|
||||
image: "coredns/coredns:latest",
|
||||
ports: ["53:53", "53:53/udp"],
|
||||
ports: ["{{PORT}}:53", "53:53", "53:53/udp"],
|
||||
volumes: ["/opt/coredns/config:/etc/coredns"],
|
||||
environment: {},
|
||||
command: ["-conf", "/etc/coredns/Corefile"]
|
||||
},
|
||||
subdomain: "dns4",
|
||||
defaultPort: 53,
|
||||
healthCheck: null,
|
||||
healthCheck: "tcp://localhost:53",
|
||||
subpathSupport: 'strip',
|
||||
setupInstructions: [
|
||||
"Create Corefile in /opt/coredns/config/",
|
||||
@@ -1007,7 +1009,9 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "adminer:latest",
|
||||
ports: ["{{PORT}}:8080"],
|
||||
volumes: [],
|
||||
volumes: [
|
||||
"/opt/adminer:/var/www/html"
|
||||
],
|
||||
environment: {
|
||||
"ADMINER_DEFAULT_SERVER": "postgres"
|
||||
}
|
||||
@@ -1099,6 +1103,7 @@ const APP_TEMPLATES = {
|
||||
popularity: 85,
|
||||
difficulty: "Easy",
|
||||
isDashboardWidget: true,
|
||||
isStaticSite: true,
|
||||
widgetSelector: ".weather-widget-container",
|
||||
subdomain: null,
|
||||
defaultPort: null,
|
||||
@@ -1126,6 +1131,7 @@ const APP_TEMPLATES = {
|
||||
popularity: 80,
|
||||
difficulty: "Easy",
|
||||
isDashboardWidget: true,
|
||||
isStaticSite: true,
|
||||
widgetSelector: ".clock-widget-container",
|
||||
subdomain: null,
|
||||
defaultPort: null,
|
||||
@@ -1908,7 +1914,9 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "traefik/whoami:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [],
|
||||
volumes: [
|
||||
"/opt/whoami/config:/config"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "whoami",
|
||||
@@ -2233,7 +2241,9 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "excalidraw/excalidraw:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [],
|
||||
volumes: [
|
||||
"/opt/excalidraw/data:/var/lib/excalidraw"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "draw",
|
||||
@@ -2258,7 +2268,9 @@ const APP_TEMPLATES = {
|
||||
docker: {
|
||||
image: "corentinth/it-tools:latest",
|
||||
ports: ["{{PORT}}:80"],
|
||||
volumes: [],
|
||||
volumes: [
|
||||
"/opt/it-tools/config:/config"
|
||||
],
|
||||
environment: {}
|
||||
},
|
||||
subdomain: "tools",
|
||||
@@ -2417,7 +2429,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "mc",
|
||||
defaultPort: 25565,
|
||||
healthCheck: null,
|
||||
healthCheck: "tcp://localhost:25565",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Server accepts the Minecraft EULA automatically",
|
||||
@@ -2451,7 +2463,7 @@ const APP_TEMPLATES = {
|
||||
},
|
||||
subdomain: "valheim",
|
||||
defaultPort: 2456,
|
||||
healthCheck: null,
|
||||
healthCheck: "tcp://localhost:2456",
|
||||
subpathSupport: 'none',
|
||||
setupInstructions: [
|
||||
"Connect via Steam: Add Server > IP:2456",
|
||||
|
||||
@@ -102,7 +102,7 @@ const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR',
|
||||
// ── Docker ──────────────────────────────────────────────────────
|
||||
const DOCKER = {
|
||||
CONTAINER_PREFIX: 'sami-',
|
||||
TIMEOUT: 30000, // 30s — timeout for docker pull/create operations
|
||||
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
|
||||
LOG_CONFIG: {
|
||||
Type: 'json-file',
|
||||
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
|
||||
|
||||
@@ -317,6 +317,9 @@ class LicenseManager {
|
||||
*/
|
||||
isExpired() {
|
||||
if (!this.activation) return true;
|
||||
// Lifetime licenses never expire
|
||||
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
|
||||
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
|
||||
return Date.now() > new Date(this.activation.expiresAt).getTime();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -306,7 +306,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
} else {
|
||||
containerId = await deployContainer(appId, config, template);
|
||||
log.info('deploy', 'Container deployed', { containerId });
|
||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort);
|
||||
await helpers.waitForHealthCheck(containerId, template.healthCheck, config.port || template.defaultPort, 30);
|
||||
log.info('deploy', 'Container is healthy', { containerId });
|
||||
}
|
||||
|
||||
@@ -420,10 +420,11 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
await logError('app-deploy', error, { appId, config });
|
||||
log.error('deploy', 'Deployment failed', { appId, error: error.message });
|
||||
try { await logError('app-deploy', error, { appId, config }); } catch (_) { /* logError failure should not mask original error */ }
|
||||
const msg = error?.message || String(error || 'Unknown error');
|
||||
log.error('deploy', 'Deployment failed', { appId, error: msg });
|
||||
const template = ctx.APP_TEMPLATES[appId];
|
||||
ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${error.message}`, 'error');
|
||||
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||
}
|
||||
}, 'apps-deploy'));
|
||||
|
||||
@@ -379,9 +379,12 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
||||
return content.slice(0, endIdx) + injection + content.slice(endIdx);
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
if (!result.success && result.error !== 'No changes to apply') {
|
||||
throw new Error(`[DC-303] Failed to add subpath config for ${subdomain}: ${result.error}`);
|
||||
}
|
||||
if (result.error === 'No changes to apply') {
|
||||
log.info('caddy', 'Subpath config already exists, reusing', { subdomain });
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a subpath config block from between its markers in the Caddyfile. */
|
||||
|
||||
@@ -25,7 +25,6 @@ module.exports = function(ctx) {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
errorResponse: ctx.errorResponse,
|
||||
log: ctx.log,
|
||||
// Additional context properties needed by routes
|
||||
APP_TEMPLATES: ctx.APP_TEMPLATES,
|
||||
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
|
||||
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
|
||||
@@ -40,26 +39,27 @@ module.exports = function(ctx) {
|
||||
ctx: ctx
|
||||
};
|
||||
|
||||
// Initialize helpers with dependencies (ctx is the Koa context)
|
||||
const helpers = initHelpers({ ...deps, ctx });
|
||||
|
||||
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
||||
const subCtx = Object.assign({}, ctx, { helpers });
|
||||
|
||||
try { router.use('/deploy', initDeploy(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
||||
// Mount sub-routers at their prefix paths.
|
||||
// Sub-modules define routes at '/' (root of their sub-router).
|
||||
// Final paths: /api/v1/apps/deploy, /api/v1/apps/remove, /api/v1/apps/templates, etc.
|
||||
|
||||
try { router.use('/remove', initRemoval(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
|
||||
try { router.use('/apps', initDeploy(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message, e.stack); }
|
||||
|
||||
try { router.use('/apps', initRemoval(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message, e.stack); }
|
||||
|
||||
try { router.use('/apps', initTemplates(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
|
||||
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message, e.stack); }
|
||||
|
||||
try { router.use('/restore', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
|
||||
try { router.use('/apps', initRestore(Object.assign({}, subCtx, { backupManager: ctx.backupManager }))); }
|
||||
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message, e.stack); }
|
||||
|
||||
try { router.use('/compose', initCompose(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
|
||||
try { router.use('/apps', initCompose(subCtx)); }
|
||||
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message, e.stack); }
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -73,9 +73,12 @@ process.on('uncaughtException', (error) => {
|
||||
try { bundledWorkflows = require('./bundled-workflows'); } catch { /* optional */ }
|
||||
|
||||
// Initialize workflow engine if bundled-workflows is available
|
||||
// NOTE: createApp() already initializes the workflow engine in src/app.js
|
||||
// This block is kept for backward compat with entry points that don't use createApp()
|
||||
let workflowEngine = null;
|
||||
if (bundledWorkflows) {
|
||||
try {
|
||||
const { fetchT } = require('./src/utils/http');
|
||||
const { WorkflowEngine } = bundledWorkflows;
|
||||
// Create a context with needed services
|
||||
const workflowCtx = {
|
||||
|
||||
@@ -539,7 +539,7 @@ async function createApp() {
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||
}));
|
||||
apiRouter.use(workflowsRoutes({
|
||||
apiRouter.use('/workflows', workflowsRoutes({
|
||||
workflowEngine: ctx.workflowEngine,
|
||||
licenseManager: ctx.licenseManager,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
|
||||
@@ -94,8 +94,12 @@ async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, addi
|
||||
* Return a safe error message without leaking internals
|
||||
*/
|
||||
function safeErrorMessage(error) {
|
||||
if (!error) return 'An internal error occurred';
|
||||
const msg = error.message || String(error);
|
||||
|
||||
// Always expose DC-prefixed user-facing errors
|
||||
if (/\[DC-\d+\]/.test(msg)) return msg;
|
||||
|
||||
// Detect port conflict errors
|
||||
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
|
||||
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
|
||||
@@ -103,7 +107,7 @@ function safeErrorMessage(error) {
|
||||
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
|
||||
}
|
||||
|
||||
// Only expose short, user-facing messages
|
||||
// Only expose short, user-facing messages (no paths, stack traces, or internal details)
|
||||
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
Executable
+379
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env bash
|
||||
# DashCaddy Host-Side Updater
|
||||
# Triggered by systemd path unit when the container writes trigger.json.
|
||||
# Reads the trigger, backs up current API + data/, copies new files, rebuilds container.
|
||||
# Writes result.json so the new container knows the outcome.
|
||||
#
|
||||
# This runs on the HOST, outside the container.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly UPDATES_DIR="/opt/dashcaddy/updates"
|
||||
readonly TRIGGER_FILE="${UPDATES_DIR}/trigger.json"
|
||||
readonly RESULT_FILE="${UPDATES_DIR}/result.json"
|
||||
readonly BACKUPS_DIR="${UPDATES_DIR}/backups"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly IMAGE_TAG="dashcaddy-dashcaddy-api:latest"
|
||||
readonly MAX_BACKUPS=3
|
||||
readonly HEALTH_TIMEOUT=60
|
||||
|
||||
# Data directory backup — stored alongside code backups so everything rolls back together
|
||||
readonly DATA_SOURCE_DIR="/opt/dashcaddy/dashcaddy-api/data"
|
||||
readonly DATA_BACKUP_PREFIX="data-backup"
|
||||
|
||||
log() { echo "[dashcaddy-update] $(date '+%Y-%m-%d %H:%M:%S') $*"; }
|
||||
|
||||
write_result() {
|
||||
local success="$1" version="$2" duration="$3"
|
||||
shift 3
|
||||
local error="${1:-}"
|
||||
|
||||
if [[ "$success" == "true" ]]; then
|
||||
cat > "$RESULT_FILE" <<EOF
|
||||
{
|
||||
"success": true,
|
||||
"version": "${version}",
|
||||
"duration": ${duration},
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
else
|
||||
cat > "$RESULT_FILE" <<EOF
|
||||
{
|
||||
"success": false,
|
||||
"version": "${version}",
|
||||
"duration": ${duration},
|
||||
"error": "${error}",
|
||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
}
|
||||
EOF
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_old_backups() {
|
||||
local count
|
||||
count=$(find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l)
|
||||
if (( count > MAX_BACKUPS )); then
|
||||
log "Cleaning old backups (${count} > ${MAX_BACKUPS})"
|
||||
find "$BACKUPS_DIR" -maxdepth 1 -mindepth 1 -type d -printf '%T+ %p\n' \
|
||||
| sort | head -n $(( count - MAX_BACKUPS )) | cut -d' ' -f2- \
|
||||
| xargs rm -rf
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data backup (rsync for efficiency + permissions) ──────────────────────────
|
||||
backup_data_dir() {
|
||||
local backup_dir="$1"
|
||||
if [[ -d "$DATA_SOURCE_DIR" ]]; then
|
||||
log "Backing up data/ to ${backup_dir}/${DATA_BACKUP_PREFIX}/"
|
||||
mkdir -p "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
rsync -a --delete "$DATA_SOURCE_DIR/" "${backup_dir}/${DATA_BACKUP_PREFIX}/" 2>/dev/null \
|
||||
|| cp -a "$DATA_SOURCE_DIR" "${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
log "Data backup complete ($(du -sh "${backup_dir}/${DATA_BACKUP_PREFIX}" 2>/dev/null | cut -f1))"
|
||||
else
|
||||
log "WARNING: Data source dir $DATA_SOURCE_DIR not found — skipping data backup"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Data restore ──────────────────────────────────────────────────────────────
|
||||
restore_data_dir() {
|
||||
local backup_dir="$1"
|
||||
local data_backup="${backup_dir}/${DATA_BACKUP_PREFIX}"
|
||||
if [[ -d "$data_backup" ]]; then
|
||||
log "Restoring data/ from backup..."
|
||||
rsync -a --delete "$data_backup/" "$DATA_SOURCE_DIR/" 2>/dev/null \
|
||||
|| cp -a "$data_backup" "$DATA_SOURCE_DIR"
|
||||
log "Data restored successfully"
|
||||
else
|
||||
log "WARNING: No data backup found at ${data_backup} — data/ not restored"
|
||||
fi
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local port="${1:-3001}"
|
||||
local timeout="$HEALTH_TIMEOUT"
|
||||
local elapsed=0
|
||||
|
||||
log "Waiting for health check (timeout: ${timeout}s)..."
|
||||
while (( elapsed < timeout )); do
|
||||
if curl -fsSL --max-time 3 "http://localhost:${port}/health" &>/dev/null; then
|
||||
log "Health check passed after ${elapsed}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$(( elapsed + 2 ))
|
||||
done
|
||||
|
||||
log "Health check FAILED after ${timeout}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Shared rollback: restore code + data ────────────────────────────────────
|
||||
rollback_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Rolling back: restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
restore_data_dir "$backup_dir"
|
||||
}
|
||||
|
||||
# ── Deployment mode ───────────────────────────────────────────────────────────
|
||||
# Reproduce the SAME container the install created so an auto-update keeps every
|
||||
# volume + env var (docker socket, Caddyfile, config/credentials, updates mount),
|
||||
# not a minimal subset. Standard installs use docker-compose (compose file in the
|
||||
# api source dir); the publish/dev host uses /opt/dashcaddy/start.sh; otherwise a
|
||||
# bare docker run is the last resort. build_image() and restart_container() both
|
||||
# honor the detected mode so build and run stay consistent.
|
||||
deploy_mode() {
|
||||
if [[ -f "$api_source_dir/docker-compose.yml" || -f "$api_source_dir/compose.yml" || -f "$api_source_dir/compose.yaml" ]]; then
|
||||
echo compose
|
||||
elif [[ -x /opt/dashcaddy/start.sh ]]; then
|
||||
echo startsh
|
||||
else
|
||||
echo run
|
||||
fi
|
||||
}
|
||||
|
||||
# Build the API image using whatever the install is wired for. Returns the build
|
||||
# command's exit status so callers can detect failure.
|
||||
build_image() {
|
||||
cd "$api_source_dir" || return 1
|
||||
case "$(deploy_mode)" in
|
||||
compose) docker compose build 2>&1 || docker-compose build 2>&1 ;;
|
||||
*) docker build -t "$IMAGE_TAG" . 2>&1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── Shared container restart — recreate with the full, install-defined spec ───
|
||||
# Recreates (rm + run / compose up) so new code AND new env vars take effect.
|
||||
restart_container() {
|
||||
cd "$api_source_dir" 2>/dev/null || true
|
||||
case "$(deploy_mode)" in
|
||||
compose)
|
||||
log "Recreating container via docker compose (full compose spec)..."
|
||||
docker compose up -d 2>&1 || docker-compose up -d 2>&1
|
||||
;;
|
||||
startsh)
|
||||
log "Recreating container via /opt/dashcaddy/start.sh (full container spec)..."
|
||||
bash /opt/dashcaddy/start.sh
|
||||
;;
|
||||
*)
|
||||
log "Recreating container via minimal docker run (fallback)..."
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
"$IMAGE_TAG"
|
||||
;;
|
||||
esac
|
||||
log "Container recreated"
|
||||
}
|
||||
|
||||
# ── Code-only restore (used after failed build when data hasn't changed yet) ──
|
||||
code_restore() {
|
||||
local backup_dir="$1"
|
||||
log "Restoring code files..."
|
||||
for item in "$backup_dir"/*.js "$backup_dir"/package.json "$backup_dir"/package-lock.json "$backup_dir"/Dockerfile "$backup_dir"/openapi.yaml "$backup_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$backup_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$backup_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$backup_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$backup_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$backup_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$backup_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local start_time
|
||||
start_time=$(date +%s)
|
||||
|
||||
# 1. Read trigger
|
||||
if [[ ! -f "$TRIGGER_FILE" ]]; then
|
||||
log "No trigger file found — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse trigger.json (uses python3 which is available on all supported distros)
|
||||
local action version from_version staging_dir api_source_dir commit
|
||||
local frontend_staging_dir frontend_target_dir
|
||||
action=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['action'])")
|
||||
version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['version'])")
|
||||
from_version=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['fromVersion'])")
|
||||
staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['stagingDir'])")
|
||||
api_source_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}'))['apiSourceDir'])")
|
||||
commit=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('commit') or '')")
|
||||
frontend_staging_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendStagingDir') or '')")
|
||||
frontend_target_dir=$(python3 -c "import json; print(json.load(open('${TRIGGER_FILE}')).get('frontendTargetDir') or '')")
|
||||
# Handle action=rollback (no new version to deploy)
|
||||
local to_version="${version}"
|
||||
|
||||
log "=== ${action^^}: v${from_version} -> v${to_version} ==="
|
||||
log "Staging: ${staging_dir}"
|
||||
log "API source: ${api_source_dir}"
|
||||
|
||||
# Consume the trigger immediately so we don't re-process on failure
|
||||
mv "$TRIGGER_FILE" "${TRIGGER_FILE}.processing"
|
||||
|
||||
# ── Handle rollback ────────────────────────────────────────────────────────
|
||||
if [[ "$action" == "rollback" ]]; then
|
||||
local backup_dir="${BACKUPS_DIR}/${version}"
|
||||
if [[ ! -d "$backup_dir" ]]; then
|
||||
log "ERROR: No backup found for version ${version}"
|
||||
write_result "false" "$version" "$(( $(date +%s) - start_time ))" "No backup found for version ${version}"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Performing rollback to v${version}..."
|
||||
rollback_restore "$backup_dir"
|
||||
|
||||
# Rebuild old code
|
||||
log "Rebuilding container..."
|
||||
build_image 2>&1 | tail -3 || true
|
||||
|
||||
restart_container
|
||||
wait_for_health || log "WARNING: Health check failed after rollback"
|
||||
|
||||
write_result "true" "$version" "$(( $(date +%s) - start_time ))"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
log "=== Rollback complete ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Handle update ───────────────────────────────────────────────────────────
|
||||
if [[ ! -d "$staging_dir" ]]; then
|
||||
log "ERROR: Staging directory not found: ${staging_dir}"
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Staging directory not found"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Backup current API code + data/
|
||||
local backup_dir="${BACKUPS_DIR}/${from_version}"
|
||||
mkdir -p "$backup_dir"
|
||||
log "Backing up current API files to ${backup_dir}"
|
||||
for item in "$api_source_dir"/*.js "$api_source_dir"/package.json "$api_source_dir"/package-lock.json "$api_source_dir"/Dockerfile "$api_source_dir"/openapi.yaml "$api_source_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$backup_dir/" 2>/dev/null || true
|
||||
done
|
||||
[[ -d "$api_source_dir/routes" ]] && cp -rf "$api_source_dir/routes" "$backup_dir/"
|
||||
[[ -d "$api_source_dir/src" ]] && cp -rf "$api_source_dir/src" "$backup_dir/"
|
||||
[[ -d "$api_source_dir/dns-providers" ]] && cp -rf "$api_source_dir/dns-providers" "$backup_dir/"
|
||||
|
||||
# Backup data/ directory (services.json, config.json, credentials, etc.)
|
||||
backup_data_dir "$backup_dir"
|
||||
|
||||
cleanup_old_backups
|
||||
|
||||
# 3. Copy new files from staging to API source
|
||||
log "Deploying new API files..."
|
||||
for item in "$staging_dir"/*.js "$staging_dir"/package.json "$staging_dir"/package-lock.json "$staging_dir"/Dockerfile "$staging_dir"/openapi.yaml "$staging_dir"/VERSION; do
|
||||
[[ -f "$item" ]] && cp -f "$item" "$api_source_dir/" 2>/dev/null || true
|
||||
done
|
||||
if [[ -d "$staging_dir/routes" ]]; then
|
||||
rm -rf "$api_source_dir/routes"
|
||||
cp -rf "$staging_dir/routes" "$api_source_dir/routes"
|
||||
fi
|
||||
if [[ -d "$staging_dir/src" ]]; then
|
||||
rm -rf "$api_source_dir/src"
|
||||
cp -rf "$staging_dir/src" "$api_source_dir/src"
|
||||
fi
|
||||
if [[ -d "$staging_dir/dns-providers" ]]; then
|
||||
rm -rf "$api_source_dir/dns-providers"
|
||||
cp -rf "$staging_dir/dns-providers" "$api_source_dir/dns-providers"
|
||||
fi
|
||||
if [[ -n "$commit" ]]; then
|
||||
echo "$commit" > "$api_source_dir/VERSION"
|
||||
fi
|
||||
|
||||
# 3b. Sync frontend
|
||||
if [[ -z "$frontend_staging_dir" ]]; then
|
||||
parent_staging=$(dirname "$staging_dir")
|
||||
[[ -d "$parent_staging/status" ]] && frontend_staging_dir="$parent_staging/status"
|
||||
fi
|
||||
if [[ -z "$frontend_target_dir" ]]; then
|
||||
for candidate in /var/www/dashcaddy-status /etc/dashcaddy/sites/status; do
|
||||
[[ -d "$candidate" ]] && frontend_target_dir="$candidate" && break
|
||||
done
|
||||
fi
|
||||
if [[ -n "$frontend_staging_dir" && -n "$frontend_target_dir" && -d "$frontend_staging_dir" ]]; then
|
||||
log "Syncing frontend: $frontend_staging_dir -> $frontend_target_dir"
|
||||
mkdir -p "$frontend_target_dir"
|
||||
[[ -f "$frontend_staging_dir/index.html" ]] && cp -f "$frontend_staging_dir/index.html" "$frontend_target_dir/index.html"
|
||||
[[ -f "$frontend_staging_dir/sw.js" ]] && cp -f "$frontend_staging_dir/sw.js" "$frontend_target_dir/sw.js"
|
||||
for sub in dist css vendor js; do
|
||||
if [[ -d "$frontend_staging_dir/$sub" ]]; then
|
||||
mkdir -p "$frontend_target_dir/$sub"
|
||||
cp -rf "$frontend_staging_dir/$sub/"* "$frontend_target_dir/$sub/" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
if [[ -d "$frontend_staging_dir/assets" ]]; then
|
||||
mkdir -p "$frontend_target_dir/assets"
|
||||
cp -rf "$frontend_staging_dir/assets/"* "$frontend_target_dir/assets/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 4. Rebuild container
|
||||
log "Rebuilding container..."
|
||||
local build_ok=false
|
||||
if build_image; then
|
||||
build_ok=true
|
||||
fi
|
||||
|
||||
if [[ "$build_ok" != "true" ]]; then
|
||||
log "ERROR: Docker build failed — rolling back code + data"
|
||||
code_restore "$backup_dir"
|
||||
build_image 2>&1 | tail -3 || true
|
||||
restart_container
|
||||
wait_for_health || true
|
||||
write_result "false" "$to_version" "$(( $(date +%s) - start_time ))" "Docker build failed"
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Restart container (recreate so new code + env vars take effect)
|
||||
restart_container
|
||||
|
||||
# 6. Health check
|
||||
if wait_for_health; then
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "=== Update successful: v${to_version} in ${duration}s ==="
|
||||
write_result "true" "$to_version" "$duration"
|
||||
else
|
||||
local duration=$(( $(date +%s) - start_time ))
|
||||
log "ERROR: Health check failed after update — rolling back code + data"
|
||||
rollback_restore "$backup_dir"
|
||||
build_image 2>&1 | tail -3 || true
|
||||
restart_container
|
||||
wait_for_health || log "WARNING: Rollback health check also failed"
|
||||
write_result "false" "$to_version" "$duration" "Health check failed after update"
|
||||
fi
|
||||
|
||||
# 7. Cleanup
|
||||
rm -f "${TRIGGER_FILE}.processing"
|
||||
rm -rf "${UPDATES_DIR}/staging" 2>/dev/null || true
|
||||
|
||||
log "=== Update process complete ==="
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user