Files
dashcaddy/dashcaddy-api/src/managers/update-manager.js
T
Hermes 089f5d2902
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=A] fix(update-manager): compose-prefixed image names probe <project>/<service> not library/<project>-<service> (DC-082)
Pre-fix: dashcaddy-dashcaddy-api:latest was normalized to library/dashcaddy-dashcaddy-api
before probing Docker Hub. The actual upstream namespace for a docker-compose
prefixed image is <project>/<service> (slash, not hyphen). Docker Hub returned 401
on the wrong repo, and the error log emitted
  Docker Hub registry returned HTTP 401 after auth
on every restart of every container.

Fix:
1. _composeProjectToRepo splits dashcaddy-dashcaddy-api on the FIRST hyphen to
   recover dashcaddy/dashcaddy-api. Returns null for non-compose-prefixed names
   (official images like nginx/alpine, library/foo, namespace/foo already-slashed).
2. _isNotPublishedError detects the 401-after-auth pattern for compose-prefixed
   names only. Steady-state for locally-built images that aren't published.
3. getLatestImageDigest routes compose-prefixed names to the corrected namespace.
   Routes already-namespaced names directly. Falls back to library/ for the
   Official Image path.
4. Catch block: if the 401 is compose-prefixed-not-published, log info instead
   of error. Real auth failures on legitimate images still log as error.

17/17 tests pass in 1.27s. Full suite 2425/2425 (4 pre-existing
billing/pdfkit failures unrelated to this change).

GLM stand-in verdict URN: urn:ump:7rhk7keukv3zrx654gbckxaycnuwm4agduf37creqbsoauszopoa
2026-08-18 19:45:08 -07:00

1306 lines
44 KiB
JavaScript

/**
* 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 `<project>/<service>` (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
// `<project>/<service>` 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 `<project>/<service>` 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();