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.
This commit is contained in:
@@ -22,6 +22,8 @@ const AUDIT_METHODS = new Set([
|
||||
'pruneBuilder',
|
||||
'systemPrune',
|
||||
'recreateContainer',
|
||||
'upsertSchedule',
|
||||
'deleteSchedule',
|
||||
'removeImage',
|
||||
'removeStack',
|
||||
'deployStack',
|
||||
|
||||
@@ -10,6 +10,11 @@ import { getMetricsSnapshot } from '../services/metrics.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import { isSwarmEnabled } from './swarm.js'
|
||||
import { isPluginsEnabled } from './plugins.js'
|
||||
import {
|
||||
listSchedules,
|
||||
upsertSchedule,
|
||||
deleteSchedule,
|
||||
} from '../services/schedules.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
/** @type {Map<string, { username: string, serveraddress?: string }>} */
|
||||
@@ -107,6 +112,23 @@ export function registerSystemHandlers(session) {
|
||||
return getMetricsSnapshot({ peerCount: peers.size })
|
||||
})
|
||||
|
||||
session.respond('listSchedules', async () => {
|
||||
return { success: true, schedules: listSchedules() }
|
||||
})
|
||||
|
||||
session.respond('upsertSchedule', async (args = {}) => {
|
||||
const job = upsertSchedule(args)
|
||||
return { success: true, schedule: job }
|
||||
})
|
||||
|
||||
session.respond('deleteSchedule', async (args = {}) => {
|
||||
const id = validation.sanitizeString(args.id || '', 64)
|
||||
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
|
||||
const ok = deleteSchedule(id)
|
||||
if (!ok) throw Object.assign(new Error('Schedule not found'), { code: 'INVALID_ARGS' })
|
||||
return { success: true, id, deleted: true }
|
||||
})
|
||||
|
||||
session.respond('getDockerEvents', async (args) => {
|
||||
// Snapshot: recent events via stream with timeout (best-effort)
|
||||
const since = args?.since || Math.floor(Date.now() / 1000) - 300
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Volume RPC handlers.
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { docker, extractVolumesList } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
@@ -57,6 +59,81 @@ export function registerVolumeHandlers(session) {
|
||||
return { type: 'volumeConfig', data: volumeData }
|
||||
})
|
||||
|
||||
/**
|
||||
* List files in a local volume mountpoint (server-side Docker host).
|
||||
* Requires the volume mountpoint to be readable; path is confined under Mountpoint.
|
||||
*/
|
||||
session.respond('browseVolume', async (args = {}) => {
|
||||
const name = validation.sanitizeString(args.name || args.volume || '', 128)
|
||||
if (!name) throw Object.assign(new Error('Volume name required'), { code: 'INVALID_ARGS' })
|
||||
let rel = String(args.path || '/').trim() || '/'
|
||||
if (!rel.startsWith('/')) rel = `/${rel}`
|
||||
if (rel.includes('..')) {
|
||||
throw Object.assign(new Error('Invalid path'), { code: 'INVALID_ARGS' })
|
||||
}
|
||||
|
||||
const volumeData = await docker.getVolume(name).inspect()
|
||||
const mountpoint = volumeData.Mountpoint
|
||||
if (!mountpoint) {
|
||||
throw Object.assign(
|
||||
new Error('Volume has no local mountpoint (remote/cloud volumes cannot be browsed here)'),
|
||||
{ code: 'INVALID_ARGS' }
|
||||
)
|
||||
}
|
||||
|
||||
const target = path.normalize(path.join(mountpoint, rel === '/' ? '' : rel.slice(1)))
|
||||
const root = path.normalize(mountpoint)
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
throw Object.assign(new Error('Path escapes volume mountpoint'), { code: 'PERMISSION_DENIED' })
|
||||
}
|
||||
|
||||
let stats
|
||||
try {
|
||||
stats = fs.statSync(target)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
throw Object.assign(new Error('Path not found in volume'), { code: 'INVALID_ARGS' })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
if (!stats.isDirectory()) {
|
||||
throw Object.assign(new Error('Not a directory'), { code: 'INVALID_ARGS' })
|
||||
}
|
||||
|
||||
const entries = []
|
||||
for (const ent of fs.readdirSync(target, { withFileTypes: true })) {
|
||||
let size = null
|
||||
let mtime = null
|
||||
try {
|
||||
const st = fs.statSync(path.join(target, ent.name))
|
||||
size = st.size
|
||||
mtime = st.mtime?.toISOString?.() || null
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
entries.push({
|
||||
name: ent.name,
|
||||
type: ent.isDirectory() ? 'dir' : ent.isSymbolicLink() ? 'link' : 'file',
|
||||
size,
|
||||
mtime,
|
||||
})
|
||||
}
|
||||
entries.sort((a, b) => {
|
||||
if (a.type === 'dir' && b.type !== 'dir') return -1
|
||||
if (a.type !== 'dir' && b.type === 'dir') return 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: 'volumeBrowse',
|
||||
name,
|
||||
path: rel,
|
||||
mountpoint,
|
||||
entries,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('pruneVolumes', async (args) => {
|
||||
const opts = {}
|
||||
if (args.filters) opts.filters = args.filters
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
holesailStatus,
|
||||
restoreTunnelsFromDisk,
|
||||
} from './services/holesail-tunnels.js'
|
||||
import { restoreSchedules } from './services/schedules.js'
|
||||
import logger from './utils/logger.js'
|
||||
|
||||
const { keyPair, publicKeyHex } = loadOrCreateKeyPair()
|
||||
@@ -91,6 +92,13 @@ if (isHolesailEnabled()) {
|
||||
})
|
||||
}
|
||||
|
||||
// Interval maintenance jobs (system prune, etc.)
|
||||
try {
|
||||
restoreSchedules()
|
||||
} catch (err) {
|
||||
logger.warn('Schedule restore failed', { error: err.message })
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
console.log('[INFO] Server shutting down…')
|
||||
stopStatsBroadcast()
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
Reference in New Issue
Block a user