Complete Track F optional Docker/Portainer ops items
CI / test (push) Successful in 10m2s

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:
2026-07-10 23:52:26 -04:00
parent b62bcd697e
commit 2b6cfc644f
13 changed files with 1083 additions and 107 deletions
+2
View File
@@ -22,6 +22,8 @@ const AUDIT_METHODS = new Set([
'pruneBuilder',
'systemPrune',
'recreateContainer',
'upsertSchedule',
'deleteSchedule',
'removeImage',
'removeStack',
'deployStack',
+22
View File
@@ -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
+77
View File
@@ -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
+8
View File
@@ -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()
+170
View File
@@ -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,
}