/** * 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' import { peers } from '../core/peer-registry.js' import logger from '../utils/logger.js' export function registerVolumeHandlers(session) { session.respond('listVolumes', async () => { try { const volumesResult = await docker.listVolumes() const volumesList = extractVolumesList(volumesResult) return { type: 'volumes', data: volumesList, success: true, volumes: volumesList, } } catch (error) { return { type: 'volumes', success: false, error: `Failed to list volumes: ${error.message}`, data: [], volumes: [], } } }) session.respond('createVolume', async (args) => { const volumeConfig = { Name: validation.sanitizeString(args.name, 128), } if (args.driver) volumeConfig.Driver = args.driver if (args.options && typeof args.options === 'object') { volumeConfig.DriverOpts = args.options } const volume = await docker.createVolume(volumeConfig) await broadcastVolumes() return { success: true, message: `Volume "${args.name}" created successfully`, data: volume.name, } }) session.respond('removeVolume', async (args) => { await docker.getVolume(args.name).remove() await broadcastVolumes() return { success: true, message: `Volume ${args.name} removed` } }) session.respond('inspectVolume', async (args) => { const volumeData = await docker.getVolume(args.name).inspect() 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 const result = await docker.pruneVolumes(opts) await broadcastVolumes() return { success: true, type: 'pruneVolumes', message: 'Unused volumes pruned', data: result, } }) } export async function broadcastVolumes() { try { const volumesResult = await docker.listVolumes() const volumesList = extractVolumesList(volumesResult) peers.broadcast(Pushes.volumes, { type: 'volumes', data: volumesList, success: true, volumes: volumesList, }) } catch (err) { logger.error('Failed to broadcast volumes', { error: err.message }) } }