b5d78d1e0f
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
157 lines
5.7 KiB
Go
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, "")
|
|
}
|