/** * Lightweight scheduled Docker maintenance jobs on the peardock server. * Not a full webhook platform — interval tasks for prune / health checks. */ import { docker } from './docker.js' import logger from '../utils/logger.js' import fs from 'fs' import path from 'path' import { randomBytes } from 'crypto' const FILE = process.env.PEARDOCK_SCHEDULES_PATH || path.join(process.cwd(), 'peardock-schedules.json') /** @type {Map} */ const jobs = new Map() function loadDisk() { try { if (!fs.existsSync(FILE)) return [] const raw = JSON.parse(fs.readFileSync(FILE, 'utf8')) return Array.isArray(raw?.jobs) ? raw.jobs : Array.isArray(raw) ? raw : [] } catch (err) { logger.warn('Failed to load schedules', { error: err.message }) return [] } } function saveDisk() { try { const payload = { version: 1, updatedAt: new Date().toISOString(), jobs: [...jobs.values()].map((j) => ({ id: j.id, kind: j.kind, intervalMs: j.intervalMs, enabled: j.enabled, lastRun: j.lastRun || null, lastError: j.lastError || null, options: j.options || {}, })), } const tmp = `${FILE}.${process.pid}.tmp` fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 }) fs.renameSync(tmp, FILE) } catch (err) { logger.warn('Failed to save schedules', { error: err.message }) } } async function runJob(job) { if (!job.enabled) return try { if (job.kind === 'systemPrune') { const volumes = Boolean(job.options?.volumes) if (typeof docker.pruneSystem === 'function') { await docker.pruneSystem({ volumes }) } else { await docker.pruneContainers({}) await docker.pruneImages({}) await docker.pruneNetworks({}) if (volumes) await docker.pruneVolumes({}) } } else if (job.kind === 'pruneImages') { await docker.pruneImages({}) } else if (job.kind === 'pruneContainers') { await docker.pruneContainers({}) } else if (job.kind === 'dockerPing') { await docker.ping() } else { throw new Error(`Unknown schedule kind: ${job.kind}`) } job.lastRun = new Date().toISOString() job.lastError = null logger.info('Scheduled job ok', { id: job.id, kind: job.kind }) } catch (err) { job.lastError = err.message || String(err) job.lastRun = new Date().toISOString() logger.warn('Scheduled job failed', { id: job.id, kind: job.kind, error: job.lastError }) } saveDisk() } function arm(job) { if (job.timer) { clearInterval(job.timer) job.timer = null } if (!job.enabled || job.intervalMs < 60_000) return job.timer = setInterval(() => { runJob(job) }, job.intervalMs) if (typeof job.timer.unref === 'function') job.timer.unref() } export function listSchedules() { return [...jobs.values()].map((j) => ({ id: j.id, kind: j.kind, intervalMs: j.intervalMs, enabled: j.enabled, lastRun: j.lastRun || null, lastError: j.lastError || null, options: j.options || {}, })) } /** * @param {{ id?: string, kind: string, intervalMs: number, enabled?: boolean, options?: object }} input */ export function upsertSchedule(input) { const kind = String(input.kind || '').trim() const allowed = new Set(['systemPrune', 'pruneImages', 'pruneContainers', 'dockerPing']) if (!allowed.has(kind)) throw Object.assign(new Error('Invalid schedule kind'), { code: 'INVALID_ARGS' }) const intervalMs = Math.max(60_000, Number(input.intervalMs) || 3_600_000) const id = input.id && String(input.id).startsWith('sch_') ? String(input.id) : `sch_${Date.now().toString(36)}_${randomBytes(3).toString('hex')}` const prev = jobs.get(id) if (prev?.timer) clearInterval(prev.timer) const job = { id, kind, intervalMs, enabled: input.enabled !== false, lastRun: prev?.lastRun, lastError: prev?.lastError, options: input.options && typeof input.options === 'object' ? input.options : {}, } jobs.set(id, job) arm(job) saveDisk() return listSchedules().find((j) => j.id === id) } export function deleteSchedule(id) { const job = jobs.get(id) if (!job) return false if (job.timer) clearInterval(job.timer) jobs.delete(id) saveDisk() return true } export function restoreSchedules() { for (const def of loadDisk()) { if (!def?.kind || !def?.id) continue const job = { id: def.id, kind: def.kind, intervalMs: Math.max(60_000, Number(def.intervalMs) || 3_600_000), enabled: def.enabled !== false, lastRun: def.lastRun, lastError: def.lastError, options: def.options || {}, } jobs.set(job.id, job) arm(job) } logger.info('Schedules restored', { count: jobs.size }) } export default { listSchedules, upsertSchedule, deleteSchedule, restoreSchedules, }