@@ -120,6 +120,20 @@ type PCIDevice struct {
120120 MDev string `json:"mdev,omitempty"`
121121}
122122
123+ // NodePrepSpec installs host prerequisites on every node in a pool via the
124+ // underlying VM's cloud-init (packages + first-boot commands). Use it for node
125+ // dependencies that must exist before workloads land — e.g. `open-iscsi` for
126+ // Longhorn. Stamped onto each node VM's `cloudInit` block, which the Proxmox
127+ // provider renders into a cloud-init vendor snippet (see E1). Proxmox-specific.
128+ type NodePrepSpec struct {
129+ // Packages are host packages installed on first boot (with the package
130+ // index refreshed first).
131+ Packages []string `json:"packages,omitempty"`
132+ // RunCmd are first-boot shell commands, run after the node's
133+ // qemu-guest-agent enablement.
134+ RunCmd []string `json:"runcmd,omitempty"`
135+ }
136+
123137// NodesSpec defines the cluster nodes
124138type NodesSpec struct {
125139 ControlPlane ControlPlaneSpec `json:"controlPlane"`
@@ -146,6 +160,9 @@ type ControlPlaneSpec struct {
146160 // GPU requests PCI/GPU passthrough for the control-plane VMs. Rare — GPUs
147161 // usually belong on workers — but supported for symmetry.
148162 GPU * GPUSpec `json:"gpu,omitempty"`
163+ // NodePrep installs host prerequisites (packages/runcmd) on the
164+ // control-plane VMs via cloud-init.
165+ NodePrep * NodePrepSpec `json:"nodePrep,omitempty"`
149166}
150167
151168// WorkerSpec defines a worker node pool
@@ -165,6 +182,9 @@ type WorkerSpec struct {
165182 // pool of one node that runs a local model). Pin the pool to the host(s)
166183 // with the device via Nodes/Targets.
167184 GPU * GPUSpec `json:"gpu,omitempty"`
185+ // NodePrep installs host prerequisites (packages/runcmd) on every node in
186+ // this pool via cloud-init — e.g. `open-iscsi` for a Longhorn storage pool.
187+ NodePrep * NodePrepSpec `json:"nodePrep,omitempty"`
168188}
169189
170190// K3sSpec defines K3s configuration
@@ -243,6 +263,9 @@ func ParseClusterSpec(r *protocol.Resource) (*ClusterSpec, error) {
243263 if gpu , ok := cp ["gpu" ].(map [string ]any ); ok {
244264 spec .Nodes .ControlPlane .GPU = parseGPUSpec (gpu )
245265 }
266+ if np , ok := cp ["nodePrep" ].(map [string ]any ); ok {
267+ spec .Nodes .ControlPlane .NodePrep = parseNodePrepSpec (np )
268+ }
246269 }
247270 if workers , ok := nodes ["workers" ].([]any ); ok {
248271 for _ , w := range workers {
@@ -265,6 +288,9 @@ func ParseClusterSpec(r *protocol.Resource) (*ClusterSpec, error) {
265288 if gpu , ok := worker ["gpu" ].(map [string ]any ); ok {
266289 ws .GPU = parseGPUSpec (gpu )
267290 }
291+ if np , ok := worker ["nodePrep" ].(map [string ]any ); ok {
292+ ws .NodePrep = parseNodePrepSpec (np )
293+ }
268294 spec .Nodes .Workers = append (spec .Nodes .Workers , ws )
269295 }
270296 }
@@ -509,6 +535,68 @@ func ApplyGPUToVMSpec(vmSpec map[string]any, gpu *GPUSpec) {
509535 }
510536}
511537
538+ // parseNodePrepSpec parses a pool's nodePrep block (packages + runcmd) from the
539+ // untyped manifest map. Returns nil-safe empty slices; callers no-op on empty.
540+ func parseNodePrepSpec (m map [string ]any ) * NodePrepSpec {
541+ np := & NodePrepSpec {}
542+ if pkgs , ok := m ["packages" ].([]any ); ok {
543+ for _ , p := range pkgs {
544+ if pkg , ok := p .(string ); ok {
545+ np .Packages = append (np .Packages , pkg )
546+ }
547+ }
548+ }
549+ if cmds , ok := m ["runcmd" ].([]any ); ok {
550+ for _ , c := range cmds {
551+ if cmd , ok := c .(string ); ok {
552+ np .RunCmd = append (np .RunCmd , cmd )
553+ }
554+ }
555+ }
556+ return np
557+ }
558+
559+ // NodePrepForNode resolves the node-prep config for node index i (across the
560+ // flat control-plane-then-workers ordering NodeNames produces), or nil when the
561+ // node's pool requests none. Mirrors GPUForNode so both VM-build paths stay in
562+ // sync.
563+ func NodePrepForNode (i , cpCount int , spec * ClusterSpec ) * NodePrepSpec {
564+ if i < cpCount {
565+ return spec .Nodes .ControlPlane .NodePrep
566+ }
567+ workerIdx := i - cpCount
568+ for _ , pool := range spec .Nodes .Workers {
569+ if workerIdx < pool .Count {
570+ return pool .NodePrep
571+ }
572+ workerIdx -= pool .Count
573+ }
574+ return nil
575+ }
576+
577+ // ApplyNodePrepToVMSpec stamps a pool's host prerequisites onto a node VM's
578+ // cloud-init block (packages + runcmd), which the Proxmox provider renders into
579+ // a cloud-init vendor snippet. No-op when np is nil or carries nothing. Shared
580+ // by both VM-build paths (create.go and the Plan mirror) so nodes come out
581+ // identical. It writes into the existing vmSpec["cloudInit"] map (built by both
582+ // paths just before this call), preserving user/sshKeys/ipConfig.
583+ func ApplyNodePrepToVMSpec (vmSpec map [string ]any , np * NodePrepSpec ) {
584+ if np == nil || (len (np .Packages ) == 0 && len (np .RunCmd ) == 0 ) {
585+ return
586+ }
587+ ci , ok := vmSpec ["cloudInit" ].(map [string ]any )
588+ if ! ok {
589+ ci = map [string ]any {}
590+ vmSpec ["cloudInit" ] = ci
591+ }
592+ if len (np .Packages ) > 0 {
593+ ci ["packages" ] = np .Packages
594+ }
595+ if len (np .RunCmd ) > 0 {
596+ ci ["runcmd" ] = np .RunCmd
597+ }
598+ }
599+
512600// ClusterToResource converts cluster state to a protocol Resource
513601func ClusterToResource (name string , spec * ClusterSpec , phase string , outputs map [string ]any , children []protocol.ChildReference ) * protocol.Resource {
514602 specMap := map [string ]any {
0 commit comments