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.
165 lines
4.8 KiB
JavaScript
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 })
|
|
}
|
|
}
|