Skip to content

feat(xapi): add an XCP-ng/XenServer backend over XAPI JSON-RPC - #1

Merged
gounthar merged 6 commits into
mainfrom
feat/xapi-backend
Aug 3, 2026
Merged

feat(xapi): add an XCP-ng/XenServer backend over XAPI JSON-RPC#1
gounthar merged 6 commits into
mainfrom
feat/xapi-backend

Conversation

@gounthar

@gounthar gounthar commented Aug 3, 2026

Copy link
Copy Markdown
Owner

What / why

clawk runs sandboxes on local hypervisors — vz on macOS, firecracker on Linux. Neither
reaches a pool, so anyone whose compute already lives on XCP-ng has to stand up separate
hardware to use it. This adds machine/xapi, a machine.Backend that drives an
XCP-ng / XenServer pool over XAPI.

machine/ is its own Go module, so nothing in internal/ or cmd/clawk is touched.

Three constraints, each ruling out an easier design

Nothing is installed in dom0 — not a binary, not a kernel, not a config file. A tool
that needs files placed in dom0 is unshippable to XCP-ng users no matter how much simpler
it makes the code. So DirectKernel specs are honoured by synthesising a read-only boot
VDI (ESP + GRUB + vmlinux + initrd) as xvda, with the OCI rootfs as xvdb and
root=/dev/xvdb, rather than by pointing PV_kernel at a dom0 path. One boot VDI per
(kernel, cmdline), cached in the SR.

Egress filtering stays out of this process. gvproxy runs in a per-host gateway VM with
a VIF on the sandbox's private (no-PIF) network, so the guest's only route out passes
through it. That preserves clawk's actual security property — root in the guest cannot
reconfigure the filter — without a dom0-resident daemon. Caps.UserModeNet, TAPNet and
UnixgramNet are consequently all false, and Spec.Net variants are rejected. That is
the design, not an omission.

Xen has no virtio-vsock. Machine.VSock returns ErrVSockUnsupported; the control
path is TCP over a management VIF via the package's ControlDialer. This is the backend's
largest divergence from the machine contract and the part most worth arguing about. No
virtio-fs either, so Spec.Shares is rejected — firecracker already reports
VirtioFS: false, so there is precedent.

Why plain XAPI JSON-RPC, and not a binding or XO

The transport is XAPI's own JSON-RPC endpoint spoken with net/http and encoding/json.
No new dependency in go.mod.

A generated XenAPI binding over XML-RPC would drag in thousands of generated types to
serve the ~20 calls in Client, and the raw VDI import is a separate HTTP PUT outside the
RPC surface either way — so the binding saves nothing on the one hard part.

Xen Orchestra's API is nicer and brings XO's ACLs, which would matter for a multi-user
agent fleet. It also puts XO in the path of a tool that otherwise talks to any pool
unaided, which is the larger claim on an operator's setup. The Client interface exists so
this stays reversible: an XO transport can land later as a second implementation, judged on
its own merits.

What actually works

Live: session.login_with_password, VM.get_power_state, VM.get_by_name_label,
session.logout.

Everything else — the other Client methods, plus Create, Destroy and checkpoint
Restore — returns an error. Deliberately: they fail loudly at the pool boundary rather
than returning a zero value that reads as success. A failing Create is the honest state
of this branch.

Platform(s) built and tested on

  • macOS (vz) — not exercised; no Apple hardware in this loop. CI's macos-14 job covers it.
  • Linux — built, vetted and tested. firecracker itself not booted (no /dev/kvm here).
  • XCP-ng 8.3.0 pool (XAPI 26.1) — the target this change is actually for.

Against the pool, confirmed by hand and by manual_test.go:

Checked Result
/jsonrpc endpoint path correct
JSON-RPC 2.0 envelope correct
4-arg login_with_password signature accepted
Error shape (code/message/data) matches what the tests assert
Login → logout round trip passes
VM.get_by_name_labelVM.get_power_state passes
Power-state enum confirmed in both Running and Halted

Checklist

  • gofmt -l . is clean
  • go build ./... && go vet ./... && go test ./... pass from the repo root — build and
    vet pass; go test ./... has one failure that is not from this change.

    TestSandboxNetNS/topology_exists_inside_the_namespace fails looking for
    /sys/class/net/br0/flags inside the netns. It reproduces identically on a clean main
    with none of this branch's code, and nothing outside machine/ references machine/xapi,
    so it is a WSL2 environment limitation rather than a regression. Flagged rather than
    ticked, because a checked box here would be false.
  • go build ./... && go vet ./... && go test ./... pass from machine/
  • One logical change per PR
  • Commit messages explain the why, not just the what
  • No Co-Authored-By / attribution trailers

Open questions for review

These are genuinely undecided, and I'd rather raise them than pick unilaterally:

  1. machine.Spec has no extension point. A local hypervisor has nothing to configure;
    a remote one has a pool address, credentials, an SR and two networks. This package takes
    them via Configure() before the first New(), which works but is process-global. A
    Backend constructor alongside the registry would be cleaner — worth doing?

  2. internal/vsockclient constructs its own transport. For this backend to work as an
    internal/sandbox.Provider, it needs to accept a dialer instead. That is the only change
    reaching outside machine/xapi, and whether it is welcome decides the shape of the rest
    of this work. Not attempted here.

  3. The snapshot contract. Suspendable/Snapshottable are documented as writing state
    into a caller-owned directory, but XAPI keeps memory images in the SR. This writes a small
    JSON pointer file into that directory instead. Is that reading acceptable, or should the
    interface grow an opaque-handle variant?

Notes for the reviewer

  • Config.InsecureTLS is new public API, off by default. A stock XCP-ng install has a
    self-signed cert, so a lab pool is otherwise unreachable. The doc comment says why it
    matters: the session token authenticating every later call crosses that connection.
  • VMByNameLabel is deliberately not on the Client interface — it is on the concrete
    JSON-RPC type. The backend addresses VMs by the ref VMCreate returned and never searches;
    only a human needs lookup-by-name. It errors on duplicate name-labels rather than guessing,
    since XAPI does not make them unique.
  • client_jsonrpc_test.go tests the wire format against httptest, so CI exercises the
    transport with no pool. Without it, the gated manual_test.go would skip and nothing
    would cover this code upstream.
  • Pool-dependent tests follow the module's existing convention — a manual_test.go gated on
    a TEST_* variable, matching kernel/manual_test.go and oci/manual_test.go.

No linked issue — this work was not tracked in one.

Summary by CodeRabbit

  • New Features
    • Added XCP-ng/XenServer integration for VM lifecycle management, pause/resume, suspend/restore, snapshots, networking, storage, and guest-agent connectivity.
    • Added configurable pool, storage, network, image, kernel, CPU, memory, TLS, timeout, and cleanup options.
    • Added a smoke-test command for validating VM startup and guest-agent connectivity.
  • Bug Fixes
    • Improved validation, session recovery, cleanup handling, and error reporting.
  • Tests
    • Added automated and manual coverage for authentication, VM states, configuration validation, and backend capabilities.

clawk runs sandboxes on local hypervisors: vz on macOS, firecracker on
Linux. Neither reaches a pool, so anyone whose compute already lives on
XCP-ng has to stand up separate hardware to use it. This adds a
machine.Backend that talks to a pool instead.

Three constraints shaped it, and each rules out an easier design:

Nothing is installed in dom0 — not a binary, not a kernel, not a config
file. A tool that needs files placed in dom0 is unshippable to XCP-ng
users no matter how much simpler it makes the code, so DirectKernel
specs are honoured by synthesising a read-only boot VDI (ESP + GRUB +
vmlinux + initrd) as xvda with the OCI rootfs as xvdb, rather than by
pointing PV_kernel at a dom0 path.

Egress filtering stays out of this process. gvproxy runs in a per-host
gateway VM with a VIF on the sandbox's private no-PIF network, so the
guest's only route out passes through it. That keeps clawk's actual
security property — root in the guest cannot reconfigure the filter —
without a dom0-resident daemon. Caps.UserModeNet, TAPNet and
UnixgramNet are consequently false and Spec.Net variants are rejected.

Xen has no virtio-vsock, so Machine.VSock returns ErrVSockUnsupported
and the control path is TCP over a management VIF via ControlDialer.
This is the backend's largest divergence from the machine contract and
the part most likely to need discussion. No virtio-fs either, so
Spec.Shares is rejected; firecracker already reports VirtioFS: false,
so there is precedent.

The transport is XAPI's own JSON-RPC endpoint spoken with net/http and
encoding/json, which adds no dependency to go.mod. A generated XenAPI
binding would drag in thousands of types to serve the ~20 calls in
Client, and the raw VDI import is a separate HTTP PUT outside the RPC
surface either way, so the binding saves nothing on the hard part. Xen
Orchestra's API is nicer and brings its ACLs, but puts XO in the path
of a tool that otherwise talks to any pool unaided. The Client
interface keeps that choice reversible.

Live so far: session.login_with_password, VM.get_power_state,
VM.get_by_name_label, session.logout. Every other Client method
returns errNotImplemented, as do Create, Destroy and checkpoint
Restore. They fail loudly rather than returning a zero value that
reads as success — a failing Create is the honest current state.

Suspend and snapshot diverge from the documented contract: it says
state is written into a caller-owned directory, but XAPI keeps memory
images in the SR, so this writes a small JSON pointer file there
instead. Whether that reading is acceptable, or the interface should
grow an opaque-handle variant, is a question for upstream.

Verified against an XCP-ng 8.3 pool: login, logout, name-label lookup
and power-state read all round-trip, with the enum confirmed in both
Running and Halted. Those calls live in manual_test.go and run only
when TEST_XAPI_POOL is set; the httptest wire tests cover the same
paths in CI, where no pool exists.

Signed-off-by: Bruno Verachten <gounthar@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@gounthar, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 665f0be4-e67e-4ca2-9158-b3166dd84bb7

📥 Commits

Reviewing files that changed from the base of the PR and between 5674fb9 and 4db5a95.

📒 Files selected for processing (3)
  • machine/xapi/client_jsonrpc.go
  • machine/xapi/client_jsonrpc_test.go
  • machine/xapi/manual_test.go
📝 Walkthrough

Walkthrough

The change adds an XAPI client and JSON-RPC transport, registers an XAPI machine backend with lifecycle and guest-control operations, adds snapshot pointer handling and tests, and introduces a configurable smoke-xapi command.

Changes

XAPI backend

Layer / File(s) Summary
Client contract and JSON-RPC transport
machine/xapi/client.go, machine/xapi/client_jsonrpc.go, machine/xapi/client_jsonrpc_test.go, machine/xapi/manual_test.go
Defines XAPI references, VM configuration, client operations, snapshot pointers, session handling, JSON-RPC requests, VM queries, error handling, and transport tests.
Backend configuration and VM lifecycle
machine/xapi/xapi.go, machine/xapi/xapi_test.go, NOTES.md
Registers the backend, validates specifications, reports capabilities, manages VM state, maps power states, preserves retryable teardown behavior, and records regression coverage.
Guest control and snapshot operations
machine/xapi/xapi.go, machine/xapi/xapi_test.go
Adds TCP guest control, pause and resume, suspend pointers, checkpoint creation, restore dispatch, and pointer round-trip tests.
XAPI smoke command
machine/cmd/smoke-xapi/main.go, .gitignore, NOTES.md
Adds configurable VM setup, state polling, guest-agent connectivity checks, optional lifecycle checks, cleanup, signals, and timeouts.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant SmokeXAPI
  participant XAPIBackend
  participant JSONRPCClient
  participant XCPPool
  participant GuestAgent
  SmokeXAPI->>XAPIBackend: Configure and create machine
  XAPIBackend->>JSONRPCClient: Create VM and query power state
  JSONRPCClient->>XCPPool: Send JSON-RPC request
  XCPPool-->>JSONRPCClient: Return session or VM state
  JSONRPCClient-->>XAPIBackend: Return XAPI result
  XAPIBackend-->>SmokeXAPI: Return machine state
  SmokeXAPI->>GuestAgent: Open TCP control connection
  GuestAgent-->>SmokeXAPI: Accept connection
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding an XCP-ng/XenServer backend using XAPI JSON-RPC.
Description check ✅ Passed The description covers the change, rationale, tested platforms, checklist status, known test limitation, and open questions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/xapi-backend

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (8)
machine/xapi/client.go (2)

125-134: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Write the pointer file atomically.

writePointer writes in place. If the process stops during the write, the file is truncated and readPointer fails, so the suspended VM in the SR becomes unreachable through this directory. Write to a temporary file in the same directory, then rename it.

♻️ Proposed change
 func writePointer(dir string, p pointer) error {
 	if err := os.MkdirAll(dir, 0o700); err != nil {
 		return err
 	}
 	b, err := json.Marshal(p)
 	if err != nil {
 		return err
 	}
-	return os.WriteFile(filepath.Join(dir, pointerName), b, 0o600)
+	final := filepath.Join(dir, pointerName)
+	tmp := final + ".tmp"
+	if err := os.WriteFile(tmp, b, 0o600); err != nil {
+		return err
+	}
+	return os.Rename(tmp, final)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/client.go` around lines 125 - 134, Update writePointer to write
the marshaled pointer to a temporary file created in the same directory, then
atomically rename it to pointerName. Preserve the existing directory creation,
permissions, and error propagation, and clean up the temporary file if writing
or renaming fails.

110-112: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Return an explicit nil Client on error.

newJSONRPCClient returns a *jsonrpcClient. When it fails, the returned pointer is nil, but the Client interface value that NewClient produces is non-nil. Callers that check cl != nil instead of err != nil then dereference a nil pointer. The current caller in machine/xapi/xapi.go checks err, so this is latent only.

♻️ Proposed change
 func NewClient(ctx context.Context, c Config) (Client, error) {
-	return newJSONRPCClient(ctx, c)
+	cl, err := newJSONRPCClient(ctx, c)
+	if err != nil {
+		return nil, err
+	}
+	return cl, nil
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/client.go` around lines 110 - 112, Update NewClient to capture
the result of newJSONRPCClient and, when it returns an error, explicitly return
a nil Client interface alongside that error; preserve the successful client and
nil error path.
machine/xapi/manual_test.go (1)

3-18: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix the example command path.

The package lives at machine/xapi. The example runs go test ./xapi, which fails from the repository root. Use the module-relative path.

♻️ Proposed change
-//	  go test ./xapi -run TestPool -v
+//	  go test ./machine/xapi -run TestPool -v

If machine/ is a separate module, state that the command runs from inside machine/.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/manual_test.go` around lines 3 - 18, Update the manual test
command in the harness comment to use the module-relative package path for
machine/xapi, or explicitly state that it must be run from inside the machine
module if machine is separate. Keep the remaining environment variables and test
flags unchanged.
machine/cmd/smoke-xapi/main.go (1)

60-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Report all missing flags, in a stable order.

The loop iterates a map, and Go randomizes map iteration order. When two required flags are missing, log.Fatalf names a different one on each run. Use an ordered slice and collect every missing name.

♻️ Proposed change
-	for name, v := range map[string]string{"url": *url, "sr": *srUUID, "mgmt-net": *mgmtNet, "kernel": *kernel} {
-		if v == "" {
-			log.Fatalf("-%s is required", name)
-		}
-	}
+	required := []struct {
+		name  string
+		value string
+	}{
+		{"url", *url}, {"sr", *srUUID}, {"mgmt-net", *mgmtNet}, {"kernel", *kernel},
+	}
+	var missing []string
+	for _, r := range required {
+		if r.value == "" {
+			missing = append(missing, "-"+r.name)
+		}
+	}
+	if len(missing) > 0 {
+		log.Fatalf("required flags missing: %s", strings.Join(missing, ", "))
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/cmd/smoke-xapi/main.go` around lines 60 - 64, Replace the map
iteration in the required-flag validation with an ordered slice containing url,
sr, mgmt-net, and kernel; collect every flag whose value is empty, then report
all missing flag names together in that stable order instead of exiting on the
first missing value.
machine/xapi/xapi_test.go (1)

153-162: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Track client closure in the fake pool.

fakePool.Close returns nil and records nothing. No test asserts that the backend logs out. That gap hides the missing v.cl.Close() call flagged in machine/xapi/xapi.go. Add a closed bool field, set it in Close, and assert it after Destroy once Destroy lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/xapi_test.go` around lines 153 - 162, Update fakePool and its
test helper: add a closed bool field, set it in fakePool.Close, and assert it is
true after Destroy completes. Extend the relevant test around withFakePool to
verify Destroy invokes the client closure.
machine/xapi/client_jsonrpc.go (2)

182-190: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Plan for session expiry.

XAPI sessions expire after an idle or absolute timeout, and the pool master invalidates all sessions on restart. sessionCall then fails with SESSION_INVALID for the rest of the client's life, because nothing re-logs in. A sandbox VM can outlive the session. Store the credentials on jsonrpcClient and retry one time after a SESSION_INVALID failure, or document that callers must recreate the client.

I can generate the re-login wrapper if you want it in this PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/client_jsonrpc.go` around lines 182 - 190, Update jsonrpcClient
and sessionCall to retain the credentials needed for authentication, detect a
SESSION_INVALID response from c.call, re-authenticate once, refresh the stored
session, and retry the original request exactly once. Preserve existing behavior
for other errors and avoid retrying repeatedly; if re-login cannot succeed,
return the resulting authentication error.

74-87: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Set MinVersion explicitly on the TLS config.

The Go client default minimum is TLS 1.2, so this is not an exploitable gap. An explicit MinVersion documents the floor and protects against a future default change.

♻️ Proposed change
 				TLSClientConfig: &tls.Config{
+					MinVersion:         tls.VersionTLS12,
 					InsecureSkipVerify: c.InsecureTLS, //nolint:gosec // opt-in; see Config.InsecureTLS
 				},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/client_jsonrpc.go` around lines 74 - 87, Update the TLS
configuration in the HTTP client construction to set TLS MinVersion explicitly
to TLS 1.2, while preserving the existing InsecureSkipVerify behavior and
transport timeouts.

Source: Linters/SAST tools

machine/xapi/xapi.go (1)

385-399: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider restoring into v.ref instead of the pointer's VM ref.

Restore resumes the VM named in the pointer file. That ref belongs to the VM that wrote the file, which is not necessarily this vm instance. A caller that restores a snapshot into a fresh Machine then drives a VM that this instance does not own, because v.ref stays empty and Start, State, and Pause keep using it. Either adopt p.VM into v.ref and set created, or reject a pointer whose VM does not match v.ref.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/xapi.go` around lines 385 - 399, Update vm.Restore to ensure the
restored VM is owned by this instance: adopt p.VM into v.ref and mark created
before resuming, or reject pointers whose VM ref differs from an already-set
v.ref. Keep subsequent Start, State, and Pause operations aligned with the ref
established by Restore.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@machine/cmd/smoke-xapi/main.go`:
- Around line 124-190: Move the current smoke-test body, including the
Create/Start/waitState/control-channel and optional-interface operations, into a
run() error function so its deferred Destroy and work-directory cleanup execute
on failures. Replace every log.Fatalf within run() with a wrapped error return
using the existing contextual messages, and have main invoke run() through
log.Fatal(run()).
- Line 47: Change the password flag initialization in main so its default is
empty rather than os.Getenv("XAPI_PASSWORD"), preventing flag.PrintDefaults from
exposing the secret. After flag.Parse(), resolve XAPI_PASSWORD into the password
value only when no command-line password was provided, preserving explicit flag
precedence.

In `@machine/xapi/client_jsonrpc_test.go`:
- Around line 38-61: Update fakeRPC.serve to use assert rather than require,
return an HTTP error when request decoding fails, and remove handler-side fatal
assertions. Add mutex protection for f.reqs, including appends and reads through
methods() or other test accessors, then move request validation assertions to
the test goroutine after the handler completes.

In `@machine/xapi/xapi.go`:
- Around line 169-174: Update the VM lifecycle around New, Create, and Destroy
so every client returned by dialClient is closed through v.cl.Close(). Ensure
Destroy performs the logout for normally torn-down machines, and ensure Create
invokes the same cleanup when creation fails, including partial-failure paths,
while preserving the existing error handling.
- Around line 344-382: Use the existing liveRef helper in Control, Pause,
Resume, Suspend, Snapshot, and Stop to read v.ref under v.mu and enforce the
created/destroyed lifecycle checks before invoking XAPI operations; preserve
each method’s existing behavior after obtaining the validated reference,
including pointer writing for Suspend and Snapshot.
- Around line 295-306: Update vm.Destroy so v.destroyed is assigned only after
teardown completes successfully; preserve the existing early return for
already-destroyed VMs, but leave the flag false when teardown returns an error
so retries remain possible and State does not report a live VM as destroyed.

---

Nitpick comments:
In `@machine/cmd/smoke-xapi/main.go`:
- Around line 60-64: Replace the map iteration in the required-flag validation
with an ordered slice containing url, sr, mgmt-net, and kernel; collect every
flag whose value is empty, then report all missing flag names together in that
stable order instead of exiting on the first missing value.

In `@machine/xapi/client_jsonrpc.go`:
- Around line 182-190: Update jsonrpcClient and sessionCall to retain the
credentials needed for authentication, detect a SESSION_INVALID response from
c.call, re-authenticate once, refresh the stored session, and retry the original
request exactly once. Preserve existing behavior for other errors and avoid
retrying repeatedly; if re-login cannot succeed, return the resulting
authentication error.
- Around line 74-87: Update the TLS configuration in the HTTP client
construction to set TLS MinVersion explicitly to TLS 1.2, while preserving the
existing InsecureSkipVerify behavior and transport timeouts.

In `@machine/xapi/client.go`:
- Around line 125-134: Update writePointer to write the marshaled pointer to a
temporary file created in the same directory, then atomically rename it to
pointerName. Preserve the existing directory creation, permissions, and error
propagation, and clean up the temporary file if writing or renaming fails.
- Around line 110-112: Update NewClient to capture the result of
newJSONRPCClient and, when it returns an error, explicitly return a nil Client
interface alongside that error; preserve the successful client and nil error
path.

In `@machine/xapi/manual_test.go`:
- Around line 3-18: Update the manual test command in the harness comment to use
the module-relative package path for machine/xapi, or explicitly state that it
must be run from inside the machine module if machine is separate. Keep the
remaining environment variables and test flags unchanged.

In `@machine/xapi/xapi_test.go`:
- Around line 153-162: Update fakePool and its test helper: add a closed bool
field, set it in fakePool.Close, and assert it is true after Destroy completes.
Extend the relevant test around withFakePool to verify Destroy invokes the
client closure.

In `@machine/xapi/xapi.go`:
- Around line 385-399: Update vm.Restore to ensure the restored VM is owned by
this instance: adopt p.VM into v.ref and mark created before resuming, or reject
pointers whose VM ref differs from an already-set v.ref. Keep subsequent Start,
State, and Pause operations aligned with the ref established by Restore.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 467bc5a4-efad-460e-ab51-3835a7878393

📥 Commits

Reviewing files that changed from the base of the PR and between b484290 and 5fdc0ad.

📒 Files selected for processing (7)
  • machine/cmd/smoke-xapi/main.go
  • machine/xapi/client.go
  • machine/xapi/client_jsonrpc.go
  • machine/xapi/client_jsonrpc_test.go
  • machine/xapi/manual_test.go
  • machine/xapi/xapi.go
  • machine/xapi/xapi_test.go

Comment thread machine/cmd/smoke-xapi/main.go Outdated
Comment thread machine/cmd/smoke-xapi/main.go
Comment thread machine/xapi/client_jsonrpc_test.go
Comment thread machine/xapi/xapi.go
Comment thread machine/xapi/xapi.go
Comment thread machine/xapi/xapi.go
Six defects, none of which the fake-pool tests could have caught,
because every one of them lives in a path no test drove.

Destroy marked the machine destroyed and then returned an error. A
second Destroy therefore returned nil having torn nothing down, and
State reported StateDestroyed for a VM still running on the pool. That
is precisely the plausible-looking lie the rest of this package exists
to refuse, so it cut deeper than a stray assignment. Teardown is split
into its own method to give Destroy a real success path — mark
destroyed, then log out — rather than a hypothetical one.

Nothing ever called Client.Close, so every Machine held a pool session
until it timed out. XAPI caps concurrent sessions per pool, which makes
this a shared resource consumed rather than a local leak a restart
clears. The logout is on Destroy's success path only: once teardown is
implemented a failed Destroy is worth retrying, and closing first would
hand the retry a dead session and bury the original error. Until
teardown lands, Destroy always fails and so never reaches the logout —
the session still leaks. That is the honest consequence of an
unimplemented Destroy, recorded in the doc comment rather than hidden.

Create wrote v.ref under the mutex while Control, Pause, Resume,
Suspend and Snapshot read it without one, and those five also skipped
the created/destroyed checks Start performed, so calling them before
Create sent the empty ref to the pool and got HANDLE_INVALID back
instead of ErrInvalidState. A liveRef helper now does both jobs in one
place and every ref-touching call goes through it.

In the smoke tool, the password flag defaulted to $XAPI_PASSWORD, and
flag.PrintDefaults prints any default that is not the zero value — so
-h wrote the password to stderr. The lookup moves after Parse. Separately
log.Fatalf reached os.Exit, skipping the Destroy and RemoveAll defers;
the body moves into run() error so failure paths clean up. Inert today
only because Create fails before the Destroy defer is registered.

The wire tests asserted with require on the httptest handler goroutine.
require.FailNow calls runtime.Goexit, which unwinds that goroutine
mid-response, so a mismatch surfaced as a client-side decode error
rather than the assertion that explains it. They use assert now, the
recorded requests are mutex-guarded behind an accessor, and the
name-label test builds one pool per case instead of reassigning a
captured slice the handler reads.

Both regressions are covered and both were confirmed to fail against
the reintroduced defects before being kept. Verified with go test -race.

Signed-off-by: Bruno Verachten <gounthar@gmail.com>
NOTES.md gains the six defects the review on PR #1 found, why each
mattered, and what is still open as issues #3-#8. The point of writing
them down is the pattern rather than the list: every one lived on a path
no test drove, which is why a fake-pool suite that passes says less about
this backend than it appears to.

It also records the caveat that is easiest to lose. The session leak is
not gone — closeClient runs on Destroy's success path and Destroy is
still a stub that always fails, so the session still leaks. The fix is
correct for when teardown lands. Its review thread has been resolved,
which removed the last visible marker, so the note is now the only thing
carrying it.

Build-order step 2 gains the conditions it needs rather than just a
warning. It is the first code that writes to a pool, and it wants an
empty, uncontended, file-based SR: VDI.clone full-copies on LVM, which
defeats the per-sandbox copy-on-write model the whole design rests on.
Which host to use, and who else is using it, stays out of this file.

.gitignore gains CONTEXT.md and .env. This repo is public, and the
session state names lab hosts, SR uuids and who owns which VM. Another
session pushes to this branch, so an accidental `git add -A` is a real
way for that to become public rather than a hypothetical one.

Signed-off-by: Bruno Verachten <gounthar@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
machine/xapi/xapi_test.go (1)

295-326: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Add a post-Create teardown-failure test.

This test never calls Create, so it does not exercise teardown for a live VM. State follows the !v.created branch, and no remote VM exists to retry.

When Create is implemented, inject a teardown failure after VM creation. Assert that the first Destroy preserves the VM state and session. Then clear the failure and verify that a retry tears down the VM and closes the client.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/xapi_test.go` around lines 295 - 326, Update
TestFailedDestroyDoesNotMarkDestroyed to call Create successfully, inject a
teardown failure after the remote VM is created, and assert the first Destroy
preserves the VM state and pool session. Clear the injected failure, retry
Destroy, and verify the VM is removed and the client/session is closed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@machine/xapi/xapi_test.go`:
- Around line 295-326: Update TestFailedDestroyDoesNotMarkDestroyed to call
Create successfully, inject a teardown failure after the remote VM is created,
and assert the first Destroy preserves the VM state and pool session. Clear the
injected failure, retry Destroy, and verify the VM is removed and the
client/session is closed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8f7bc83-08c3-4a8c-bf6d-78ad75c1d25e

📥 Commits

Reviewing files that changed from the base of the PR and between 5fdc0ad and ed87e8c.

📒 Files selected for processing (6)
  • .gitignore
  • NOTES.md
  • machine/cmd/smoke-xapi/main.go
  • machine/xapi/client_jsonrpc_test.go
  • machine/xapi/xapi.go
  • machine/xapi/xapi_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • machine/cmd/smoke-xapi/main.go
  • machine/xapi/client_jsonrpc_test.go
  • machine/xapi/xapi.go

Three review findings, issues #4, #5 and #6. The one that changes
behaviour is the session.

XAPI expires sessions, and the transport logged in once and held that ref
for its lifetime, so a pool that timed one out left every subsequent call
failing forever. That matters more here than on a local hypervisor: a
Machine outlives the operations performed on it, and an idle sandbox will
sit past the pool's timeout as a matter of course rather than as an edge
case. The issue offered documenting the limitation instead, which reads
the normal case as the exception.

sessionCall now detects SESSION_INVALID, logs in again and retries once.
Once, not in a loop, so a pool refusing every login produces an error
rather than a spin. A generation counter behind a dedicated login mutex
keeps concurrent callers to one re-login between them instead of one
each, and Close marks the client closed so a re-login racing with it
cannot revive a dead one; it ends the fresh ref rather than leaking it on
the pool. The credentials are kept for this. Config already held them for
the life of the process, so nothing is newly exposed.

What a fake pool cannot settle is whether XAPI really names the failure
SESSION_INVALID over JSON-RPC. The unit tests assert the retry against a
fake returning the name this package expects, so they would pass just as
green if the match were wrong and the retry never fired.
TestPoolSessionRecovery_Manual ends its own session behind the client's
back and watches the recovery. It is read-only apart from that and needs
no write authorisation on the pool. Not yet run: both lab hosts are
unreachable from here.

NewClient returned newJSONRPCClient's pair straight through, which
converts a nil *jsonrpcClient into a non-nil Client interface holding it.
Latent, since the only caller checks the error, but a trap for the next
one. The test compares against nil directly rather than through testify,
which sees the nil pointer inside and would pass either way.

writePointer wrote in place, so a crash mid-write left a truncated
pointer to a snapshot the pool had taken successfully: pool state intact,
the only handle to it gone. It now writes a temp file in the same
directory, syncs, and renames over the target. By hand rather than
pulling renameio into this module, since machine/ is separate and this is
the one place that needs it, which is the reasoning that kept the
transport on net/http.

TLS MinVersion is now explicit. Go's floor is already 1.2, so this
changes nothing today; it is documentation, and insurance against that
default moving under the session token this connection carries.

Fixes #4
Fixes #5
Fixes #6

Signed-off-by: Bruno Verachten <gounthar@gmail.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
machine/xapi/client.go (1)

174-183: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider fsyncing the directory after the rename.

f.Sync() makes the temp file's contents durable. The rename itself stays in the directory's metadata cache. If the host crashes right after os.Rename, the directory entry can be lost and readPointer returns the previous pointer instead of this one. The truncated-file case this function targets is still prevented, so this is hardening only.

♻️ Proposed directory sync
 	if err = os.Rename(tmp, filepath.Join(dir, pointerName)); err != nil {
 		return err
 	}
-	return nil
+	// Sync the directory so the rename itself survives a crash, not just
+	// the contents of the file it points at.
+	d, err := os.Open(dir)
+	if err != nil {
+		return err
+	}
+	defer d.Close()
+	return d.Sync()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@machine/xapi/client.go` around lines 174 - 183, Update the pointer-write flow
after os.Rename in the relevant function to open the containing directory and
fsync it before returning, propagating any open or sync errors. Preserve the
existing file sync, close, rename ordering and error handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@machine/xapi/client.go`:
- Around line 174-183: Update the pointer-write flow after os.Rename in the
relevant function to open the containing directory and fsync it before
returning, propagating any open or sync errors. Preserve the existing file sync,
close, rename ordering and error handling.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9dede966-165c-47d1-a3a2-6676a5d08bfb

📥 Commits

Reviewing files that changed from the base of the PR and between ed87e8c and 5674fb9.

📒 Files selected for processing (5)
  • machine/xapi/client.go
  • machine/xapi/client_jsonrpc.go
  • machine/xapi/client_jsonrpc_test.go
  • machine/xapi/manual_test.go
  • machine/xapi/xapi_test.go

Two review findings, both small, both about not letting a plausible
configuration mean something other than what it says.

newJSONRPCClient accepted any URL. On an http:// pool the login puts the
password on the wire in the clear, and every call after it puts the
session ref there too. InsecureTLS does not cover this: waiving
certificate verification is a much smaller concession than dropping TLS,
and an operator who set the one has not agreed to the other. The URL is
now parsed and a non-https scheme, or a missing host, is refused. Parsing
also means a URL with no scheme fails naming the field, rather than
surfacing later out of http.NewRequestWithContext as an error that
mentions neither Config nor URL.

That has a knock-on in the tests: the fake pool served plain HTTP, so it
now serves TLS with a self-signed certificate and the tests opt into
InsecureTLS. That is the shape of a stock XCP-ng install, so the suite
exercises a slightly more honest path than it did.

poolConfigFromEnv read TEST_XAPI_INSECURE as != "0", so only that exact
string turned verification on. TEST_XAPI_INSECURE=false and =no both left
it off, which is the opposite of what they say, on the one setting that
decides whether the pool's certificate is checked. It is parsed as a
boolean now, and an unparseable value fails the test rather than falling
back to a default, because the default here is the insecure one and
guessing about TLS is not a thing to do quietly.

Verified against XCP-ng 8.3.0 / XAPI 26.1 on the lab pool: the manual
suite still logs in, recovers an ended session and refuses the
unimplemented calls with the scheme check in place.

Signed-off-by: Bruno Verachten <gounthar@gmail.com>
@gounthar
gounthar merged commit 06c15a4 into main Aug 3, 2026
3 checks passed
@gounthar
gounthar deleted the feat/xapi-backend branch August 3, 2026 20:59
@gounthar
gounthar restored the feat/xapi-backend branch August 3, 2026 20:59
gounthar added a commit that referenced this pull request Aug 4, 2026
Restore resumed the VM named in the pointer file but never wrote the ref back
to v.ref, so a machine restored rather than created in this process was left
with an empty ref. Every later call — Start, State, Stop, Pause — then drove
a different VM from the one that had actually been resumed. The liveRef check
added in #1 turns that into ErrInvalidState rather than a silent wrong-VM
call, which is better but still wrong: a restored machine ought to be usable.

Restore now takes the ref and marks the machine created, but only after the
pool confirms the resume. Marking it created off the back of a failed resume
would leave it answering for a VM that is not running.

Issue #3 asked to settle the adjacent question at the same time, so: Restore
is legal only on a machine that has been neither created nor destroyed. Both
refusals fall out of adoption. Repointing a created machine would orphan the
VM it already holds, with nothing left to tear it down; restoring after
Destroy would resurrect a machine the caller has finished with, which Create
already refuses for the same reason.

rootVDI and bootVDI stay empty on a restored machine — the pointer file
records the VM ref only. XAPI can be asked for the VBDs when teardown needs
them, which is noted where teardown will be written.

Fixes #3

Signed-off-by: Bruno Verachten <gounthar@gmail.com>
gounthar added a commit that referenced this pull request Aug 4, 2026
fix(xapi): three lifecycle defects from the PR #1 review
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant