Files
felhom-controller/controller/internal/web/samba_ensure_job.go
T
admin b5d78d1e0f v0.147.0 — feedback slice 1: pressing a button says something
The systemic complaint, twice in one evening: you press a button and nothing
happens. No progress, no ETA, no named result. Three worst offenders, fixed on
the two patterns already here (deploy 3-step panel, storage-init status poll).
No new framework — that is a ROADMAP item; three targeted cards ship tonight.

4a — a verification restore names its result. The flash said the app had been
restored "to a verification folder on the drive"; which folder, on which drive,
was invisible, so the customer could not go and look at what they had just asked
for. Full path now. The restore page gained a listing of existing verification
copies (app, size, date, path) — nothing anywhere showed these, so they piled up
and the only way to find them was SSH — each with a double-confirmed delete.

That delete is the only one this release adds, so it names a STACK, never a
path: the Manager resolves the name inside a backups/offsite-restore root it
computed itself and refuses anything landing outside. Red-proofed — neutralise
the name guard and stack:"" resolves to the offsite-restore ROOT and takes every
copy with it. Refusals are asserted as non-effects.

4b — Megosztás enable shows what it is waiting for. Enabling ran ReconcileSamba
synchronously inside the POST handler; on a golden without felhom-samba baked
that is compose pulling ~100MB, i.e. minutes of an apparently-hung form post
followed by "Beállítás mentve." whether or not anything came up. Detached +
polled now, distinguishing "képfájl letöltése" from "indítás" — decided BEFORE
the work starts, since afterwards the image is always present. Success is
probed, not inferred (compose up -d exits 0 on a crash-loop). The password form
starts the same job: with UserSet false reconcile deploys nothing, so on a fresh
box that is where the pull actually happens.

4c — "Távoli mentés most" streams real progress. restic was already reporting
bytes and percent; the runner seam used CombinedOutput() and discarded them. The
manual run now passes --json and scans stdout line-by-line: total bytes, percent,
current app. Manual only — the nightly stays silent, pinned by a test that fails
if it ever passes --json. The poll now arms unconditionally, closing a race the
manual trigger always ran: the redirect rendered before the goroutine wrote
LastStatus=running, so the poll never armed and the page sat static during the
very run just started. Red-proofed twice.

Also closes the golden/controller infra-image drift at the source: infra.Images()
derives from the existing pins and --print-infra-images exposes it, so the golden
bake can stop carrying its own copy. That copy had already drifted — felhom-samba
was never added, so the golden baked 3 of 4, which is why enabling Megosztás
pulled at runtime in the first place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
2026-07-19 09:30:30 +02:00

157 lines
5.7 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
}
// 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, "")
}