v0.85.0: self-update reworked — in-guest pull + agent swap (Phase 1)

Replaces the dead in-container docker-compose self-update (composePath doesn't
exist in the LXC guest). The controller now docker-logins+pulls the target image
in-guest (shared socket, its registry token), then delegates the container swap to
the host agent (agentapi.SwapController -> POST /controller/swap), which owns the
restart + health-verify + rollback. Removed performUpdate compose flow /
updateComposeFile / composePath. NewUpdater takes an AgentSwapper. DryRun reports
agent_reachable + pull_capable. UI button + poll unchanged; latest-only.

Tests: up-to-date no-op / pull-fail agent-not-called / happy pull-then-swap /
no-agent unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtXesNa2LGbMmE4DNL6SE7
This commit is contained in:
2026-06-26 21:26:14 +02:00
parent e0cf78bb90
commit 3c1e91b5f0
5 changed files with 325 additions and 126 deletions
+47
View File
@@ -372,6 +372,53 @@ func (c *Client) GuestReboot(ctx context.Context) error {
return err
}
// SwapResult mirrors the agent's 202 from POST /controller/swap (agentic controller update, Phase 1).
type SwapResult struct {
Status string `json:"status"` // "swapping"
PreviousImage string `json:"previous_image"` // image before the swap (for the UI/log)
TargetImage string `json:"target_image"`
}
// SwapController asks the agent to swap the in-guest controller to `image` (which the controller has
// already pulled into the guest's docker storage). The agent responds 202 and performs the swap+verify
// +rollback asynchronously, EXTERNALLY to this controller container (so it survives this process being
// killed by the swap). Latest-only is enforced by the caller (queryRegistry); the agent re-validates
// the ref shape.
func (c *Client) SwapController(ctx context.Context, image string) (SwapResult, error) {
var out SwapResult
body, err := c.post(ctx, "/controller/swap", map[string]string{"image": image})
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /controller/swap: %w", err)
}
return out, nil
}
// SwapStatus mirrors the agent's GET /controller/swap/status (observability for the post-restart UI).
type SwapStatus struct {
State string `json:"state"` // none | swapping | done | failed
InFlight bool `json:"in_flight"`
Current string `json:"current"`
Previous string `json:"previous"`
Target string `json:"target"`
Error string `json:"error"`
}
// SwapStatus reads the last/in-flight swap outcome for this guest.
func (c *Client) SwapStatus(ctx context.Context) (SwapStatus, error) {
var out SwapStatus
body, err := c.get(ctx, "/controller/swap/status")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /controller/swap/status: %w", err)
}
return out, nil
}
// EjectResult mirrors POST /disks/eject (the dependent-guest warning).
type EjectResult struct {
VMID int `json:"vmid"`