v0.3.2: reversible SetConfig step in --selftest=task (slice-4 pre-check)
Append a reversible SetConfig write+revert to runSelftestTask: read GuestConfig, write a `description` marker, verify it landed, restore the original (or delete if absent), verify the restore. Handles PVE's dual-mode SetConfig return (empty UPID = synchronous; UPID = WaitTask+assert OK). Live self-gate PASSED on demo-felhom / guest 9999. Findings: - LXC `description` write is synchronous (empty UPID) — dual-mode modeling confirmed; empty string is success, not an error. - PVE appends a trailing newline to `description` on read; slice-4 reconcile must normalize description comparisons (hence normDesc helper). First live exercise of the VM.Config.* privilege cluster. Standing operator token rotated during the run; new secret stored out-of-band, not in the repo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+137
-1
@@ -15,6 +15,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -26,7 +27,7 @@ import (
|
||||
|
||||
// version is the agent version. Overridable at build time with
|
||||
// -ldflags "-X main.version=<v>"; defaults to the in-repo CHANGELOG version.
|
||||
var version = "0.3.1"
|
||||
var version = "0.3.2"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
@@ -287,10 +288,145 @@ func runSelftestTask(ctx context.Context, cfg config.Config, logger *slog.Logger
|
||||
}
|
||||
fmt.Printf(" [ ok ] %-16s exitstatus=%s\n", st.name, status.ExitStatus)
|
||||
}
|
||||
|
||||
// Reversible SetConfig exercise: this is the first live use of the VM.Config.*
|
||||
// privilege cluster. It round-trips the cosmetic `description` field (no runtime
|
||||
// effect, fully reversible) to prove SetConfig works under the scoped token
|
||||
// before slice 4's reconcile is built on top of it.
|
||||
if rc := selftestSetConfig(ctx, client, vmid); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
fmt.Println("=== selftest=task OK ===")
|
||||
return 0
|
||||
}
|
||||
|
||||
// selftestSetConfig performs a reversible write+revert of the LXC `description`
|
||||
// field on vmid and asserts both land. Returns 0 on success, non-zero (with a
|
||||
// printed [FAIL] line) on any failure — the caller stops on a non-zero return.
|
||||
func selftestSetConfig(ctx context.Context, client *proxmox.Client, vmid int) int {
|
||||
// 1. Read current state; capture the original description (may be absent).
|
||||
cfg, err := client.GuestConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s GuestConfig: %v\n", "setconfig", err)
|
||||
return 1
|
||||
}
|
||||
origDesc, origPresent, err := extraString(cfg, "description")
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s decode description: %v\n", "setconfig", err)
|
||||
return 1
|
||||
}
|
||||
// PVE normalizes `description` by appending a trailing newline on read, so all
|
||||
// comparisons here use normDesc (strip trailing newlines) and restores write the
|
||||
// normalized original — otherwise an exact-match check sees false drift. This is
|
||||
// load-bearing intel for slice-4 reconcile (compare descriptions normalized).
|
||||
origDesc = normDesc(origDesc)
|
||||
|
||||
// 2. Write the marker.
|
||||
marker := "felhom-selftest " + time.Now().UTC().Format(time.RFC3339)
|
||||
if rc := applySetConfig(ctx, client, vmid, "setconfig", map[string]string{"description": marker}); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
// 3. Verify the marker landed.
|
||||
cfg, err = client.GuestConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s GuestConfig (verify): %v\n", "setconfig", err)
|
||||
return 1
|
||||
}
|
||||
got, present, err := extraString(cfg, "description")
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s decode description (verify): %v\n", "setconfig", err)
|
||||
return 1
|
||||
}
|
||||
if !present || normDesc(got) != marker {
|
||||
fmt.Printf(" [FAIL] %-16s write did not land: present=%v got=%q want=%q\n", "setconfig", present, got, marker)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf(" [ ok ] %-16s description verified == marker\n", "verify-write")
|
||||
|
||||
// 4. Restore the original value (or clear it if it was absent originally).
|
||||
var revert map[string]string
|
||||
if origPresent {
|
||||
revert = map[string]string{"description": origDesc}
|
||||
} else {
|
||||
revert = map[string]string{"delete": "description"}
|
||||
}
|
||||
if rc := applySetConfig(ctx, client, vmid, "setconfig-revert", revert); rc != 0 {
|
||||
return rc
|
||||
}
|
||||
|
||||
// 5. Confirm the restore.
|
||||
cfg, err = client.GuestConfig(ctx, vmid)
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s GuestConfig (revert verify): %v\n", "setconfig-revert", err)
|
||||
return 1
|
||||
}
|
||||
got, present, err = extraString(cfg, "description")
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s decode description (revert verify): %v\n", "setconfig-revert", err)
|
||||
return 1
|
||||
}
|
||||
if origPresent {
|
||||
if !present || normDesc(got) != origDesc {
|
||||
fmt.Printf(" [FAIL] %-16s revert did not restore: present=%v got=%q want=%q\n", "setconfig-revert", present, normDesc(got), origDesc)
|
||||
return 1
|
||||
}
|
||||
} else if present {
|
||||
fmt.Printf(" [FAIL] %-16s revert did not clear: still present got=%q\n", "setconfig-revert", got)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf(" [ ok ] %-16s description restored to original\n", "verify-revert")
|
||||
return 0
|
||||
}
|
||||
|
||||
// applySetConfig runs one SetConfig and asserts success, handling PVE's dual-mode
|
||||
// return: a UPID means async (WaitTask + assert exitstatus OK); an empty string
|
||||
// means PVE applied it synchronously (not an error — phase1-2/mutate.go contract).
|
||||
func applySetConfig(ctx context.Context, client *proxmox.Client, vmid int, step string, params map[string]string) int {
|
||||
upid, err := client.SetConfig(ctx, vmid, params)
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s %v\n", step, err)
|
||||
return 1
|
||||
}
|
||||
if upid == "" {
|
||||
// Synchronous path: empty UPID is a clean success.
|
||||
fmt.Printf(" [ ok ] %-16s synchronous exitstatus=OK\n", step)
|
||||
return 0
|
||||
}
|
||||
status, err := client.WaitTask(ctx, upid, proxmox.WaitOptions{})
|
||||
if err != nil {
|
||||
fmt.Printf(" [FAIL] %-16s %s %v\n", step, upid, err)
|
||||
return 1
|
||||
}
|
||||
if status.ExitStatus != "OK" {
|
||||
fmt.Printf(" [FAIL] %-16s %s exitstatus=%s\n", step, upid, status.ExitStatus)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf(" [ ok ] %-16s %s exitstatus=%s\n", step, upid, status.ExitStatus)
|
||||
return 0
|
||||
}
|
||||
|
||||
// normDesc strips trailing newlines that PVE appends to the `description` field
|
||||
// on read, so a written value round-trips equal. (PVE stores `description` with a
|
||||
// trailing "\n"; comparing raw would always mismatch.)
|
||||
func normDesc(s string) string { return strings.TrimRight(s, "\n") }
|
||||
|
||||
// extraString reads a string-valued key from GuestConfig.Extra (raw JSON). It
|
||||
// returns ("", false, nil) when the key is absent, and decodes the JSON string
|
||||
// otherwise.
|
||||
func extraString(cfg proxmox.GuestConfig, key string) (string, bool, error) {
|
||||
raw, ok := cfg.Extra[key]
|
||||
if !ok || len(raw) == 0 {
|
||||
return "", false, nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return s, true, nil
|
||||
}
|
||||
|
||||
// --- small helpers / flag type ---
|
||||
|
||||
func envOr(key, def string) string {
|
||||
|
||||
Reference in New Issue
Block a user