/** * Update Management Module * Checks for Docker image updates, manages update scheduling, * and provides rollback capabilities */ const Docker = require('dockerode'); const EventEmitter = require('events'); const fs = require('fs'); const path = require('path'); const https = require('https'); const platformPaths = require('../../platform-paths'); const { log } = require('../utils/logging'); const docker = new Docker(); const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformPaths.dataDir, 'update-config.json'); const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json'); const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour // DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points // at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA // records even when the host's IPv6 path to public registries (Docker Hub, // ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack, // every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds // per ENETUNREACH on the unreachable family. Without an explicit request timeout // the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per // tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like // `at internalConnectMultiple (node:net:1114:18)`. // // TUNABLES — keep conservative; the digest check is a background poll, not // user-facing. Worst-case latency per query: // 1st attempt: REGISTRY_REQUEST_TIMEOUT_MS (10s) // 1st retry : REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s) // ───────────────────────────────────────────────────────────────────── // per-container ceiling: 20.5s (REGISTRY_MAX_RETRIES=1) const REGISTRY_REQUEST_TIMEOUT_MS = 10000; // hard per-request socket timeout const REGISTRY_MAX_RETRIES = 1; // extra attempts after first failure const REGISTRY_RETRY_BACKOFF_MS = 500; // delay before retry (transient blips) const REGISTRY_TRANSIENT_ERROR_CODES = new Set([ 'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN', 'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH', ]); class UpdateManager extends EventEmitter { constructor() { super(); this.config = this.loadConfig(); this.history = this.loadHistory(); this.availableUpdates = new Map(); this.checking = false; this.checkInterval = null; } /** * Start update checking and auto-update scheduler */ start() { if (this.checking) return; log.info('update', 'Starting update checks'); this.checking = true; // Initial check this.checkForUpdates(); // Schedule periodic checks this.checkInterval = setInterval(() => this.checkForUpdates(), CHECK_INTERVAL); // Start auto-update scheduler (checks every hour) this.startAutoUpdateScheduler(); } /** * Stop update checking */ stop() { if (!this.checking) return; log.info('update', 'Stopping update checks'); this.checking = false; if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } if (this.autoUpdateInterval) { clearInterval(this.autoUpdateInterval); this.autoUpdateInterval = null; } } /** * Trigger bundled workflows for an event */ triggerWorkflows(eventType, eventData) { if (!this.workflowEngine) { log.info('update', 'Workflow engine not set, skipping workflow trigger'); return; } try { this.workflowEngine.triggerForEvent(eventType, eventData) .then(results => { if (results && results.length > 0) { log.info('update', `Triggered workflows for ${eventType}`, { count: results.length }); } }) .catch(err => { log.error('update', err); }); } catch (error) { log.error('update', error); } } /** * Set the workflow engine for triggering workflows */ setWorkflowEngine(workflowEngine) { this.workflowEngine = workflowEngine; log.info('update', 'Workflow engine configured'); } /** * Check for updates for all containers */ async checkForUpdates() { try { const containers = await docker.listContainers({ all: true }); for (const containerInfo of containers) { try { const container = docker.getContainer(containerInfo.Id); const inspect = await container.inspect(); const imageName = inspect.Config.Image; const currentDigest = inspect.Image; // Check if update available const latestDigest = await this.getLatestImageDigest(imageName); if (latestDigest && latestDigest !== currentDigest) { this.availableUpdates.set(containerInfo.Id, { containerId: containerInfo.Id, containerName: containerInfo.Names[0].replace(/^\//, ''), imageName, currentDigest: currentDigest.substring(0, 12), latestDigest: latestDigest.substring(0, 12), currentTag: this.extractTag(imageName), detectedAt: new Date().toISOString() }); this.emit('update-available', this.availableUpdates.get(containerInfo.Id)); } else { this.availableUpdates.delete(containerInfo.Id); } } catch (error) { log.error('update', error, null, { containerName: containerInfo.Names[0] }); } } log.info('update', 'Checked for updates', { availableCount: this.availableUpdates.size }); } catch (error) { log.error('update', error); } } /** * Get latest image digest from registry * * DC-082: when the image name is a docker-compose prefixed name like * `dashcaddy-dashcaddy-api:latest` (single hyphen-separated, no slash), * the existing code normalized it to `library/dashcaddy-dashcaddy-api` * before probing Docker Hub. The actual upstream namespace for a * compose-prefixed image is `/` (with slash) — Docker * Compose hyphenates the project name and service name when tagging * locally. The pre-fix code probed the wrong repo, Docker Hub returned * HTTP 401 (the repo doesn't exist), and the error log showed * `Docker Hub registry returned HTTP 401 after auth` on every restart * for the local dashcaddy-api image. The fix: split on the FIRST hyphen * for compose-prefixed names so the lookup targets the correct * namespace. * * Compose-prefixed shape: `^[a-z0-9]+-[a-z0-9][a-z0-9_-]*$` (no slash, * lowercase, both halves non-empty). Examples: * dashcaddy-dashcaddy-api -> dashcaddy/dashcaddy-api * myproject-myservice -> myproject/myservice * nginx -> library/nginx (official, unchanged) * library/nginx -> library/nginx (official, unchanged) * dashcaddy/some-image -> dashcaddy/some-image (already has slash) * ghcr.io/x/y -> ghcr.io/x/y (handled below) */ async getLatestImageDigest(imageName) { // DC-082: declare `remainder` at the function scope so the catch block // can classify the error against the image-name shape (compose-prefixed // local images produce a steady-state 401 that should log as info, not // error). let remainder = imageName; try { // Parse image name — strip any leading registry host first let imageTag = 'latest'; remainder = imageName; const lastColon = imageName.lastIndexOf(':'); // Only treat as tag if the colon is AFTER the last slash (avoids `ghcr.io:443/...`) const lastSlash = imageName.lastIndexOf('/'); if (lastColon > lastSlash) { imageTag = imageName.substring(lastColon + 1); remainder = imageName.substring(0, lastColon); } // ghcr.io: GitHub Container Registry (tokenless for public images) if (remainder.startsWith('ghcr.io/')) { return await this.getGhcrDigest(remainder, imageTag); } // Docker Hub images (library/nginx OR org/image with single slash). // Special-case docker-compose prefixed names (single hyphen, no slash, // lowercase) — split on the FIRST hyphen to recover the original // `/` namespace. See DC-082. if (!remainder.includes('/')) { const composeRepo = this._composeProjectToRepo(remainder); if (composeRepo) { return await this.getDockerHubDigest(composeRepo, imageTag); } // Not a compose-prefixed name — fall through to the library/ default return await this.getDockerHubDigest(remainder, imageTag); } if (remainder.split('/').length === 2) { return await this.getDockerHubDigest(remainder, imageTag); } // gcr.io / quay.io / registry.gitlab.com — currently unsupported log.warn('update', 'Custom registry not yet supported', { remainder }); return null; } catch (error) { // DC-082: a "registry returned HTTP 401 after auth" against a // compose-prefixed local image is the steady-state when the image // is built locally and the upstream namespace on Docker Hub // doesn't exist (or is private). The token endpoint returns 200 // with an empty-access JWT, and the authed manifest GET 401s. // Log these as a clean info not-found line instead of an error // so dashboards and PagerDuty don't fire on every restart. if (this._isNotPublishedError(error, remainder)) { log.info('update', 'No upstream registry image — skipping update check', { imageName, remainder }); return null; } log.error('update', error, null, { imageName }); return null; } } /** * DC-082: split a docker-compose prefixed image name on the FIRST hyphen * to recover the original `/` namespace. Returns null * for names that don't match the compose-prefixed shape — callers fall * through to the standard library/-prefixed official-image path. * * Compose-prefixed shape: * - Contains exactly one or more hyphens * - No slash * - Lowercase letters / digits / hyphens / underscores only * - Both halves (before first hyphen, after first hyphen) are non-empty * - First char is a letter or digit (not a hyphen) */ _composeProjectToRepo(remainder) { if (typeof remainder !== 'string' || remainder.length === 0) return null; if (remainder.includes('/')) return null; // already namespaced if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) { // Not a compose-prefixed name — let the library/ path handle it // (this is the official-image path: e.g. `nginx`, `alpine`). return null; } const firstHyphen = remainder.indexOf('-'); // Defensive: indexOf must find a hyphen (regex requires it), but guard // against any future regex drift. if (firstHyphen <= 0 || firstHyphen === remainder.length - 1) return null; const project = remainder.substring(0, firstHyphen); const service = remainder.substring(firstHyphen + 1); if (!project || !service) return null; return `${project}/${service}`; } /** * DC-082: detect the "registry returned 401 after auth" pattern that * signals "this image has no public upstream on Docker Hub" (as opposed * to a genuine auth failure or transient network error). Steady-state * for compose-prefixed local images that aren't published. */ _isNotPublishedError(error, remainder) { if (!error || typeof error.message !== 'string') return false; if (!error.message.includes('HTTP 401')) return false; // Constrain to the compose-prefixed path — a real auth failure on a // legitimate `library/foo` or `namespace/foo` probe should still log // as an error (it never auto-heals). if (typeof remainder !== 'string' || remainder.includes('/')) return false; if (!/^[a-z0-9][a-z0-9_-]*-[a-z0-9][a-z0-9_-]*$/i.test(remainder)) { return false; } return true; } /** * Get image digest from GitHub Container Registry (ghcr.io) * Public images are tokenless via the registry-1.docker.io-style bearer flow, * but using ghcr.io's own auth endpoint. * * DC-078: hardened — `family: 4` to avoid the dual-stack DNS race when the * host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in * error.log every check cycle). Hard request timeout caps each attempt. */ async getGhcrDigest(repository, tag) { // ghcr.io uses the same OCI distribution spec as Docker Hub const imageRepo = repository.replace(/^ghcr\.io\//, ''); const res = await this.fetchWithReliability({ hostname: 'ghcr.io', path: `/v2/${imageRepo}/manifests/${tag}`, headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json' }, }); return res.headers['docker-content-digest'] || null; } /** * Get image digest from Docker Hub * * DC-078: hardened — see getGhcrDigest comment. Resolves a 401 → token via * `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only + * timeout policy via `fetchWithReliability`. */ async getDockerHubDigest(repository, tag) { // Normalize repository name const repo = repository.includes('/') ? repository : `library/${repository}`; const firstAttempt = await this.fetchWithReliability({ hostname: 'registry-1.docker.io', path: `/v2/${repo}/manifests/${tag}`, headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json' }, }); if (firstAttempt.statusCode !== 401) { if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) { throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`); } return firstAttempt.headers['docker-content-digest'] || null; } // 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once. const authHeader = firstAttempt.headers['www-authenticate']; const authUrl = this.parseAuthHeader(authHeader); if (!authUrl) { throw new Error('Authentication required but no auth URL found'); } const token = await this.fetchAuthToken(authUrl); const authed = await this.fetchWithReliability({ hostname: 'registry-1.docker.io', path: `/v2/${repo}/manifests/${tag}`, headers: { 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', 'Authorization': `Bearer ${token}`, }, }); if (authed.statusCode < 200 || authed.statusCode >= 300) { throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`); } return authed.headers['docker-content-digest'] || null; } /** * Single hardened HTTPS probe — DC-078. * * Reliability properties: * 1. `family: 4` — IPv4-only DNS lookup. Avoids dual-stack races where a * single unreachable IPv6 destination consumes the default 30-second * connect timeout before the IPv4 fallback succeeds (manifested in * error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`). * 2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) — caps total * latency for any single probe attempt. * 3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES) * with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT * retry on HTTP 4xx/5xx — those are real responses we should surface. * * Returns {statusCode, headers, body} so callers can read whichever response * header or body bytes they need. For digest probes the body is drained and * discarded; for auth-token fetches the JSON body is parsed. * * @param {object} opts * @param {string} opts.hostname * @param {string} opts.path * @param {object} [opts.headers] * @param {number} [opts.maxBodyBytes=65536] — protect against runaway bodies */ async fetchWithReliability(opts) { const maxBodyBytes = opts.maxBodyBytes || 65536; let attempt = 0; while (attempt <= REGISTRY_MAX_RETRIES) { try { const result = await this._httpsRequestOnce({ hostname: opts.hostname, path: opts.path, headers: opts.headers || {}, maxBodyBytes, }); return result; } catch (error) { // Drain retryable transient errors; non-transient (HTTP status) errors // and code-less errors are surfaced directly to the caller. if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) { throw error; } if (attempt >= REGISTRY_MAX_RETRIES) { throw error; } attempt += 1; // Brief backoff before retry to let transient blips settle. await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS)); } } // Defensive — should not reach here because the loop either throws or returns. throw new Error('fetchWithReliability exhausted retries'); } /** * One-shot HTTPS request helper for fetchWithReliability — DC-078. * Returns {statusCode, headers, body} on 2xx and most non-2xx responses * (the caller decides what to do with non-2xx). Throws on transient * network errors so the retry policy catches them. */ _httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) { return new Promise((resolve, reject) => { const options = { hostname, path: urlPath, method: 'GET', family: 4, // DC-078: IPv4-only — see top-of-file comment headers, timeout: REGISTRY_REQUEST_TIMEOUT_MS, // DC-078: hard per-request cap }; const req = https.request(options, (res) => { let body = ''; let size = 0; let aborted = false; res.on('data', (chunk) => { if (aborted) return; size += chunk.length; if (size > maxBodyBytes) { aborted = true; res.destroy(); const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`); err.code = 'ERR_RESPONSE_TOO_LARGE'; reject(err); return; } body += chunk; }); res.on('end', () => { if (aborted) return; resolve({ statusCode: res.statusCode, headers: res.headers, body, }); }); }); // Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout // is hit — make it an explicit error so fetchWithReliability's retry policy catches it. req.on('timeout', () => { req.destroy(new Error('request timeout')); const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`); err.code = 'ETIMEDOUT'; reject(err); }); req.on('error', (err) => { // Tag errors missing .code so the retry policy recognizes transient ones. if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT'; reject(err); }); req.end(); }); } /** * Fetch an auth token from a registry's WWW-Authenticate realm URL — DC-078. * Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the * JSON body and returns the `token` or `access_token` field. */ async fetchAuthToken(authUrl) { const url = new URL(authUrl); const result = await this.fetchWithReliability({ hostname: url.hostname, path: url.pathname + url.search, maxBodyBytes: 16384, // auth tokens are <2 KB; cap to a small bound }); if (result.statusCode !== 200) { throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`); } let auth; try { auth = JSON.parse(result.body); } catch (parseErr) { // Surface a clean error — otherwise a malformed token response throws // SyntaxError with the raw body snippet, which is hard to diagnose // against the offending realm URL in a log line. throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`); } const token = auth.token || auth.access_token; if (!token) throw new Error(`No token in auth response from ${authUrl}`); return token; } /** * Parse authentication header */ parseAuthHeader(header) { if (!header) return null; const match = header.match(/Bearer realm="([^"]+)"/); if (!match) return null; const url = new URL(match[1]); const params = header.match(/service="([^"]+)"/); if (params) url.searchParams.set('service', params[1]); const scope = header.match(/scope="([^"]+)"/); if (scope) url.searchParams.set('scope', scope[1]); return url.toString(); } /** * Extract tag from image name */ extractTag(imageName) { const parts = imageName.split(':'); return parts.length > 1 ? parts[parts.length - 1] : 'latest'; } /** * Update a container */ async updateContainer(containerId, options = {}) { const startTime = Date.now(); log.info('update', 'Starting update for container', { containerId }); this.emit('update-start', { containerId, timestamp: new Date().toISOString() }); try { const container = docker.getContainer(containerId); const inspect = await container.inspect(); const imageName = inspect.Config.Image; const containerName = inspect.Name.replace(/^\//, ''); const oldImageId = inspect.Image; // Get old image digest for rollback let oldImageDigest = null; try { const oldImage = docker.getImage(oldImageId); const oldImageInspect = await oldImage.inspect(); oldImageDigest = oldImageInspect.RepoDigests?.[0] || oldImageId; log.info('update', 'Stored old image digest', { digestPrefix: oldImageDigest.substring(0, 40) }); } catch (error) { log.warn('update', 'Could not get old image digest', { error: error.message }); } // Create backup of current state const backup = { containerId, containerName, imageName, imageId: oldImageId, imageDigest: oldImageDigest, config: inspect.Config, hostConfig: inspect.HostConfig, networkSettings: inspect.NetworkSettings, timestamp: new Date().toISOString() }; // Emit pre-update event for bundled workflows (e.g., backup-before-update) this.emit('pre-update', { containerId, containerName, imageName, backup }); // Also trigger workflows for pre-update event directly this.triggerWorkflows('pre-update', { containerId, containerName, appId: containerName, imageName }); // Pull latest image log.info('update', 'Pulling latest image', { imageName }); await this.pullImage(imageName); // Stop container log.info('update', 'Stopping container', { containerName }); await container.stop(); // Remove old container log.info('update', 'Removing old container', { containerName }); await container.remove(); // Create new container with same configuration log.info('update', 'Creating new container', { containerName }); const newContainer = await docker.createContainer({ name: containerName, Image: imageName, ...backup.config, HostConfig: backup.hostConfig }); // Start new container log.info('update', 'Starting new container', { containerName }); await newContainer.start(); // Extended verification with health checks and port accessibility log.info('update', 'Performing extended verification'); await this.verifyContainerExtended(newContainer, inspect, options.verifyTimeout || 60000); // Get new image ID const newInspect = await newContainer.inspect(); const newImageId = newInspect.Image; // Remove old image only after successful verification if (oldImageId !== newImageId) { try { log.info('update', 'Removing old image', { oldImageIdPrefix: oldImageId.substring(0, 12) }); const oldImage = docker.getImage(oldImageId); await oldImage.remove({ force: false }); log.info('update', 'Old image removed successfully'); } catch (error) { log.warn('update', 'Could not remove old image (may be in use)', { error: error.message }); } } const duration = Date.now() - startTime; const historyEntry = { containerId: newContainer.id, containerName, imageName, oldImageId: oldImageId.substring(0, 12), newImageId: newImageId.substring(0, 12), timestamp: new Date().toISOString(), duration, status: 'success', backup }; this.addToHistory(historyEntry); this.availableUpdates.delete(containerId); this.emit('update-complete', historyEntry); log.info('update', 'Update completed', { durationMs: duration }); return historyEntry; } catch (error) { const duration = Date.now() - startTime; const historyEntry = { containerId, timestamp: new Date().toISOString(), duration, status: 'failed', error: error.message }; this.addToHistory(historyEntry); this.emit('update-failed', historyEntry); // Attempt rollback if (options.autoRollback !== false) { log.info('update', 'Attempting rollback', { containerId }); try { await this.rollbackUpdate(containerId); } catch (rollbackError) { log.error('update', rollbackError); } } throw error; } } /** * Pull Docker image */ async pullImage(imageName) { return new Promise((resolve, reject) => { docker.pull(imageName, (err, stream) => { if (err) { reject(err); return; } docker.modem.followProgress(stream, (err, output) => { if (err) { reject(err); } else { resolve(output); } }); }); }); } /** * Verify container is running and healthy */ async verifyContainer(container, timeout = 30000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { try { const inspect = await container.inspect(); if (inspect.State.Running) { // Check health if health check is configured if (inspect.State.Health) { if (inspect.State.Health.Status === 'healthy') { return true; } } else { // No health check, just verify it's running return true; } } // Wait before checking again await new Promise(resolve => setTimeout(resolve, 1000)); } catch (error) { throw new Error(`Container verification failed: ${error.message}`); } } throw new Error('Container verification timeout'); } /** * Extended container verification with health checks and port accessibility * @param {object} container - Docker container object * @param {object} oldInspect - Old container inspect data for port comparison * @param {number} timeout - Verification timeout in milliseconds (default: 60000) */ async verifyContainerExtended(container, oldInspect, timeout = 60000) { const startTime = Date.now(); const maxAttempts = Math.floor(timeout / 2000); // Check every 2 seconds let lastError = null; log.info('update', 'Extended verification', { maxAttempts, timeoutSec: timeout / 1000 }); for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const inspect = await container.inspect(); // Step 1: Verify container is running if (!inspect.State.Running) { lastError = 'Container is not running'; throw new Error(lastError); } // Step 2: Check Docker health check if available if (inspect.State.Health) { if (inspect.State.Health.Status === 'healthy') { log.info('update', 'Container health check: healthy'); return true; } else if (inspect.State.Health.Status === 'unhealthy') { lastError = 'Container health check failed (unhealthy)'; throw new Error(lastError); } // Status is 'starting' - continue waiting log.info('update', 'Health check status', { status: inspect.State.Health.Status, attempt: attempt + 1, maxAttempts }); } else { // Step 3: No Docker health check - verify HTTP port accessibility const ports = this.extractPorts(inspect); if (ports.length > 0) { // Try to access the first HTTP port const primaryPort = ports[0]; const testUrl = `http://localhost:${primaryPort.hostPort}`; try { const response = await fetch(testUrl, { signal: AbortSignal.timeout(3000), redirect: 'manual' }); // Accept 2xx, 3xx, 4xx as "accessible" (server is responding) if (response.status >= 200 && response.status < 500) { log.info('update', 'Port accessible', { hostPort: primaryPort.hostPort, httpStatus: response.status }); // Wait a bit more to ensure stability if (attempt >= 2) { log.info('update', 'Container verified successfully'); return true; } } } catch (fetchError) { lastError = `Port ${primaryPort.hostPort} not accessible: ${fetchError.message}`; log.info('update', lastError, { attempt: attempt + 1, maxAttempts }); } } else { // No ports exposed - just verify it's running for a few cycles if (attempt >= 5) { log.info('update', 'Container running without exposed ports (verified)'); return true; } } } // Wait before next attempt if (attempt < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, 2000)); } } catch (error) { lastError = error.message; log.info('update', 'Verification attempt failed', { attempt: attempt + 1, error: lastError }); if (attempt < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, 2000)); } } } // Verification failed const duration = Date.now() - startTime; throw new Error(`Extended verification failed after ${duration}ms: ${lastError || 'timeout'}`); } /** * Extract port mappings from container inspect data * @param {object} inspect - Container inspect data * @returns {Array} Array of port mappings */ extractPorts(inspect) { const ports = []; if (inspect.NetworkSettings && inspect.NetworkSettings.Ports) { for (const [containerPort, bindings] of Object.entries(inspect.NetworkSettings.Ports)) { if (bindings && bindings.length > 0) { for (const binding of bindings) { if (binding.HostPort) { ports.push({ containerPort: containerPort.split('/')[0], hostPort: binding.HostPort, protocol: containerPort.split('/')[1] || 'tcp' }); } } } } } return ports; } /** * Rollback to previous version */ async rollbackUpdate(containerId) { log.info('update', 'Rolling back container', { containerId }); // Find last successful update in history const lastUpdate = this.history .filter(h => h.containerId === containerId && h.status === 'success' && h.backup) .pop(); if (!lastUpdate || !lastUpdate.backup) { throw new Error('No backup found for rollback'); } const backup = lastUpdate.backup; try { // Stop and remove current container try { const container = docker.getContainer(containerId); await container.stop(); await container.remove(); } catch (error) { // Container might not exist, continue } // Recreate container from backup const newContainer = await docker.createContainer({ name: backup.containerName, Image: backup.imageName, ...backup.config, HostConfig: backup.hostConfig }); await newContainer.start(); log.info('update', 'Rollback completed', { containerName: backup.containerName }); this.emit('rollback-complete', { containerId, containerName: backup.containerName }); return true; } catch (error) { log.error('update', error); throw error; } } /** * Schedule update for maintenance window */ scheduleUpdate(containerId, scheduledTime) { const delay = new Date(scheduledTime).getTime() - Date.now(); if (delay < 0) { throw new Error('Scheduled time must be in the future'); } setTimeout(() => { this.updateContainer(containerId).catch(error => { log.error('update', error); }); }, delay); log.info('update', 'Update scheduled', { containerId, scheduledTime }); } /** * Get available updates */ getAvailableUpdates() { return Array.from(this.availableUpdates.values()); } /** * Get update history */ getHistory(limit = 50) { return this.history.slice(-limit).reverse(); } /** * Get changelog and release information from Docker Hub * @param {string} imageName - Docker image name (e.g., "nginx:latest" or "linuxserver/plex") * @returns {Object} Changelog information including tags, description, and URLs */ async getChangelog(imageName) { try { // Parse image name const [fullRepo, tag] = imageName.split(':'); const imageTag = tag || 'latest'; // Normalize repository name for Docker Hub API let repo = fullRepo; let namespace = 'library'; if (fullRepo.includes('/')) { const parts = fullRepo.split('/'); namespace = parts[0]; repo = parts.slice(1).join('/'); } const repoPath = namespace === 'library' ? repo : `${namespace}/${repo}`; // Fetch repository info from Docker Hub API const repoInfo = await this.fetchDockerHubRepo(repoPath, namespace === 'library'); // Fetch available tags const tags = await this.fetchDockerHubTags(repoPath, namespace === 'library'); // Build the Docker Hub URL const hubUrl = namespace === 'library' ? `https://hub.docker.com/_/${repo}` : `https://hub.docker.com/r/${namespace}/${repo}`; return { imageName, currentTag: imageTag, repository: { name: repoPath, description: repoInfo?.description || 'No description available', shortDescription: repoInfo?.description?.substring(0, 200) || '', starCount: repoInfo?.star_count || 0, pullCount: repoInfo?.pull_count || 0, lastUpdated: repoInfo?.last_updated || null }, tags: tags.slice(0, 10).map(t => ({ name: t.name, lastPushed: t.last_pushed || t.tag_last_pushed, digest: t.digest?.substring(0, 12) || 'unknown', size: t.full_size || t.size || 0 })), urls: { dockerHub: hubUrl, tags: `${hubUrl}/tags`, dockerfile: repoInfo?.dockerfile_url || null }, changelog: this.formatChangelog(repoInfo, tags, imageTag) }; } catch (error) { log.error('update', error, null, { imageName }); // Return basic info even on error const [fullRepo] = imageName.split(':'); const repoPath = fullRepo.includes('/') ? fullRepo : `library/${fullRepo}`; return { imageName, error: error.message, urls: { dockerHub: `https://hub.docker.com/r/${repoPath.replace('library/', '_/')}`, }, changelog: 'Unable to fetch changelog. Visit Docker Hub for details.' }; } } /** * Fetch repository info from Docker Hub */ async fetchDockerHubRepo(repoPath, isLibrary) { return new Promise((resolve, reject) => { const apiPath = isLibrary ? `/v2/repositories/library/${repoPath}` : `/v2/repositories/${repoPath}`; const options = { hostname: 'hub.docker.com', path: apiPath, method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': 'DashCaddy/1.0' } }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { if (res.statusCode === 200) { resolve(JSON.parse(data)); } else { resolve(null); } } catch (e) { resolve(null); } }); }); req.on('error', () => resolve(null)); req.setTimeout(10000, () => { req.destroy(); resolve(null); }); req.end(); }); } /** * Fetch available tags from Docker Hub */ async fetchDockerHubTags(repoPath, isLibrary) { return new Promise((resolve, reject) => { const apiPath = isLibrary ? `/v2/repositories/library/${repoPath}/tags?page_size=20&ordering=last_updated` : `/v2/repositories/${repoPath}/tags?page_size=20&ordering=last_updated`; const options = { hostname: 'hub.docker.com', path: apiPath, method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': 'DashCaddy/1.0' } }; const req = https.request(options, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { if (res.statusCode === 200) { const parsed = JSON.parse(data); resolve(parsed.results || []); } else { resolve([]); } } catch (e) { resolve([]); } }); }); req.on('error', () => resolve([])); req.setTimeout(10000, () => { req.destroy(); resolve([]); }); req.end(); }); } /** * Format changelog from repo info and tags */ formatChangelog(repoInfo, tags, currentTag) { const lines = []; if (repoInfo?.description) { lines.push(`**${repoInfo.description.split('\n')[0]}**`); lines.push(''); } // Find current and latest tags const latestTag = tags.find(t => t.name === 'latest'); const currentTagInfo = tags.find(t => t.name === currentTag); if (latestTag?.last_pushed || latestTag?.tag_last_pushed) { const lastUpdated = new Date(latestTag.last_pushed || latestTag.tag_last_pushed); lines.push(`Latest update: ${lastUpdated.toLocaleDateString()}`); } if (tags.length > 0) { lines.push(''); lines.push('Recent tags:'); tags.slice(0, 5).forEach(t => { const date = t.last_pushed || t.tag_last_pushed; const dateStr = date ? new Date(date).toLocaleDateString() : 'unknown'; lines.push(` - ${t.name} (${dateStr})`); }); } if (repoInfo?.pull_count) { lines.push(''); lines.push(`Total pulls: ${repoInfo.pull_count.toLocaleString()}`); } return lines.join('\n') || 'No changelog available'; } /** * Start the auto-update scheduler — runs hourly, applies updates in maintenance windows */ startAutoUpdateScheduler() { const AUTO_CHECK_INTERVAL = 60 * 60 * 1000; // 1 hour // Delay first run by 10 minutes to let containers start setTimeout(() => this.runAutoUpdates(), 10 * 60 * 1000); this.autoUpdateInterval = setInterval(() => this.runAutoUpdates(), AUTO_CHECK_INTERVAL); const count = Object.values(this.config.autoUpdate || {}).filter(c => c.enabled).length; if (count > 0) { log.info('update', 'Auto-update scheduler started', { containerCount: count }); } } /** * Execute auto-updates for all configured containers */ async runAutoUpdates() { const autoConfig = this.config.autoUpdate || {}; const now = new Date(); const hour = now.getHours(); const dayOfWeek = now.getDay(); // 0 = Sunday const dayOfMonth = now.getDate(); for (const [containerId, cfg] of Object.entries(autoConfig)) { if (!cfg.enabled) continue; // Check maintenance window (e.g., "02:00-05:00") if (cfg.maintenanceWindow) { const [startStr, endStr] = cfg.maintenanceWindow.split('-').map(s => s.trim()); const startHour = parseInt(startStr); const endHour = parseInt(endStr); if (startHour <= endHour) { if (hour < startHour || hour >= endHour) continue; } else { // Wraps midnight (e.g., "22:00-04:00") if (hour < startHour && hour >= endHour) continue; } } else { // Default: only run between 2AM and 4AM if (hour < 2 || hour >= 4) continue; } // Check schedule const shouldRun = cfg.schedule === 'daily' || (cfg.schedule === 'weekly' && dayOfWeek === 0) || // Sunday (cfg.schedule === 'monthly' && dayOfMonth === 1); if (!shouldRun) continue; // Check if already ran today const lastRun = cfg.lastAutoUpdate ? new Date(cfg.lastAutoUpdate) : null; if (lastRun && lastRun.toDateString() === now.toDateString()) continue; // Check if this container has an available update const update = this.availableUpdates.get(containerId); if (!update) continue; log.info('update', 'Auto-updating container', { containerName: update.containerName, schedule: cfg.schedule }); this.emit('auto-update-start', { containerId, containerName: update.containerName, schedule: cfg.schedule }); try { const result = await this.updateContainer(containerId, { autoRollback: cfg.autoRollback !== false }); cfg.lastAutoUpdate = now.toISOString(); this.saveConfig(); log.info('update', 'Auto-update completed', { containerName: update.containerName }); this.emit('auto-update-complete', { containerId, containerName: update.containerName, result }); } catch (error) { log.error('update', error, null, { containerName: update.containerName }); cfg.lastAutoUpdate = now.toISOString(); // Don't retry same day this.saveConfig(); this.emit('auto-update-failed', { containerId, containerName: update.containerName, error: error.message }); } } } /** * Get auto-update configuration for all containers */ getAutoUpdateConfig() { return this.config.autoUpdate || {}; } /** * Configure auto-update for a container */ configureAutoUpdate(containerId, config) { if (!this.config.autoUpdate) { this.config.autoUpdate = {}; } this.config.autoUpdate[containerId] = { enabled: config.enabled !== false, schedule: config.schedule || 'weekly', maintenanceWindow: config.maintenanceWindow, autoRollback: config.autoRollback !== false, securityOnly: config.securityOnly || false }; this.saveConfig(); } /** * Add entry to history */ addToHistory(entry) { this.history.push(entry); // Keep only last 100 entries if (this.history.length > 100) { this.history = this.history.slice(-100); } this.saveHistory(); } /** * Load configuration */ loadConfig() { try { if (fs.existsSync(UPDATE_CONFIG_FILE)) { return JSON.parse(fs.readFileSync(UPDATE_CONFIG_FILE, 'utf8')); } } catch (error) { log.error('update', error); } return { autoUpdate: {} }; } /** * Save configuration */ saveConfig() { try { fs.writeFileSync(UPDATE_CONFIG_FILE, JSON.stringify(this.config, null, 2)); } catch (error) { log.error('update', error); } } /** * Load history */ loadHistory() { try { if (fs.existsSync(UPDATE_HISTORY_FILE)) { return JSON.parse(fs.readFileSync(UPDATE_HISTORY_FILE, 'utf8')); } } catch (error) { log.error('update', error); } return []; } /** * Save history */ saveHistory() { try { fs.writeFileSync(UPDATE_HISTORY_FILE, JSON.stringify(this.history, null, 2)); } catch (error) { log.error('update', error); } } } // Export singleton instance module.exports = new UpdateManager();