mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
ca346916c1
* fix(auto-update): paid-gate execute route and harden image-check watchdog Auto-update execution is a paid capability, but POST /api/auto-update/execute was reachable by any admin regardless of license. Add the paid guard so it matches the rest of the surface (scheduled-task management and fleet refresh). The scheduler dispatch to remote nodes still works because the controlling instance forwards its tier with the request. Restrict GET /api/image-updates/fleet to admins. The single-node status endpoint that drives the sidebar update dot stays open to all roles. Replace the image-check watchdog timer that released the run lock after five minutes. On a healthy but slow scan it let a manual refresh start a second concurrent check, duplicating notifications and racing the status writes. The scan now owns its lock for its full duration, and every Docker socket and filesystem read is bounded so a wedged daemon or mount cannot stall a scan forever. * test(auto-update): assert the debug skip-log branch in the image-check guard The concurrency-guard test covered the warn branch for a trigger arriving past the long-run threshold but never exercised the developer-mode debug skip log. Add a case that enables developer mode and asserts the debug line fires for a mid-scan trigger under the threshold.
436 lines
19 KiB
TypeScript
436 lines
19 KiB
TypeScript
import path from 'path';
|
|
import YAML from 'yaml';
|
|
import DockerController from './DockerController';
|
|
import { DatabaseService } from './DatabaseService';
|
|
import { FileSystemService } from './FileSystemService';
|
|
import { RegistryService } from './RegistryService';
|
|
import { NodeRegistry } from './NodeRegistry';
|
|
import { NotificationService } from './NotificationService';
|
|
import { sanitizeNotificationMessage } from '../utils/notificationMessage';
|
|
import { parseImageRef, getRemoteDigest } from './registry-api';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { getErrorMessage } from '../utils/errors';
|
|
import { sanitizeForLog } from '../utils/safeLog';
|
|
|
|
const BACKFILL_KEY = 'image_update_notifications_backfilled';
|
|
|
|
export interface ImageCheckResult {
|
|
hasUpdate: boolean;
|
|
error?: string;
|
|
}
|
|
|
|
// ─── Compose file helpers ────────────────────────────────────────────────────
|
|
|
|
export function loadDotEnv(content: string): Record<string, string> {
|
|
const vars: Record<string, string> = {};
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const eqIdx = trimmed.indexOf('=');
|
|
if (eqIdx < 1) continue;
|
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
let val = trimmed.slice(eqIdx + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
(val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
vars[key] = val;
|
|
}
|
|
return vars;
|
|
}
|
|
|
|
export interface ComposeServiceImage {
|
|
service: string;
|
|
image: string;
|
|
}
|
|
|
|
export function extractServiceImagesFromCompose(
|
|
yamlContent: string,
|
|
envVars: Record<string, string>,
|
|
): ComposeServiceImage[] {
|
|
let parsed: Record<string, unknown>;
|
|
try {
|
|
parsed = YAML.parse(yamlContent) as Record<string, unknown>;
|
|
} catch {
|
|
return [];
|
|
}
|
|
if (!parsed?.services || typeof parsed.services !== 'object') return [];
|
|
|
|
const out: ComposeServiceImage[] = [];
|
|
for (const [service, svc] of Object.entries(parsed.services as Record<string, unknown>)) {
|
|
if (!svc || typeof svc !== 'object') continue;
|
|
const raw = (svc as Record<string, unknown>).image;
|
|
if (!raw || typeof raw !== 'string') continue;
|
|
|
|
let ref = raw.replace(
|
|
/\$\{([^}]+)\}/g,
|
|
(_: string, expr: string) => {
|
|
const defaultMatch = expr.match(/^([^:-]+)(?::?-)(.+)$/);
|
|
if (defaultMatch) {
|
|
return envVars[defaultMatch[1]] ?? defaultMatch[2];
|
|
}
|
|
return envVars[expr] ?? '';
|
|
}
|
|
);
|
|
|
|
ref = ref.trim();
|
|
if (!ref || ref.includes('${') || ref.startsWith('sha256:')) continue;
|
|
out.push({ service, image: ref });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function extractImagesFromCompose(
|
|
yamlContent: string,
|
|
envVars: Record<string, string>,
|
|
): string[] {
|
|
return extractServiceImagesFromCompose(yamlContent, envVars).map(e => e.image);
|
|
}
|
|
|
|
// ─── Service ──────────────────────────────────────────────────────────────────
|
|
|
|
export class ImageUpdateService {
|
|
private static instance: ImageUpdateService;
|
|
private intervalId: NodeJS.Timeout | null = null;
|
|
private startupTimeoutId: NodeJS.Timeout | null = null;
|
|
private isRunning = false;
|
|
private checkStartedAt = 0;
|
|
private lastManualRefreshAt = 0;
|
|
|
|
private static readonly INTERVAL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
|
private static readonly STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 min after boot
|
|
private static readonly MANUAL_COOLDOWN_MS = 2 * 60 * 1000; // 2 min between manual triggers
|
|
private static readonly INTER_IMAGE_DELAY_MS = 300; // be polite to registries
|
|
private static readonly CHECK_TIMEOUT_MS = 5 * 60 * 1000; // threshold for the "running long" skip warning
|
|
private static readonly SOCKET_TIMEOUT_MS = 30 * 1000; // per-call cap on Docker socket / filesystem reads
|
|
|
|
public static get manualCooldownMinutes(): number {
|
|
return ImageUpdateService.MANUAL_COOLDOWN_MS / (60 * 1000);
|
|
}
|
|
|
|
private constructor() { }
|
|
|
|
public static getInstance(): ImageUpdateService {
|
|
if (!ImageUpdateService.instance) {
|
|
ImageUpdateService.instance = new ImageUpdateService();
|
|
}
|
|
return ImageUpdateService.instance;
|
|
}
|
|
|
|
public start() {
|
|
if (this.intervalId) return;
|
|
this.startupTimeoutId = setTimeout(() => this.check(), ImageUpdateService.STARTUP_DELAY_MS);
|
|
this.intervalId = setInterval(() => this.check(), ImageUpdateService.INTERVAL_MS);
|
|
}
|
|
|
|
public stop() {
|
|
if (this.startupTimeoutId) {
|
|
clearTimeout(this.startupTimeoutId);
|
|
this.startupTimeoutId = null;
|
|
}
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggers a check immediately, unless one is already running or the
|
|
* manual cooldown (MANUAL_COOLDOWN_MS) has not elapsed.
|
|
* Returns false if rate-limited, true if a check was started.
|
|
*/
|
|
public triggerManualRefresh(): boolean {
|
|
const now = Date.now();
|
|
if (now - this.lastManualRefreshAt < ImageUpdateService.MANUAL_COOLDOWN_MS) {
|
|
return false;
|
|
}
|
|
this.lastManualRefreshAt = now;
|
|
this.check().catch(e => console.error('[ImageUpdateService] Manual refresh error:', e));
|
|
return true;
|
|
}
|
|
|
|
public isChecking(): boolean {
|
|
return this.isRunning;
|
|
}
|
|
|
|
// ─── Core check ──────────────────────────────────────────────────────────
|
|
|
|
private async check() {
|
|
// The finally block is the sole owner of isRunning, so a scan that
|
|
// overruns can never have its lock released out from under it. A
|
|
// previous fixed timer cleared the lock after CHECK_TIMEOUT_MS, which
|
|
// let a manual refresh start a second concurrent check on a healthy but
|
|
// slow scan, duplicating notifications and racing the status writes.
|
|
// Registry calls are bounded (10s) and the Docker/filesystem reads are
|
|
// wrapped in withTimeout, so the scan body always settles and the
|
|
// finally releases the lock; the only thing the guard below protects
|
|
// against is a concurrent trigger arriving mid-scan.
|
|
if (this.isRunning) {
|
|
const elapsedMs = Date.now() - this.checkStartedAt;
|
|
if (elapsedMs >= ImageUpdateService.CHECK_TIMEOUT_MS) {
|
|
console.warn(`[ImageUpdateService] A check has been running for ${Math.round(elapsedMs / 60_000)} minute(s); skipping this trigger. The Docker socket may be unresponsive.`);
|
|
} else if (isDebugEnabled()) {
|
|
console.log('[ImageUpdateService:debug] Check already in progress; skipping this trigger.');
|
|
}
|
|
return;
|
|
}
|
|
this.isRunning = true;
|
|
this.checkStartedAt = Date.now();
|
|
console.log('[ImageUpdateService] Starting image update check...');
|
|
|
|
try {
|
|
const db = DatabaseService.getInstance();
|
|
// Only check local nodes - remote nodes run their own instance
|
|
for (const node of db.getNodes()) {
|
|
if (node.type !== 'local' || !node.id) continue;
|
|
try {
|
|
await this.checkNode(node.id, node.name, db);
|
|
} catch (e) {
|
|
console.error(`[ImageUpdateService] Error on node ${node.name}:`, e);
|
|
}
|
|
}
|
|
console.log('[ImageUpdateService] Image update check complete.');
|
|
} catch (e) {
|
|
console.error('[ImageUpdateService] Check failed:', e);
|
|
} finally {
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
|
|
private async checkNode(nodeId: number, nodeName: string, db: DatabaseService) {
|
|
const docker = DockerController.getInstance(nodeId);
|
|
const fs = FileSystemService.getInstance(nodeId);
|
|
const composeDir = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId));
|
|
|
|
// Phase 1: Filesystem discovery (all stacks with compose files)
|
|
const stacks = await withTimeout(fs.getStacks(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getStacks');
|
|
const stackImages = new Map<string, Set<string>>();
|
|
for (const name of stacks) stackImages.set(name, new Set());
|
|
|
|
if (isDebugEnabled()) {
|
|
console.log(`[ImageUpdateService:debug] Node ${nodeId}: Phase 1 complete - ${stacks.length} stack(s) found`);
|
|
}
|
|
|
|
// Phase 2: Parse compose files for image refs
|
|
for (const stackName of stacks) {
|
|
try {
|
|
const content = await withTimeout(fs.getStackContent(stackName), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getStackContent');
|
|
|
|
// Load .env for variable resolution (best-effort)
|
|
let envVars: Record<string, string> = {};
|
|
try {
|
|
const hasEnv = await withTimeout(fs.envExists(stackName), ImageUpdateService.SOCKET_TIMEOUT_MS, 'envExists');
|
|
if (hasEnv) {
|
|
const envContent = await withTimeout(fs.getEnvContent(stackName), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getEnvContent');
|
|
envVars = loadDotEnv(envContent);
|
|
}
|
|
} catch {
|
|
// .env file exists but unreadable; continue with process.env only
|
|
}
|
|
// Docker Compose precedence: host env overrides .env
|
|
const merged: Record<string, string> = { ...envVars };
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) merged[k] = v;
|
|
}
|
|
|
|
for (const img of extractImagesFromCompose(content, merged)) {
|
|
stackImages.get(stackName)?.add(img);
|
|
}
|
|
} catch (e) {
|
|
console.warn(`[ImageUpdateService] Could not parse compose for "${stackName}":`, e);
|
|
}
|
|
}
|
|
|
|
if (isDebugEnabled()) {
|
|
const composeImageCount = [...stackImages.values()].reduce((sum, s) => sum + s.size, 0);
|
|
console.log(`[ImageUpdateService:debug] Node ${nodeId}: Phase 2 complete - ${composeImageCount} image(s) extracted from compose files`);
|
|
}
|
|
|
|
// Phase 3: Container augmentation (captures actual deployed image tags)
|
|
try {
|
|
const containers = await withTimeout(docker.getAllContainers(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'getAllContainers');
|
|
for (const c of containers) {
|
|
const workingDir: string | undefined = c.Labels?.['com.docker.compose.project.working_dir'];
|
|
if (!workingDir) continue;
|
|
|
|
const resolved = path.resolve(workingDir);
|
|
if (resolved !== composeDir && !resolved.startsWith(composeDir + path.sep)) continue;
|
|
|
|
const stackName = path.basename(resolved);
|
|
const imageRef: string = c.Image ?? '';
|
|
if (!imageRef || imageRef.startsWith('sha256:')) continue;
|
|
|
|
// Only augment stacks found on the filesystem
|
|
if (stackImages.has(stackName)) {
|
|
stackImages.get(stackName)?.add(imageRef);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('[ImageUpdateService] Container augmentation failed:', e);
|
|
}
|
|
|
|
if (isDebugEnabled()) {
|
|
const totalBeforeDedup = [...stackImages.values()].reduce((sum, s) => sum + s.size, 0);
|
|
console.log(`[ImageUpdateService:debug] Node ${nodeId}: Phase 3 complete - ${totalBeforeDedup} image(s) across all stacks (pre-dedup)`);
|
|
}
|
|
|
|
// Phase 4: Deduplicate and check all unique images
|
|
const allImages = new Set<string>();
|
|
for (const imgs of stackImages.values()) for (const img of imgs) allImages.add(img);
|
|
|
|
const imageUpdateMap = new Map<string, ImageCheckResult>();
|
|
|
|
for (const imageRef of allImages) {
|
|
try {
|
|
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
|
|
} catch (e) {
|
|
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
|
imageUpdateMap.set(imageRef, { hasUpdate: false, error: String(e) });
|
|
}
|
|
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
|
}
|
|
|
|
// Read previous state to detect new updates for notifications
|
|
const previousState = db.getStackUpdateStatus(nodeId);
|
|
|
|
// One-time backfill: pre-existing has_update rows predate the notification pipeline;
|
|
// treat them as unnotified on first run so users get a catch-up entry per affected stack.
|
|
const isBackfilled = db.getSystemState(BACKFILL_KEY) === '1';
|
|
|
|
// Write status for ALL stacks (including those with no pullable images)
|
|
const now = Date.now();
|
|
let updatesFound = 0;
|
|
const newlyUpdated: string[] = [];
|
|
for (const [stackName, images] of stackImages) {
|
|
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img)?.hasUpdate === true);
|
|
if (hasUpdate) {
|
|
updatesFound++;
|
|
// Notify only on state transition: was false/absent, now true
|
|
if (!isBackfilled || !previousState[stackName]) {
|
|
newlyUpdated.push(stackName);
|
|
}
|
|
}
|
|
db.upsertStackUpdateStatus(nodeId, stackName, hasUpdate, now);
|
|
}
|
|
|
|
// Dispatch notifications for stacks that newly have updates
|
|
if (newlyUpdated.length > 0) {
|
|
const notifier = NotificationService.getInstance();
|
|
for (const stackName of newlyUpdated) {
|
|
try {
|
|
await notifier.dispatchAlert(
|
|
'info',
|
|
'image_update_available',
|
|
`[Node: ${nodeName}] Stack "${stackName}" has image updates available.`,
|
|
{ stackName, actor: 'system:image-update' },
|
|
);
|
|
} catch (e) {
|
|
console.error(`[ImageUpdateService] Failed to dispatch update notification for "${stackName}":`, e);
|
|
// Direct DB write to avoid recursing through dispatchAlert if it is what failed.
|
|
// Key on the local default: the iterated `nodeId` may be a remote's id in the
|
|
// control plane's DB, and the UI never queries that row (it proxies instead).
|
|
try {
|
|
const localNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
|
db.addNotificationHistory(localNodeId, {
|
|
level: 'error',
|
|
category: 'system',
|
|
message: sanitizeNotificationMessage(
|
|
`[Node: ${nodeName}] Failed to notify about image updates for stack "${stackName}": ${getErrorMessage(e, String(e))}`,
|
|
{ composeDir: NodeRegistry.getInstance().getComposeDir(localNodeId) },
|
|
),
|
|
timestamp: Date.now(),
|
|
actor_username: 'system:image-update',
|
|
});
|
|
} catch (dbErr) {
|
|
console.error('[ImageUpdateService] Failed to record dispatch error:', dbErr);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark the backfill flag after the first run so future checks use strict transitions.
|
|
if (!isBackfilled) {
|
|
db.setSystemState(BACKFILL_KEY, '1');
|
|
}
|
|
|
|
console.log(`[ImageUpdateService] Node ${nodeId}: checked ${allImages.size} image(s), ${updatesFound} stack(s) with updates`);
|
|
|
|
// Prune stale entries for stacks no longer on disk (reuse previousState to avoid extra DB read)
|
|
for (const staleStack of Object.keys(previousState)) {
|
|
if (!stackImages.has(staleStack)) {
|
|
db.clearStackUpdateStatus(nodeId, staleStack);
|
|
}
|
|
}
|
|
}
|
|
|
|
public async checkImage(docker: DockerController, imageRef: string): Promise<ImageCheckResult> {
|
|
const parsed = parseImageRef(imageRef);
|
|
if (!parsed) return { hasUpdate: false };
|
|
|
|
if (isDebugEnabled()) {
|
|
console.log(`[ImageUpdateService] Checking ${imageRef}: registry=${parsed.registry} repo=${parsed.repo} tag=${parsed.tag}`);
|
|
}
|
|
|
|
// Look up stored credentials for this registry
|
|
const credentials = await RegistryService.getInstance().getAuthForRegistry(parsed.registry);
|
|
if (isDebugEnabled()) {
|
|
console.log(`[ImageUpdateService] ${imageRef}: credentials ${credentials ? 'found' : 'none'}`);
|
|
}
|
|
|
|
// Get local digest from RepoDigests
|
|
let localDigest: string | null = null;
|
|
try {
|
|
const inspect = await withTimeout(docker.getDocker().getImage(imageRef).inspect(), ImageUpdateService.SOCKET_TIMEOUT_MS, 'inspect');
|
|
const repoDigests: string[] = inspect.RepoDigests ?? [];
|
|
|
|
for (const rd of repoDigests) {
|
|
if (!rd.includes('@sha256:')) continue;
|
|
const [, digest] = rd.split('@');
|
|
|
|
if (rd.includes(parsed.repo) || rd.includes(parsed.registry) || repoDigests.length === 1) {
|
|
localDigest = digest;
|
|
break;
|
|
}
|
|
}
|
|
} catch {
|
|
return { hasUpdate: false, error: `Failed to inspect local image "${imageRef}"` };
|
|
}
|
|
|
|
if (!localDigest) return { hasUpdate: false };
|
|
|
|
const remoteDigest = await getRemoteDigest(parsed.registry, parsed.repo, parsed.tag, credentials);
|
|
if (!remoteDigest) {
|
|
return { hasUpdate: false, error: `Registry unreachable for ${parsed.registry}/${parsed.repo}:${parsed.tag}` };
|
|
}
|
|
|
|
const hasUpdate = localDigest !== remoteDigest;
|
|
console.log(
|
|
`[ImageUpdateService] ${imageRef}: ` +
|
|
`local=${localDigest.slice(0, 27)}... remote=${remoteDigest.slice(0, 27)}... update=${hasUpdate}`
|
|
);
|
|
return { hasUpdate };
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Reject after `ms` if `p` has not settled. Docker socket and filesystem reads
|
|
* have no built-in timeout, so without this a wedged daemon would hang a scan
|
|
* forever and hold the run lock until the process restarts. The rejecting await
|
|
* lets the scan body unwind so the `finally` releases the lock and the next
|
|
* interval can retry. Handlers are attached to `p` so a late settle does not
|
|
* surface as an unhandled rejection.
|
|
*/
|
|
function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
p.then(
|
|
(value) => { clearTimeout(timer); resolve(value); },
|
|
(err) => { clearTimeout(timer); reject(err); },
|
|
);
|
|
});
|
|
}
|