Skip to content

Commit d10a971

Browse files
authored
fix(proxmox): auto-select snippets-capable storage for cloud-init (#154)
The cloud-init snippet path (qemu-guest-agent enablement + E1 packages/runcmd) required the configured/disk storage to hold snippets; on a local-lvm-only node it silently no-op'd, which stalls DHCP-mode IP discovery for ~10 minutes and drops requested node prep. - client.PickSnippetsStorage (pure, unit-tested): prefer the configured storage when it's snippets-capable, else the node's first snippets-capable storage (sorted), else ok=false. - The handler resolves the storage via ListNodeStorages before uploading, so an LVM-default node still lands its snippet on e.g. "local". - Fail-fast: when a VM explicitly requested packages/runcmd and the node has no snippets-capable storage, create returns an actionable error instead of a silently-broken node. The best-effort agent-only case still degrades gracefully (logged, non-fatal), preserving prior behavior. Hardens both the bootstrap QGA path and the E1 per-VM vendor snippet (they share applyCloudInitVendorSnippet). Tests: PickSnippetsStorage (prefer/fallback/none); handler auto-select (LVM default → snippet on local) and fail-fast (no snippets storage → error); existing E1 handler test updated to serve /storage.
1 parent f44b518 commit d10a971

5 files changed

Lines changed: 254 additions & 27 deletions

File tree

ROADMAP.md

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -550,16 +550,21 @@ plan/state); harden the provider contract before the ecosystem widens.
550550
no-ops when the disk storage is `local-lvm` (LVM storages can't hold
551551
`snippets` content). Use a static `ip=…` or a snippet-capable storage
552552
until the QGA-install path is hardened (follow-up below).
553-
- [~] **Bootstrap QGA robustness (follow-up).** `EnsureQemuAgentSnippet`
554-
needs a `snippets`-capable storage; on a `local-lvm`-only node it
555-
no-ops, so DHCP-mode bootstrap can't discover the VM's IP. *Done:* the
556-
DHCP IP-wait timeout now fails with an **actionable** error naming the
557-
likely cause (qemu-guest-agent not running) and the static-IP
558-
workaround, instead of a bare timeout. *Remaining (the real fix):*
559-
install qemu-guest-agent without depending on the disk storage — pick a
560-
snippets-capable storage automatically (the proxmox client now has
561-
`ListNodeStorages`), or fail fast at create time when no such storage
562-
exists rather than after a 10-minute IP-wait.
553+
- [x] **Bootstrap QGA robustness (follow-up) — FIXED.** `EnsureQemuAgentSnippet`
554+
needed a `snippets`-capable storage; on a `local-lvm`-only node it
555+
no-op'd, so DHCP-mode bootstrap couldn't discover the VM's IP. *Earlier:*
556+
the DHCP IP-wait timeout fails with an **actionable** error naming the
557+
likely cause + the static-IP workaround. *Now the real fix:* the
558+
cloud-init snippet path **auto-selects a snippets-capable storage**
559+
`client.PickSnippetsStorage` (pure, unit-tested) prefers the configured
560+
storage when it can hold snippets, else the node's first snippets-capable
561+
one; the handler resolves it via `ListNodeStorages` before uploading. So a
562+
node whose default/disk storage is LVM-only still gets its qemu-guest-agent
563+
(and E1 packages/runcmd) snippet on `local`. When a VM **explicitly**
564+
requested `packages`/`runcmd` and the node has **no** snippets-capable
565+
storage, create now **fails fast** with a clear error instead of producing
566+
a silently-broken node; the agent-only case still degrades gracefully.
567+
Also hardens the E1 per-VM vendor snippet, which shares this path.
563568
- [x] **Bug: Cluster apply doesn't detect an out-of-band child-VM
564569
deletion — FIXED.** Surfaced 2026-07-07 recovering a k3s worker whose
565570
VM had been deleted outside openctl: Cluster `Get` reported `Ready`

pkg/proxmox/client/client.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,42 @@ func (c *Client) ListNodeStorages(ctx context.Context, node string) ([]*NodeStor
135135
return out, nil
136136
}
137137

138+
// storageSupportsSnippets reports whether a storage lists the "snippets"
139+
// content type — required to host cloud-init user/vendor-data. LVM-backed
140+
// stores (local-lvm, etc.) can't; a "dir" store like "local" typically can.
141+
func storageSupportsSnippets(s *NodeStorage) bool {
142+
for c := range strings.SplitSeq(s.Content, ",") {
143+
if strings.TrimSpace(c) == "snippets" {
144+
return true
145+
}
146+
}
147+
return false
148+
}
149+
150+
// PickSnippetsStorage chooses a storage that can hold cloud-init snippets from a
151+
// node's storage list. It prefers `preferred` when that storage exists and is
152+
// snippets-capable; otherwise it returns the first snippets-capable storage
153+
// (sorted by ID for determinism). ok is false when the node has none — callers
154+
// should fail fast at create time rather than after a long IP-discovery wait.
155+
// Pure (no network) so it is unit-testable independent of the API.
156+
func PickSnippetsStorage(storages []*NodeStorage, preferred string) (storage string, ok bool) {
157+
var capable []string
158+
for _, s := range storages {
159+
if !storageSupportsSnippets(s) {
160+
continue
161+
}
162+
if s.Storage == preferred {
163+
return preferred, true
164+
}
165+
capable = append(capable, s.Storage)
166+
}
167+
if len(capable) == 0 {
168+
return "", false
169+
}
170+
sort.Strings(capable)
171+
return capable[0], true
172+
}
173+
138174
// NodeBridge is a network bridge on a node, as returned by
139175
// /nodes/<node>/network?type=bridge.
140176
type NodeBridge struct {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package client
2+
3+
import "testing"
4+
5+
func storages() []*NodeStorage {
6+
return []*NodeStorage{
7+
{Storage: "local-lvm", Type: "lvmthin", Content: "images,rootdir", Active: 1},
8+
{Storage: "local", Type: "dir", Content: "snippets,vztmpl,iso", Active: 1},
9+
{Storage: "cephfs", Type: "cephfs", Content: "snippets,backup", Active: 1},
10+
}
11+
}
12+
13+
func TestPickSnippetsStorage_PrefersConfiguredWhenCapable(t *testing.T) {
14+
got, ok := PickSnippetsStorage(storages(), "cephfs")
15+
if !ok || got != "cephfs" {
16+
t.Errorf("got (%q,%v), want (cephfs,true) — configured snippets-capable storage preferred", got, ok)
17+
}
18+
}
19+
20+
func TestPickSnippetsStorage_FallsBackWhenPreferredNotCapable(t *testing.T) {
21+
// Preferred is the LVM disk store (no snippets) → fall back to the first
22+
// snippets-capable, sorted by ID: "cephfs" < "local".
23+
got, ok := PickSnippetsStorage(storages(), "local-lvm")
24+
if !ok || got != "cephfs" {
25+
t.Errorf("got (%q,%v), want (cephfs,true) — first snippets-capable by ID", got, ok)
26+
}
27+
}
28+
29+
func TestPickSnippetsStorage_FallsBackWhenNoPreferred(t *testing.T) {
30+
got, ok := PickSnippetsStorage(storages(), "")
31+
if !ok || got != "cephfs" {
32+
t.Errorf("got (%q,%v), want (cephfs,true)", got, ok)
33+
}
34+
}
35+
36+
func TestPickSnippetsStorage_NoneCapable(t *testing.T) {
37+
only := []*NodeStorage{
38+
{Storage: "local-lvm", Type: "lvmthin", Content: "images,rootdir", Active: 1},
39+
}
40+
if got, ok := PickSnippetsStorage(only, "local-lvm"); ok {
41+
t.Errorf("got (%q,true), want (\"\",false) — LVM-only node has no snippets storage", got)
42+
}
43+
}

pkg/proxmox/handler/handler.go

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,9 @@ func (h *Handler) createVMFromTemplate(ctx context.Context, name, node string, s
354354
snippetStorage = s
355355
}
356356
}
357-
h.applyCloudInitVendorSnippet(ctx, node, vmid, snippetStorage, spec.CloudInit)
357+
if err := h.applyCloudInitVendorSnippet(ctx, node, vmid, snippetStorage, spec.CloudInit); err != nil {
358+
return nil, err
359+
}
358360
}
359361

360362
if spec.StartOnCreate {
@@ -375,34 +377,62 @@ func (h *Handler) createVMFromTemplate(ctx context.Context, name, node string, s
375377
// otherwise it uses the shared static qemu-agent snippet — the pre-existing,
376378
// metal-validated path, byte-identical.
377379
//
378-
// Every failure is non-fatal and surfaced via debug logs: a storage that can't
379-
// hold snippets (e.g. LVM) just means no agent/packages, not a failed create —
380-
// matching the prior behavior.
381-
func (h *Handler) applyCloudInitVendorSnippet(ctx context.Context, node string, vmid int, snippetStorage string, ci *resources.CloudInitSpec) {
380+
// The snippet storage is resolved automatically: `preferredStorage` is used
381+
// when it can hold snippets, otherwise the node's first snippets-capable
382+
// storage — so a node whose default/disk storage is LVM-only (which can't hold
383+
// snippets) still works, and the qemu-guest-agent snippet lands instead of
384+
// silently no-op'ing (which stalls DHCP-mode IP discovery for ~10 minutes).
385+
//
386+
// Failure handling depends on intent:
387+
// - When the VM explicitly requested packages/runcmd, any failure (no
388+
// snippets-capable storage, upload, config) is FATAL and returned — the
389+
// user asked for node prep we can't deliver, so fail fast at create rather
390+
// than produce a silently-broken node.
391+
// - For the best-effort qemu-guest-agent-only case (no packages/runcmd), a
392+
// failure degrades gracefully (logged, nil) — matching the prior behavior.
393+
func (h *Handler) applyCloudInitVendorSnippet(ctx context.Context, node string, vmid int, preferredStorage string, ci *resources.CloudInitSpec) error {
394+
custom := ci != nil && (len(ci.Packages) > 0 || len(ci.RunCmd) > 0)
395+
// fail returns err when the caller explicitly requested packages/runcmd,
396+
// else swallows it (best-effort agent snippet).
397+
fail := func(err error) error {
398+
if custom {
399+
return err
400+
}
401+
debugf("applyCloudInitVendorSnippet: %v (best-effort agent snippet skipped)", err)
402+
return nil
403+
}
404+
405+
storages, err := h.client.ListNodeStorages(ctx, node)
406+
if err != nil {
407+
return fail(fmt.Errorf("list storages on %s: %w", node, err))
408+
}
409+
storage, ok := client.PickSnippetsStorage(storages, preferredStorage)
410+
if !ok {
411+
return fail(fmt.Errorf("no snippets-capable storage on node %q (cloud-init needs a storage with the \"snippets\" content type, e.g. \"local\")", node))
412+
}
413+
382414
var snippetName string
383-
if ci != nil && (len(ci.Packages) > 0 || len(ci.RunCmd) > 0) {
415+
if custom {
384416
content, err := client.RenderVendorData(ci.Packages, ci.RunCmd)
385417
if err != nil {
386-
debugf("applyCloudInitVendorSnippet: render vendor data: %v", err)
387-
return
418+
return fail(fmt.Errorf("render vendor data: %w", err))
388419
}
389420
snippetName = client.VendorSnippetName(vmid)
390-
if err := h.client.UploadSnippet(ctx, node, snippetStorage, snippetName, content); err != nil {
391-
debugf("applyCloudInitVendorSnippet: upload vendor snippet: %v", err)
392-
return
421+
if err := h.client.UploadSnippet(ctx, node, storage, snippetName, content); err != nil {
422+
return fail(fmt.Errorf("upload vendor snippet: %w", err))
393423
}
394424
} else {
395-
if err := h.client.EnsureQemuAgentSnippet(ctx, node, snippetStorage); err != nil {
396-
debugf("applyCloudInitVendorSnippet: ensure agent snippet: %v", err)
397-
return
425+
if err := h.client.EnsureQemuAgentSnippet(ctx, node, storage); err != nil {
426+
return fail(fmt.Errorf("ensure agent snippet: %w", err))
398427
}
399428
snippetName = client.QemuAgentSnippetName
400429
}
401-
cicustom := fmt.Sprintf("vendor=%s:snippets/%s", snippetStorage, snippetName)
430+
cicustom := fmt.Sprintf("vendor=%s:snippets/%s", storage, snippetName)
402431
debugf("applyCloudInitVendorSnippet: setting cicustom=%s", cicustom)
403432
if err := h.client.ConfigureVM(ctx, node, vmid, map[string]any{"cicustom": cicustom}); err != nil {
404-
debugf("applyCloudInitVendorSnippet: set cicustom: %v", err)
433+
return fail(fmt.Errorf("set cicustom: %w", err))
405434
}
435+
return nil
406436
}
407437

408438
func primaryDiskStorage(spec *resources.VMSpec) string {
@@ -508,7 +538,9 @@ func (h *Handler) createVMFromCloudImage(ctx context.Context, name, node string,
508538
// detection, plus any requested packages/runcmd). Using vendor= (not
509539
// user=) so it merges with Proxmox's generated cloud-init user-data
510540
// instead of replacing it.
511-
h.applyCloudInitVendorSnippet(ctx, node, vmid, spec.CloudImage.Storage, spec.CloudInit)
541+
if err := h.applyCloudInitVendorSnippet(ctx, node, vmid, spec.CloudImage.Storage, spec.CloudInit); err != nil {
542+
return nil, err
543+
}
512544

513545
// Regenerate cloud-init with new settings (error ignored - non-fatal)
514546
if spec.CloudInit != nil {

pkg/proxmox/handler/handler_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,11 @@ func TestCreateVMUploadsPerVMVendorSnippet(t *testing.T) {
363363
}})
364364
case r.URL.Path == "/api2/json/cluster/nextid" && r.Method == "GET":
365365
json.NewEncoder(w).Encode(map[string]any{"data": "200"})
366+
case r.URL.Path == "/api2/json/nodes/pve1/storage" && r.Method == "GET":
367+
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{
368+
{"storage": "local", "type": "dir", "content": "snippets,vztmpl,iso", "active": 1},
369+
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "active": 1},
370+
}})
366371
case r.URL.Path == "/api2/json/nodes/pve1/qemu/9000/clone" && r.Method == "POST":
367372
json.NewEncoder(w).Encode(map[string]any{"data": ""})
368373
case r.URL.Path == "/api2/json/nodes/pve1/storage/local/upload" && r.Method == "POST":
@@ -431,6 +436,112 @@ func TestCreateVMUploadsPerVMVendorSnippet(t *testing.T) {
431436
}
432437
}
433438

439+
// snippetHardeningServer is a create-path fake whose /storage response is
440+
// configurable, so tests can exercise auto-selection and the no-snippets case.
441+
// It records the upload target storage and the cicustom value.
442+
func snippetHardeningServer(t *testing.T, storageData []map[string]any, uploadStorage *string, cicustom *string) *httptest.Server {
443+
t.Helper()
444+
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
445+
switch {
446+
case r.URL.Path == "/api2/json/nodes" && r.Method == "GET":
447+
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]string{{"node": "pve1"}}})
448+
case r.URL.Path == "/api2/json/nodes/pve1/qemu" && r.Method == "GET":
449+
json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{
450+
{"vmid": 9000, "name": "ubuntu-template", "template": 1, "status": "stopped"},
451+
}})
452+
case r.URL.Path == "/api2/json/cluster/nextid" && r.Method == "GET":
453+
json.NewEncoder(w).Encode(map[string]any{"data": "200"})
454+
case r.URL.Path == "/api2/json/nodes/pve1/storage" && r.Method == "GET":
455+
json.NewEncoder(w).Encode(map[string]any{"data": storageData})
456+
case r.URL.Path == "/api2/json/nodes/pve1/qemu/9000/clone" && r.Method == "POST":
457+
json.NewEncoder(w).Encode(map[string]any{"data": ""})
458+
case strings.HasSuffix(r.URL.Path, "/upload") && r.Method == "POST":
459+
// /api2/json/nodes/pve1/storage/<storage>/upload
460+
parts := strings.Split(r.URL.Path, "/")
461+
*uploadStorage = parts[len(parts)-2]
462+
json.NewEncoder(w).Encode(map[string]any{"data": ""})
463+
case r.URL.Path == "/api2/json/nodes/pve1/qemu/200/config":
464+
if err := r.ParseForm(); err != nil {
465+
t.Fatalf("ParseForm: %v", err)
466+
}
467+
if c := r.Form.Get("cicustom"); c != "" {
468+
*cicustom = c
469+
}
470+
json.NewEncoder(w).Encode(map[string]any{"data": ""})
471+
default:
472+
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
473+
}
474+
}))
475+
}
476+
477+
func createVMWithPackages(t *testing.T, h *Handler) (*protocol.Response, error) {
478+
t.Helper()
479+
return h.Handle(context.Background(), &protocol.Request{
480+
Version: protocol.ProtocolVersion,
481+
Action: protocol.ActionCreate,
482+
ResourceType: "VirtualMachine",
483+
Manifest: &protocol.Resource{
484+
APIVersion: "proxmox.openctl.io/v1",
485+
Kind: "VirtualMachine",
486+
Metadata: protocol.ResourceMetadata{Name: "longhorn-node"},
487+
Spec: map[string]any{
488+
"node": "pve1",
489+
"template": map[string]any{"name": "ubuntu-template"},
490+
"cloudInit": map[string]any{"packages": []any{"open-iscsi"}},
491+
},
492+
},
493+
})
494+
}
495+
496+
// TestCreateVMAutoSelectsSnippetsStorage: the preferred/default storage is
497+
// LVM-only (no snippets), so the vendor snippet must land on the node's
498+
// snippets-capable storage instead of silently failing.
499+
func TestCreateVMAutoSelectsSnippetsStorage(t *testing.T) {
500+
var uploadStorage, cicustom string
501+
server := snippetHardeningServer(t, []map[string]any{
502+
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "active": 1},
503+
{"storage": "local", "type": "dir", "content": "snippets,vztmpl", "active": 1},
504+
}, &uploadStorage, &cicustom)
505+
defer server.Close()
506+
507+
// Default storage is the LVM one (can't hold snippets).
508+
h := New(&protocol.ProviderConfig{Endpoint: server.URL, TokenID: "t", TokenSecret: "s", Node: "pve1",
509+
Defaults: map[string]string{"storage": "local-lvm"}})
510+
resp, err := createVMWithPackages(t, h)
511+
if err != nil {
512+
t.Fatalf("Handle: %v", err)
513+
}
514+
if resp.Status != protocol.StatusSuccess {
515+
t.Fatalf("status = %s, resp=%+v", resp.Status, resp)
516+
}
517+
if uploadStorage != "local" {
518+
t.Errorf("uploaded to %q, want local (auto-selected snippets-capable storage)", uploadStorage)
519+
}
520+
if cicustom != "vendor=local:snippets/openctl-vendor-200.yaml" {
521+
t.Errorf("cicustom = %q, want vendor=local:snippets/openctl-vendor-200.yaml", cicustom)
522+
}
523+
}
524+
525+
// TestCreateVMFailsFastWhenNoSnippetsStorage: packages/runcmd were explicitly
526+
// requested but the node has no snippets-capable storage, so create must fail
527+
// fast with an actionable error rather than produce a silently-broken node.
528+
func TestCreateVMFailsFastWhenNoSnippetsStorage(t *testing.T) {
529+
var uploadStorage, cicustom string
530+
server := snippetHardeningServer(t, []map[string]any{
531+
{"storage": "local-lvm", "type": "lvmthin", "content": "images,rootdir", "active": 1},
532+
}, &uploadStorage, &cicustom)
533+
defer server.Close()
534+
535+
h := New(&protocol.ProviderConfig{Endpoint: server.URL, TokenID: "t", TokenSecret: "s", Node: "pve1"})
536+
_, err := createVMWithPackages(t, h)
537+
if err == nil {
538+
t.Fatal("expected a fail-fast error when no snippets-capable storage exists")
539+
}
540+
if !strings.Contains(err.Error(), "snippets") {
541+
t.Errorf("error should mention snippets storage, got: %v", err)
542+
}
543+
}
544+
434545
func cloneValues(in url.Values) url.Values {
435546
out := make(url.Values, len(in))
436547
for k, values := range in {

0 commit comments

Comments
 (0)