Files
dashcaddy/dashcaddy-api/update-manager.js
Sami bdf3f247b1 feat: add 7 new features — exec shell, SSE events, compose import, docker resources, resource limits, email notifications, auto-updates
- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards)
- Live dashboard updates via SSE (resource alerts, health changes, update notices)
- Docker Compose import with YAML parsing, preview, and dependency-ordered deploy
- Volume & network management modal with disk usage overview
- CPU/memory resource limits on deploy and live update
- Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy
- Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly)

New deps: ws, js-yaml, nodemailer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 16:15:14 -07:00

1005 lines
30 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 docker = new Docker();
const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(__dirname, 'update-config.json');
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(__dirname, '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;
console.log('[UpdateManager] 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;
console.log('[UpdateManager] Stopping update checks');
this.checking = false;
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
if (this.autoUpdateInterval) {
clearInterval(this.autoUpdateInterval);
this.autoUpdateInterval = null;
}
}
/**
* 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) {
console.error(`[UpdateManager] Error checking ${containerInfo.Names[0]}:`, error.message);
}
}
console.log(`[UpdateManager] Found ${this.availableUpdates.size} updates available`);
} catch (error) {
console.error('[UpdateManager] Error checking for updates:', error.message);
}
}
/**
* Get latest image digest from registry
*/
async getLatestImageDigest(imageName) {
try {
// Parse image name
const [repository, tag] = imageName.split(':');
const imageTag = tag || 'latest';
// For Docker Hub images
if (!repository.includes('/') || repository.split('/').length === 2) {
return await this.getDockerHubDigest(repository, imageTag);
}
// For other registries (would need authentication)
console.warn(`[UpdateManager] Custom registry not yet supported: ${repository}`);
return null;
} catch (error) {
console.error(`[UpdateManager] Error getting digest for ${imageName}:`, error.message);
return null;
}
}
/**
* 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();
console.log(`[UpdateManager] 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;
console.log(`[UpdateManager] Stored old image digest: ${oldImageDigest.substring(0, 40)}...`);
} catch (error) {
console.warn(`[UpdateManager] Could not get old image digest: ${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()
};
// Pull latest image
console.log(`[UpdateManager] Pulling latest image: ${imageName}`);
await this.pullImage(imageName);
// Stop container
console.log(`[UpdateManager] Stopping container: ${containerName}`);
await container.stop();
// Remove old container
console.log(`[UpdateManager] Removing old container: ${containerName}`);
await container.remove();
// Create new container with same configuration
console.log(`[UpdateManager] Creating new container: ${containerName}`);
const newContainer = await docker.createContainer({
name: containerName,
Image: imageName,
...backup.config,
HostConfig: backup.hostConfig
});
// Start new container
console.log(`[UpdateManager] Starting new container: ${containerName}`);
await newContainer.start();
// Extended verification with health checks and port accessibility
console.log(`[UpdateManager] 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 {
console.log(`[UpdateManager] Removing old image: ${oldImageId.substring(0, 12)}`);
const oldImage = docker.getImage(oldImageId);
await oldImage.remove({ force: false });
console.log(`[UpdateManager] Old image removed successfully`);
} catch (error) {
console.warn(`[UpdateManager] Could not remove old image (may be in use): ${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);
console.log(`[UpdateManager] Update completed in ${duration}ms`);
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) {
console.log(`[UpdateManager] Attempting rollback for ${containerId}`);
try {
await this.rollbackUpdate(containerId);
} catch (rollbackError) {
console.error(`[UpdateManager] Rollback failed:`, rollbackError.message);
}
}
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;
console.log(`[UpdateManager] Extended verification with ${maxAttempts} attempts over ${timeout/1000}s`);
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') {
console.log(`[UpdateManager] 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
console.log(`[UpdateManager] Health check 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) {
console.log(`[UpdateManager] Port ${primaryPort.hostPort} is accessible (HTTP ${response.status})`);
// Wait a bit more to ensure stability
if (attempt >= 2) {
console.log(`[UpdateManager] Container verified successfully`);
return true;
}
}
} catch (fetchError) {
lastError = `Port ${primaryPort.hostPort} not accessible: ${fetchError.message}`;
console.log(`[UpdateManager] ${lastError} (attempt ${attempt + 1}/${maxAttempts})`);
}
} else {
// No ports exposed - just verify it's running for a few cycles
if (attempt >= 5) {
console.log(`[UpdateManager] 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;
console.log(`[UpdateManager] Verification attempt ${attempt + 1} failed: ${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) {
console.log(`[UpdateManager] 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();
console.log(`[UpdateManager] Rollback completed for ${backup.containerName}`);
this.emit('rollback-complete', { containerId, containerName: backup.containerName });
return true;
} catch (error) {
console.error(`[UpdateManager] Rollback failed:`, error.message);
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 => {
console.error(`[UpdateManager] Scheduled update failed:`, error.message);
});
}, delay);
console.log(`[UpdateManager] Update scheduled for ${containerId} at ${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) {
console.error(`[UpdateManager] Error fetching changelog for ${imageName}:`, error.message);
// 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) {
console.log(`[UpdateManager] Auto-update scheduler started (${count} container(s) configured)`);
}
}
/**
* 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;
console.log(`[UpdateManager] Auto-updating ${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();
console.log(`[UpdateManager] Auto-update completed for ${update.containerName}`);
this.emit('auto-update-complete', { containerId, containerName: update.containerName, result });
} catch (error) {
console.error(`[UpdateManager] Auto-update failed for ${update.containerName}:`, error.message);
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) {
console.error('[UpdateManager] Error loading config:', error.message);
}
return { autoUpdate: {} };
}
/**
* Save configuration
*/
saveConfig() {
try {
fs.writeFileSync(UPDATE_CONFIG_FILE, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error('[UpdateManager] Error saving config:', error.message);
}
}
/**
* Load history
*/
loadHistory() {
try {
if (fs.existsSync(UPDATE_HISTORY_FILE)) {
return JSON.parse(fs.readFileSync(UPDATE_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[UpdateManager] Error loading history:', error.message);
}
return [];
}
/**
* Save history
*/
saveHistory() {
try {
fs.writeFileSync(UPDATE_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[UpdateManager] Error saving history:', error.message);
}
}
}
// Export singleton instance
module.exports = new UpdateManager();