Files
peardock/server/handlers/volumes.js
T
snxraven 2b6cfc644f
CI / test (push) Successful in 10m2s
Complete Track F optional Docker/Portainer ops items
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.
2026-07-10 23:52:26 -04:00

165 lines
4.8 KiB
JavaScript

/**
* 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 })
}
}