Skip to content

Control Plane API Reference

This document describes the HTTP endpoints currently implemented by the Control Plane, including request parameters, response structures, and directly executable curl commands for testing.

API-first design: All Control Plane capabilities are exposed through the REST API as the primary interface. The Web administration interface (WebUI) itself is just one client of this API, equal to any third-party system or automation script. The guiding principle: Everything faces the Control Plane. Third-party integrations always call the Control Plane API and never bypass it to operate directly on an Agent or the data plane.

The Control Plane is a persistent HTTP service running on the Controller node, responsible for:

  • Adding Workers
  • Removing Workers
  • Querying Worker inventory
  • Querying Agent status
  • Maintaining dnsmasq/dhcp-hosts.conf
  • Calling Agents to create or delete iSCSI LUNs

It does not generate iPXE menus, serve static files, or directly operate tgtadm/targetcli.


1. General Information

1.1 Base URL

Local example:

text
http://localhost:4839

Replace with the actual address if you expose it on a different port or domain.

1.2 Environment File

The container reads environment variables through the Compose file:

yaml
env_file:
  - ./control_plane/control_plane.env

The Control Plane code does not parse a .env file directly; it reads container environment variables via os.getenv(...).

1.3 Authentication

If the environment variable IPXE_CP_TOKEN is empty, the Control Plane does not require authentication.
If IPXE_CP_TOKEN is set, all endpoints except GET /healthz must include:

http
Authorization: Bearer <IPXE_CP_TOKEN>

Example:

bash
export BASE_URL=http://localhost:4839
export TOKEN=replace-me

curl with authentication:

bash
curl -s "$BASE_URL/workers" \
  -H "Authorization: Bearer $TOKEN"

2. Files Are the Source of Truth

The current Control Plane state files are divided as follows:

FileMeaning
config/agents.ymlAgent node inventory and scheduling roles
state/workers.ymlWorker storage inventory
dnsmasq/dhcp-hosts.confSingle source of truth for MAC -> hostname bindings
state/operations.jsonlControl Plane operation audit trail

Notes:

  • workers.yml does not store MAC addresses.
  • In the Compose file, mount the entire dnsmasq directory into the container, not just the single dhcp-hosts.conf file. The Control Plane performs an atomic replace using a temporary file when writing.
  • dnsmasq/dhcp-hosts.conf contains one binding per line, with a fixed format:
text
00:0c:29:b9:8b:2d,worker-01

3. Endpoint Overview

MethodPathDescription
GET/healthzHealth check
GET/boot-varsDynamic iPXE boot variable injection, no auth required
GET/devices/reportiPXE device info reporting (no auth, 11 fields, see 16.6)
GET/devicesDevice pool list (state filter, see 16.1)
GET/devices/{mac}Single device detail (see 16.2)
POST/devicesManually register a device into the pool (see 16.3)
POST/devices/importBulk import device manifest (see 16.4)
DELETE/devices/{mac}Revoke a device (see 16.5)
GET/settings/auto-registerGet the global auto-register switch (runtime state, see 5.1)
PUT/settings/auto-registerToggle the global auto-register switch (persisted, takes effect immediately, see 5.1)
GET/agentsList Agents and their capabilities
POST/agentsRegister a new Agent (writes agents.yml; 409 if id exists)
POST/agents/probeProbe an Agent and auto-derive registration parameters (preview, no file writes)
PUT/agents/{agent_id}Update an Agent’s configuration (id cannot be changed; leave token empty to keep the current value)
GET/agents/{agent_id}/lunsList iSCSI targets/LUNs on a given Agent
GET/mastersAggregate master image inventory from all storage nodes (for clone selection)
POST/agents/{agent_id}/luns/diskCreate a disk LUN on a given Agent (master clone / empty disk)
POST/agents/{agent_id}/luns/cdCreate a CD (ISO virtual drive) LUN on a given Agent
DELETE/agents/{agent_id}/lunsDelete a LUN/target on a given Agent
POST/agents/{agent_id}/luns/scanTrigger an Agent to scan its image directory and rebuild targets
POST/workersRegister a Worker identity (hostname binding; mac optional — providing it binds the device directly)
POST/workers/batchBulk-create Workers (count + naming rule, per-item independent, optional macs direct bind, see 7.6)
POST/workers/{worker_id}/luns/diskCreate a system disk LUN for a given Worker
POST/workers/luns/disk/batchBatch create system disks for multiple Workers (each specifies a storage node)
DELETE/workers/{worker_id}/luns/disk/{os}Delete a single system disk of a Worker (with option to keep/delete .img file)
PUT/workers/{worker_id}/default-osSet the Worker’s default boot configuration (OS / menu item / timeout)
GET/workersList Workers
GET/workers/{worker_id}Query a single Worker
GET/workers/{worker_id}/statusQuery Worker inventory and real-time status
DELETE/workers/{worker_id}Delete a Worker
POST/workers/delete/batchBatch delete Workers (each item processed independently, with success/failure summary)
GET/operationsRead operation audit log

4. GET /healthz

Description

Health check endpoint. Does not change any state and requires no authentication.

curl

bash
curl -s "$BASE_URL/healthz"

Successful Response

json
{"status":"ok"}

5. GET /boot-vars

Description

Provides per-worker boot variables for iPXE boot scripts. This endpoint does not require authentication and only exposes variables needed for booting within a controlled network.

Note: This endpoint is read-only (a projection of boot variables, no write side effects). Auto-registration (new MACs entering the device pool) has been consolidated into GET /devices/report (see 16.6), which boot.ipxe.cfg chains before /boot-vars.

The Control Plane identifies the device and projects boot variables in the following order:

  1. hostnamestate/workers.yml (by hostname or worker_id)
  2. macstate/devices.yml (device inventory) → bound_worker_idstate/workers.yml
  3. config/agents.yml (data-plane address of the default boot disk’s Agent)

Then returns the corresponding iSCSI server, default menu item, and menu timeout for that Worker.

By default, it returns an iPXE script snippet for maximum compatibility, which can be executed directly by iPXE’s chain. Add format=json to receive JSON (useful for manual debugging).

Field Origins

The response of /boot-vars is a projection of the inventory:

Response FieldOrigin
base_iqnThe IQN of the Worker’s default boot disk (the disk for default_os; if not set, the first disk) with the last colon-and-following part stripped. Not returned if the Worker has no system disk (iPXE falls back to the static default in boot.ipxe.cfg).
iscsi_serverThe iscsi_server of the Agent that hosts the default boot disk (same disk selection rule as above). Not returned when there is no system disk.
iscsi_sepThe iSCSI root separator (the part between ${iscsi-server} and ${base-iqn}). The root-path assembly is done on the iPXE side. Generated according to the Agent backend type: :::1: for stgt backends (lun placeholder 1), :::: for LIO backends (empty placeholder). The backend type is determined first from the Agent’s tags in agents.yml (the presence of lio/stgt), then by querying the Agent’s /capabilities backend field, and finally defaulting to stgt format if the query fails. Not returned when there is no system disk.
menu_defaultDerived chain: workers.yml default_os (set separately after disk creation) > boot.menu_default (explicit configuration) > reboot (loop reboot waiting when not configured)
menu_timeoutWhen a default boot is configured: boot.menu_timeout > IPXE_CP_BOOT_MENU_TIMEOUT (default 5000). When in reboot loop: always uses IPXE_CP_AUTO_BOOT_TIMEOUT (default 1). Units are milliseconds.

Worker lookup rules (hostname takes precedence):

text
hostname -> workers.yml (by hostname or worker_id)
hostname miss or not provided -> mac -> devices.yml (device inventory) -> bound_worker_id -> workers.yml
not identified and mac provided ->
  - device in pool (pooled) -> reboot loop (menu-default=reboot + short timeout), waiting for binding
  - device revoked -> empty script (menu stays)
  - unknown MAC + auto-register on -> enter the device pool (see “Auto-Registration” below), return reboot loop
  - unknown MAC + auto-register off -> empty script (menu stays)

Default Boot Item Rules

The default boot item is derived by /boot-vars in the following order:

text
default_os (set separately after disk creation, see 7.3) -> boot.menu_default (explicit config) -> reboot (not configured)
  • Recommended approach: after creating a system disk, call PUT /workers/{worker_id}/default-os to set the default boot OS:
    text
    os=ubuntu  -> menu_default=ubuntu
    os=debian  -> menu_default=debian
    os=windows -> menu_default=windows
  • Alternatively, you can leave default_os unset and use boot.menu_default to specify a default item on the iPXE menu (e.g., menu-install during installation, or exit).
  • When neither is configured, menu_default returns reboot (a short-timeout reboot loop waiting for the admin to create a disk / set a default OS; exit is only used when explicitly configured).

Auto-Registration (Zero-touch Provisioning)

When a new device boots without an identity, iPXE first chains /devices/report (11-field report, see 16.6) and then requests /boot-vars. If the MAC is not registered, the Control Plane only adds it to the device pool (it no longer creates a Worker automatically):

  1. GET /devices/report: unknown MAC with auto-register on → written to state/devices.yml (state=pooled, fingerprint stored, source=ipxe)
  2. GET /boot-vars: MAC in the pool and unbound → returns menu-default=reboot + short timeout, rebooting in a loop until the admin binds it
  3. After the admin binds the device to a Worker (WebUI / API; single bind 16.7, batch bind preview/execution 16.9/16.10), the next boot follows the Worker configuration normally

Configuration (environment variables):

VariableDefaultDescription
IPXE_CP_AUTO_REGISTERtrueStartup default for auto-registration — its semantics are “whether a new MAC automatically enters the device pool”; the runtime value can be toggled via GET/PUT /settings/auto-register (persisted to state/settings.json, survives restarts, takes precedence over the env var, see 5.1)
IPXE_CP_AUTO_BOOT_TIMEOUT1Menu timeout in milliseconds during the reboot loop.

The entire auto-registration process is logged as operations (device.register). On failure, the inventory is rolled back and an empty script is returned, so the next request will retry without affecting the iPXE boot process.

Anti-Impersonation (Binding as Authentication)

When a request carries a mac, the Control Plane verifies that the device is bound to the Worker matched by the hostname (bound_worker_id); if not (device bound to another Worker / unbound / unknown), the request is denied — an empty script is returned and the boot vars are not leaked. Requests without a mac (hostname only) cannot be verified and remain allowed for compatibility. This makes the device↔Worker binding the boot-time authentication boundary: only the bound device can receive the Worker's boot configuration (e.g., base_iqn / iscsi-server).

Query Parameters

ParameterRequiredDefaultDescription
macNoNoneMAC address. The backend normalizes it by stripping : / - / .. Both colon-separated (00:0c:29:b9:8b:2d) and mac:hexraw (000c29b98b2d) formats are supported.
hostnameNoNoneHostname, e.g., worker-01.
formatNoipxeipxe or json.

It is recommended to pass at least one of mac or hostname. From iPXE, it is best to pass both:

text
/boot-vars?mac=${mac}&hostname=${hostname}

Note: Although ${mac:hexraw} is technically equivalent to ${mac} (both are normalized), some real iPXE firmware expands the hexraw modifier incorrectly (possibly empty). In practice, you must use colon-separated ${mac} — do not change it back to hexraw.

curl (iPXE Format)

bash
curl -s "$BASE_URL/boot-vars?mac=000c29b98b2d&hostname=worker-01"

Example success response:

ipxe
#!ipxe
# boot vars for worker-01
set base-iqn iqn.2026-07.com.controller
set iscsi-server 192.168.80.3
set iscsi-sep :::1:
set menu-default ubuntu
set menu-timeout 5000

Registered but no default boot configured (no system disk / no default_os / no explicit boot.menu_default):

ipxe
#!ipxe
# boot vars for worker-01
set menu-default reboot
set menu-timeout 1

New MAC (triggered auto-registration) or completely unrecognized, and if auto-registration fails or is disabled, returns an empty script:

ipxe
#!ipxe
# no per-worker boot vars found

curl (JSON Format)

bash
curl -s "$BASE_URL/boot-vars?mac=000c29b98b2d&hostname=worker-01&format=json"

Example success response:

json
{
  "base_iqn": "iqn.2026-07.com.controller",
  "iscsi_server": "192.168.80.3",
  "iscsi_sep": ":::1:",
  "menu_default": "ubuntu",
  "menu_timeout": 5000
}

Registered but no default boot configured:

json
{
  "menu_default": "reboot",
  "menu_timeout": 1
}

Unrecognized and no auto-registration triggered:

json
{}

iPXE Integration

At the end of tftp/boot.ipxe.cfg, this endpoint is fetched:

ipxe
chain --autofree http://${controller_ip}:4839/boot-vars?mac=${mac}&hostname=${hostname} || goto vars-done
# If the chain fails (endpoint unreachable), it silently continues with the static defaults at the top of this file.
# On success, the returned base-iqn / iscsi-server may override the static defaults; derived variables must be re-built.
# isset guard: do not override iscsi-sep if /boot-vars already provided a backend-specific separator (stgt `:::1:` / LIO `::::`)
isset ${iscsi-sep} || set iscsi-sep :::1:
isset ${hostname} && set initiator-iqn ${base-iqn}:${hostname} || set initiator-iqn ${base-iqn}:${mac}

:vars-done

In menu.ipxe, each OS and installation entry uses ${iscsi-sep} in the root-path (e.g., set root-path iscsi:${iscsi-server}${iscsi-sep}${base-iqn}:${hostname}.windows). The iscsi: protocol prefix and assembly structure remain static; only the separator is projected from the backend.

Agent Data-Plane Address

/boot-vars returns the data-plane address that Workers use to connect to iSCSI, not the Agent’s HTTP API address. It is recommended to explicitly configure it in config/agents.yml:

yaml
agents:
  storage-lio-01:
    base_url: http://host.docker.internal:4840
    iscsi_server: 192.168.80.3

If iscsi_server is not configured, the Control Plane falls back to the host portion of base_url. However, when base_url is host.docker.internal, this value is not suitable for physical Workers.

5.1 GET/PUT /settings/auto-register

Description

The global auto-register switch controls whether a new MAC automatically enters the device pool (see “Auto-Registration” in Section 5). When enabled, unknown MACs requesting /devices/report or /boot-vars are written to state/devices.yml (state=pooled); when disabled, new MACs receive an empty script and must be registered manually via POST /devices or POST /devices/import. Registered devices and bound Workers are unaffected — the switch only applies to new MACs.

There are two ways to enable/disable auto-registration; the runtime API takes precedence over the env var:

MethodTakes effectPersistencePriority
Env var IPXE_CP_AUTO_REGISTER=true/false (compose environment, see config table above)Container startupWith compose configLow (startup default)
PUT /settings/auto-registerImmediatelystate/settings.json, survives restartsHigh

GET /settings/auto-register

Queries the effective value: runtime state (state/settings.json) takes precedence; falls back to the env var default when unset.

Response:

FieldTypeDescription
enabledboolWhether auto-registration is currently on
json
{"enabled": true}

PUT /settings/auto-register

Toggles the switch and persists it; takes effect immediately; recorded as an operation (settings.auto_register).

Request body:

FieldRequiredTypeDescription
enabledYesboolfalse = disable auto-registration (new MACs are no longer auto-registered)

Response: same as GET, returns the new {"enabled": bool}.

curl

bash
# Enable
curl -X PUT http://<host>:4839/settings/auto-register \
  -H "Authorization: Bearer $CP_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": true}'

# Disable
curl -X PUT http://<host>:4839/settings/auto-register \
  -H "Authorization: Bearer $CP_TOKEN" -H "Content-Type: application/json" \
  -d '{"enabled": false}'

6. GET /agents

Description

Lists Agents configured in config/agents.yml. By default, it also probes each Agent’s /healthz and /capabilities in real time.

Query Parameters

ParameterRequiredDefaultDescription
liveNotrueWhether to probe Agent status and capabilities in real time.

curl

Live probe:

bash
curl -s "$BASE_URL/agents?live=true" \
  -H "Authorization: Bearer $TOKEN"

Configuration only, no probing:

bash
curl -s "$BASE_URL/agents?live=false" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
[
  {
    "id": "storage-lio-01",
    "base_url": "http://10.0.0.11:4840",
    "role": {
      "disk": true,
      "cd": false
    },
    "enabled": true,
    "tags": [
      "storage",
      "lio"
    ],
    "health": "ok",
    "capabilities": {
      "backend": "lio",
      "fs_type": "btrfs",
      "cd": false,
      "persistent": "saveconfig (auto-load on start)",
      "base_iqn": "iqn.2026-07.com.controller",
      "clone": "reflink (FICLONE) -> shutil.copy fallback",
      "empty_disk": "truncate (sparse)"
    }
  }
]

6.1 POST /agents

Description

Registers a new Agent: writes to config/agents.yml and takes effect immediately (the Agent will be included in disk creation / mount scheduling). Duplicate id registration returns 409.

Recommended flow: first fill in the API address in the WebUI (or use POST /agents/probe, see 6.2) and probe to automatically obtain role, tags, data-plane address, etc. After confirming, call this endpoint to complete registration. Direct full-parameter submission is also supported.

Request Body Fields

FieldRequiredDescription
idYesAgent identifier. Automatically lowercased. Same character rules as worker id (letters, digits, dots, underscores, hyphens).
base_urlYesAgent Control Plane API address. Must start with http:// or https://. Trailing / is removed automatically.
tokenNoAgent authentication token. Supports ${ENV} environment variable placeholder (expanded when the Control Plane reads it). Leave empty for Agents without authentication.
iscsi_serverNoiSCSI data-plane address (business network IP). Falls back to the hostname of base_url if omitted.
roleNoRoles: disk = can create system disks (storage node), cd = can mount ISOs (optical drive node). Default {disk: false, cd: false}.
tagsNoFree-form tag array (e.g., storage/lio/stgt), for display. The lio/stgt tags also participate in /boot-vars separator derivation.
enabledNoWhether the Agent is enabled. Default true.

curl

bash
curl -s -X POST "$BASE_URL/agents" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "storage-stgt-02",
    "base_url": "http://host.docker.internal:4840",
    "token": "${STORAGE_STGT_02_TOKEN}",
    "iscsi_server": "192.168.1.6",
    "role": {"disk": true, "cd": false},
    "tags": ["storage", "stgt"],
    "enabled": true
  }'

Success Response (201)

json
{
  "id": "storage-stgt-02",
  "base_url": "http://host.docker.internal:4840",
  "iscsi_server": "192.168.1.6",
  "role": {"disk": true, "cd": false},
  "enabled": true,
  "tags": ["storage", "stgt"]
}

Error Responses

Status CodeScenario
400Invalid id format / base_url does not start with http(s)
409Agent id already exists

6.2 POST /agents/probe

Description

Probes an Agent and auto-derives registration parameters (read-only preview, no files are written): calls the Agent’s /healthz (no authentication) and /capabilities (with Bearer token), and derives:

ParameterDerivation Rule
role.diskAlways true (Agent is an iSCSI storage node)
role.cdFrom capabilities.cd
tags["storage", backend] (where backend is lio or stgt; also used for /boot-vars separator derivation)
iscsi_serverFalls back to base_url hostname if not provided

Request Body Fields

FieldRequiredDescription
base_urlYesAgent Control Plane API address. Must start with http:// or https://.
tokenNoAgent authentication token. Required if the Agent has IPXE_AGENT_TOKEN configured (the Agent does not echo its own token, so it cannot be retrieved automatically).
agent_idNoIn edit scenarios: when token is left empty, the probe will use the token already stored in the registry for this Agent. Ignored for unknown ids.

curl

bash
curl -s -X POST "$BASE_URL/agents/probe" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"base_url": "http://host.docker.internal:4840", "token": "${STORAGE_STGT_02_TOKEN}"}'

Success Response

json
{
  "base_url": "http://host.docker.internal:4840",
  "role": {"disk": true, "cd": false},
  "tags": ["storage", "stgt"],
  "iscsi_server": "host.docker.internal",
  "enabled": true,
  "backend": "stgt",
  "fs_type": "btrfs",
  "base_iqn": "iqn.2026-07.com.controller",
  "clone": "reflink (FICLONE) -> shutil.copy fallback",
  "empty_disk": "truncate (sparse)",
  "persistent": "auto-scan on startup"
}

Error Responses

Status CodeScenario
400base_url does not start with http(s)
502Agent unreachable (/healthz failed) or /capabilities call failed (e.g., wrong token)

6.3 PUT /agents/

Description

Updates an existing Agent: overwrites the corresponding entry in config/agents.yml and takes effect immediately (disk creation / mount scheduling uses the new configuration). The id cannot be changed (taken from the path parameter). An empty string for token means keep the current value (the API never echoes the token, so the frontend cannot fill it back in).

Use cases: iSCSI server configuration changes — data-plane address migration, API address change, token rotation, disable/enable a node.

Request Body Fields

FieldRequiredDescription
base_urlYesAgent Control Plane API address. Must start with http:// or https://. Trailing / is removed automatically.
tokenNoEmpty string = keep the current value (do not overwrite). Pass a new value to rotate. Supports ${ENV} placeholders.
iscsi_serverNoiSCSI data-plane address. Falls back to the hostname of base_url if omitted.
roleNoRoles: disk = can create system disks, cd = can mount ISOs. Default {disk: false, cd: false}.
tagsNoFree-form tag array.
enabledNoWhether the Agent is enabled. false disables it (it will no longer participate in disk creation/mount scheduling or liveness probes). Default true.

curl

bash
curl -s -X PUT "$BASE_URL/agents/storage-stgt-02" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "base_url": "http://host.docker.internal:4840",
    "token": "",
    "iscsi_server": "192.168.1.8",
    "role": {"disk": true, "cd": false},
    "tags": ["storage", "stgt"],
    "enabled": true
  }'

Success Response (200)

json
{
  "id": "storage-stgt-02",
  "base_url": "http://host.docker.internal:4840",
  "iscsi_server": "192.168.1.8",
  "role": {"disk": true, "cd": false},
  "enabled": true,
  "tags": ["storage", "stgt"]
}

Error Responses

Status CodeScenario
400base_url does not start with http(s)
404Agent id not found

Probing during edit: In edit scenarios, it is recommended to first call POST /agents/probe (6.2) to verify the new address is reachable before saving. If token is left empty, pass the agent_id parameter; the backend will automatically use the token stored in the registry for that Agent.


7. POST /workers

Description

Registers a Worker’s identity: hostname binding. Storage and identity are separated — this endpoint does not create any system disks. System disks must be created separately via POST /workers/{worker_id}/luns/disk (see 7.1). mac is now optional:

  • Without mac → a pure idle Worker (hostname binding only; no device authorized). Bind a device later via POST /devices/{mac}/bind (16.7).
  • With mac → the device must exist in the device pool (state=pooled); it is validated and bound directly (one-to-one authorization). If the device is out-of-pool or already bound, a 409 is returned — register first, then bind.

The Control Plane will:

  1. Validate worker_id, hostname; validate mac if provided
  2. Write to state/workers.yml (disks as empty array, state=registered)
  3. If mac is provided: bind the device (write state/devices.yml bound_worker_id + write to dnsmasq/dhcp-hosts.conf)
  4. Send a HUP signal to the ipxe-dnsmasq container via Docker:
bash
docker exec ipxe-dnsmasq killall -HUP dnsmasq
  1. If windows_iso is specified, additionally call the Agent to create a CD target (installation optical drive, unrelated to system disks).

Request Body Fields

FieldRequiredDescription
worker_idYesWorker identifier. Automatically lowercased. Allowed characters: letters, digits, dots, underscores, hyphens.
macNoWorker NIC MAC address, e.g., 00:0c:29:b9:8b:2d. If provided, the device must already be in the pool (see 16); the device is bound to this Worker in the same call.
hostnameNoHostname. Defaults to worker_id if not provided.
archNoArchitecture. Defaults to x86_64.
windows_isoNoWindows installation ISO filename. When provided, an installation CD target is created during registration.
bootNoiPXE menu default item and timeout configuration. If omitted, /boot-vars derives them from the default boot OS and global defaults. Writes to the same ledger fields as the 7.3 default-os endpoint; later calls override earlier ones.

boot Fields

FieldRequiredDescription
menu_defaultNoDefault item on the iPXE main menu (auto-selected after the menu times out); valid values in the 7.3 table; case-insensitive, e.g., ubuntu, debian, windows, exit.
menu_timeoutNoiPXE menu timeout in milliseconds, e.g., 5000; 0 means the menu waits indefinitely and never auto-selects.

When boot is omitted:

  • menu_default defaults to default_os (set separately after disk creation, see 7.3); if not set, defaults to reboot (loop reboot waiting for configuration, see section 5).
  • menu_timeout defaults to IPXE_CP_BOOT_MENU_TIMEOUT (currently 5000) when a default boot is configured; in the reboot loop it is fixed to IPXE_CP_AUTO_BOOT_TIMEOUT (currently 1 ms, see section 5).

Therefore, most Workers do not need to pass boot. For example:

json
{
  "worker_id": "worker-01",
  "mac": "00:0c:29:b9:8b:2d"
}

After registration, the Worker has no system disk, so /boot-vars returns menu-default reboot with a 1 ms timeout — the Worker enters a fast reboot loop waiting for the admin to create a disk / set a default boot OS:

ipxe
set menu-default reboot
set menu-timeout 1

After creating a system disk, call PUT /workers/{worker_id}/default-os (see 7.3) to set the default boot OS, and menu-default will switch to that OS’s menu item (e.g., ubuntu).

Only pass boot when you want to override the menu behavior:

json
{
  "worker_id": "worker-01",
  "mac": "00:0c:29:b9:8b:2d",
  "boot": {
    "menu_default": "exit",
    "menu_timeout": 0
  }
}

During Windows installation, if you want to default to the installation menu:

json
{
  "worker_id": "worker-win-build",
  "mac": "00:0c:29:b9:8b:11",
  "windows_iso": "Win11_24H2.iso",
  "boot": {
    "menu_default": "menu-install",
    "menu_timeout": 3000
  }
}

PUT /workers/{worker_id}/mac (Rebind Mapping)

Changes the Worker’s MAC binding (hostname unchanged). It is mapped internally to a device rebind: the new MAC must be in the pool (state=pooled) and is bound to this Worker; the old device (if bound to this Worker) is released back to the pool. The audit trail records device.bind (new) + device.unbind (old) plus the compatibility worker.mac.update.

Request body: {"mac": "00:0c:29:b9:8b:2d"}

409: new MAC out-of-pool / revoked / already bound to another Worker; old device bound to an unexpected Worker.

Idempotent: setting the same MAC again returns changed=false.

7.1 POST /workers/{worker_id}/luns/disk

Description

Creates a system disk LUN for a given Worker. System disks are categorized by OS; a Worker can have multiple system disks for different OSes (at most one per OS). The Control Plane will:

  1. Verify the Worker exists and does not already have a disk for that OS (returns 409 if it does).
  2. Determine the corresponding OS for the disk: the request body os is required and determines the IQN suffix and filename.
  3. Select a storage Agent (specified by disk_agent or auto-selected).
  4. Assemble the IQN and backing filename (base-iqn:worker-id.os).
  5. Call the Agent to create the disk target (master clone or empty disk).
  6. Update the Worker’s disks inventory in state/workers.yml (append to array). On the first disk creation, state transitions from registered to ready.

This endpoint resides under the /luns/ namespace to reserve space for future data disks (/luns/data). In a multi-OS scenario, the default boot OS is determined by the os parameter of PUT /workers/{worker_id}/default-os.

Path Parameters

ParameterRequiredDescription
worker_idYesWorker identifier.

Request Body Fields

FieldRequiredDescription
typeYesmaster or empty.
nameConditionally requiredRequired when type=master. The master image filename.
sizeConditionally requiredRequired when type=empty. The empty disk size, e.g., 40G.
osYesThe OS this system disk corresponds to (determines the IQN suffix and filename). Allowed values: windows, ubuntu, debian, centos, esxi (menu.ipxe OS items).
disk_agentNoSpecify a storage Agent. If omitted, the Control Plane auto-selects one.

7.1.1 Clone from Master

curl

bash
curl -s -X POST "$BASE_URL/workers/worker-01/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "master",
    "os": "ubuntu",
    "name": "_tpl_ubuntu_2204.img"
  }'

7.1.2 Create Empty Disk

curl

bash
curl -s -X POST "$BASE_URL/workers/worker-00/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "empty",
    "os": "ubuntu",
    "size": "40G"
  }'

Example Success Response (master clone)

json
{
  "hostname": "worker-01",
  "arch": "x86_64",
  "state": "ready",
  "disks": [
    {
      "agent": "storage-lio-01",
      "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu",
      "filename": "worker-01.ubuntu.img",
      "backing": "/home/iscsi_img/worker-01.ubuntu.img",
      "os": "ubuntu",
      "source": {
        "type": "master",
        "name": "_tpl_ubuntu_2204.img"
      }
    }
  ],
  "cd": null,
  "worker_id": "worker-01",
  "mac": "00:0c:29:b9:8b:2d"
}

Example Success Response (empty disk)

json
{
  "hostname": "worker-00",
  "arch": "x86_64",
  "state": "ready",
  "disks": [
    {
      "agent": "storage-lio-01",
      "iqn": "iqn.2026-07.com.controller:worker-00.ubuntu",
      "filename": "worker-00.ubuntu.img",
      "backing": "/home/iscsi_img/worker-00.ubuntu.img",
      "os": "ubuntu",
      "source": {
        "type": "empty",
        "size": "40G"
      }
    }
  ],
  "cd": null,
  "worker_id": "worker-00",
  "mac": "00:0c:29:b9:8b:00"
}

7.1.3 Batch Create System Disks (POST /workers/luns/disk/batch)

Batch deployment scenario: apply the same disk parameters to multiple Workers, each using its pre-assigned storage node (targets[].agent is required — it is generated by the WebUI’s “take over selected Workers” or drag-and-drop assignment; there is no common automatic allocation).

Same as single-disk creation: master clones from a master image, empty creates a blank disk. At most one disk per os is allowed; if a Worker already has one it is automatically skipped (not considered a failure). Successfully created Workers automatically set default_os to the OS of this batch — batch deployment goes directly to the default boot without needing an extra PUT /workers/{worker_id}/default-os call (single-disk creation does NOT set this automatically). Each item is processed independently; a failure of one does not affect the others. Returns a summary of succeeded / skipped / failed.

Request Body Fields

FieldRequiredDescription
typeYesmaster or empty.
osYesThe OS for the system disk (same for all Workers in the batch; determines the IQN suffix and filename).
nameConditionally requiredRequired when type=master. The master image filename.
sizeConditionally requiredRequired when type=empty. The empty disk size, e.g., 40G.
targetsYesArray of {worker_id, agent}: the Worker identifier and its assigned storage node.

curl

bash
curl -s -X POST "$BASE_URL/workers/luns/disk/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "master",
    "os": "ubuntu",
    "name": "_tpl_ubuntu_2204.img",
    "targets": [
      { "worker_id": "worker-01", "agent": "storage-lio-01" },
      { "worker_id": "worker-02", "agent": "storage-lio-01" },
      { "worker_id": "worker-03", "agent": "storage-stgt-01" }
    ]
  }'

Example Response

json
{
  "succeeded": [
    { "worker_id": "worker-01", "agent": "storage-lio-01", "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu" },
    { "worker_id": "worker-03", "agent": "storage-stgt-01", "iqn": "iqn.2026-07.com.controller:worker-03.ubuntu" }
  ],
  "skipped": [
    { "worker_id": "worker-02", "reason": "already has a ubuntu system disk" }
  ],
  "failed": [
    { "worker_id": "worker-04", "agent": "storage-lio-01", "error": "worker not found: worker-04" }
  ]
}

7.2 Windows Installation: Identity Registration + ISO + System Disk

Windows installation is a two-step process: first register the identity (optionally specifying the installation ISO), then create the system disk.

7.2.1 Identity Registration (with ISO)

bash
curl -s -X POST "$BASE_URL/workers" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_id": "worker-win-build",
    "mac": "00:0c:29:b9:8b:11",
    "windows_iso": "Win11_24H2.iso"
  }'

After registration, the response has state=installing (a CD target exists) and disks as an empty array.

7.2.2 Create System Disk

bash
curl -s -X POST "$BASE_URL/workers/worker-win-build/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "empty",
    "os": "windows",
    "size": "80G"
  }'

Response after creation:

json
{
  "hostname": "worker-win-build",
  "arch": "x86_64",
  "state": "installing",
  "disks": [
    {
      "agent": "storage-lio-01",
      "iqn": "iqn.2026-07.com.controller:worker-win-build.windows",
      "filename": "worker-win-build.windows.img",
      "backing": "/home/iscsi_img/worker-win-build.windows.img",
      "os": "windows",
      "source": {
        "type": "empty",
        "size": "80G"
      }
    }
  ],
  "cd": {
    "agent": "controller-stgt",
    "iqn": "iqn.2026-07.com.controller:worker-win-build.windows.iso",
    "iso": "Win11_24H2.iso",
    "backing": "/home/iscsi_img/Win11_24H2.iso"
  },
  "worker_id": "worker-win-build",
  "mac": "00:0c:29:b9:8b:11"
}

After installation finishes, the CD target is cleaned up as part of the Worker deletion flow.

Common Errors

HTTP StatusCommon Causes
400Parameter format error; os not in {windows/ubuntu/debian/centos/esxi}; type=master but name missing; type=empty but size missing
401Missing or incorrect Token
404Worker not found when creating a system disk
409worker_id already exists; hostname already exists; MAC already bound; Worker already has a disk for that OS (duplicate os); IQN already exists on Agent; backing file already exists
500dnsmasq reload failed; file write failure; other unexpected errors
503Agent unreachable; docker.sock unavailable

7.3 PUT /workers/{worker_id}/default-os

Description

What the “default boot OS” is for: a Worker can have multiple system disks (at most one per OS, e.g., ubuntu + windows). On every boot, the iPXE menu auto-selects one item after its timeout — the default boot OS configured here decides which item is auto-selected, and also decides which disk’s connection info /boot-vars projects (base_iqn / iscsi_server come from the default boot disk, see section 5). Without it, the menu auto-selects reboot with a 1 ms timeout and loops rebooting until the admin finishes configuration — never silently booting into the wrong OS.

Note: os is not an arbitrary name for the disk; it is the ID of a menu.ipxe OS menu item (same enum as the os of disk creation in 7.1), one-to-one with an attached system disk.

The /boot-vars menu_default derivation chain:

text
default_os (the `os` field of this endpoint, highest priority) -> boot.menu_default (the `menu_default` field of this endpoint) -> reboot (not configured, loop reboot waiting)

The three request body fields can be sent individually or in combination; at least one must be provided. Sending null (or an empty string) clears the corresponding item. This endpoint can be called repeatedly; later calls override earlier ones — they write the same ledger fields as the boot passed at registration (see 7.0).

Requirements:

  • When setting os: The Worker must already have a system disk for that OS (created by POST /workers/{worker_id}/luns/disk), otherwise a 400 is returned listing the current system disks. In the multi-disk model, use os to precisely match the OS to boot by default.
  • When setting menu_default: The value must be a valid item ID from the menu.ipxe main menu (strict validation to prevent an empty choose --default in iPXE).
  • When setting menu_timeout: Non-negative integer; 0 means the menu waits indefinitely (never auto-selects, waits for a human keypress); clearing restores the default IPXE_CP_BOOT_MENU_TIMEOUT (currently 5000).

Path Parameters

ParameterRequiredDescription
worker_idYesWorker identifier.

Request Body Fields

FieldRequiredDescription
osNoDefault boot OS (a menu item ID, not a disk name) — only windows ubuntu debian centos esxi are allowed (same enum as disk creation in 7.1), case-insensitive (auto-lowercased), and must match a system disk already attached to this Worker. Pass null to clear.
menu_defaultNoDefault item on the iPXE main menu (auto-selected after the menu times out). See the valid values table below; case-insensitive (auto-lowercased). Pass null to clear.
menu_timeoutNoMenu timeout in milliseconds, non-negative integer; 0 means the menu waits indefinitely (never auto-selects, waits for a human keypress). Pass null to clear, restoring IPXE_CP_BOOT_MENU_TIMEOUT (currently 5000).

Valid menu_default Values (menu.ipxe Main Menu Item IDs)

CategoryValid Values
Operating Systemswindows ubuntu debian centos esxi
Tools / Installationmenu-diag menu-install
Advancedconfig shell reboot exit

Example: Set Default OS

bash
curl -s -X PUT "$BASE_URL/workers/worker-01/default-os" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "os": "ubuntu"
  }'

Example: Set Menu Default Item and Timeout

bash
curl -s -X PUT "$BASE_URL/workers/worker-win-build/default-os" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "menu_default": "menu-install",
    "menu_timeout": 3000
  }'

Example: Clear Default OS

bash
curl -s -X PUT "$BASE_URL/workers/worker-01/default-os" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "os": null
  }'

Success Response

Returns the full Worker inventory, including the set fields like default_os, boot.menu_default, boot.menu_timeout.

Common Errors

HTTP StatusCommon Causes
400None of the three fields provided; os does not match any attached system disk; menu_default not in the valid values table; menu_timeout negative
401Missing or incorrect Token
404Worker not found
409Setting os when the Worker has no system disks

7.4 DELETE /workers/{worker_id}/luns/disk/

Description

Deletes a single system disk of a given Worker (by OS name; os is case-insensitive). The Control Plane will:

  1. Verify the Worker exists and has a disk for that OS (returns 404 if not).
  2. Call the hosting Agent to delete the iSCSI target.
  3. Remove the disk entry from the Worker’s disks array in state/workers.yml.
  4. Cleanup linkage: if the deleted disk was the default boot OS (default_os), both default_os and any matching boot.menu_default are cleared (to prevent iPXE from booting to a deleted disk).
  5. When the last disk is deleted, state reverts from ready to registered (waiting for a new disk to be created).

Query Parameters

ParameterDefaultDescription
delete_filefalseWhether to also delete the backing .img file. false only deletes the target (the .img is retained and can be re-attached).
ignore_missing_targetfalseIf true, ignores a 404 iqn not found from the Agent and continues with the inventory cleanup.

Example: Delete System Disk, Keep .img

bash
curl -s -X DELETE "$BASE_URL/workers/worker-01/luns/disk/ubuntu" \
  -H "Authorization: Bearer $TOKEN"

Example: Delete System Disk and .img File

bash
curl -s -X DELETE "$BASE_URL/workers/worker-01/luns/disk/ubuntu?delete_file=true" \
  -H "Authorization: Bearer $TOKEN"

Success Response

Returns the full Worker inventory (with disks no longer containing the deleted system disk; if it was the default, default_os/boot.menu_default are cleared; if no disks remain, state=registered).

Common Errors

HTTP StatusCommon Causes
400Invalid os
401Missing or incorrect Token
404Worker not found, or the Worker does not have a disk for that OS

7.6 POST /workers/batch

Description

Bulk-creates Workers (per-item independent — a failure of one item does not affect the others; idempotent — re-running does not duplicate Workers). worker_ids are generated as name_prefix + an index (worker-01, worker-02, …; the index starts at 01 and its width adapts to countcount=100 yields worker-001worker-100).

  • Without macs → all Workers are idle (hostname binding only, no device authorized); bind them later via POST /devices/{mac}/bind (16.7)
  • With macs (must be the same length as count) → each MAC is validated against the device pool and bound directly (same semantics as passing mac in section 7: the device must be state=pooled; pool-miss / already bound → that item is failed and not created, retry after fixing)

windows_iso is not supported (register installation ISOs one by one via section 7).

Request Body Fields

FieldRequiredDescription
countYesNumber of Workers to create, 1–100
name_prefixNoWorker ID prefix, default worker-. The generated worker_ids must be valid (letters, digits, dot, underscore, hyphen allowed); an invalid prefix rejects the whole batch with 400
macsNoArray of MAC addresses (format 00:0c:29:b9:8b:2d); when provided, its length must equal count, and each MAC is validated and bound directly
archNoArchitecture. Defaults to x86_64
bootNoiPXE menu default and timeout config; fields as in section 7

Idempotency and Failure Classification

  • succeeded: created by this call (with macs: includes the bind — an item is only created after its bind succeeds)
  • skipped: worker_id already exists (result of re-running the same request)
  • failed: device not in pool / revoked / already bound / invalid MAC / hostname conflict etc. — the item is not created, the others are unaffected; retry after fixing

Example

bash
curl -s -X POST "$BASE_URL/workers/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "count": 3,
    "name_prefix": "worker-",
    "macs": ["00:0c:29:b9:8b:01", "00:0c:29:b9:8b:02", "00:0c:29:b9:8b:03"]
  }'

Success Response (200)

json
{
  "succeeded": [
    {"worker_id": "worker-01", "hostname": "worker-01", "mac": "00:0c:29:b9:8b:01"}
  ],
  "skipped": [],
  "failed": [
    {
      "worker_id": "worker-02",
      "hostname": "worker-02",
      "mac": "00:0c:29:b9:8b:02",
      "error": "device already bound to worker-01: 00:0c:29:b9:8b:02"
    }
  ]
}

Common Errors

HTTP StatusCommon Causes
400name_prefix empty / generated worker_id invalid / macs length not equal to count
401Missing or incorrect Token
422count missing or out of 1–100 range

8. GET /workers

Description

Lists the inventory of all current Workers. The mac field in the response is a real-time reverse lookup from dnsmasq/dhcp-hosts.conf.

curl

bash
curl -s "$BASE_URL/workers" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
[
  {
    "hostname": "worker-00",
    "arch": "x86_64",
    "state": "ready",
    "disks": [
      {
        "agent": "storage-lio-01",
        "iqn": "iqn.2026-07.com.controller:worker-00.ubuntu",
        "filename": "worker-00.ubuntu.img",
        "backing": "/home/iscsi_img/worker-00.ubuntu.img",
        "os": "ubuntu",
        "source": {
          "type": "empty",
          "size": "40G"
        }
      }
    ],
    "cd": null,
    "worker_id": "worker-00",
    "mac": "00:0c:29:b9:8b:00"
  }
]

9. GET /workers/

Description

Queries the inventory record of a single Worker.

Path Parameters

ParameterRequiredDescription
worker_idYesWorker identifier.

curl

bash
curl -s "$BASE_URL/workers/worker-01" \
  -H "Authorization: Bearer $TOKEN"

Success Response

The response structure is identical to the successful POST /workers result.


10. GET /workers/{worker_id}/status

Description

Queries the Worker’s inventory information and performs real-time checks:

  • Whether the hostname’s MAC exists in dnsmasq/dhcp-hosts.conf.
  • Whether the corresponding disk target(s) exist on the Agent(s).
  • Whether the corresponding CD target exists on the Agent.

Path Parameters

ParameterRequiredDescription
worker_idYesWorker identifier.

curl

bash
curl -s "$BASE_URL/workers/worker-01/status" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "worker": {
    "hostname": "worker-01",
    "arch": "x86_64",
    "state": "ready",
    "disks": [
      {
        "agent": "storage-lio-01",
        "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu",
        "filename": "worker-01.ubuntu.img",
        "backing": "/home/iscsi_img/worker-01.ubuntu.img",
        "os": "ubuntu",
        "source": {
          "type": "master",
          "name": "_tpl_ubuntu_2204.img"
        }
      }
    ],
    "cd": null,
    "worker_id": "worker-01",
    "mac": "00:0c:29:b9:8b:2d"
  },
  "actual": {
    "dnsmasq": {
      "hostname": "worker-01",
      "mac": "00:0c:29:b9:8b:2d"
    },
    "disks": [
      {
        "os": "ubuntu",
        "exists": true,
        "target": {
          "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu",
          "luns": [
            {
              "backing": "/home/iscsi_img/worker-01.ubuntu.img"
            }
          ]
        }
      }
    ],
    "cd": null
  }
}

11. DELETE /workers/

Description

Deletes a Worker. The Control Plane will:

  1. Look up the Worker’s disk and CD inventory from workers.yml.
  2. Cascade-unbind devices: every device whose bound_worker_id is this Worker is released back to the pool (state=pooled, bound_worker_id=null; the devices are not revoked) — the unbind is persisted first, and if it fails the deletion is aborted.
  3. If a CD target exists, delete it first.
  4. Then delete the disk target(s).
  5. Remove the Worker from workers.yml.
  6. Remove the mac,hostname line from dnsmasq/dhcp-hosts.conf.
  7. Send HUP to ipxe-dnsmasq.

The same cascade-unbind applies to POST /workers/delete/batch (11.1).

Path Parameters

ParameterRequiredDescription
worker_idYesWorker identifier to delete.

Query Parameters

ParameterRequiredDefaultDescription
delete_diskNofalseWhether to also delete backing .img files.
ignore_missing_targetNofalseIf true, ignores 404 iqn not found from the Agent and continues.

curl

Delete only targets, keep .img files:

bash
curl -s -X DELETE "$BASE_URL/workers/worker-01?delete_disk=false" \
  -H "Authorization: Bearer $TOKEN"

Delete targets and .img files:

bash
curl -s -X DELETE "$BASE_URL/workers/worker-01?delete_disk=true" \
  -H "Authorization: Bearer $TOKEN"

Ignore missing targets on the Agent:

bash
curl -s -X DELETE "$BASE_URL/workers/worker-01?delete_disk=true&ignore_missing_target=true" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "deleted": "worker-01",
  "delete_disk": false,
  "dnsmasq_removed": true
}

11.1 POST /workers/delete/batch

Description

Batch delete Workers. Each item is processed independently; a failure of one does not affect the others. Returns a summary of succeeded / failed. The processing per Worker is identical to the single deletion in section 11 (delete CD/system disk targets → remove from inventory → remove dnsmasq binding). After all successful items are processed, the inventory is saved and dnsmasq is reloaded only once. Non-existent Workers are counted as failed (worker not found).

Request Body Fields

FieldRequiredDefaultDescription
worker_idsYesArray of Worker identifiers to delete.
delete_diskNofalseWhether to also delete backing .img files.
ignore_missing_targetNofalseIf true, ignores 404 iqn not found from the Agent and continues.

curl

bash
curl -s -X POST "$BASE_URL/workers/delete/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_ids": ["worker-01", "worker-02"],
    "delete_disk": false,
    "ignore_missing_target": true
  }'

Example Success Response

json
{
  "succeeded": [
    {"worker_id": "worker-01", "hostname": "worker-01"}
  ],
  "failed": [
    {"worker_id": "worker-03", "error": "worker not found: worker-03"}
  ]
}

12. GET /operations

Description

Reads the Control Plane operation audit log. This file is an incremental query interface over state/operations.jsonl.

Query Parameters

ParameterRequiredDefaultDescription
sinceNo0Only return records with id > since.
limitNo1000Maximum number of records to return.
macNoOnly return operations of this device (MAC, normalized then filtered by the mac field); used for viewing device binding history.

curl

Read from the beginning:

bash
curl -s "$BASE_URL/operations" \
  -H "Authorization: Bearer $TOKEN"

Incremental read:

bash
curl -s "$BASE_URL/operations?since=10&limit=100" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "next_cursor": 5,
  "entries": [
    {
      "id": 1,
      "ts": "2026-07-27T14:20:00+00:00",
      "op": "create_worker",
      "status": "started",
      "worker_id": "worker-01",
      "client": "172.18.0.1"
    },
    {
      "id": 2,
      "ts": "2026-07-27T14:20:01+00:00",
      "op": "agent.create_disk",
      "status": "ok",
      "worker_id": "worker-01",
      "agent": "storage-lio-01",
      "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu"
    }
  ]
}

13. Typical Test Sequence

It is recommended to verify in this order:

13.1 Check Service Liveness

bash
curl -s "$BASE_URL/healthz"

13.2 Check Boot Variable Projection

bash
curl -s "$BASE_URL/boot-vars?mac=000c29b98b2d&hostname=worker-01"
curl -s "$BASE_URL/boot-vars?mac=000c29b98b2d&hostname=worker-01&format=json"

13.3 Check Agent Configuration and Capabilities

Note: config/agents.yml is read from inside the Control Plane container. If an Agent is on the same host as the Control Plane, you cannot use http://localhost:4840 because localhost inside the container points to the Control Plane container itself.

The default Compose file already configures:

yaml
extra_hosts:
  - "host.docker.internal:host-gateway"

Therefore, for an Agent on the same host, configure it as:

yaml
base_url: http://host.docker.internal:4840
bash
curl -s "$BASE_URL/agents?live=true" \
  -H "Authorization: Bearer $TOKEN"

13.4 Register Worker Identity (hostname + MAC binding)

bash
curl -s -X POST "$BASE_URL/workers" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "worker_id": "worker-00",
    "mac": "00:0c:29:b9:8b:00"
  }'

At this point, the Worker is MAC-bound but has no system disk (state=registered, disks is an empty array).

13.5 Create a System Disk for worker-00 (empty disk)

bash
curl -s -X POST "$BASE_URL/workers/worker-00/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "empty",
    "os": "ubuntu",
    "size": "40G"
  }'

After the disk is created, state transitions from registered to ready.

13.6 Set Default Boot Configuration

bash
curl -s -X PUT "$BASE_URL/workers/worker-00/default-os" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "os": "ubuntu"
  }'

Now /boot-vars will return menu-default=ubuntu. See section 7.3 for examples of setting menu items and timeout.

13.7 Query Worker Inventory

bash
curl -s "$BASE_URL/workers/worker-00" \
  -H "Authorization: Bearer $TOKEN"

13.8 Query Real-Time Status

bash
curl -s "$BASE_URL/workers/worker-00/status" \
  -H "Authorization: Bearer $TOKEN"

13.9 Delete Worker, Keeping the Empty Disk File

bash
curl -s -X DELETE "$BASE_URL/workers/worker-00?delete_disk=false" \
  -H "Authorization: Bearer $TOKEN"

This step is ideal for a workflow where you create an empty disk, then manually rename it to a master image.


14. Agent iSCSI LUN/Target Management

Description

The Control Plane can directly manage iSCSI targets/LUNs on any Agent. Requests are forwarded by the Control Plane to the Agent (the Agent’s Bearer token is provided by config/agents.yml), so callers only need the Control Plane Token and never need to contact the Agent directly.

Differences from Worker lifecycle endpoints (POST /workers, DELETE /workers/{worker_id}):

  • Worker endpoints are inventory-oriented: they automatically assemble IQNs, write state/workers.yml, and write dnsmasq bindings.
  • LUN management endpoints are data-plane direct management: they do not write any inventory; they directly operate targets on the Agent. Suitable for master image management, manual troubleshooting, temporary ISO mounting, etc.

All endpoints require authentication (IPXE_CP_TOKEN). If the Agent is not found, 404 agent not found is returned. If the Agent is unreachable, 503 is returned. Business validation errors from the Agent side (e.g., IQN prefix mismatch, file already exists, IQN already exists) are passed through with the Agent’s status code and detail:

json
{"agent": "storage-lio-01", "error": "iqn base mismatch: ..."}

14.1 GET /agents/{agent_id}/luns

Lists all iSCSI targets/LUNs on the specified Agent. The response structure depends on the Agent backend (stgt includes a tid field, LIO is the parsed output of targetcli). The Control Plane passes it through unchanged.

Path Parameters

ParameterRequiredDescription
agent_idYesAgent identifier, corresponding to a key in config/agents.yml.

curl

bash
curl -s "$BASE_URL/agents/storage-lio-01/luns" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
[
  {
    "iqn": "iqn.2026-07.com.controller:worker-01.ubuntu",
    "luns": [
      {
        "backing": "/home/iscsi_img/worker-01.ubuntu.img"
      }
    ]
  }
]

14.2 POST /agents/{agent_id}/luns/disk

Creates a disk LUN on the specified Agent. Pass master to clone from a master image (prefers btrfs / ZFS(≥2.2) reflink for instant cloning), or pass size to create an empty sparse disk. If the Agent is not configured with role.disk, it returns 400 agent ... not configured for disk role.

Path Parameters

ParameterRequiredDescription
agent_idYesAgent identifier.

Request Body Fields

FieldRequiredDescription
iqnYesTarget IQN. Must be prefixed with the Agent’s base_iqn.
filenameNoBacking filename. If omitted, the Agent auto-generates one from the IQN.
masterConditionally requiredMaster image filename (located in DISK_DIR). Mutually exclusive with size.
sizeConditionally requiredEmpty disk size, e.g., 40G. Mutually exclusive with master.

curl

bash
# Clone from master
curl -s -X POST "$BASE_URL/agents/storage-lio-01/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "iqn": "iqn.2026-07.com.controller:worker-02.ubuntu",
    "master": "_tpl_ubuntu_2204.img"
  }'

# Create empty disk
curl -s -X POST "$BASE_URL/agents/storage-lio-01/luns/disk" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "iqn": "iqn.2026-07.com.controller:worker-02.ubuntu",
    "filename": "worker-02.ubuntu.img",
    "size": "40G"
  }'

Example Success Response

json
{
  "iqn": "iqn.2026-07.com.controller:worker-02.ubuntu",
  "backing": "/home/iscsi_img/worker-02.ubuntu.img"
}

14.3 POST /agents/{agent_id}/luns/cd

Creates a CD (ISO virtual drive) LUN on the specified Agent. Only Agents with role.cd set to true support this. If the Agent is not configured for the CD role (e.g., LIO), it returns 400 agent ... not configured for cd role. Backend capability limitations are passed through from the Agent.

Request Body Fields

FieldRequiredDescription
isoYesISO filename (present in DISK_DIR).
iqnNoTarget IQN. If omitted, the Agent auto-generates one from base_iqn:iso_filename.

curl

bash
curl -s -X POST "$BASE_URL/agents/controller-stgt/luns/cd" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "iso": "Win11_24H2.iso"
  }'

14.4 DELETE /agents/{agent_id}/luns

Deletes a LUN/target on the specified Agent.

Query Parameters

ParameterRequiredDefaultDescription
iqnYesNoneTarget IQN to delete.
delete_fileNofalseWhether to also delete the backing file (.img/.iso).
ignore_missingNofalseIf true, treats a 404 iqn not found from the Agent as success.

curl

bash
# Delete only the target, keep the backing file
curl -s -X DELETE "$BASE_URL/agents/storage-lio-01/luns?iqn=iqn.2026-07.com.controller:worker-02.ubuntu" \
  -H "Authorization: Bearer $TOKEN"

# Delete target and backing file, continue even if target was already missing
curl -s -X DELETE "$BASE_URL/agents/storage-lio-01/luns?iqn=iqn.2026-07.com.controller:worker-02.ubuntu&delete_file=true&ignore_missing=true" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "deleted": "iqn.2026-07.com.controller:worker-02.ubuntu",
  "delete_file": false
}

When ignoring a missing target:

json
{
  "deleted": "iqn.2026-07.com.controller:worker-02.ubuntu",
  "delete_file": true,
  "ignored_missing": true
}

14.5 POST /agents/{agent_id}/luns/scan

Triggers the Agent to scan its image directory and rebuild targets for missing .img/.iso files (file-is-truth). For stgt backends, the result includes what was recreated. For LIO backends, due to saveconfig persistence, it usually skips everything.

curl

bash
curl -s -X POST "$BASE_URL/agents/storage-lio-01/luns/scan" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "created": [
    {
      "iqn": "iqn.2026-07.com.controller:worker-02.ubuntu",
      "cd": false
    }
  ],
  "skipped": []
}

15. GET /masters (Master Image List)

Description

Aggregates and lists the master images from all enabled disk-role Agents (enabled=true and role.disk=true). Master images are identified by the storage Agent’s background scanning thread (every 30 seconds by default), which looks for image files in DISK_DIR whose names contain the _tpl_ marker (e.g., _tpl_ubuntu_2204.img).

Used by the WebUI for the master clone drop-down list. It has no linkage to Worker creation APIs — purely a read-only query, does not change any state or write inventory.

Failure Tolerance

  • A single Agent unreachable or authentication failure: that node returns an error field and logs an audit master.list (failed), but does not block the overall result.
  • All nodes fail: the overall response is 502.
  • Partial success / no available nodes: returns 200 (with an empty agents array if no nodes).

curl

bash
curl -s "$BASE_URL/masters" \
  -H "Authorization: Bearer $TOKEN"

Example Success Response

json
{
  "agents": [
    {
      "agent": "storage-lio-01",
      "iscsi_server": "192.168.80.3",
      "masters": [
        {"name": "_tpl_ubuntu_2204.img", "size": 10737418240, "mtime": 1785643200},
        {"name": "_tpl_debian_12.img", "size": 8589934592, "mtime": 1785729600}
      ]
    }
  ]
}
FieldDescription
agentsArray, one entry per Agent with the disk role enabled.
agents[].agentAgent identifier (key in config/agents.yml).
agents[].iscsi_serverData-plane iSCSI address (same fallback rule as /boot-vars).
agents[].mastersArray of masters, each {name, size, mtime}: filename / size in bytes / modification timestamp.
agents[].errorError details if querying this node failed (absent for successful nodes).

16. Device Pool (Device Inventory)

The device inventory (state/devices.yml) is the bottom-layer entity of the three-layer entity model (device / Worker / system disk): auto-registration and manual import only enter the device pool — registration ≠ authorization: a device only gains an identity after being bound to a Worker. The binding relationship is authoritative on the device side (bound_worker_id); the Worker side only projects it and does not store it.

Binding semantics (P2): device ↔ Worker is strictly one-to-one. A device binds via POST /devices/{mac}/bind (16.7); force=true performs an atomic rebind (pre-check → new binding persisted → old binding cleared → rollback on failure). Unbinding (DELETE /devices/{mac}/bind, 16.8) returns the device to the pool; the Worker's system disks are kept. Deleting a Worker cascades an unbind (11). POST /workers with a mac also binds directly (7).

Readiness projection: Worker responses (list / detail / status) derive two fields from the ledger:

  • bound_device: the MAC of the device bound to this Worker (null if none)
  • readiness: ready (bound device + at least one system disk) / partial (bound device or system disk) / idle (neither)

Device States

StateDescription
pooledIn the device pool, unbound (auto-registered / manually registered / bulk imported)
boundBound to a Worker (one-to-one); see bound_worker_id
revokedRevoked; no longer accepts reports and cannot be re-registered

Record Structure (Example)

yaml
devices:
  "00:0c:29:b9:8b:2d":
    mac: 00:0c:29:b9:8b:2d
    uuid: "4c4c4544-..."          # SMBIOS UUID (dual factor, optional)
    state: pooled                 # pooled | bound | revoked
    bound_worker_id: null         # authoritative here; worker side only projects
    key_hash: null                # filled in the security blueprint phase; empty for now
    source: ipxe                  # ipxe (auto) | manual (manual entry/import)
    fingerprint:                  # reported values; updated by device reports
      manufacturer: ASUSTeK COMPUTER INC.
      product: ROG Zephyrus G15
      serial: "..."
      cpumodel: "Intel(R) Core(TM) Ultra 7 155H"
      mem_total: 32768            # normalized decimal (accepts 0x hex reports)
      mem_type: DDR5
      mem_speed: 5600
      chip: RTL8125
      busid: "0110ec8125"
    first_seen: 2026-08-15T10:00:00+08:00
    last_seen: 2026-08-15T12:00:00+08:00

16.1 GET /devices

Device pool list with state filtering (all / pooled / bound / revoked, default all).

curl:

bash
curl http://<host>:4839/devices?state=pooled \
  -H "Authorization: Bearer $CP_TOKEN"

Successful response: an array; each item is a device record (see “Record Structure” above).

16.2 GET /devices/

Single device detail (bound Worker, fingerprint, first/last report). mac uses the colon format (00:0c:29:b9:8b:2d).

404: device not found.

16.3 POST /devices

Manually register a device: MAC (+ optional UUID / manufacturer / product / serial) into the pool.

Request body:

FieldRequiredTypeDescription
macYesstrMAC address
uuidNostrSMBIOS UUID (dual factor, optional)
manufacturer / product / serialNostrReported info; initial inventory values only, superseded by device reports

Successful response (201): device record (state=pooled, source=manual).

409: device already exists (including revoked — revoked devices cannot be re-registered).

16.4 POST /devices/import

Bulk import a device manifest (pre-import of the MAC manifest): each entry is independent; duplicates are skipped; invalid/revoked entries count as failed.

Request body:

FieldRequiredTypeDescription
entriesYesarrayManifest array; each entry follows the 16.3 request body (mac required)

Successful response:

FieldDescription
createdMACs newly added to the pool
skippedAlready existing (pooled/bound) entries and reasons
failedInvalid MAC / revoked entries and reasons

16.5 DELETE /devices/

Revoke a device: pooledrevoked; the record stays in the inventory (audit retention).

409: device is bound to a Worker (must unbind first) or already revoked.

16.6 GET /devices/report

iPXE device info reporting endpoint (no auth; boot.ipxe.cfg chains it before /boot-vars): updates the fingerprint + last_seen; unknown MAC with auto-register on → enters the pool. Returns an empty response (no script side effects for chain).

ParameterRequiredDescription
macYesMAC; accepts colon format (${mac}) and compact hex (${netX/mac})
uuid / manufacturer / product / serial / cpumodel / mem-type / chip / busidNoString fields; empty values tolerated
mem-total / mem-speedNoInteger; accepts 0x hex and decimal; stored normalized as decimal

Behavior:

  • Registered device: updates the fingerprint (non-empty fields overwrite) + last_seen; state unchanged
  • Revoked device: ignored (not updated, not resurrected)
  • Unknown MAC + auto-register on: enters the pool (state=pooled, source=ipxe)
  • Unknown MAC + auto-register off: ignored

curl (simulating an iPXE report):

bash
curl "http://<host>:4839/devices/report?mac=000c29b98b2d&uuid=4c4c4544-...&manufacturer=ASUSTeK%20COMPUTER%20INC.&product=ROG%20Zephyrus%20G15&cpumodel=Intel(R)%20Core(TM)%20Ultra%207%20155H&mem-total=0x8000&mem-type=DDR5&mem-speed=5600&chip=RTL8125&busid=0110ec8125"

16.7 POST /devices/{mac}/bind

Binds a device to a Worker (one-to-one authorization). 409 by default when the device or the Worker is already bound; force=true performs an atomic rebind: pre-check → new binding persisted → old binding cleared (old device back to the pool) → on failure the ledger snapshot is restored and dnsmasq is best-effort restored (see “Implementation Boundaries”, §17). Idempotent: re-binding the same device to the same Worker returns 200 without changes.

Query ParameterRequiredDefaultDescription
worker_idYesTarget Worker.
forceNofalseAtomic rebind when device or Worker is already bound.

404: device or Worker not found. 409: device revoked / already bound (without force) / Worker already bound (without force) / dnsmasq conflict.

Rebind scenarios with force=true:

  • Device bound to worker-01, rebind to worker-02 → device moves; worker-01 becomes idle (no device)
  • worker-02 also bound another device → that device is released back to the pool
  • Device already bound to worker-02 and worker-02 bound to this device → idempotent success

The audit trail records device.bind with old_worker_id / old_device_mac (rebind history).

curl:

bash
curl -X POST "http://<host>:4839/devices/00:0c:29:b9:8b:2d/bind?worker_id=worker-01&force=false" \
  -H "Authorization: Bearer $CP_TOKEN"

Successful response (200): the device record (state=bound, bound_worker_id=worker-01).

16.8 DELETE /devices/{mac}/bind

Unbinds a device: back to the pool (state=pooled, bound_worker_id=null), the dnsmasq binding is removed and reloaded; the Worker's system disks are kept (its readiness degrades to partial/idle).

409: device not bound. 404: device not found.

curl:

bash
curl -X DELETE "http://<host>:4839/devices/00:0c:29:b9:8b:2d/bind" \
  -H "Authorization: Bearer $CP_TOKEN"

16.9 POST /devices/bind/batch/preview

Bulk bind preview (read-only, no writes): pairs the manifest into a pairing table.

Request body:

FieldRequiredDescription
modeNomanifest (default): use pairs; sequential: pair macs[i]worker_ids[i] by index (lengths must match, otherwise 400).
pairsNoArray of {mac, worker_id, manufacturer?, product?, serial?, uuid?}; the optional fields are declaration columns compared against the reported fingerprint (see below).
macs / worker_idsNoUsed by mode=sequential.

Classification per entry (independent, no whole-batch rejection):

  • matched: device pooled + Worker exists + Worker unbound (includes device_state, worker_state, fingerprint_mismatch)
  • conflicts: device already bound / Worker already bound / duplicate MAC in manifest / Worker not found
  • not_found: device out of pool (device not in pool), revoked, or invalid MAC

fingerprint_mismatch is null when consistent; otherwise {"fields": ["serial", ...]} — declaration values that differ from reported values (both non-empty). It is advisory and does not block binding.

Successful response: {matched: [...], conflicts: [...], not_found: [...], summary: {total, ok, conflict, not_found}}.

16.10 POST /devices/bind/batch

Bulk bind execution (idempotent, per-entry independent; failures of one entry do not affect the rest). Same request body as 16.9. Classification:

  • succeeded: bound now (with fingerprint_mismatch marker if declared values differ)
  • skipped: already bound (same Worker) / device already bound to another Worker / Worker already bound / duplicate MAC in manifest
  • failed: device not found (out-of-pool — import first via 16.3/16.4) / invalid MAC

Audit: besides the device.bind.batch summary, every succeeded entry also records a per-device device.bind (mac / worker_id), keeping device binding history (GET /operations?mac=) complete; skipped / failed are only counted in the summary.

Successful response: {succeeded: [...], skipped: [...], failed: [...]}. Re-running an already-completed manifest returns everything as skipped.

curl:

bash
curl -X POST "http://<host>:4839/devices/bind/batch" \
  -H "Authorization: Bearer $CP_TOKEN" \
  -d '{"mode":"manifest","pairs":[{"mac":"00:0c:29:b9:8b:2d","worker_id":"worker-01"}]}'

17. Current Implementation Boundaries

The current version supports:

  • Worker identity registration (hostname binding; mac optional — providing it binds the device directly)
  • Bulk Worker creation (count + naming rule, optional macs direct bind, POST /workers/batch, 7.6)
  • Device inventory (auto pool entry / manual registration / bulk import / revoke, /devices endpoints)
  • Device↔Worker one-to-one binding (bind / force rebind / unbind / batch bind preview + execution, 16.7–16.10)
  • Worker mac rebind mapping (PUT /workers/{worker_id}/mac, 7)
  • Cascade unbind on Worker deletion (11 / 11.1)
  • Boot-vars anti-impersonation (binding as authentication, 5)
  • iPXE device info reporting (11-field fingerprint, GET /devices/report; auto-registration only enters the pool, no Worker creation)
  • Worker system disk creation (POST /workers/{worker_id}/luns/disk)
  • Setting Worker default boot configuration (OS / menu item / timeout, PUT /workers/{worker_id}/default-os)
  • Worker deletion
  • Agent selection
  • Agent LUN direct management (list / create disk / create CD / delete / scan)
  • Master image list query (GET /masters, backed by periodic background scanning on storage nodes)
  • Windows ISO special handling
  • dnsmasq hostname bindings
  • Worker and operation audit trail queries
  • Multi-OS system disks (a Worker can have disks for multiple OSes, at most one per OS, distinguished by os, with default_os determining the default boot)

The current version does not yet include:

  • Automatic IP management
  • Automatic master image lifecycle management
  • Scheduled reconciliation
  • Data disk attachment (the /luns/data namespace is reserved)
  • Real transactions for the file-based storage: rollback = rewrite; if a rebind's old-binding cleanup fails and the restore also fails, locate and fix manually via the audit trail (16.7)

Component Ports

Control

  • dnsmasq: 67, 66
  • nginx: 4838
  • Control Plane: 4839

iSCSI Server

  • Agent: 4840
  • LIO / stgt: 3260