From 280a7f80e50fc2eed7d09047794ae3f58d7b9674 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 9 Jun 2026 13:53:36 +0200 Subject: [PATCH] fix(proxmox): DestroyLXC DELETE takes no body + force=1 (live restore-test teardown) The slice-6 restore-test teardown failed live: DELETE /lxc/{vmid} with a form body returns HTTP 501 'Unexpected content for method DELETE', and a booted scratch needs force=1. Move purge/destroy-unreferenced-disks/force to the query string, send no body. Regression test locks the contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/proxmox/backup_test.go | 63 +++++++++++++++++++++++++++++++++ internal/proxmox/mutate.go | 11 +++--- 2 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 internal/proxmox/backup_test.go diff --git a/internal/proxmox/backup_test.go b/internal/proxmox/backup_test.go new file mode 100644 index 0000000..6576e08 --- /dev/null +++ b/internal/proxmox/backup_test.go @@ -0,0 +1,63 @@ +package proxmox + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +// TestDestroyLXC_DeleteContract locks the DELETE wire contract: no request body (a form +// body → PVE HTTP 501 "Unexpected content for method 'DELETE'"), flags in the query string, +// force=1 so a still-running scratch can be torn down. Regression guard for the slice-6 +// teardown bug. +func TestDestroyLXC_DeleteContract(t *testing.T) { + var gotMethod, gotURL string + var gotBody string + m := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { + gotMethod = r.Method + gotURL = r.URL.String() + if r.Body != nil { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + } + return jsonResp(200, `{"data":"UPID:demo:0000:vzdestroy::root@pam:"}`), nil + }} + c := newTestClient(m) + upid, err := c.DestroyLXC(context.Background(), 990000) + if err != nil { + t.Fatalf("DestroyLXC: %v", err) + } + if upid == "" { + t.Error("expected a UPID") + } + if gotMethod != http.MethodDelete { + t.Errorf("method = %s, want DELETE", gotMethod) + } + if gotBody != "" { + t.Errorf("DELETE must send NO body, got %q", gotBody) + } + for _, want := range []string{"/nodes/demo-felhom/lxc/990000", "purge=1", "destroy-unreferenced-disks=1", "force=1"} { + if !strings.Contains(gotURL, want) { + t.Errorf("URL %q missing %q", gotURL, want) + } + } +} + +// TestVzdump_NotesTemplate confirms the notes-template param is sent when set. +func TestVzdump_NotesTemplate(t *testing.T) { + var gotBody string + m := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + return jsonResp(200, `{"data":"UPID:demo:0:vzdump::root@pam:"}`), nil + }} + c := newTestClient(m) + if _, err := c.Vzdump(context.Background(), VzdumpOptions{VMID: 9001, Storage: "local", Mode: ModeSnapshot, Notes: "felhom {{guestname}}"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(gotBody, "notes-template=") { + t.Errorf("vzdump body missing notes-template: %q", gotBody) + } +} diff --git a/internal/proxmox/mutate.go b/internal/proxmox/mutate.go index bbc83fc..08d7bf8 100644 --- a/internal/proxmox/mutate.go +++ b/internal/proxmox/mutate.go @@ -96,11 +96,12 @@ func (c *Client) DestroyLXC(ctx context.Context, vmid int) (string, error) { if vmid == 0 { return "", fmt.Errorf("proxmox: DestroyLXC needs a vmid") } - v := url.Values{} - v.Set("purge", "1") - v.Set("destroy-unreferenced-disks", "1") - path := fmt.Sprintf("/nodes/%s/lxc/%d", c.node, vmid) - return c.dataString(ctx, http.MethodDelete, path, v) + // DELETE takes NO request body on PVE (a form body → HTTP 501 "Unexpected content for + // method 'DELETE'"); the flags go in the query string. `force=1` destroys even a running + // guest (the scratch may still be booted at teardown), `purge=1` drops it from jobs/HA, + // `destroy-unreferenced-disks=1` reaps orphaned volumes. + path := fmt.Sprintf("/nodes/%s/lxc/%d?purge=1&destroy-unreferenced-disks=1&force=1", c.node, vmid) + return c.dataString(ctx, http.MethodDelete, path, nil) } // Snapshot creates an LXC snapshot via POST /nodes/{node}/lxc/{vmid}/snapshot.