Files
felhom-controller/controller/internal/web/samba_ensure_job.go
T
admin badf17bebd v0.151.0 — the Megosztás page stops reloading, and says how to connect
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first
poll of every steady-state page load reported a terminal job that never ran and
the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty
(liveness must never be contradicted) belongs to the 'running' LEVEL field
beside it, and is now pinned by its own regression test.

S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot
re-arm the reload on the page it just caused. failed/needs_password/in-flight
are never consumed. Unified async-job feedback stays the ROADMAP item.

S-2/S-5: new connect card with the Windows form, the Mac form and the direct
smb://<IP>, read from the SAMBA container's netns (the controller is on a docker
bridge and would answer 172.x). Derived per render, cached nowhere - the address
is a DHCP lease. Underivable => the line is omitted.

sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three
ways. 23/23 packages green.
2026-07-20 10:46:21 +02:00

192 lines
7.4 KiB
Go

package web
import (
"sync"
"time"
)
// Samba bring-up progress (v0.147.0, feedback slice 4b).
//
// THE PROBLEM: enabling Megosztás on a fresh box ran `ReconcileSamba()` synchronously inside the
// POST handler. On a golden that had not baked felhom-samba, that call is `docker compose up -d`
// pulling ~100MB from a private registry — minutes of an apparently-hung form post, then a redirect
// with a flash reading „Beállítás mentve." whether or not anything had actually come up. Observed
// live, twice. The image is now baked (felhom-agent build-golden.sh, golden >= 0.147.x), but this
// card still covers the pre-0.147 goldens and every future image update.
//
// SHAPE: deliberately the storage-init / netstorage-add one (storage_init_job.go) — detached job,
// single-flight slot, deep-copied snapshot, phase strings the template maps to Hungarian. No new
// framework; a unified async-job feedback layer is a ROADMAP item, not this slice.
//
// State is in-memory and lost on restart, exactly like RestoreOpStatus. That is acceptable here: the
// terminal truth is „is the container running", which the page re-reads from the stack manager on
// every load anyway — the job only explains the WAIT.
type sambaEnsureJob struct {
Phase string `json:"phase"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
// sambaPhasePulling — the pinned image is NOT in local Docker storage, so compose will fetch it.
// This is the phase worth naming: it is the multi-minute one, and the only honest explanation for
// why nothing appears to happen.
sambaPhasePulling = "pulling"
// sambaPhaseStarting — image already local (baked golden / previously pulled): seconds.
sambaPhaseStarting = "starting"
// sambaPhaseRunning — terminal success, PROBED (compose up -d exits 0 on a crash-loop, so the
// job's success condition is container liveness, never the compose exit code).
sambaPhaseRunning = "running"
// sambaPhaseNeedsPassword — not a failure: sharing is on but the household password is unset, so
// reconcile deliberately deploys nothing. The card must say so instead of spinning forever.
sambaPhaseNeedsPassword = "needs_password"
sambaPhaseFailed = "failed"
sambaPhaseIdle = "idle"
)
// A pull that has not finished in 15 minutes is not slow, it is broken (the registry is unreachable
// or the disk is full) — end the job so the card can say so rather than spin indefinitely.
const sambaEnsureDeadline = 15 * time.Minute
type sambaEnsureState struct {
mu sync.Mutex
running bool
cur *sambaEnsureJob
}
func (s *sambaEnsureState) acquire(job *sambaEnsureJob) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return false
}
s.running = true
cp := *job
s.cur = &cp
return true
}
func (s *sambaEnsureState) release() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}
func (s *sambaEnsureState) set(job *sambaEnsureJob) {
s.mu.Lock()
cp := *job
s.cur = &cp
s.mu.Unlock()
}
// snapshot returns a copy of the last / in-flight job (nil = never ran this process).
func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
s.mu.Lock()
defer s.mu.Unlock()
if s.cur == nil {
return nil
}
cp := *s.cur
return &cp
}
// consumeIfRunning is snapshot() with SERVE-ONCE semantics for the one phase the client reads as an
// edge rather than a level.
//
// `running` means "the bring-up you were watching has finished" — the client answers it by repainting
// the page (a full reload, because the „Állapot" badge is server-rendered). A phase that stays
// `running` in the snapshot therefore re-arms that reload on every subsequent page load: the loop
// S-1 fixed by dropping the idle→running coercion would come straight back after the next REAL
// bring-up, since the finished job outlives it in memory (S-4 core,
// felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md). Reporting it exactly once is what
// makes the edge an edge.
//
// Consumed: terminal `running`, and only while the single-flight slot is free — the job goroutine
// sets the phase before its deferred release(), and eating it inside that window would lose the
// success the customer is waiting for.
//
// NOT consumed: `failed` and `needs_password` (durable explanations — the client stops the timer and
// shows a card, with no reload, so stickiness is informative and cannot loop) and every in-flight
// phase (`pulling`/`starting`, which must survive being polled).
//
// Accepted cost: with two tabs open on /sharing during a bring-up, whichever polls first gets the
// success banner and the other sees plain `idle`. Both then show the true state — `running` is still
// on the level channel and the badge re-renders from the liveness probe either way.
func (s *sambaEnsureState) consumeIfRunning() *sambaEnsureJob {
s.mu.Lock()
defer s.mu.Unlock()
if s.cur == nil {
return nil
}
cp := *s.cur
if !s.running && cp.Phase == sambaPhaseRunning {
s.cur = nil
}
return &cp
}
// startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is
// already in flight (a double-submit must not start a second compose up on the same stack dir).
//
// The opening phase is decided BEFORE the work starts, by asking whether the image is already local
// — afterwards the answer is always yes and the card could never truthfully say „letöltés".
func (s *Server) startSambaEnsure() bool {
now := time.Now().UTC()
phase := sambaPhaseStarting
if s.stackMgr != nil && !s.stackMgr.SambaImagePresent() {
phase = sambaPhasePulling
}
job := &sambaEnsureJob{Phase: phase, StartedAt: now, UpdatedAt: now}
if !s.sambaEnsure.acquire(job) {
return false
}
go s.runSambaEnsureJob(job)
return true
}
func (s *Server) runSambaEnsureJob(job *sambaEnsureJob) {
defer s.sambaEnsure.release()
advance := func(phase, errMsg string) {
job.Phase = phase
job.Error = errMsg
job.UpdatedAt = time.Now().UTC()
s.sambaEnsure.set(job)
}
done := make(chan error, 1)
go func() { done <- s.stackMgr.ReconcileSamba() }()
select {
case err := <-done:
if err != nil {
s.logger.Printf("[ERROR] [sharing] samba reconcile failed after %s: %v", time.Since(job.StartedAt).Round(time.Second), err)
advance(sambaPhaseFailed, err.Error())
return
}
case <-time.After(sambaEnsureDeadline):
// The reconcile goroutine is left running — compose owns its own lifecycle and killing it
// mid-pull would leave a partial layer set. We stop REPORTING on it, which is the honest
// thing the customer needs; a later page load re-probes liveness for the real answer.
s.logger.Printf("[ERROR] [sharing] samba reconcile still running after %s — giving up on the progress card", sambaEnsureDeadline)
advance(sambaPhaseFailed, "a megosztási szolgáltatás előkészítése túl sokáig tartott")
return
}
// Reconcile returned nil — but nil ALSO covers "deliberately did nothing". Distinguish the two,
// because a card that says „fut" while nothing is deployed is the same silence in a new costume.
if s.settings != nil && !s.settings.GetSMBSettings().UserSet {
advance(sambaPhaseNeedsPassword, "")
return
}
if !s.stackMgr.SambaRunning() {
s.logger.Printf("[ERROR] [sharing] samba reconcile reported success but the container is not running")
advance(sambaPhaseFailed, "a megosztási szolgáltatás nem indult el")
return
}
s.logger.Printf("[INFO] [sharing] samba ready after %s", time.Since(job.StartedAt).Round(time.Second))
advance(sambaPhaseRunning, "")
}