controller v0.271.0: automatic app updates — the update leg after the off-site copy, the backup gate waits, the switch (09 6.4 part 7; R-680, R-678, R-643)
gates / gates (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-24 22:07:24 +02:00
parent 1495ca43fb
commit 9cf13a3add
33 changed files with 1672 additions and 14 deletions
@@ -0,0 +1,93 @@
package web
import (
"html"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// v0.271.0 — the per-box switch for AUTOMATIC app updates (`09` §3 decision 12: ON by default) on the
// settings page, in both languages, through the REAL router, handler and template; and the saved choice
// read back from settings.json's accessor the leg reads.
//
// COMPANION RED-PROOF (REPORT.md): make GetAppUpdateUnattended return false for an absent key — the
// first assertion fails at "a box that never saved the switch must show it ON".
func TestAppUpdateSwitch_DefaultOnAndSaved(t *testing.T) {
s := testPageServer(t)
hu := html.UnescapeString(getPage(t, s, "/settings").Body.String())
if !strings.Contains(hu, `data-app-update-unattended="true"`) || !strings.Contains(hu, "checked data-app-update-unattended") {
t.Fatal("a box that never saved the switch must show it ON (decision 12)")
}
if !strings.Contains(hu, "Alkalmazások automatikus frissítése") || !strings.Contains(hu, "kipróbált lépéssel") {
t.Error("the Hungarian page must carry the card's title and its one sentence")
}
en := html.UnescapeString(getPage(t, s, "/settings?lang=en").Body.String())
if !strings.Contains(en, "Automatic app updates") || !strings.Contains(en, "only with a tested step") {
t.Error("the English page must carry the card's title and its one sentence")
}
if strings.Contains(en, "kipróbált lépéssel") {
t.Error("the Hungarian sentence must be GONE from the English page")
}
// OFF: an unchecked box posts no field
rec := postForm(t, s, "/settings/app-update", url.Values{})
if rec.Code != 200 {
t.Fatalf("POST off = %d", rec.Code)
}
if s.settings.GetAppUpdateUnattended() {
t.Fatal("saving with the box unchecked must turn the switch OFF")
}
body := html.UnescapeString(rec.Body.String())
if !strings.Contains(body, "kikapcsolva") || !strings.Contains(body, `data-app-update-unattended="false"`) {
t.Error("the page after saving OFF must say so and show the box unchecked")
}
// ON again
postForm(t, s, "/settings/app-update", url.Values{"app_update_unattended": {"on"}})
if !s.settings.GetAppUpdateUnattended() {
t.Fatal("saving with the box checked must turn the switch back ON")
}
}
// TestAutoUpdateLine_PageSaysItOnce — a successful automatic step is told on the app page only, in the
// request's language, and only while the app still runs what that step installed.
//
// COMPANION RED-PROOF (REPORT.md): drop the AutoUpdateLine block from appDetailHandler — this fails at
// "the English page must carry the automatic-update line".
func TestAutoUpdateLine_PageSaysItOnce(t *testing.T) {
s, _ := credsHarness(t)
sd := filepath.Join(s.cfg.Paths.StacksDir, "crafty")
write := func(pin string) {
app := "deployed: true\npinned_images:\n web: " + pin + "\nlast_auto_update:\n at: \"2026-09-25T02:21:00Z\"\n outcome: done\n to:\n web: crafty:2.0.0\n"
if err := os.WriteFile(filepath.Join(sd, "app.yaml"), []byte(app), 0o644); err != nil {
t.Fatal(err)
}
_ = s.stackMgr.ScanStacks()
}
s.loadTemplates()
render := func(lang string) string {
rr := httptest.NewRecorder()
s.appDetailHandler(rr, httptest.NewRequest("GET", "/apps/crafty?lang="+lang, nil), "crafty")
return html.UnescapeString(rr.Body.String())
}
write("crafty:2.0.0")
en, hu := render("en"), render("hu")
const enLine, huLine = "Automatic update at", "Automatikus frissítés"
if !strings.Contains(en, enLine) || !strings.Contains(en, "— done.") {
t.Fatalf("the English page must carry the automatic-update line")
}
if !strings.Contains(hu, huLine+" ") || !strings.Contains(hu, "— sikeres.") || strings.Contains(hu, enLine) {
t.Fatalf("the Hungarian page must carry its own line and not the English one")
}
if !strings.Contains(hu, `data-auto-update="done"`) {
t.Error("the line must render in its own marked alert")
}
// a later manual update moved the pin: the line is history and goes
write("crafty:2.1.0")
if strings.Contains(render("en"), enLine) {
t.Error("the line must go once the app no longer runs what the automatic step installed")
}
}
+55
View File
@@ -839,6 +839,14 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug s
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastUpdateUndone != nil {
data["UpdateUndoneLine"] = s.msg(r, "app_info.update_undone", found.Name, undoneWhen(found.AppConfig.LastUpdateUndone.At))
}
// v0.271.0 (`09` §6.4 part 7): a SUCCESSFUL automatic update is told on the page only — no mail.
// An undone or held automatic step keeps the lines above (they already say it), so this line is for
// `done` alone, and only while the app still runs what that step installed (a later manual update or
// a restore makes it history, and the page stops saying it).
if found.Deployed && found.AppConfig != nil && found.AppConfig.LastAutoUpdate != nil &&
found.AppConfig.LastAutoUpdate.Outcome == stacks.LegOutcomeDone && sameImageMap(found.AppConfig.LastAutoUpdate.To, found.AppConfig.PinnedImages) {
data["AutoUpdateLine"] = s.msg(r, "app_info.auto_update_done", undoneWhen(found.AppConfig.LastAutoUpdate.At))
}
s.executeTemplate(w, r, "app_info", data)
}
@@ -2144,6 +2152,9 @@ func (s *Server) systemPageData() map[string]interface{} {
data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
data["HubEnabled"] = s.cfg.Hub.Enabled
// v0.271.0: the automatic app-update switch (absent = ON).
data["AppUpdateUnattended"] = s.settings == nil || s.settings.GetAppUpdateUnattended()
// Self-update status
data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
if s.updater != nil {
@@ -2824,6 +2835,37 @@ func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request)
s.executeTemplate(w, r, "settings_notifications", data)
}
// settingsAppUpdateHandler saves the per-box switch for AUTOMATIC app updates (v0.271.0, `09` §3
// decision 12). An unchecked box posts nothing, so absence of the field means OFF here — this is the
// household's explicit save, unlike the stored default (absent = ON). The leg reads the switch at its
// next start and before every press, so the choice takes effect before the next night.
func (s *Server) settingsAppUpdateHandler(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
on := r.FormValue("app_update_unattended") == "on" || r.FormValue("app_update_unattended") == "true"
if s.settings == nil {
data := s.systemPageData()
data["AppUpdateError"] = s.msg(r, "settings.app_update_save_error")
s.executeTemplate(w, r, "settings_system", data)
return
}
if err := s.settings.SetAppUpdateUnattended(on); err != nil {
s.logger.Printf("[ERROR] [web] saving app_update.unattended=%v failed: %v", on, err)
data := s.systemPageData()
data["AppUpdateError"] = s.msg(r, "settings.app_update_save_error")
s.executeTemplate(w, r, "settings_system", data)
return
}
s.logger.Printf("[INFO] [web] automatic app updates switched %s by the household (app_update.unattended=%v)", map[bool]string{true: "ON", false: "OFF"}[on], on)
s.reportTriggerNow()
data := s.systemPageData()
if on {
data["AppUpdateSuccess"] = s.msg(r, "settings.app_update_on")
} else {
data["AppUpdateSuccess"] = s.msg(r, "settings.app_update_off")
}
s.executeTemplate(w, r, "settings_system", data)
}
func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
data := s.notificationsPageData()
@@ -3539,6 +3581,19 @@ func generateFileBrowserConfig(paths []settings.StoragePath, importSource bool)
// undoneWhen renders last_update_undone.at for the page line, in the box's time zone; the raw value
// when it does not parse (a record is never hidden for its format).
// sameImageMap reports whether two service→image maps are equal (nil equals empty).
func sameImageMap(a, b map[string]string) bool {
if len(a) != len(b) {
return false
}
for k, v := range a {
if b[k] != v {
return false
}
}
return true
}
func undoneWhen(rfc3339 string) string {
t, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
+2
View File
@@ -674,6 +674,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsNotificationsTestHandler(w, r)
case path == "/settings/app-email" && r.Method == http.MethodPost:
s.settingsAppEmailHandler(w, r)
case path == "/settings/app-update" && r.Method == http.MethodPost:
s.settingsAppUpdateHandler(w, r)
case path == "/settings/storage/add" && r.Method == http.MethodPost:
s.settingsStorageAddHandler(w, r)
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
@@ -38,6 +38,8 @@
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
{{else if .UpdateUndoneLine}}
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
{{else if .AutoUpdateLine}}
<div class="alert alert-info" style="margin-top:1rem" data-auto-update="done">{{.AutoUpdateLine}}</div>
{{end}}
{{if .MissingStorageLabel}}
@@ -143,6 +143,26 @@
</div>
</div>
<!-- Section: Automatic app updates (v0.271.0, `09` §3 decision 12) — the per-box switch, ON by default. -->
<div class="settings-card" id="app-update">
<h3>{{T "settings_system.app_update_title"}}</h3>
<p class="settings-card-desc">{{T "settings_system.app_update_desc"}}</p>
{{if .AppUpdateSuccess}}<div class="alert alert-info">{{.AppUpdateSuccess}}</div>{{end}}
{{if .AppUpdateError}}<div class="alert alert-error">{{.AppUpdateError}}</div>{{end}}
<form method="POST" action="/settings/app-update">
{{.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="app_update_unattended" value="on" {{if .AppUpdateUnattended}}checked{{end}} data-app-update-unattended="{{.AppUpdateUnattended}}">
{{T "settings_system.app_update_toggle"}}
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{T "settings_system.app_update_save"}}</button>
</div>
</form>
</div>
<!-- Section: Network (R-66) — the box's own address. Every value is live-computed per render and
stored NOWHERE (S-5: a DHCP lease persisted anywhere eventually misdirects people); a missing
value renders „—" because an address-less row beats a wrong address. -->
@@ -266,6 +266,26 @@
</div>
<div class="settings-card" id="app-update">
<h3>Alkalmazások automatikus frissítése</h3>
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
<form method="POST" action="/settings/app-update">
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
Automatikus frissítés bekapcsolva
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
</div>
<div class="settings-card">
<h3>Hálózat</h3>
<div class="settings-grid">
@@ -306,6 +306,26 @@
</div>
<div class="settings-card" id="app-update">
<h3>Alkalmazások automatikus frissítése</h3>
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
<form method="POST" action="/settings/app-update">
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
Automatikus frissítés bekapcsolva
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
</div>
<div class="settings-card">
<h3>Hálózat</h3>
<div class="settings-grid">
@@ -223,6 +223,26 @@
</div>
<div class="settings-card" id="app-update">
<h3>Alkalmazások automatikus frissítése</h3>
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
<form method="POST" action="/settings/app-update">
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
Automatikus frissítés bekapcsolva
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
</div>
<div class="settings-card">
<h3>Hálózat</h3>
<div class="settings-grid">
@@ -256,6 +256,26 @@
</div>
<div class="settings-card" id="app-update">
<h3>Alkalmazások automatikus frissítése</h3>
<p class="settings-card-desc">Ha be van kapcsolva, a doboz minden éjjel, a távoli mentés után magától frissíti az alkalmazásaidat: egyszerre egyet, mindig csak kipróbált lépéssel, és ha valami nem sikerül, visszaállítja az előző változatot.</p>
<form method="POST" action="/settings/app-update">
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="app_update_unattended" value="on" data-app-update-unattended="">
Automatikus frissítés bekapcsolva
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
</div>
<div class="settings-card">
<h3>Hálózat</h3>
<div class="settings-grid">