Skip to content

Commit fc6ad0a

Browse files
authored
feat(k3s): per-pool nodePrep (cloud-init packages/runcmd) (E2) (#146)
Surface E1's cloud-init packages/runcmd through the k3s Cluster spec as a per-pool `nodePrep: {packages, runcmd}` block on the control-plane and each worker pool, so a pool installs its own host prerequisites — e.g. open-iscsi for a Longhorn storage pool. Mirrors the A3 GPU-per-pool shape field-for-field: NodePrepSpec + parseNodePrepSpec + NodePrepForNode(i, cpCount) + ApplyNodePrepToVMSpec, wired into BOTH VM-build paths (operative create.go and the Plan mirror cluster_plan.go) via the shared resources helper so nodes come out identical. ApplyNodePrepToVMSpec writes into the existing cloudInit map, preserving user/sshKeys/ipConfig. #NodePrep CUE def on both pools. Makes F1 (Longhorn) usable — the tier that needs open-iscsi on nodes. Tests: parse+resolve across the flat control-plane-then-workers ordering, apply-into-cloudInit (nil/empty no-op, preserves siblings, creates map if absent), and an operative GenerateDispatchRequests test asserting only the target pool's VMs get the prereqs.
1 parent 1fc6c29 commit fc6ad0a

7 files changed

Lines changed: 287 additions & 4 deletions

File tree

ROADMAP.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,20 @@ prerequisite (Longhorn); the rest are convenience + robustness.
168168
omitempty, special-char round-trip), spec parse, and a handler wiring test
169169
asserting the per-VM upload + cicustom. *Metal validation (does open-iscsi
170170
actually install) is a homelab follow-up, like A1–A4.*
171-
- [ ] **E2 — k3s node-pool node prep.** Surface E1 through the k3s Cluster
172-
spec (a per-pool `nodePrep`/`packages`/`runcmd` block) so a worker pool
173-
installs its own host deps, composed into the node VMs (mirrors the
174-
GPU-per-pool shape from A3).
171+
- [x] **E2 — k3s node-pool node prep.** The k3s Cluster spec now takes a
172+
per-pool `nodePrep: {packages, runcmd}` block (on control-plane and each
173+
worker pool), stamped onto that pool's node VMs' `cloudInit` (which the
174+
Proxmox provider renders via E1). Mirrors the GPU-per-pool shape from A3
175+
exactly: `NodePrepSpec` + `parseNodePrepSpec` + `NodePrepForNode(i,cpCount)`
176+
+ `ApplyNodePrepToVMSpec` (writes into the existing `cloudInit` map,
177+
preserving user/sshKeys/ipConfig), wired into **both** VM-build paths
178+
(operative `create.go` + Plan mirror `cluster_plan.go`) via the shared
179+
helper. `#NodePrep` CUE def on both pools. So a worker pool installs its
180+
own host deps — e.g. `open-iscsi` for a Longhorn storage pool. Tests:
181+
parse+resolve across the flat node ordering, apply-into-cloudInit
182+
(nil/empty no-op, preserves siblings), and an operative-path
183+
`GenerateDispatchRequests` test asserting only the target pool's VMs get
184+
the prereqs. **Makes F1 (Longhorn) usable.**
175185

176186
### F. Storage
177187
- [ ] **F1 — Longhorn Platform component.** Add `longhorn` to the opt-in

internal/controller/providers/k3s/cluster_plan.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ func buildVMManifest(clusterName, nodeName string, i, cpCount int, size k3sresou
237237
// GPU/PCI passthrough for this node's pool — kept identical to the
238238
// operative create.go path via the shared resources helpers.
239239
k3sresources.ApplyGPUToVMSpec(vm.Spec, k3sresources.GPUForNode(i, cpCount, spec))
240+
// Host prerequisites (cloud-init packages/runcmd) for this node's pool —
241+
// same shared helper as create.go so both paths emit identical VMs.
242+
k3sresources.ApplyNodePrepToVMSpec(vm.Spec, k3sresources.NodePrepForNode(i, cpCount, spec))
240243
return vm
241244
}
242245

internal/schema/schemas/k3s/cluster.cue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,16 @@ import "openctl.io/schemas/base"
9696
cpuType?: string | *"host"
9797
}
9898

99+
// #NodePrep installs host prerequisites on every node in a pool via the node
100+
// VM's cloud-init — e.g. "open-iscsi" for a Longhorn storage pool. Rendered
101+
// into a cloud-init vendor snippet by the Proxmox provider.
102+
#NodePrep: {
103+
// Host packages installed on first boot (package index refreshed first).
104+
packages?: [...string]
105+
// First-boot shell commands, run after qemu-guest-agent is enabled.
106+
runcmd?: [...string]
107+
}
108+
99109
#ClusterSpec: {
100110
// Which infrastructure provider runs the VMs. Currently only
101111
// "proxmox" is implemented; other providers may follow.
@@ -152,6 +162,9 @@ import "openctl.io/schemas/base"
152162
// GPU/PCI passthrough for the control-plane VMs. Rare (GPUs usually
153163
// belong on workers) but supported for symmetry.
154164
gpu?: #GPU
165+
// Host prerequisites (packages/runcmd) installed on the
166+
// control-plane VMs via cloud-init.
167+
nodePrep?: #NodePrep
155168
}
156169
// Worker (agent) node pools. Each pool can have its own size.
157170
workers?: [...{
@@ -172,6 +185,9 @@ import "openctl.io/schemas/base"
172185
// GPU/PCI passthrough for every node in this pool. Pin the pool to
173186
// the host(s) with the device via nodes/targets.
174187
gpu?: #GPU
188+
// Host prerequisites (packages/runcmd) installed on every node in
189+
// this pool via cloud-init — e.g. open-iscsi for a Longhorn pool.
190+
nodePrep?: #NodePrep
175191
}]
176192
}
177193

pkg/k3s/cluster/create.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ func (c *Creator) GenerateDispatchRequests() []*protocol.DispatchRequest {
139139
// cpu host + hostpci). No-op when the pool requests no GPU.
140140
resources.ApplyGPUToVMSpec(manifest.Spec, resources.GPUForNode(i, len(cpNodes), c.spec))
141141

142+
// Host prerequisites for this node's pool (cloud-init packages/runcmd,
143+
// e.g. open-iscsi for Longhorn). No-op when the pool requests none.
144+
resources.ApplyNodePrepToVMSpec(manifest.Spec, resources.NodePrepForNode(i, len(cpNodes), c.spec))
145+
142146
// Place this VM on a specific provider endpoint/host when the
143147
// pool defines placement; otherwise leave spec.context/spec.node
144148
// unset so the provider uses its configured defaults.

pkg/k3s/cluster/create_placement_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,66 @@ func TestGenerateDispatchRequests_GPU(t *testing.T) {
187187
}
188188
}
189189
}
190+
191+
// TestGenerateDispatchRequests_NodePrep verifies a per-pool nodePrep block
192+
// stamps cloud-init packages/runcmd onto only that pool's node VMs (via the
193+
// operative create path), leaving other pools' cloudInit untouched.
194+
func TestGenerateDispatchRequests_NodePrep(t *testing.T) {
195+
spec := &resources.ClusterSpec{
196+
Compute: resources.ComputeSpec{
197+
Provider: "proxmox",
198+
Image: resources.ImageSpec{Template: "ubuntu-template"},
199+
Default: resources.DefaultSizeSpec{CPUs: 2, MemoryMB: 4096, DiskGB: 40},
200+
},
201+
Nodes: resources.NodesSpec{
202+
ControlPlane: resources.ControlPlaneSpec{Count: 1},
203+
Workers: []resources.WorkerSpec{
204+
{Name: "general", Count: 1},
205+
{Name: "storage", Count: 1, NodePrep: &resources.NodePrepSpec{
206+
Packages: []string{"open-iscsi"},
207+
RunCmd: []string{"systemctl enable iscsid"},
208+
}},
209+
},
210+
},
211+
SSH: resources.SSHSpec{User: "ubuntu"},
212+
}
213+
214+
byID := map[string]*protocol.Resource{}
215+
for _, req := range NewCreator("dev", spec, &protocol.ProviderConfig{}).GenerateDispatchRequests() {
216+
byID[req.ID] = req.Manifest
217+
}
218+
219+
// The storage worker's cloudInit carries the prereqs, preserving user.
220+
storage := byID["vm-dev-storage-0"]
221+
if storage == nil {
222+
t.Fatalf("missing storage node request; got %v", byID)
223+
}
224+
ci, _ := storage.Spec["cloudInit"].(map[string]any)
225+
if ci == nil || ci["user"] != "ubuntu" {
226+
t.Fatalf("storage cloudInit missing/clobbered: %v", storage.Spec["cloudInit"])
227+
}
228+
if pkgs, _ := ci["packages"].([]string); len(pkgs) != 1 || pkgs[0] != "open-iscsi" {
229+
t.Errorf("storage packages wrong: %v", ci["packages"])
230+
}
231+
if cmds, _ := ci["runcmd"].([]string); len(cmds) != 1 || cmds[0] != "systemctl enable iscsid" {
232+
t.Errorf("storage runcmd wrong: %v", ci["runcmd"])
233+
}
234+
235+
// The general worker and control plane must NOT get packages/runcmd.
236+
for _, id := range []string{"vm-dev-cp-0", "vm-dev-general-0"} {
237+
m := byID[id]
238+
if m == nil {
239+
t.Fatalf("missing %s", id)
240+
}
241+
ci, _ := m.Spec["cloudInit"].(map[string]any)
242+
if ci == nil {
243+
t.Fatalf("%s missing cloudInit", id)
244+
}
245+
if _, ok := ci["packages"]; ok {
246+
t.Errorf("%s should not have cloudInit.packages", id)
247+
}
248+
if _, ok := ci["runcmd"]; ok {
249+
t.Errorf("%s should not have cloudInit.runcmd", id)
250+
}
251+
}
252+
}

pkg/k3s/resources/cluster.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
124138
type 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
513601
func ClusterToResource(name string, spec *ClusterSpec, phase string, outputs map[string]any, children []protocol.ChildReference) *protocol.Resource {
514602
specMap := map[string]any{

pkg/k3s/resources/nodeprep_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package resources
2+
3+
import (
4+
"testing"
5+
6+
"github.com/openctl/openctl/pkg/protocol"
7+
)
8+
9+
// TestParseAndResolveNodePrep covers parsing a per-pool nodePrep spec and
10+
// resolving it to the right node index across the flat control-plane-then-
11+
// workers ordering — mirrors the GPU resolution both VM-build paths rely on.
12+
func TestParseAndResolveNodePrep(t *testing.T) {
13+
r := &protocol.Resource{Spec: map[string]any{
14+
"compute": map[string]any{"provider": "proxmox"},
15+
"nodes": map[string]any{
16+
"controlPlane": map[string]any{"count": float64(1)},
17+
"workers": []any{
18+
map[string]any{"name": "general", "count": float64(2)},
19+
map[string]any{"name": "storage", "count": float64(1), "nodePrep": map[string]any{
20+
"packages": []any{"open-iscsi", "nfs-common"},
21+
"runcmd": []any{"systemctl enable iscsid"},
22+
}},
23+
},
24+
},
25+
}}
26+
27+
spec, err := ParseClusterSpec(r)
28+
if err != nil {
29+
t.Fatalf("ParseClusterSpec: %v", err)
30+
}
31+
if spec.Nodes.Workers[0].NodePrep != nil {
32+
t.Errorf("general pool should have no nodePrep")
33+
}
34+
np := spec.Nodes.Workers[1].NodePrep
35+
if np == nil || len(np.Packages) != 2 || np.Packages[0] != "open-iscsi" {
36+
t.Fatalf("storage pool nodePrep mis-parsed: %+v", np)
37+
}
38+
if len(np.RunCmd) != 1 || np.RunCmd[0] != "systemctl enable iscsid" {
39+
t.Errorf("runcmd mis-parsed: %+v", np.RunCmd)
40+
}
41+
42+
// Flat ordering: index 0 = CP, 1-2 = general workers, 3 = storage worker.
43+
cpCount := 1
44+
if NodePrepForNode(0, cpCount, spec) != nil {
45+
t.Errorf("CP node should resolve to no nodePrep")
46+
}
47+
if NodePrepForNode(1, cpCount, spec) != nil || NodePrepForNode(2, cpCount, spec) != nil {
48+
t.Errorf("general workers should resolve to no nodePrep")
49+
}
50+
if NodePrepForNode(3, cpCount, spec) != np {
51+
t.Errorf("storage worker (index 3) should resolve to the storage pool nodePrep")
52+
}
53+
}
54+
55+
func TestApplyNodePrepToVMSpec(t *testing.T) {
56+
// Nil is a no-op.
57+
vm := map[string]any{"cloudInit": map[string]any{"user": "ubuntu"}}
58+
ApplyNodePrepToVMSpec(vm, nil)
59+
if _, ok := vm["cloudInit"].(map[string]any)["packages"]; ok {
60+
t.Errorf("nil nodePrep should be a no-op")
61+
}
62+
63+
// Empty spec is also a no-op (no packages, no runcmd).
64+
ApplyNodePrepToVMSpec(vm, &NodePrepSpec{})
65+
if _, ok := vm["cloudInit"].(map[string]any)["packages"]; ok {
66+
t.Errorf("empty nodePrep should be a no-op")
67+
}
68+
69+
// Full stamp writes into the existing cloudInit map, preserving user.
70+
ApplyNodePrepToVMSpec(vm, &NodePrepSpec{
71+
Packages: []string{"open-iscsi"},
72+
RunCmd: []string{"systemctl enable iscsid"},
73+
})
74+
ci := vm["cloudInit"].(map[string]any)
75+
if ci["user"] != "ubuntu" {
76+
t.Errorf("cloudInit.user should be preserved, got %v", ci["user"])
77+
}
78+
if pkgs, ok := ci["packages"].([]string); !ok || len(pkgs) != 1 || pkgs[0] != "open-iscsi" {
79+
t.Errorf("packages not stamped into cloudInit: %v", ci["packages"])
80+
}
81+
if cmds, ok := ci["runcmd"].([]string); !ok || len(cmds) != 1 || cmds[0] != "systemctl enable iscsid" {
82+
t.Errorf("runcmd not stamped into cloudInit: %v", ci["runcmd"])
83+
}
84+
}
85+
86+
// TestApplyNodePrepToVMSpec_CreatesCloudInitWhenAbsent proves the stamp is
87+
// robust to a VM spec that somehow lacks a cloudInit map (both build paths
88+
// construct one first, but the helper shouldn't panic if it's missing).
89+
func TestApplyNodePrepToVMSpec_CreatesCloudInitWhenAbsent(t *testing.T) {
90+
vm := map[string]any{}
91+
ApplyNodePrepToVMSpec(vm, &NodePrepSpec{Packages: []string{"curl"}})
92+
ci, ok := vm["cloudInit"].(map[string]any)
93+
if !ok {
94+
t.Fatalf("cloudInit should have been created, got %T", vm["cloudInit"])
95+
}
96+
if pkgs := ci["packages"].([]string); pkgs[0] != "curl" {
97+
t.Errorf("packages = %v", pkgs)
98+
}
99+
}

0 commit comments

Comments
 (0)