diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 357da690..dab1c503 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -18,6 +18,7 @@ export interface Site { export interface Node { id: number; name: string; + nodeType: 'proxmox' | 'dummy' | 'docker'; siteId: number; ipv4Address: string | null; apiUrl: string | null; diff --git a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx index f3a36799..07e0d6ef 100644 --- a/create-a-container/client/src/pages/nodes/NodeFormPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodeFormPage.tsx @@ -21,8 +21,9 @@ import type { Node } from '@/lib/types'; const schema = z.object({ name: z.string().min(1, 'Required'), + nodeType: z.enum(['proxmox', 'dummy', 'docker']).default('proxmox'), ipv4Address: z.string().optional(), - apiUrl: z.string().url('Must be a valid URL').or(z.literal('')).optional(), + apiUrl: z.string().optional(), tokenId: z.string().optional(), secret: z.string().optional(), tlsVerify: z.boolean().optional(), @@ -49,6 +50,10 @@ export function NodeFormPage() { const { register, handleSubmit, reset, watch, setValue, formState } = useForm({ resolver: zodResolver(schema), defaultValues: { + name: '', + nodeType: 'proxmox', + ipv4Address: '', + apiUrl: '', tlsVerify: true, nvidiaAvailable: false, imageStorage: 'local', @@ -58,11 +63,13 @@ export function NodeFormPage() { }); const tlsVerify = watch('tlsVerify'); const nvidiaAvailable = watch('nvidiaAvailable'); + const nodeType = watch('nodeType'); useEffect(() => { if (node) { reset({ name: node.name, + nodeType: node.nodeType || 'proxmox', ipv4Address: node.ipv4Address || '', apiUrl: node.apiUrl || '', tokenId: node.tokenId || '', @@ -107,8 +114,8 @@ export function NodeFormPage() { title={isEdit ? 'Edit node' : 'New node'} subtitle={ isEdit - ? 'Update Proxmox connection details and storage settings.' - : 'Register a Proxmox node with API credentials and default storage.' + ? 'Update node connection details and storage settings.' + : 'Register a node with connection details and default storage.' } backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }} maxWidth="3xl" @@ -135,6 +142,14 @@ export function NodeFormPage() { hasError={!!formState.errors.name} {...register('name')} /> +
+ + +
- - - setValue('tlsVerify', c)} - /> + {nodeType === 'proxmox' && ( + <> + + + setValue('tlsVerify', c)} + /> + + )}
diff --git a/create-a-container/client/src/pages/nodes/NodesListPage.tsx b/create-a-container/client/src/pages/nodes/NodesListPage.tsx index 7de9f89a..06d32fa7 100644 --- a/create-a-container/client/src/pages/nodes/NodesListPage.tsx +++ b/create-a-container/client/src/pages/nodes/NodesListPage.tsx @@ -47,6 +47,14 @@ function NvidiaBadge({ n }: { n: Node }) { } function CredentialsBadge({ n }: { n: Node }) { + if (n.nodeType === 'dummy') { + return Not required; + } + + if (n.nodeType === 'docker') { + return n.apiUrl ? Host set : Missing; + } + return n.hasSecret ? Set : Missing; } diff --git a/create-a-container/models/node.js b/create-a-container/models/node.js index b3c79061..fc3488a6 100644 --- a/create-a-container/models/node.js +++ b/create-a-container/models/node.js @@ -1,10 +1,12 @@ 'use strict'; const { - Model + Model, + Op } = require('sequelize'); const https = require('https'); const ProxmoxApi = require('../utils/proxmox-api'); const DummyApi = require('../utils/dummy-api'); +const DockerApi = require('../utils/docker-api'); module.exports = (sequelize, DataTypes) => { class Node extends Model { @@ -24,6 +26,30 @@ module.exports = (sequelize, DataTypes) => { }); } + hasApiAccess() { + if (this.nodeType === 'dummy') return true; + if (this.nodeType === 'docker') return !!this.apiUrl; + return !!(this.apiUrl && this.tokenId && this.secret); + } + + static provisionableWhere() { + return { + [Op.or]: [ + { nodeType: 'dummy' }, + { + nodeType: 'docker', + apiUrl: { [Op.ne]: null }, + }, + { + nodeType: 'proxmox', + apiUrl: { [Op.ne]: null }, + tokenId: { [Op.ne]: null }, + secret: { [Op.ne]: null }, + }, + ], + }; + } + /** * Create an API client for this node, selected by `nodeType`. * @@ -42,6 +68,13 @@ module.exports = (sequelize, DataTypes) => { return new DummyApi(this); } + if (this.nodeType === 'docker') { + if (!this.apiUrl) { + throw new Error(`Node ${this.name}: Missing Docker configuration (apiUrl / DOCKER_HOST is required)`); + } + return new DockerApi(this); + } + if (!this.apiUrl || !this.tokenId || !this.secret) { throw new Error(`Node ${this.name}: Missing Proxmox configuration (apiUrl, tokenId, and secret are required)`); } diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index f962b776..e617b3d3 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -438,14 +438,7 @@ router.post( // provisioned. const nodeWhere = { siteId: site.id, - [Sequelize.Op.or]: [ - { nodeType: 'dummy' }, - { - apiUrl: { [Sequelize.Op.ne]: null }, - tokenId: { [Sequelize.Op.ne]: null }, - secret: { [Sequelize.Op.ne]: null }, - }, - ], + ...Node.provisionableWhere(), }; if (wantsNvidia) nodeWhere.nvidiaAvailable = true; const node = await Node.findOne({ diff --git a/create-a-container/routers/api/v1/nodes.js b/create-a-container/routers/api/v1/nodes.js index 2cbb3303..888e8ef0 100644 --- a/create-a-container/routers/api/v1/nodes.js +++ b/create-a-container/routers/api/v1/nodes.js @@ -5,6 +5,7 @@ const express = require('express'); const https = require('https'); const { Node, Site, Container } = require('../../../models'); +const { isValidDockerHost } = require('../../../utils/docker-api'); const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } = require('../../../middlewares/api'); @@ -16,6 +17,7 @@ function serialize(n) { return { id: n.id, name: n.name, + nodeType: n.nodeType, siteId: n.siteId, ipv4Address: n.ipv4Address, apiUrl: n.apiUrl, @@ -35,6 +37,28 @@ async function loadSite(req) { return site; } +function normalizeNodeType(nodeType) { + return nodeType || 'proxmox'; +} + +function validateNodeInput({ nodeType, apiUrl }) { + const type = normalizeNodeType(nodeType); + + if (!['proxmox', 'docker', 'dummy'].includes(type)) { + throw new ApiError(400, 'invalid_node_type', 'Node type must be proxmox, docker, or dummy'); + } + + if (type === 'docker' && (!apiUrl || !isValidDockerHost(apiUrl))) { + throw new ApiError( + 400, + 'invalid_docker_host', + 'Docker host must use unix://, tcp://, http://, or https:// without extra path components', + ); + } + + return type; +} + router.get( '/', asyncHandler(async (req, res) => { @@ -63,7 +87,7 @@ router.get( const node = await Node.findOne({ where: { id: parseInt(req.params.id, 10), siteId: site.id }, }); - if (!node || !node.apiUrl || !node.tokenId || !node.secret) return ok(res, []); + if (!node || !node.hasApiAccess()) return ok(res, []); try { const client = await node.api(); const storages = await client.datastores(node.name, 'vztmpl', true); @@ -80,10 +104,12 @@ router.post( apiAdmin, asyncHandler(async (req, res) => { const site = await loadSite(req); - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = + const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; + const type = validateNodeInput({ nodeType, apiUrl }); const node = await Node.create({ name, + nodeType: type, ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, @@ -109,10 +135,15 @@ router.put( where: { id: parseInt(req.params.id, 10), siteId: site.id }, }); if (!node) throw new ApiError(404, 'not_found', 'Node not found'); - const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = + const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } = req.body || {}; + const type = validateNodeInput({ + nodeType: nodeType || node.nodeType, + apiUrl, + }); const update = { name, + nodeType: type, ipv4Address: ipv4Address || null, apiUrl: apiUrl || null, tokenId: tokenId || null, diff --git a/create-a-container/utils/container-status.js b/create-a-container/utils/container-status.js index 0c94c9d5..b561bf5c 100644 --- a/create-a-container/utils/container-status.js +++ b/create-a-container/utils/container-status.js @@ -41,8 +41,8 @@ const STATUS_VALUES = Object.freeze(Object.values(STATUS)); const ACTIVE_JOB_STATUSES = ['pending', 'running']; -function nodeHasCreds(node) { - return !!(node && node.apiUrl && node.tokenId && node.secret); +function nodeHasApiAccess(node) { + return !!(node && typeof node.hasApiAccess === 'function' && node.hasApiAccess()); } /** @@ -118,7 +118,7 @@ function decideStatus(facts) { */ async function computeContainerStatus({ container, Job, api, snapshot }) { const node = container.node; - const hasCreds = nodeHasCreds(node); + const hasCreds = nodeHasApiAccess(node); const hasVmid = container.containerId != null; // --- Determine Proxmox presence / run state --- @@ -202,7 +202,7 @@ async function computeContainerStatuses(containers, Job) { const node = group[0].node; let snapshot = { ok: false, data: null }; - if (nodeHasCreds(node)) { + if (nodeHasApiAccess(node)) { try { const api = await node.api(); const data = await api.clusterResources('lxc'); diff --git a/create-a-container/utils/docker-api.js b/create-a-container/utils/docker-api.js new file mode 100644 index 00000000..43b3cca4 --- /dev/null +++ b/create-a-container/utils/docker-api.js @@ -0,0 +1,558 @@ +const axios = require('axios'); + +const MANAGER_NODE_LABEL = 'org.mieweb.opensource-server.node-id'; +const MANAGER_CONTAINER_LABEL = 'org.mieweb.opensource-server.container-id'; + +function isValidDockerHost(host) { + if (typeof host !== 'string' || host.trim() === '') return false; + + try { + const url = new URL(host.trim()); + + if (url.protocol === 'unix:') { + return !!url.pathname && url.pathname !== '/'; + } + + if (url.protocol === 'tcp:') { + return !!url.hostname && !!url.port && (url.pathname === '' || url.pathname === '/'); + } + + if (url.protocol === 'http:' || url.protocol === 'https:') { + return !!url.hostname && (url.pathname === '' || url.pathname === '/'); + } + + return false; + } catch (err) { + return false; + } +} + +function parseDockerHost(host) { + if (!isValidDockerHost(host)) { + throw new Error( + 'Invalid Docker host. Supported formats: unix:///var/run/docker.sock, tcp://host:2375, http://host:2375, https://host:2376', + ); + } + + const raw = host.trim(); + const url = new URL(raw); + + if (url.protocol === 'unix:') { + return { + baseURL: 'http://docker', + socketPath: decodeURIComponent(url.pathname), + }; + } + + if (url.protocol === 'tcp:') { + return { + baseURL: `http://${url.host}`, + }; + } + + return { + baseURL: raw.replace(/\/$/, ''), + }; +} + +function parseEnvString(envStr) { + const env = {}; + if (!envStr || typeof envStr !== 'string') return env; + + for (const pair of envStr.split('\0')) { + const eq = pair.indexOf('='); + if (eq > 0) { + env[pair.substring(0, eq)] = pair.substring(eq + 1); + } + } + + return env; +} + +function envObjectToArray(envObj) { + return Object.entries(envObj || {}).map(([key, value]) => `${key}=${value}`); +} + +function envArrayToObject(envArray) { + const env = {}; + for (const pair of envArray || []) { + const eq = pair.indexOf('='); + if (eq > 0) { + env[pair.substring(0, eq)] = pair.substring(eq + 1); + } + } + return env; +} + +function task(kind, id = '') { + return `docker:${kind}:${id}`; +} + +function isSystemContainer(options = {}) { + const entrypoint = Array.isArray(options.entrypoint) + ? options.entrypoint.join(' ') + : options.entrypoint || ''; + + const image = options.ostemplate || options.reference || ''; + + return ( + entrypoint.includes('/sbin/init') || + entrypoint.includes('systemd') || + image.includes('/base:') || + image.includes('/base@') || + image.endsWith('/base') + ); +} + +class DockerApi { + constructor(node = {}) { + if (!node.apiUrl) { + throw new Error('DockerApi requires node.apiUrl'); + } + + if (!node.id) { + throw new Error('DockerApi requires node.id'); + } + + if (!node.name) { + throw new Error('DockerApi requires node.name'); + } + + this.node = node; + const dockerConfig = parseDockerHost(node.apiUrl); + + this.http = axios.create({ + ...dockerConfig, + timeout: 120000, + }); + + this.lastPulledImage = null; + } + + async request(method, url, options = {}) { + const response = await this.http.request({ + method, + url, + params: options.params, + data: options.data, + responseType: options.responseType, + }); + return response.data; + } + + labels(vmid) { + return { + [MANAGER_NODE_LABEL]: String(this.node.id), + [MANAGER_CONTAINER_LABEL]: String(vmid), + }; + } + + labelFilters(extra = {}) { + return JSON.stringify({ + label: [ + `${MANAGER_NODE_LABEL}=${String(this.node.id)}`, + ...Object.entries(extra).map(([k, v]) => `${k}=${v}`), + ], + }); + } + + async findContainerByVmid(vmid) { + const containers = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters({ [MANAGER_CONTAINER_LABEL]: String(vmid) }), + }, + }); + + if (!containers.length) { + throw new Error(`Docker container for manager id ${vmid} not found`); + } + + return containers[0]; + } + + async inspectByVmid(vmid) { + const container = await this.findContainerByVmid(vmid); + return this.request('get', `/containers/${container.Id}/json`); + } + + async nextId() { + const existing = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters(), + }, + }); + + const used = new Set( + existing + .map((c) => c.Labels?.[MANAGER_CONTAINER_LABEL]) + .filter(Boolean) + .map(Number), + ); + + let candidate = Date.now() % 1000000000; + while (used.has(candidate)) candidate += 1; + return candidate; + } + + async nodes() { + try { + const swarmNodes = await this.request('get', '/nodes'); + return swarmNodes.map((n) => ({ + node: n.Description?.Hostname || n.ID, + status: n.Status?.State === 'ready' ? 'online' : n.Status?.State || 'unknown', + type: 'node', + })); + } catch (err) { + if (err.response?.status && err.response.status !== 404 && err.response.status !== 503) { + throw err; + } + + const info = await this.request('get', '/info'); + return [ + { + node: this.node.name, + status: 'online', + type: 'node', + id: info.ID, + }, + ]; + } + } + + async nodeNetwork(nodeName) { + try { + const swarmNodes = await this.request('get', '/nodes'); + const swarmNode = swarmNodes.find((n) => n.Description?.Hostname === nodeName || n.ID === nodeName); + + if (!swarmNode) return []; + + const inspected = await this.request('get', `/nodes/${swarmNode.ID}`); + const address = inspected.Status?.Addr || null; + + return address + ? [ + { + iface: 'docker-swarm', + type: 'docker', + active: true, + address, + }, + ] + : []; + } catch (err) { + return []; + } + } + + async clusterResources(type = null) { + if (type && type !== 'lxc' && type !== 'vm') return []; + + const containers = await this.request('get', '/containers/json', { + params: { + all: true, + filters: this.labelFilters(), + }, + }); + + return containers.map((c) => ({ + vmid: Number(c.Labels?.[MANAGER_CONTAINER_LABEL]), + name: (c.Names?.[0] || '').replace(/^\//, ''), + type: 'lxc', + status: c.State === 'running' ? 'running' : 'stopped', + node: this.node.name || 'docker', + })); + } + + async datastores() { + return [ + { + storage: 'docker', + type: 'docker', + content: 'container', + enabled: 1, + active: 1, + total: 0, + avail: 0, + used: 0, + }, + ]; + } + + async storageContents() { + // Docker images are cached by Docker Engine itself. The create-container job + // uses storageContents() to skip Proxmox OCI pulls, but Docker's image create + // endpoint already reuses local images/layers when available. + return []; + } + + async pullOciImage(node, storage, options = {}) { + const image = options.reference; + if (!image) throw new Error('Docker image reference is required'); + + this.lastPulledImage = image; + + const stream = await this.request('post', '/images/create', { + params: { fromImage: image }, + responseType: 'stream', + }); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + return task('pull', image); + } + + async createLxc(node, options = {}) { + const image = this.lastPulledImage || options.reference || options.ostemplate; + + if (!image) { + throw new Error('Docker image reference is required'); + } + + const vmid = options.vmid; + const name = options.hostname || `manager-${vmid}`; + + const body = { + Image: image, + Hostname: name, + Labels: this.labels(vmid), + Env: [], + HostConfig: { + NetworkMode: 'bridge', + }, + }; + + if (isSystemContainer(options)) { + body.HostConfig.Privileged = true; + body.HostConfig.CgroupnsMode = 'host'; + body.HostConfig.Tmpfs = { + '/run': 'rw,nosuid,nodev,mode=755', + '/run/lock': 'rw,nosuid,nodev,noexec,mode=755', + '/tmp': 'rw,nosuid,nodev', + }; + } + + if (options.memory) { + body.HostConfig.Memory = Number(options.memory) * 1024 * 1024; + } + + if (options.cores) { + body.HostConfig.NanoCpus = Number(options.cores) * 1000000000; + } + + const created = await this.request('post', '/containers/create', { + params: { name }, + data: body, + }); + + return task('create', created.Id); + } + + async getLxcTemplates() { + return []; + } + + async cloneLxc() { + throw new Error('Docker nodes do not support cloning Proxmox LXC templates; use a Docker image template'); + } + + async lxcConfig(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + + const network = Object.values(inspect.NetworkSettings?.Networks || {})[0] || {}; + const env = (inspect.Config?.Env || []).join('\0'); + const entrypoint = Array.isArray(inspect.Config?.Entrypoint) + ? inspect.Config.Entrypoint.join(' ') + : inspect.Config?.Entrypoint || null; + + return { + hostname: inspect.Config?.Hostname || inspect.Name?.replace(/^\//, ''), + env, + entrypoint, + cores: inspect.HostConfig?.NanoCpus + ? Math.round(inspect.HostConfig.NanoCpus / 1000000000) + : null, + memory: inspect.HostConfig?.Memory + ? Math.round(inspect.HostConfig.Memory / 1024 / 1024) + : null, + rootfs: null, + net0: `name=eth0,hwaddr=${network.MacAddress || ''},ip=${network.IPAddress || 'dhcp'},bridge=docker0`, + }; + } + + async recreateForConfig(vmid, config = {}) { + const inspect = await this.inspectByVmid(vmid); + const existing = await this.findContainerByVmid(vmid); + + const wasRunning = inspect.State?.Running; + const name = inspect.Name?.replace(/^\//, '') || inspect.Config?.Hostname || `manager-${vmid}`; + + const env = envArrayToObject(inspect.Config?.Env || []); + const deleteList = typeof config.delete === 'string' ? config.delete.split(',') : []; + + if (deleteList.includes('env')) { + for (const key of Object.keys(env)) delete env[key]; + } + + Object.assign(env, parseEnvString(config.env)); + + let entrypoint = inspect.Config?.Entrypoint || undefined; + if (deleteList.includes('entrypoint')) entrypoint = undefined; + if (config.entrypoint) entrypoint = config.entrypoint.split(' '); + + if (wasRunning) { + await this.request('post', `/containers/${existing.Id}/stop`).catch(() => {}); + } + + await this.request('delete', `/containers/${existing.Id}`, { + params: { force: true }, + }); + + const body = { + Image: inspect.Config.Image, + Hostname: inspect.Config.Hostname, + Labels: { + ...(inspect.Config.Labels || {}), + ...this.labels(vmid), + }, + Env: envObjectToArray(env), + Entrypoint: entrypoint, + HostConfig: { + ...(inspect.HostConfig || {}), + NetworkMode: inspect.HostConfig?.NetworkMode || 'bridge', + }, + }; + + delete body.HostConfig.Binds; + delete body.HostConfig.Mounts; + delete body.HostConfig.PortBindings; + + await this.request('post', '/containers/create', { + params: { name }, + data: body, + }); + } + + async updateLxcConfig(node, vmid, config = {}) { + const hasContainerConfigChanges = + config.env !== undefined || + config.entrypoint !== undefined || + String(config.delete || '').includes('env') || + String(config.delete || '').includes('entrypoint'); + + if (hasContainerConfigChanges) { + await this.recreateForConfig(vmid, config); + } + + const container = await this.findContainerByVmid(vmid); + const update = {}; + + if (config.memory) update.Memory = Number(config.memory) * 1024 * 1024; + if (config.cores) update.NanoCpus = Number(config.cores) * 1000000000; + + if (Object.keys(update).length > 0) { + await this.request('post', `/containers/${container.Id}/update`, { + data: update, + }); + } + } + + async startLxc(node, vmid) { + const container = await this.findContainerByVmid(vmid); + await this.request('post', `/containers/${container.Id}/start`); + return task('start', container.Id); + } + + async stopLxc(node, vmid) { + const container = await this.findContainerByVmid(vmid); + await this.request('post', `/containers/${container.Id}/stop`).catch((err) => { + if (err.response?.status !== 304) throw err; + }); + return task('stop', container.Id); + } + + async getLxcStatus(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + return { + status: inspect.State?.Running ? 'running' : 'stopped', + vmid, + }; + } + + async deleteContainer(node, vmid, force = false) { + const container = await this.findContainerByVmid(vmid); + await this.request('delete', `/containers/${container.Id}`, { + params: { force: !!force }, + }); + return { data: null }; + } + + async waitForTask() { + // Docker Engine API calls used here either complete synchronously or stream + // until completion before returning. There is no Proxmox-style background UPID + // to poll, so this satisfies the shared NodeApi task contract as a no-op. + return { status: 'stopped', exitstatus: 'OK' }; + } + + async lxcInterfaces(node, vmid) { + const inspect = await this.inspectByVmid(vmid); + const network = Object.values(inspect.NetworkSettings?.Networks || {})[0] || {}; + + return [ + { + name: 'eth0', + hwaddr: network.MacAddress || null, + inet: network.IPAddress ? `${network.IPAddress}/24` : null, + 'ip-addresses': network.IPAddress + ? [{ 'ip-address-type': 'inet', 'ip-address': network.IPAddress }] + : [], + }, + ]; + } + + async getLxcMacAddress(node, vmid) { + const interfaces = await this.lxcInterfaces(node, vmid); + return interfaces[0]?.hwaddr || null; + } + + async getLxcIpAddress(node, vmid, maxRetries = 10, retryDelay = 3000) { + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + const interfaces = await this.lxcInterfaces(node, vmid); + const ip = interfaces[0]?.['ip-addresses']?.[0]?.['ip-address']; + + if (ip) return ip; + + if (attempt < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + } + } + + return null; + } + + async getLxcNetworkInfo(node, vmid) { + const interfaces = await this.lxcInterfaces(node, vmid); + const eth0 = interfaces[0]; + + return { + macAddress: eth0?.hwaddr || null, + ipv4Address: eth0?.['ip-addresses']?.[0]?.['ip-address'] || null, + }; + } + + async updateAcl() { + // Docker has no Proxmox-style ACL path. No-op to keep NodeApi contract. + } + + async syncLdapRealm() { + // Docker has no Proxmox LDAP realm. No-op to keep NodeApi contract. + } +} + +module.exports = DockerApi; +module.exports.isValidDockerHost = isValidDockerHost;