Replaced all 49 console.log/warn/error calls in src/managers/update-manager.js with log.info/log.warn/log.error from src/utils/logging. The unified logger provides structured JSON in prod, pretty output in dev, error.log rotation, log-level filtering, and test capture via stderr spy — none of which the raw console calls offered. Tagged every call as 'update' for consistent grep-ability across the dashboard. Mixed-content strings (containerName, schedule, imageName, error.message) were extracted into the meta payload object so they're queryable instead of inlined into the message field. 1539/1539 Jest tests pass. ESLint clean for the file (14 pre-existing warnings unchanged, zero new). Codex grade A.
1104 lines
33 KiB
JavaScript
1104 lines
33 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
|
|
|
|
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
|
|
*/
|
|
async getLatestImageDigest(imageName) {
|
|
try {
|
|
// Parse image name — strip any leading registry host first
|
|
let imageTag = 'latest';
|
|
let 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)
|
|
if (!remainder.includes('/') || 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) {
|
|
log.error('update', error, null, { imageName });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async getGhcrDigest(repository, tag) {
|
|
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
|
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
|
return new Promise((resolve, reject) => {
|
|
const options = {
|
|
hostname: 'ghcr.io',
|
|
path: `/v2/${imageRepo}/manifests/${tag}`,
|
|
method: 'GET',
|
|
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'
|
|
}
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
if (res.statusCode === 401) {
|
|
const authHeader = res.headers['www-authenticate'];
|
|
const authUrl = this.parseAuthHeader(authHeader);
|
|
if (authUrl) {
|
|
// ghcr.io auth endpoint accepts scope=repository:owner/name:pull
|
|
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
|
} else {
|
|
reject(new Error('Authentication required but no auth URL found'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (res.statusCode !== 200) {
|
|
// Drain body to avoid socket leak
|
|
res.resume();
|
|
reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
|
|
const digest = res.headers['docker-content-digest'];
|
|
resolve(digest || null);
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get image digest from Docker Hub
|
|
*/
|
|
async getDockerHubDigest(repository, tag) {
|
|
return new Promise((resolve, reject) => {
|
|
// Normalize repository name
|
|
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
|
|
|
const options = {
|
|
hostname: 'registry-1.docker.io',
|
|
path: `/v2/${repo}/manifests/${tag}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
|
}
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
if (res.statusCode === 401) {
|
|
// Need to authenticate
|
|
const authHeader = res.headers['www-authenticate'];
|
|
const authUrl = this.parseAuthHeader(authHeader);
|
|
|
|
if (authUrl) {
|
|
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
|
} else {
|
|
reject(new Error('Authentication required but no auth URL found'));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const digest = res.headers['docker-content-digest'];
|
|
resolve(digest || null);
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/**
|
|
* Authenticate and get digest
|
|
*/
|
|
async authenticateAndGetDigest(authUrl, originalOptions) {
|
|
return new Promise((resolve, reject) => {
|
|
https.get(authUrl, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
try {
|
|
const auth = JSON.parse(data);
|
|
const token = auth.token || auth.access_token;
|
|
|
|
if (!token) {
|
|
reject(new Error('No token in auth response'));
|
|
return;
|
|
}
|
|
|
|
// Retry original request with token
|
|
const options = {
|
|
...originalOptions,
|
|
headers: {
|
|
...originalOptions.headers,
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
};
|
|
|
|
const req = https.request(options, (res) => {
|
|
const digest = res.headers['docker-content-digest'];
|
|
resolve(digest || null);
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.end();
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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();
|