Requirements
Confirm your Kubernetes cluster meets these requirements before CosmicAC can run jobs on it.
Before you deploy CosmicAC on your host machine, your Kubernetes cluster must meet the requirements on this page so CosmicAC can run jobs on it.
The CosmicAC stack includes a GPU worker, cosmicac-wrk-server-k8s-nvidia. The worker is a control-plane agent that connects to your cluster's Kubernetes API. It creates GPU virtual machines as KubeVirt VMIs, along with their supporting resources. The worker doesn't install the operators, drivers, or node configuration it relies on. You install and maintain them on your cluster. For how the worker connects to your cluster and runs jobs, see Deployment architecture.
Use the readiness checklist that follows to confirm your cluster is ready, not to build one. Any version or value on this page comes from the CosmicAC reference cluster. Treat it as a reference point, not a hard requirement, unless the page marks it as required.
Readiness checklist
Your cluster is ready when all the following are true:
- Kubernetes version supported by KubeVirt 1.7 and CDI 1.62, with v1.33.x as the reference
- At least one GPU node ready, with whole-device VFIO passthrough configured
- NVIDIA driver, Fabric Manager, and NVSwitch healthy on each GPU node
- GPU resource advertised on each node as
nvidia.com/<gpuType>, plusmellanox.com/<ib>for multi-node - KubeVirt installed and healthy, with the HostDevices feature gate enabled
- CDI installed and healthy, with a scratch StorageClass set
- KubeVirt GPU device plugin daemonset running and advertising the GPU resource name
- KubeVirt CR permittedHostDevices listing your GPU and IB PCI IDs
- A working StorageClass named
local-pathavailable, ideally with WaitForFirstConsumer - Secret
ghcr-pull-credwith its five keys in the workload namespace - ConfigMap
bootstrap-cmin the workload namespace, for in-guest container bootstrap - Worker kubeconfig or ServiceAccount granted the RBAC in Section 7
- Multus, Whereabouts, OVS CNI, and
subcluster-manager-sainstalled for multi-node inference
The NVIDIA driver and Fabric Manager often come from the NVIDIA GPU Operator, but the Operator itself isn't a readiness gate. The worker consumes GPUs through KubeVirt PCI passthrough, so the hard requirement is the passthrough path in Section 3, not the GPU Operator's health. On the CosmicAC reference cluster, VMIs run normally even when the GPU Operator's ClusterPolicy reports notReady.
1. Cluster baseline
| Setting | Requirement | Notes |
|---|---|---|
| Kubernetes version | A version supported by KubeVirt 1.7 and CDI 1.62 | Reference v1.33.x. Newer minor versions are fine if KubeVirt and CDI still support them. |
| Container runtime | Any CRI. Reference CRI-O, and containerd also works | The worker passes GPUs to VMs through PCI passthrough, so the cluster doesn't need the NVIDIA container runtime for this path. |
| Host OS on GPU nodes | Linux with IOMMU and VFIO support | Reference Ubuntu 24.04 LTS, specifically 24.04.3 with kernel 6.8.x. See Section 2. |
| CNI | Any standard CNI | Reference Calico. Overlay-networking workloads layer Multus on top of your CNI rather than replacing it. See Section 8. |
| DNS and egress | Working cluster DNS and outbound egress from VMIs | Needed so guests can docker login and pull images. node-local-dns is optional and isn't a worker prerequisite. The reference cluster uses it for cache parity. |
2. GPU node requirements
You need at least one GPU node. The CosmicAC reference cluster uses eight NVIDIA H100 80 GB SXM GPUs and eight ConnectX-7 InfiniBand NICs per node, but a single GPU is the minimum for a single-node workload.
2.1 Node labels
Each GPU node must carry the following labels:
| Label | Why it matters |
|---|---|
nvidia.com/gpu.present=true | Node selector for the KubeVirt GPU device plugin daemonset. Without this label, the node doesn't advertise the passthrough GPU resource. See Section 3. |
node-role.kubernetes.io/kubevirt | Marks the node as a KubeVirt VM host so virt-handler schedules VMIs there. Any KubeVirt VM-host marker works. This is the reference. |
The reference cluster also carries a node-role.kubernetes.io/gpu label, but the worker's VMI spec doesn't select it. The label is informational and not required for the worker.
2.2 Whole-device passthrough (VFIO)
VMIs claim whole GPUs and IB PFs through PCI passthrough, so each GPU node must bind those devices to vfio-pci at the host level. Each GPU node must meet the following:
- IOMMU enabled and the host GPU driver kept off the passed-through devices
- Every GPU PCI ID and every IB PF PCI ID bound to vfio-pci
- NVSwitch left on its native driver, with Fabric Manager healthy so NVLink works across the passed-through GPUs
The NVIDIA Network Operator isn't required for the current VFIO and IB mechanism. IB PFs appear as plain passthrough PCI devices, not through the Network Operator.
2.3 Verify resource advertisement
After host preparation and the KubeVirt GPU device plugin from Section 3, each GPU node must advertise the GPU as an allocatable resource named nvidia.com/<gpuType>. For multi-node jobs, it must also advertise the IB PF as mellanox.com/<ibName>.
On the CosmicAC reference cluster, each GPU node advertises the following resources:
nvidia.com/GH100_H100_SXM5_80GB = 8
mellanox.com/cx7_ib_pf = 8
nvidia.com/gpu = 0 # expected: the worker requests the passthrough name, not nvidia.com/gpunvidia.com/gpu can legitimately be 0. The worker requests nvidia.com/<gpu.type> and mellanox.com/<ib_name>, not the generic nvidia.com/gpu, so the advertised name must match the name you configure the worker to request. The worker takes the InfiniBand name from K8S_IB_NAME in your deployment configuration.
3. GPU and virtualization platform
This section lists the hard requirements for the worker's passthrough path, then the reference implementation that satisfies them.
3.1 Hard requirements
| Requirement | Detail |
|---|---|
| KubeVirt installed and healthy | Runs VMs as VirtualMachineInstance. Reference version v1.7.0. |
| KubeVirt HostDevices feature gate enabled | Required for PCI passthrough of GPUs and IB into VMIs. |
| CDI installed and healthy | Imports VM root disks from a registry into DataVolumes. Reference version v1.62.0. |
| KubeVirt GPU device plugin running | Advertises the passed-through GPU resource to KubeVirt. Reference daemonset nvidia-kubevirt-gpu-dp-daemonset, image docker.io/anurlan/kubevirt-gpu-device-plugin:v1.5.0-fm, args --fm-enabled=true --fm-address=127.0.0.1:6666, nodeSelector nvidia.com/gpu.present=true. |
permittedHostDevices configured | The KubeVirt CR must permit your GPU PCI ID, and the IB PCI ID for multi-node. See Section 3.3. |
| NVIDIA driver and Fabric Manager healthy on the host | Any delivery method works, whether the GPU Operator, a custom node image, or a manual install. Required for the GPUs to be usable and for NVLink. |
3.2 Reference implementation
On the CosmicAC reference cluster, the following components meet these requirements:
| Component | Namespace | Version | Notes |
|---|---|---|---|
| NVIDIA GPU Operator | gpu-operator | v25.3.1 | Delivers the NVIDIA driver, container toolkit, DCGM, and Fabric Manager components. Its ClusterPolicy health isn't a worker readiness gate. VMIs run even when it reports notReady. |
| Node Feature Discovery | gpu-operator | 0.17.3 | Ships with the GPU-operator chart. |
The GPU Operator installs the nvidia and nvidia-cdi RuntimeClasses on the reference cluster, but they aren't a prerequisite. The worker's VMIs don't set runtimeClassName.
3.3 Required KubeVirt permittedHostDevices
KubeVirt allows passthrough only for PCI devices you explicitly permit. The kubevirt/kubevirt CR must permit your GPU and IB PFs in its permittedHostDevices. The reference value is as follows:
spec:
configuration:
permittedHostDevices:
pciHostDevices:
- pciVendorSelector: "10DE:2330" # NVIDIA H100 SXM5 80GB
resourceName: nvidia.com/GH100_H100_SXM5_80GB
externalResourceProvider: true # advertised by the KubeVirt GPU device plugin
- pciVendorSelector: "15B3:1021" # Mellanox ConnectX-7 IB PF
resourceName: mellanox.com/cx7_ib_pf
externalResourceProvider: falseOn different hardware, replace the pciVendorSelector PCI vendor:device pair and the resourceName to match your GPUs and NICs. The resourceName must match what the worker requests. See Section 2.3.
4. Storage
The worker creates DataVolumes for VM root disks and optional PersistentVolumeClaims for external data disks, with storageClassName: local-path. Your cluster must meet the following requirements:
- A working StorageClass named
local-pathmust exist. It doesn't have to be the cluster default, but prefer WaitForFirstConsumer so the scheduler places the VMI on a GPU node before it provisions the disk locally. - CDI must have a scratch StorageClass set for image imports. Reference value
scratchSpaceStorageClass: local-pathwith the HonorWaitForFirstConsumer feature gate enabled.
On the CosmicAC reference cluster, each node uses the local-path provisioner rancher.io/local-path with WaitForFirstConsumer. Provide enough local NVMe storage. VM root disks are commonly 150Gi or larger, so size the storage for your model cache.
4.1 Team storage volumes
Team storage volumes are optional. A team storage volume is persistent storage that a team attaches to its GPU Container Jobs. Each volume is a Gcore file share, so volumes work only when your cluster runs on Gcore. On any other cluster, leave the feature off.
To turn on volumes for a rack, set the Gcore project, region, and API token for its worker. See Team storage volumes. When a user creates a volume, CosmicAC creates a Network File System (NFS) file share through the Gcore API. Gcore then creates a StorageClass named gcore-nfs-<share-id> in your cluster.
Volumes also require the following:
- The extra worker permissions in Section 7.
- A deployment with a single cosmicac-wrk-ork.
5. Registry credentials in ghcr-pull-cred
The worker pulls private images from the GitHub Container Registry (GHCR) at two stages. Each stage reads different keys from the same secret in your workload namespace:
| Consumer | When | Keys it reads |
|---|---|---|
| CDI importer pod | Before the VM boots, imports the root-disk image into a DataVolume | accessKeyId, secretKey |
In-guest docker-bootstrap.sh | After the VM boots, logs in to the registry and pulls the inference container | DOCKER_REGISTRY, DOCKER_USERNAME, DOCKER_TOKEN |
KubeVirt references the secret as the vmImagePullSecret, named ghcr-pull-cred by default. The credentials are a GitHub personal access token (PAT) with at least read:packages, SSO-authorized for the tetherto org. Create the secret with all five keys:
kubectl -n <workload-namespace> create secret generic ghcr-pull-cred \
--from-literal=accessKeyId='<github-username>' \
--from-literal=secretKey='<github-PAT>' \
--from-literal=DOCKER_REGISTRY='ghcr.io' \
--from-literal=DOCKER_USERNAME='<github-username>' \
--from-literal=DOCKER_TOKEN='<github-PAT>'The resulting secret holds these five keys:
| Key | Consumer |
|---|---|
accessKeyId | CDI importer, as the registry username |
secretKey | CDI importer, as the registry token (PAT) |
DOCKER_REGISTRY | In-guest docker-bootstrap.sh, set to ghcr.io |
DOCKER_USERNAME | In-guest docker-bootstrap.sh |
DOCKER_TOKEN | In-guest docker-bootstrap.sh |
5.1 Bootstrap ConfigMap
Workloads that boot an in-guest container mount a bootstrap-cm ConfigMap as a CD-ROM. The ConfigMap must exist in the workload namespace before the worker starts. The ConfigMap holds two scripts and a systemd unit for each script. The bootstrap script prepares the guest and starts the job's container. The host agent script reports details about the guest to the container.
The worker repository ships a ready-to-apply manifest at setup/cfg-create.yml. Change its namespace to your workload namespace, then apply it:
kubectl apply -f setup/cfg-create.ymlThe following is the complete ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: bootstrap-cm
namespace: <workload-namespace> # e.g. cosmic-ac
data:
docker-bootstrap.sh: |
#!/bin/bash
set -eu
# Helper: wait for block device
wait_for_dev() { for i in {1..60}; do [ -b "$1" ] && return 0; sleep 1; done; echo "ERROR: $1 not found"; lsblk -b; exit 1; }
# Wait out cloud-init / unattended-upgrades / multi-node IB apt holding dpkg.
for _ in $(seq 1 300); do
fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || break
sleep 1
done
for _ in 1 2 3 4 5; do
DEBIAN_FRONTEND=noninteractive apt-get update -y \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates docker.io parted e2fsprogs util-linux jq \
&& break
sleep 10
done
dpkg -s docker.io >/dev/null 2>&1 || { echo "ERROR: apt install failed"; exit 1; }
systemctl enable --now docker
# Setup PVC disk
ROOT_DISK="$(findmnt -n -o SOURCE / | sed 's/[0-9]*$//' | xargs basename)"
[ -f /etc/default/docker-bootstrap ] && source /etc/default/docker-bootstrap
MNT="${MNT:-}"
DISK_SERIAL="${DISK_SERIAL:-}"
ENV_FILE="/etc/default/docker-bootstrap.env"
if [ -n "${MNT}" ]; then
# A team storage volume is addressed by its virtio serial. Size alone cannot
# identify it, and picking the wrong disk would reformat somebody's data.
if [ -n "${DISK_SERIAL}" ]; then
BY_ID="/dev/disk/by-id/virtio-${DISK_SERIAL}"
wait_for_dev "${BY_ID}"
DISK="$(readlink -f "${BY_ID}")"
[ -b "${DISK}" ] || { echo "ERROR: ${BY_ID} did not resolve to a block device"; lsblk -b; exit 1; }
else
# Legacy instances created before DISK_SERIAL: take the largest non-root
# disk. A provisioner may hand back slightly less than the requested size
# (KubeVirt itself tolerates up to 10 %), so no absolute floor is applied
# beyond ruling out the cloud-init disk.
PVC_DISK_NAME="$(lsblk -b -dn -o NAME,TYPE,SIZE | awk -v r="${ROOT_DISK}" '$2=="disk" && $1!=r && $1!~/^(sr|loop)/ && $3>=1073741824 {print $1,$3}' | sort -k2,2nr | head -n1 | awk '{print $1}')"
[ -z "${PVC_DISK_NAME}" ] && { echo "ERROR: Could not find a data disk to mount at ${MNT}"; lsblk -b; exit 1; }
DISK="/dev/${PVC_DISK_NAME}"
fi
PART="${DISK}1"
wait_for_dev "${DISK}"
# A reattached volume already carries its GPT label and ext4 filesystem, so
# both of these are skipped and the existing data is preserved.
[ ! -b "${PART}" ] && { parted -s "${DISK}" mklabel gpt mkpart primary ext4 1MiB 100%; udevadm settle || true; }
wait_for_dev "${PART}"
blkid "${PART}" | grep -q 'TYPE=' || mkfs.ext4 -F "${PART}"
mkdir -p "${MNT}"
UUID="$(blkid -s UUID -o value "${PART}")"
grep -q "${UUID}" /etc/fstab || echo "UUID=${UUID} ${MNT} ext4 defaults,nofail 0 2" >> /etc/fstab
mount -a
else
echo "MNT not set, skipping external disk setup"
fi
# Docker login and pull
SECRET_MNT="/mnt/registrycred"
mkdir -p "${SECRET_MNT}"
if ! mountpoint -q "${SECRET_MNT}"; then
wait_for_dev /dev/sr0
mount -t iso9660 -o ro /dev/sr0 "${SECRET_MNT}"
else
echo "${SECRET_MNT} already mounted, reusing"
fi
REGISTRY="$(cat "${SECRET_MNT}/DOCKER_REGISTRY")"
USERNAME="$(cat "${SECRET_MNT}/DOCKER_USERNAME")"
DOCKER_CONFIG="/run/docker-config"
mkdir -p "${DOCKER_CONFIG}" && chmod 700 "${DOCKER_CONFIG}"
cat "${SECRET_MNT}/DOCKER_TOKEN" | docker login "${REGISTRY}" -u "${USERNAME}" --password-stdin
IMAGE_FULL="${IMG:-${USERNAME}/cosmicac-wrk-agent-instance:latest}"
NESTED_IMG="${NESTED_IMG:-}"
# Retry each pull up to 3 times (transient registry/network failures)
pull_with_retry() {
local img="$1" attempt=1
until docker pull "${img}"; do
if [ "${attempt}" -ge 3 ]; then
echo "ERROR: docker pull failed for ${img} after ${attempt} attempts"
return 1
fi
echo "WARN: docker pull failed for ${img} (attempt ${attempt}/3), retrying..."
attempt=$((attempt + 1))
sleep $((attempt * 2))
done
}
# Pull agent + nested images in parallel (each with up to 3 retries)
IMAGES=("${IMAGE_FULL}")
[ -n "${NESTED_IMG}" ] && [ "${NESTED_IMG}" != "${IMAGE_FULL}" ] && IMAGES+=("${NESTED_IMG}")
PIDS=()
for img in "${IMAGES[@]}"; do
pull_with_retry "${img}" & PIDS+=($!)
done
ec=0; for p in "${PIDS[@]}"; do wait "$p" || ec=1; done
[ "$ec" -eq 0 ] || { echo "ERROR: image pull failed"; exit 1; }
docker logout "${REGISTRY}" || true
rm -rf "${DOCKER_CONFIG}" && umount "${SECRET_MNT}" || true
# Run container if not already running
CONTAINER_NAME="${NAME:-cosmicac-container}"
if docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "Container ${CONTAINER_NAME} already running"
elif docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
# Created/Exited from a prior failed start — start it (systemd Restart retries if this fails).
docker start "${CONTAINER_NAME}"
elif [ -n "${IMG}" ]; then
# Host agent FIFO lives under /var/run/cosmicac (bind-mounted into agent).
mkdir -p /var/run/cosmicac/host-agent
if [ -x /usr/local/bin/cosmicac-host-net-agent.sh ]; then
systemctl daemon-reload 2>/dev/null || true
# Overlay iface (if any) is discovered into /etc/default/cosmicac-overlay by cloud-init.
systemctl enable --now cosmicac-host-net-agent.service 2>/dev/null \
|| nohup env HOST_AGENT_DIR=/var/run/cosmicac/host-agent /usr/local/bin/cosmicac-host-net-agent.sh >/var/log/cosmicac-host-net-agent.log 2>&1 &
# Wait briefly so cmd_in.pipe exists before the agent container starts.
for i in $(seq 1 40); do
[ -p /var/run/cosmicac/host-agent/cmd_in.pipe ] && break
sleep 0.25
done
fi
# Inference: nested container gets GPUs; --gpus all on agent can leave it Created.
BASE_CMD="docker run -d --name ${CONTAINER_NAME} --init --restart unless-stopped --ipc=host"
[ -z "${NESTED_IMG:-}" ] && BASE_CMD="${BASE_CMD} --gpus all"
[ -n "${MNT}" ] && BASE_CMD="${BASE_CMD} -v ${MNT}:${MNT}"
# Nested docker (INFERENCE_MODE=docker) needs host docker socket + host-agent FIFOs.
# OVERLAY_IFACE is appended to the env file by cosmicac-setup-overlay-nic.sh on multi-node guests.
if [ -n "${NESTED_IMG}" ]; then
mkdir -p /var/run/cosmicac/host-agent
BASE_CMD="${BASE_CMD} --network host -v /var/run/docker.sock:/var/run/docker.sock -v /var/run/cosmicac:/var/run/cosmicac -e HOST_AGENT_DIR=/var/run/cosmicac/host-agent"
fi
if [ "${JOB_TYPE:-}" = "GPU_CONTAINER" ]; then
BASE_CMD="${BASE_CMD} --privileged --cap-add=NET_ADMIN --cap-add=SYS_ADMIN --device /dev/net/tun"
# Host-agent FIFOs only; GPU_CONTAINER keeps bridge networking and no docker socket.
BASE_CMD="${BASE_CMD} -v /var/run/cosmicac:/var/run/cosmicac -e HOST_AGENT_DIR=/var/run/cosmicac/host-agent"
fi
[ -f "${ENV_FILE}" ] && BASE_CMD="${BASE_CMD} --env-file ${ENV_FILE}"
eval "${BASE_CMD} ${IMG}"
fi
# Still Created/Exited → fail unit so Restart=on-failure re-enters the docker start branch.
if [ -n "${IMG:-}" ] && ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
echo "ERROR: ${CONTAINER_NAME} not running"; docker ps -a --filter "name=^${CONTAINER_NAME}$" || true; exit 1
fi
echo "Bootstrap completed successfully."
cosmicac-host-net-agent.sh: |
#!/bin/bash
# CosmicAC host agent — runs on the VMI host (NOT in the agent container).
# Container talks over FIFO: <op>|<reply_fifo>[|<arg>]
# ops: ping | ip | ib | gpu | cpu
set -eu
D="${HOST_AGENT_DIR:-/var/run/cosmicac/host-agent}"
C="${D}/cmd_in.pipe"
IB_SYS=/sys/class/infiniband
GPU_QUERY=index,uuid,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw
PROC_STAT="${HOST_AGENT_PROC_STAT:-/proc/stat}"
# Gap between the two /proc/stat reads op_cpu diffs.
CPU_SAMPLE_SEC="${HOST_AGENT_CPU_SAMPLE_SEC:-0.25}"
T="${HOST_AGENT_TIMEOUT_SEC:-8}"
# How long op_ip waits for DHCP on the overlay NIC (agent boots right after netplan).
IP_WAIT_SEC="${HOST_AGENT_IP_WAIT_SEC:-30}"
# Cap concurrent handle() subshells so a flood of FIFO lines cannot leak jobs.
MAX_JOBS="${HOST_AGENT_MAX_JOBS:-8}"
# OVERLAY_IFACE env → /etc/default/cosmicac-overlay → non-default-route NIC (prefer one with IPv4).
resolve_overlay_iface () {
[ -n "${OVERLAY_IFACE:-}" ] && [ "$OVERLAY_IFACE" != auto ] && { echo "$OVERLAY_IFACE"; return; }
if [ -f /etc/default/cosmicac-overlay ]; then
# shellcheck disable=SC1091
. /etc/default/cosmicac-overlay
fi
[ -n "${OVERLAY_IFACE:-}" ] && [ "$OVERLAY_IFACE" != auto ] && { echo "$OVERLAY_IFACE"; return; }
local p i cands=()
p=$(ip -4 route show default 2>/dev/null | awk '{print $5; exit}' || true)
shopt -s nullglob
for i in /sys/class/net/*/device; do
i=$(basename "$(dirname "$i")")
case $i in lo|wg*|docker*|br-*|veth*|virbr*|tun*|tap*) continue;; esac
[ "$i" = "${p:-}" ] && continue
cands+=("$i")
done
for i in "${cands[@]+"${cands[@]}"}"; do
ip -4 -o addr show dev "$i" 2>/dev/null | grep -q . && { echo "$i"; return; }
done
[ "${#cands[@]}" -gt 0 ] && { echo "${cands[0]}"; return; }
return 1
}
mkdir -p "$D" && chmod 0777 "$D"
rm -f "$C" && mkfifo "$C" && chmod 666 "$C"
log () { printf '%s %s\n' "$(date -Is)" "$*" >&2; }
# JSON-encode a string (includes quotes; jq escapes controls like \r/\n/\t).
jesc () { printf '%s' "${1:-}" | jq -Rs .; }
ok () { printf '{"ok":true,"op":"%s","data":%s}\n' "$1" "$2"; }
fail () { printf '{"ok":false,"op":"%s","error":%s}\n' "$1" "$(jesc "$2")"; }
op_ping () { ok ping '{}'; }
op_ip () {
local iface="${1:-}" ip n=0
[ -z "$iface" ] && iface="$(resolve_overlay_iface || true)"
[ -n "${iface:-}" ] || { fail ip 'no overlay iface'; return; }
while [ "$n" -lt "$IP_WAIT_SEC" ]; do
ip="$(timeout "$T" ip -4 -o addr show dev "$iface" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1 || true)"
[ -n "${ip:-}" ] && break
sleep 1
n=$((n + 1))
done
[ -n "${ip:-}" ] && ok ip "{\"ip\":\"$ip\",\"iface\":$(jesc "$iface")}" || fail ip "no ipv4 on ${iface}"
}
op_ib () {
[ -d "$IB_SYS" ] || { ok ib '{"present":false,"hcaPrefix":null,"hcas":[]}'; return; }
local names=() n p=null
for n in "$IB_SYS"/*; do [ -e "$n" ] && names+=("$(basename "$n")"); done
for n in "${names[@]+"${names[@]}"}"; do case "$n" in ibp*) p='"ibp"'; break;; esac; done
[ "$p" = null ] && for n in "${names[@]+"${names[@]}"}"; do case "$n" in mlx5_*) p='"mlx5"'; break;; esac; done
local arr='[' f=1
for n in "${names[@]+"${names[@]}"}"; do [ $f -eq 1 ] || arr+=','; f=0; arr+="\"$n\""; done
arr+=']'
ok ib "{\"present\":$([ ${#names[@]} -gt 0 ] && echo true || echo false),\"hcaPrefix\":$p,\"hcas\":$arr}"
}
op_cpu () {
local a b pct
[ -r "$PROC_STAT" ] || { fail cpu "cannot read ${PROC_STAT}"; return; }
a="$(awk '/^cpu /{print; exit}' "$PROC_STAT")"
sleep "$CPU_SAMPLE_SEC"
b="$(awk '/^cpu /{print; exit}' "$PROC_STAT")"
pct="$(printf '%s\n%s\n' "$a" "$b" | awk '
NR==1 { for (i = 2; i <= 9 && i <= NF; i++) t1 += $i; i1 = $5 + $6 }
NR==2 { for (i = 2; i <= 9 && i <= NF; i++) t2 += $i; i2 = $5 + $6 }
END {
dt = t2 - t1; di = i2 - i1
if (dt <= 0) exit 1
p = (dt - di) / dt * 100
if (p < 0) p = 0
if (p > 100) p = 100
printf "%.2f", p
}')" || { fail cpu 'no cpu time elapsed between samples'; return; }
[ -n "${pct:-}" ] || { fail cpu 'cpu sample unavailable'; return; }
ok cpu "{\"percent\":${pct},\"cores\":$(nproc 2>/dev/null || echo null)}"
}
op_gpu () {
local csv
command -v nvidia-smi >/dev/null 2>&1 || { fail gpu 'nvidia-smi not found on host'; return; }
csv="$(timeout "$T" nvidia-smi --query-gpu="$GPU_QUERY" --format=csv,noheader,nounits 2>&1 || true)"
[ -n "${csv:-}" ] || { fail gpu 'no nvidia-smi output'; return; }
ok gpu "{\"csv\":$(jesc "$csv")}"
}
handle () {
local line="$1" op reply arg i=0 base
IFS='|' read -r op reply arg <<<"$line" || true
[ -n "${op:-}" ] && [ -n "${reply:-}" ] || return 0
# Reply FIFO must be a direct child of $D named resp_*.pipe (no ../ or nested paths).
base="$(basename -- "$reply")"
case "$base" in resp_*.pipe) ;; *) log "reject $reply"; return 0 ;; esac
[ "$reply" = "${D}/${base}" ] || { log "reject $reply"; return 0; }
while [ ! -p "$reply" ] && [ "$i" -lt 50 ]; do sleep 0.05; i=$((i + 1)); done
[ -p "$reply" ] || { log "missing $reply"; return 0; }
{
case "$op" in
ping) op_ping ;;
ip) op_ip "${arg:-}" ;;
ib) op_ib ;;
gpu) op_gpu ;;
cpu) op_cpu ;;
*) fail "$op" 'unknown op' ;;
esac
} >"$reply" 2>/dev/null || true
rm -f "$reply"
}
running_jobs () { jobs -pr 2>/dev/null | grep -c . || true; }
# Reap exited children so the jobs table does not grow unbounded.
reap_finished () {
local pid running
running=" $(jobs -pr 2>/dev/null | tr '\n' ' ') "
for pid in $(jobs -p 2>/dev/null); do
case "$running" in
*" $pid "*) ;;
*) wait "$pid" 2>/dev/null || true ;;
esac
done
}
wait_for_slot () {
reap_finished
while [ "$(running_jobs)" -ge "$MAX_JOBS" ]; do
wait -n 2>/dev/null || { sleep 0.05; wait 2>/dev/null || true; }
reap_finished
done
}
log "listening on $C (max ${MAX_JOBS} concurrent handlers)"
while IFS= read -r line || [ -n "${line:-}" ]; do
[ -z "${line:-}" ] && continue
wait_for_slot
( handle "$line" ) &
done <>"$C"
cosmicac-host-net-agent.service: |
[Unit]
Description=CosmicAC host agent (FIFO IPC for inference container)
After=network-online.target
Wants=network-online.target
Before=docker-bootstrap.service
[Service]
Type=simple
Environment=HOST_AGENT_DIR=/var/run/cosmicac/host-agent
EnvironmentFile=-/etc/default/cosmicac-overlay
ExecStart=/usr/local/bin/cosmicac-host-net-agent.sh
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
docker-bootstrap.service: |
[Unit]
Description=Deferred Docker Bootstrap (login + pull + run)
Wants=network-online.target cosmicac-host-net-agent.service
After=network-online.target multi-user.target cosmicac-host-net-agent.service apt-daily.service apt-daily-upgrade.service
StartLimitIntervalSec=600
StartLimitBurst=3
[Service]
Type=oneshot
ExecStart=/usr/local/bin/docker-bootstrap.sh
RemainAfterExit=yes
Restart=on-failure
RestartSec=20
[Install]
WantedBy=multi-user.targetVerify it exists:
kubectl get configmap bootstrap-cm -n <workload-namespace> # must exist6. Required container images
Your cluster and the VM guests must be able to pull the following:
| Image | Used by |
|---|---|
ghcr.io/tetherto/kubevirt-image:latest | Default vmImage for the VMI root disk, pulled by CDI |
ghcr.io/tetherto/cosmicac-wrk-agent-inference:latest | In-guest inference container |
docker.io/anurlan/kubevirt-gpu-device-plugin:v1.5.0-fm | KubeVirt GPU device plugin daemonset |
bitnami/kubectl:latest | Subcluster manager pod, overlay networking only |
debian:bookworm-slim, alpine:latest | OVS/VXLAN and WireGuard containers, overlay networking only |
ghcr.io/k8snetworkplumbingwg/multus-cni:v4.2.4-thick | Multus daemonset, overlay networking only |
ghcr.io/k8snetworkplumbingwg/whereabouts:latest | Whereabouts IPAM, overlay networking only |
The ghcr.io/tetherto/* images are private. Access them with the ghcr-pull-cred PAT from Section 5.
7. Worker access and RBAC
The worker authenticates to your API server with a kubeconfig. The identity behind that kubeconfig needs two sets of permissions.
Namespace-scoped permissions cover the resources the worker creates and manages in the workload namespace, and, for overlay networking, in kube-system:
| API group | Resources | Verbs |
|---|---|---|
"", the core group | configmaps, secrets, persistentvolumeclaims, pods, services | create, get, list, delete |
apps | deployments, daemonsets | create, get, delete |
cdi.kubevirt.io | datavolumes | create, get, delete |
kubevirt.io | virtualmachineinstances | create, get, delete |
k8s.cni.cncf.io | network-attachment-definitions | create, get, delete. Overlay networking only |
Cluster-scoped permissions let the worker report cluster capacity through getClusterResources, which getInfo and the getClusterResources RPC both use. That call lists all nodes and all pods cluster-wide to compute GPU, CPU, and memory totals and usage:
| API group | Resources | Verbs | Scope |
|---|---|---|---|
"", the core group | nodes | get, list | cluster-wide |
"", the core group | pods | list | all namespaces |
The following notes apply:
podsneeds namespaced list and get so the worker can poll VMI launcher pods for status, and cluster-wide list forgetClusterResources.nodesis cluster-scoped, so it requires a ClusterRole.- For overlay networking,
configmapsalso needs update and patch. The worker rewrites the WireGuard peers ConfigMap as clients join or leave.
Team storage volumes need extra permissions. When a user creates a volume, the worker waits for the StorageClass that Gcore creates. When a user deletes a volume, the worker checks that no VM still mounts the volume, and then cleans up the objects that Gcore created for the file share.
If you turn on team storage volumes, grant the worker the following permissions:
| API group | Resources | Verbs | Scope |
|---|---|---|---|
kubevirt.io | virtualmachineinstances | list | workload namespace |
storage.k8s.io | storageclasses | get, delete | cluster-wide |
"", the core group | persistentvolumes | delete | cluster-wide |
"", the core group | persistentvolumeclaims | list | all namespaces |
"", the core group | persistentvolumeclaims | delete | kube-system |
apps | deployments | delete | kube-system |
The namespaced set binds through a Role and RoleBinding in the workload namespace. A ClusterRole and ClusterRoleBinding cover the cluster-scoped node and pod reads along with the kube-system objects that overlay networking uses. The grant follows least privilege, and the worker never needs cluster-admin.
8. Overlay networking add-ons
CosmicAC builds an OVS/VXLAN subcluster for every multi-node Managed Inference replica, and joins that replica's nodes over the subcluster. Without these add-ons, a job that needs more GPUs than one node holds can't run. A workload that requires a per-instance isolated network uses the same add-ons, plus a WireGuard gateway. See Multi-node replicas.
Your cluster needs the following outcomes:
| Outcome | Needed for | Detail |
|---|---|---|
| Multus CNI installed | Multi-node | Attaches the secondary overlay interface to the VMI through NetworkAttachmentDefinition. Reference image ghcr.io/k8snetworkplumbingwg/multus-cni:v4.2.4-thick. |
| Whereabouts IPAM installed | Multi-node | Cluster-wide IPAM for the overlay range. Reference ghcr.io/k8snetworkplumbingwg/whereabouts:latest. |
| OVS CNI available on nodes | Multi-node | Provides the ovs CNI type used by the NAD bridge. Reference daemonsets ovs-cni-amd64 and ovs-node. |
| NAD CRD present | Multi-node | network-attachment-definitions.k8s.cni.cncf.io, provided by Multus. The worker creates NADs of CNI type ovs with whereabouts IPAM. |
subcluster-manager-sa + RBAC | Multi-node | ServiceAccount in kube-system that can label nodes and watch pods. ClusterRole rules grant nodes get,list,watch,patch,update and pods get,list,watch, bound through a ClusterRoleBinding. |
| UDP LoadBalancer | Isolated networks | The WireGuard gateway is a Service of type: LoadBalancer on UDP 51820 with externalTrafficPolicy: Local. Your LB must support UDP. The reference manifests carry a Gcore-specific annotation loadbalancer.gcore.com/floating-ip-cleanup. On another provider, use the matching annotation or drop it. |
These add-ons layer on top of your primary CNI. They don't replace it.
9. Observability and logs
CosmicAC collects logs and metrics from its services. You store them:
- CosmicAC provides:
cosmicac-wrk-monitorand the producers inside every service. - You provide: a Prometheus and a Loki.
CosmicAC runs jobs without Prometheus or Loki. Job metric charts need your Prometheus, and log history needs your Loki. Both also cover monitoring beyond CosmicAC, such as cluster, node, and host metrics.
Model health works without either one, from a separate system. See Model health.
For more information, see:
- Observability architecture for how the pieces connect and what CosmicAC retains.
- Connect CosmicAC to your Prometheus and Loki for the steps to set both URLs and add CosmicAC as a scrape target.
- Job logs for what a job records.
- Monitor API and Observability settings API for the routes behind both.
10. Verification checklist
Run these checks from a workstation with kubectl and virtctl pointed at the cluster.
10.1 Base platform
# Nodes Ready + control plane healthy; k8s version supported by KubeVirt/CDI
kubectl get nodes -o wide
kubectl version -o json | jq -r '.serverVersion.gitVersion'
# Per GPU node: GPU (and IB) advertised under the name the worker requests
kubectl get nodes -o json | jq -r '
.items[] | .metadata.name + " gpu=" +
(.status.allocatable["nvidia.com/GH100_H100_SXM5_80GB"] // "0") + " ib=" +
(.status.allocatable["mellanox.com/cx7_ib_pf"] // "0")'
# KubeVirt + CDI healthy; HostDevices feature gate on
kubectl -n kubevirt get kubevirt kubevirt -o jsonpath='{.status.observedKubeVirtVersion}{"\n"}'
kubectl -n kubevirt get kubevirt kubevirt -o jsonpath='{.spec.configuration.developerConfiguration.featureGates}{"\n"}' # includes HostDevices
kubectl -n cdi get cdi cdi -o jsonpath='{.status.observedVersion}{"\n"}'
# KubeVirt GPU device plugin advertising the passthrough resource
kubectl -n kube-system get ds nvidia-kubevirt-gpu-dp-daemonset
# permittedHostDevices includes your GPU/IB
kubectl -n kubevirt get kubevirt kubevirt -o jsonpath='{.spec.configuration.permittedHostDevices}{"\n"}' | jq .
# A working local-path StorageClass exists (need not be the default)
kubectl get sc
# Workload-namespace prerequisites
kubectl -n <workload-namespace> get secret ghcr-pull-cred
kubectl -n <workload-namespace> get configmap bootstrap-cm
# Device-admission smoke test: a VMI that CLAIMS a full GPU is admitted + scheduled.
# NOTE: with no boot disk this only proves device admission + scheduling, NOT a
# working guest. For a real boot test, add a DataVolume root disk.
cat <<'EOF' | kubectl apply -f -
apiVersion: kubevirt.io/v1
kind: VirtualMachineInstance
metadata:
name: gpu-smoke
namespace: default
spec:
domain:
devices:
gpus:
- deviceName: nvidia.com/GH100_H100_SXM5_80GB
name: gpu0
resources:
requests: { memory: 8Gi }
volumes: []
EOF
kubectl get vmi gpu-smoke -w # reaches Scheduled/Running (device admitted), then:
kubectl delete vmi gpu-smokeThe checks pass when nodes are Ready, each GPU node reports the expected gpu= and ib= counts, KubeVirt is healthy with HostDevices on, CDI is healthy, a working local-path StorageClass exists, ghcr-pull-cred and bootstrap-cm are present, and the cluster admits and schedules the smoke VMI.
10.2 Overlay networking
Run these checks if your cluster serves multi-node inference.
# Multus / Whereabouts / OVS daemonsets Ready in kube-system
kubectl -n kube-system get ds | grep -iE 'multus|whereabouts|ovs'
# NAD CRD present
kubectl get crd network-attachment-definitions.k8s.cni.cncf.io
# Subcluster manager SA + RBAC present
kubectl -n kube-system get sa subcluster-manager-sa
kubectl get clusterrole subcluster-manager-role
kubectl get clusterrolebinding subcluster-manager-binding
# LoadBalancer can allocate an external IP for UDP services (provider-specific)