forked from snxraven/peardock
Implement volume browse, stack env file, service scale, create secret/config, disconnect network, registry Hub search, server schedules, resource editor, and fleet environment tags. Mark roadmap Tracks A–F complete.
171 lines
4.9 KiB
JavaScript
171 lines
4.9 KiB
JavaScript
/**
|
|
* 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<string, { id: string, kind: string, intervalMs: number, enabled: boolean, lastRun?: string, lastError?: string, options?: object, timer?: NodeJS.Timeout }>} */
|
|
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,
|
|
}
|