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
+11 -2
View File
@@ -243,8 +243,17 @@ func main() {
// --- Initialize self-updater ---
var updater *selfupdate.Updater
if cfg.SelfUpdate.Enabled {
composePath := filepath.Join(filepath.Dir(cfg.Paths.DataDir), "docker-compose.yml")
updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, composePath, logger, cfg.Logging.Level == "debug")
// Phase 1: the host agent performs the container swap. Build a (nil-able) agent client from the
// provisioned local-API config; when absent (un-provisioned guest) self-update is unavailable.
var swapAgent selfupdate.AgentSwapper
if cfg.LocalAPI.Endpoint != "" && cfg.LocalAPI.Token != "" {
if ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint); err != nil {
logger.Printf("[WARN] Self-update: agent client init failed (%v) — updates unavailable", err)
} else {
swapAgent = ac
}
}
updater = selfupdate.NewUpdater(&cfg.SelfUpdate, &cfg.Git, Version, cfg.Paths.DataDir, swapAgent, logger, cfg.Logging.Level == "debug")
updater.SetBackupRunningCheck(func() bool {
return backupMgr != nil && backupMgr.IsRunning()
})
+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"`
+113 -124
View File
@@ -2,17 +2,17 @@ package selfupdate
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
@@ -32,34 +32,53 @@ type UpdateStatus struct {
LastState *UpdateState `json:"last_state,omitempty"`
}
// AgentSwapper is the agent local-API capability the updater delegates the actual container swap to
// (agentic controller update, Phase 1). The agent — external to this controller container — rewrites
// /etc/felhom-controller-image, restarts the bootstrap unit, verifies health, and rolls back. Satisfied
// by *agentapi.Client; faked in tests. nil when this is not a provisioned guest (no agent) → updates
// are unavailable (the old in-container compose flow is GONE).
type AgentSwapper interface {
SwapController(ctx context.Context, image string) (agentapi.SwapResult, error)
}
// Updater manages controller self-updates.
type Updater struct {
cfg *config.SelfUpdateConfig
gitCfg *config.GitConfig
currentVer string
dataDir string
composePath string // e.g., "/opt/docker/felhom-controller/docker-compose.yml"
logger *log.Logger
debug bool
cfg *config.SelfUpdateConfig
gitCfg *config.GitConfig
currentVer string
dataDir string
agent AgentSwapper // Phase 1: the host agent performs the swap (nil → updates unavailable)
logger *log.Logger
debug bool
mu sync.Mutex
latestVersion string
lastCheck *CheckResult
updateRunning bool
backupRunning func() bool
// Seams (default to the real implementations; overridden in tests to avoid network/docker).
queryFn func() (string, error) // resolve latest registry tag (default u.queryRegistry)
pullFn func(targetImage) error // pull the image in-guest (default u.pullImage)
}
// NewUpdater creates a new Updater instance.
func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir, composePath string, logger *log.Logger, debug bool) *Updater {
return &Updater{
cfg: cfg,
gitCfg: gitCfg,
currentVer: currentVersion,
dataDir: dataDir,
composePath: composePath,
logger: logger,
debug: debug,
// targetImage is a tiny named type so the pullFn seam reads clearly.
type targetImage = string
// NewUpdater creates a new Updater instance. agent may be nil (un-provisioned guest → no self-update).
func NewUpdater(cfg *config.SelfUpdateConfig, gitCfg *config.GitConfig, currentVersion, dataDir string, agent AgentSwapper, logger *log.Logger, debug bool) *Updater {
u := &Updater{
cfg: cfg,
gitCfg: gitCfg,
currentVer: currentVersion,
dataDir: dataDir,
agent: agent,
logger: logger,
debug: debug,
}
u.queryFn = u.queryRegistry
u.pullFn = u.pullImage
return u
}
func (u *Updater) dbg(format string, args ...interface{}) {
@@ -120,7 +139,7 @@ func (u *Updater) CheckForUpdate() CheckResult {
}
// Query registry
latestStr, err := u.queryRegistry()
latestStr, err := u.queryFn()
if err != nil {
result.Error = fmt.Sprintf("Registry lekérdezés sikertelen: %v", err)
u.logger.Printf("[WARN] [selfupdate] Registry check failed: %v", err)
@@ -241,9 +260,9 @@ type DryRunResult struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
UpdateAvailable bool `json:"update_available"`
ComposeWritable bool `json:"compose_writable"`
CurrentImageLine string `json:"current_image_line"`
NewImageLine string `json:"new_image_line"`
AgentReachable bool `json:"agent_reachable"` // the host agent (which performs the swap) is wired
PullCapable bool `json:"pull_capable"` // registry creds present for the in-guest pull
TargetImage string `json:"target_image"` // what we would pull + swap to
BackupRunning bool `json:"backup_running"`
Error string `json:"error,omitempty"`
}
@@ -254,7 +273,6 @@ func (u *Updater) DryRun() *DryRunResult {
CurrentVersion: u.currentVer,
}
// Check for update
check := u.CheckForUpdate()
result.LatestVersion = check.LatestVersion
result.UpdateAvailable = check.UpdateAvailable
@@ -263,33 +281,12 @@ func (u *Updater) DryRun() *DryRunResult {
return result
}
// Check compose file
data, err := os.ReadFile(u.composePath)
if err != nil {
result.Error = fmt.Sprintf("Compose fájl nem olvasható: %v", err)
return result
}
// Find current image line
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
match := re.Find(data)
if match != nil {
result.CurrentImageLine = string(match)
}
// Build new image line
// The new flow: pull in-guest, then the agent swaps. Report those two capabilities.
result.AgentReachable = u.agent != nil
result.PullCapable = u.gitCfg.Username != "" && u.gitCfg.Token != ""
if check.UpdateAvailable {
result.NewImageLine = fmt.Sprintf("image: %s:%s", u.cfg.Image, check.LatestVersion)
result.TargetImage = fmt.Sprintf("%s:%s", u.cfg.Image, check.LatestVersion)
}
// Check writability
f, err := os.OpenFile(u.composePath, os.O_WRONLY, 0)
if err == nil {
f.Close()
result.ComposeWritable = true
}
// Check backup running
if u.backupRunning != nil {
result.BackupRunning = u.backupRunning()
}
@@ -320,10 +317,11 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error {
return fmt.Errorf("Mentés fut, próbálja később")
}
// Compose file accessible check
if _, err := os.Stat(u.composePath); err != nil {
// Agent reachable check — the host agent performs the swap; without it there is no update path
// (the old in-container docker-compose flow is removed).
if u.agent == nil {
u.mu.Unlock()
return fmt.Errorf("docker-compose.yml nem elérhető: %w", err)
return fmt.Errorf("A frissítés nem érhető el (nincs gazda-ügynök)")
}
u.updateRunning = true
@@ -350,7 +348,10 @@ func (u *Updater) TriggerUpdate(initiatedBy string) error {
return nil
}
// performUpdate runs the actual update steps in a goroutine.
// performUpdate runs the actual update in a goroutine: pull the target image IN-GUEST (shared docker
// socket, our registry token), then delegate the container SWAP to the host agent (which owns the
// restart + verify + rollback). This controller process is expected to be killed when the agent swaps;
// success/failure is detected on the NEXT boot by VerifyStartup (current version vs target).
func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initiatedBy string) {
defer func() {
u.mu.Lock()
@@ -359,7 +360,7 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
}()
u.dbg("performUpdate: starting — target=%s image=%s", targetVersion, targetImage)
// 1. Write pending state
// 1. Write pending state (VerifyStartup reads this on the next boot to mark success/failure).
state := &UpdateState{
Status: "pending",
PreviousVersion: u.currentVer,
@@ -374,96 +375,72 @@ func (u *Updater) performUpdate(targetVersion, targetImage, previousImage, initi
return
}
// 2. Docker pull
u.dbg("performUpdate: step 2 — docker pull %s", targetImage)
u.logger.Printf("[INFO] [selfupdate] Pulling image: %s", targetImage)
pullStart := time.Now()
pullOut, pullErr := runCommand("docker", "pull", targetImage)
if pullErr != nil {
// 2. Pull the target image into the guest's docker storage (via the shared socket). Auth with the
// existing registry token (login → pull → logout). On failure the AGENT IS NEVER CALLED and the
// current controller keeps running untouched.
if err := u.pullFn(targetImage); err != nil {
state.Status = "failed"
state.Error = fmt.Sprintf("docker pull failed: %v — %s", pullErr, pullOut)
state.Error = err.Error()
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[ERROR] [selfupdate] Docker pull failed: %v — %s", pullErr, pullOut)
u.logger.Printf("[ERROR] [selfupdate] %v", err)
return
}
u.logger.Printf("[INFO] [selfupdate] Image pulled successfully: %s", targetImage)
u.dbg("performUpdate: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond))
u.logger.Printf("[INFO] [selfupdate] Image pulled into guest: %s", targetImage)
// 3. Update compose file (replace image tag)
u.dbg("performUpdate: step 3 — updating compose file %s", u.composePath)
if err := u.updateComposeFile(targetImage); err != nil {
// 3. Delegate the swap to the host agent (it restarts the bootstrap unit + verifies + rolls back).
u.logger.Printf("[INFO] [selfupdate] Requesting agent controller swap → %s (this controller will restart)", targetImage)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := u.agent.SwapController(ctx, targetImage)
if err != nil {
state.Status = "failed"
state.Error = fmt.Sprintf("compose update failed: %v", err)
state.Error = fmt.Sprintf("agent swap request failed: %v", err)
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[ERROR] [selfupdate] Compose file update failed: %v", err)
u.logger.Printf("[ERROR] [selfupdate] Agent swap request failed: %v", err)
return
}
u.logger.Printf("[INFO] [selfupdate] Compose file updated with new image: %s", targetImage)
// 4. Docker compose up -d (this kills the current container)
u.dbg("performUpdate: step 4 — docker compose up -d")
u.logger.Printf("[INFO] [selfupdate] Running docker compose up -d — container will restart")
composeDir := strings.TrimSuffix(u.composePath, "/docker-compose.yml")
upOut, upErr := runCommand("docker", "compose", "-f", u.composePath, "-p", "felhom-controller", "up", "-d")
if upErr != nil {
state.Status = "failed"
state.Error = fmt.Sprintf("docker compose up -d failed: %v — %s", upErr, upOut)
state.CompletedAt = time.Now().UTC().Format(time.RFC3339)
SaveState(u.dataDir, state)
u.logger.Printf("[ERROR] [selfupdate] docker compose up -d failed: %v — %s (dir: %s)", upErr, upOut, composeDir)
return
}
// If we're still alive after compose up -d, log it.
// Normally this process should be killed when Docker replaces the container.
u.logger.Printf("[WARN] [selfupdate] Still running after docker compose up -d — expected to be replaced")
time.Sleep(30 * time.Second)
u.logger.Printf("[WARN] [selfupdate] Still alive 30s after docker compose up -d")
u.logger.Printf("[INFO] [selfupdate] Agent accepted swap (status=%s, previous=%s) — awaiting restart", res.Status, res.PreviousImage)
// We now expect to be killed by the agent's `docker rm -f` + `docker run`. If we survive (e.g. the
// agent's verify is still polling), just wait — the new container replaces us shortly.
}
// updateComposeFile reads the compose file, replaces the image tag, and writes it back atomically.
func (u *Updater) updateComposeFile(newImage string) error {
u.logger.Printf("[INFO] [selfupdate] Updating compose file")
data, err := os.ReadFile(u.composePath)
if err != nil {
u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err)
return fmt.Errorf("reading compose file: %w", err)
// pullImage authenticates to the registry and pulls targetImage into the guest's docker storage. The
// token is passed via stdin (never argv) and the session is logged out afterwards.
func (u *Updater) pullImage(targetImage string) error {
if u.gitCfg.Username == "" || u.gitCfg.Token == "" {
return fmt.Errorf("docker pull: registry hitelesítő adatok hiányoznak")
}
// Replace image line: "image: gitea.dooplex.hu/admin/felhom-controller:..." → new image
re := regexp.MustCompile(`(image:\s*)gitea\.dooplex\.hu/admin/felhom-controller:\S+`)
// Log old image line for debugging
oldMatch := re.Find(data)
if oldMatch != nil {
u.dbg("updateComposeFile: %q → %q", string(oldMatch), "image: "+newImage)
} else {
u.dbg("updateComposeFile: no matching image line found in %s", u.composePath)
host := registryHost(u.cfg.Image)
u.dbg("pullImage: docker login %s as %s", host, u.gitCfg.Username)
if out, err := runCommandStdin(u.gitCfg.Token, "docker", "login", host, "-u", u.gitCfg.Username, "--password-stdin"); err != nil {
return fmt.Errorf("docker login failed: %v — %s", err, out)
}
defer func() {
if out, err := runCommand("docker", "logout", host); err != nil {
u.logger.Printf("[WARN] [selfupdate] docker logout failed: %v — %s", err, out)
}
}()
newData := re.ReplaceAll(data, []byte("${1}"+newImage))
if bytes.Equal(data, newData) {
u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: no image line found to replace")
return fmt.Errorf("no image line found to replace in compose file")
u.logger.Printf("[INFO] [selfupdate] Pulling image: %s", targetImage)
pullStart := time.Now()
if out, err := runCommand("docker", "pull", targetImage); err != nil {
return fmt.Errorf("docker pull failed: %v — %s", err, out)
}
// Atomic write: write to .tmp, then rename
tmpPath := u.composePath + ".tmp"
if err := os.WriteFile(tmpPath, newData, 0644); err != nil {
u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err)
return fmt.Errorf("writing temp compose file: %w", err)
}
if err := os.Rename(tmpPath, u.composePath); err != nil {
u.logger.Printf("[ERROR] [selfupdate] Failed to update compose file: %v", err)
return fmt.Errorf("renaming compose file: %w", err)
}
u.dbg("pullImage: docker pull completed in %s", time.Since(pullStart).Round(time.Millisecond))
return nil
}
// registryHost extracts the registry host from a full image reference
// ("gitea.dooplex.hu/admin/felhom-controller" → "gitea.dooplex.hu").
func registryHost(image string) string {
if i := strings.IndexByte(image, '/'); i > 0 {
return image[:i]
}
return image
}
// VerifyStartup checks the update state file on startup.
// Called once from main.go before the scheduler starts.
// Returns the state if a pending update was detected, nil otherwise.
@@ -522,3 +499,15 @@ func runCommand(name string, args ...string) (string, error) {
return out.String(), err
}
// runCommandStdin executes a command, feeding `stdin` on its standard input (used for
// `docker login --password-stdin` so the token is never in argv/ps). Returns combined output.
func runCommandStdin(stdin, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Stdin = strings.NewReader(stdin)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
@@ -0,0 +1,132 @@
package selfupdate
import (
"context"
"fmt"
"io"
"log"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
const imageBase = "gitea.dooplex.hu/admin/felhom-controller"
type fakeAgent struct {
mu sync.Mutex
calls []string
err error
}
func (f *fakeAgent) SwapController(_ context.Context, image string) (agentapi.SwapResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, image)
return agentapi.SwapResult{Status: "swapping", PreviousImage: imageBase + ":0.77.0"}, f.err
}
func (f *fakeAgent) swapCalls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.calls...)
}
func newTestUpdater(t *testing.T, current string, agent AgentSwapper) *Updater {
t.Helper()
cfg := &config.SelfUpdateConfig{Enabled: true, Image: imageBase}
git := &config.GitConfig{Username: "u", Token: "tok"}
return NewUpdater(cfg, git, current, t.TempDir(), agent, log.New(io.Discard, "", 0), false)
}
// waitDone polls until an in-flight update finishes (performUpdate runs async).
func waitDone(t *testing.T, u *Updater) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for u.IsUpdateRunning() {
if time.Now().After(deadline) {
t.Fatal("update did not finish in time")
}
time.Sleep(2 * time.Millisecond)
}
}
// Scenario C — already latest: no pull, no agent call.
func TestTriggerUpdate_UpToDate_NoPullNoAgent(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.84.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
err := u.TriggerUpdate("test")
if err == nil {
t.Fatal("expected 'no update' error when already latest")
}
if pulled {
t.Error("must NOT pull when up-to-date")
}
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (up-to-date)", n)
}
}
// Scenario D — pull fails: agent never called, current controller untouched.
func TestPerformUpdate_PullFails_AgentNotCalled(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.77.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
u.pullFn = func(string) error { return fmt.Errorf("registry unreachable") }
if err := u.TriggerUpdate("test"); err != nil {
t.Fatalf("TriggerUpdate returned %v, want nil (async)", err)
}
waitDone(t, u)
if n := len(agent.swapCalls()); n != 0 {
t.Errorf("agent called %d times, want 0 (pull failed)", n)
}
st, _ := LoadState(u.dataDir)
if st == nil || st.Status != "failed" {
t.Errorf("state = %+v, want failed", st)
}
}
// Happy path — pull then delegate the swap to the agent with the right ref.
func TestPerformUpdate_Happy_PullThenSwap(t *testing.T) {
agent := &fakeAgent{}
u := newTestUpdater(t, "0.77.0", agent)
u.queryFn = func() (string, error) { return "0.84.0", nil }
var pulledImage string
u.pullFn = func(img string) error { pulledImage = img; return nil }
if err := u.TriggerUpdate("test"); err != nil {
t.Fatalf("TriggerUpdate returned %v, want nil", err)
}
waitDone(t, u)
want := imageBase + ":0.84.0"
if pulledImage != want {
t.Errorf("pulled %q, want %q", pulledImage, want)
}
calls := agent.swapCalls()
if len(calls) != 1 || calls[0] != want {
t.Errorf("agent swap calls = %v, want exactly [%q]", calls, want)
}
}
// No agent (un-provisioned guest) → update unavailable, never pulls.
func TestTriggerUpdate_NoAgent_Unavailable(t *testing.T) {
u := newTestUpdater(t, "0.77.0", nil)
u.queryFn = func() (string, error) { return "0.84.0", nil }
pulled := false
u.pullFn = func(string) error { pulled = true; return nil }
if err := u.TriggerUpdate("test"); err == nil {
t.Fatal("expected error when no agent is wired")
}
if pulled {
t.Error("must not pull when no agent")
}
}