v0.341.0: the night update takes a security_fix ladder step (D39 = B, 09 §3 decision 255, R-1239); every other leg guard unchanged; the leg names the CVE ids; MinAgent 0.158.1
gates / gates (push) Successful in 1m29s
gates / gates (push) Successful in 1m29s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -873,6 +873,14 @@ sends no mail; the app page says „Automatikus frissítés %s-kor — sikeres."
|
||||
One summary line per night (`[update-leg] update leg (after-offsite): done=… undone=… held=… failed=…
|
||||
skipped=… [skipped: app=reason, …]`) in the log and in the hub report's `update_leg`. After `done`/`undone` the
|
||||
app's steps-left and badge are fresh at once (R-678). `stacks.update_window` is removed (it was never read).
|
||||
**A security-fix step (v0.341.0, `09` §3 decision 255, D39 = B).** The leg also takes a ladder entry with
|
||||
`"verdict": "security_fix"` — an urgent upstream fix proven on the box (scratch 9202, the guarded Update) whose bench run
|
||||
is optional, with its public notice (`security`: CVE/GHSA ids, the advisory, the first fixed version). Every other rule
|
||||
above holds for it unchanged and in the same order (held, stopped by the household, `needs_person`, the failed step,
|
||||
`files_may_change`, W+5h). The leg logs `[update-leg] <app>: security fix <ids> taken (<from> → <to>)`, the hub
|
||||
report's step carries `security: [ids]`, and the summary line ends `[security fix <app>: <ids> <outcome>]`. A
|
||||
controller before 0.341.0 skips such a step at night (`not_proven`); a person's press takes it on every controller
|
||||
with ladders (the press never read the verdict).
|
||||
|
||||
**Leftovers (v0.272.0).** The backup page says in plain words when a whole-box backup was skipped because it
|
||||
cannot fit (the agent's numbers; R-685). A restore that lifts an update hold removes that hold's undo copies
|
||||
|
||||
@@ -52,6 +52,22 @@ type LadderEntry struct {
|
||||
// PostgreSQL major (pgconvert.go). omitempty: an entry without it prints exactly as before, so R-680's
|
||||
// failed-step records (tied to LadderPrint) are not reset by this field's arrival.
|
||||
EngineConversion *EngineConversion `yaml:"engine_conversion" json:"engine_conversion,omitempty"`
|
||||
// Security (v0.341.0, `09` §3 decision 255, D39 = B) is a `security_fix` step's public notice: the CVE/GHSA ids,
|
||||
// the advisory's address and the first fixed upstream version. The night leg names the ids when it takes the step.
|
||||
// omitempty, like EngineConversion: an entry without it prints (LadderPrint) exactly as before.
|
||||
Security *LadderSecurity `yaml:"security" json:"security,omitempty"`
|
||||
}
|
||||
|
||||
// LadderVerdictSecurityFix is the second verdict the night leg takes (v0.341.0, decision 255): an urgent upstream fix
|
||||
// proven on the box (scratch 9202, the guarded Update) whose bench run is optional. A person's press has never read the
|
||||
// verdict, so on every controller with ladders it was already pressable by hand; before v0.341.0 the leg skips it.
|
||||
const LadderVerdictSecurityFix = "security_fix"
|
||||
|
||||
// LadderSecurity is the `security` object of a security_fix ladder entry (the catalog's `scripts/ladder.py` checks it).
|
||||
type LadderSecurity struct {
|
||||
IDs []string `yaml:"ids" json:"ids"`
|
||||
Advisory string `yaml:"advisory" json:"advisory"`
|
||||
FixedIn string `yaml:"fixed_in" json:"fixed_in"`
|
||||
}
|
||||
|
||||
// LadderMarks is the `marks` object of a ladder entry. NeedsPerson is JSON null (nil) or the tester's
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
// It presses the SAME public guarded Update a person presses (StartGuardedUpdate) — no second update
|
||||
// path — one app at a time, ONE tested step per app per night, and never:
|
||||
// - an app whose installed version has no ladder entry (older than the ladder, or no test record);
|
||||
// - a step whose verdict is not `proven`, or that carries the `needs_person` mark;
|
||||
// - a step whose verdict is not `proven` or `security_fix` (v0.341.0, decision 255 — an urgent fix proven on the
|
||||
// box, bench optional; every other guard below holds for it unchanged), or that carries the `needs_person` mark;
|
||||
// - a `files_may_change` step unless a fresh WHOLE copy of the app exists on the box (decision 25/26);
|
||||
// - the step that was undone or held last time, while the catalog's ladder is unchanged (R-680);
|
||||
// - a held app, an app that is current, ahead, or unorderable.
|
||||
@@ -92,6 +93,8 @@ type LegStep struct {
|
||||
From map[string]string `json:"from,omitempty"`
|
||||
To map[string]string `json:"to,omitempty"`
|
||||
Seconds float64 `json:"seconds,omitempty"`
|
||||
// Security names the public notice of a security_fix step the leg took (v0.341.0, decision 255): its CVE/GHSA ids.
|
||||
Security []string `json:"security,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateLegSummary is one night's leg — the operator's summary line and the hub report's `update_leg`.
|
||||
@@ -123,9 +126,15 @@ func (s *UpdateLegSummary) Line() string {
|
||||
if s.Stopped != "" {
|
||||
stop = " stopped=" + s.Stopped
|
||||
}
|
||||
return fmt.Sprintf("update leg (%s): done=%d undone=%d held=%d failed=%d skipped=%d%s in %s [skipped: %s]",
|
||||
sec := ""
|
||||
for _, st := range s.Steps {
|
||||
if len(st.Security) > 0 && st.Outcome != LegOutcomeSkipped {
|
||||
sec += fmt.Sprintf(" [security fix %s: %s %s]", st.App, strings.Join(st.Security, ","), st.Outcome)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("update leg (%s): done=%d undone=%d held=%d failed=%d skipped=%d%s in %s [skipped: %s]%s",
|
||||
s.Trigger, s.Done, s.Undone, s.Held, s.Failed, s.Skipped, stop,
|
||||
s.EndedAt.Sub(s.StartedAt).Round(time.Second), strings.Join(skips, ", "))
|
||||
s.EndedAt.Sub(s.StartedAt).Round(time.Second), strings.Join(skips, ", "), sec)
|
||||
}
|
||||
|
||||
func (s *UpdateLegSummary) add(st LegStep) {
|
||||
@@ -334,10 +343,17 @@ func (m *Manager) runUpdateLeg(ctx context.Context, trigger string, manual bool)
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[INFO] [update-leg] %s: step pressed %s → %s", name, summarisePin(entry.From), summarisePin(entry.To))
|
||||
var secIDs []string
|
||||
if entry.Verdict == LadderVerdictSecurityFix {
|
||||
if entry.Security != nil {
|
||||
secIDs = entry.Security.IDs
|
||||
}
|
||||
m.logger.Printf("[INFO] [update-leg] %s: security fix %s taken (%s → %s)", name, strings.Join(secIDs, ","), summarisePin(entry.From), summarisePin(entry.To))
|
||||
}
|
||||
m.setLegFlags(true, true)
|
||||
outcome := m.legWait(ctx, o, name)
|
||||
m.setLegFlags(true, false)
|
||||
step := LegStep{App: name, Outcome: outcome, From: entry.From, To: entry.To, Seconds: o.Now().Sub(t0).Round(100 * time.Millisecond).Seconds()}
|
||||
step := LegStep{App: name, Outcome: outcome, From: entry.From, To: entry.To, Seconds: o.Now().Sub(t0).Round(100 * time.Millisecond).Seconds(), Security: secIDs}
|
||||
sum.add(step)
|
||||
m.recordAutoUpdate(name, &AutoUpdateRecord{At: t0.UTC().Format(time.RFC3339), Outcome: outcome, From: entry.From, To: entry.To})
|
||||
m.logger.Printf("[INFO] [update-leg] %s: step ended %s after %.1f s", name, outcome, step.Seconds)
|
||||
@@ -438,7 +454,7 @@ func (m *Manager) legCandidate(ctx context.Context, name string, o *UpdateLegOpt
|
||||
return LadderEntry{}, LegSkipOlderThanLadder
|
||||
}
|
||||
e := ladder[idx]
|
||||
if e.Verdict != "proven" {
|
||||
if e.Verdict != "proven" && e.Verdict != LadderVerdictSecurityFix {
|
||||
return e, LegSkipNotProven
|
||||
}
|
||||
if e.Marks.NeedsPerson != nil && strings.TrimSpace(*e.Marks.NeedsPerson) != "" {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.341.0 — D39 = B (`09` §3 decision 255): the night leg takes a `security_fix` step like a proven one, and every
|
||||
// other guard holds for it unchanged and in the same order. Same real leg over the real guarded update as
|
||||
// unattended_test.go (legManager: nextcloud pinned at A, the catalog at C, ladder A→B→C).
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): against the OLD verdict line (`e.Verdict != "proven"`) the first two tests fail
|
||||
// (the step is skipped as not_proven). The held and stopped-by-household guards run BEFORE the verdict line, so the old
|
||||
// line cannot turn them red; their red-proof is the mutation "a security_fix step returns before the guards" (both
|
||||
// fail). The unknown-verdict test's red-proof is the mutation "any verdict but unrecorded is taken" (it fails).
|
||||
|
||||
const secMarks = `{"files_may_change": false, "needs_person": null, "memory_tight": false}`
|
||||
|
||||
// securityLine is a security_fix ladder entry A→B with its public notice, optionally with other marks.
|
||||
func securityLine(from, to, marks string) string {
|
||||
return ` - {"from": {"web": "` + from + `"}, "to": {"web": "` + to + `"}, "digest": {"web": "sha256:` +
|
||||
strings.Repeat("a", 64) + `"}, "verdict": "security_fix", "marks": ` + marks +
|
||||
`, "security": {"ids": ["CVE-2026-96652", "CVE-2026-96654"], "advisory": "https://www.cisa.gov/kev", "fixed_in": "1.43.3.10861"}, "seed": "none"}` + "\n"
|
||||
}
|
||||
|
||||
func TestD39_SecurityFixStepIsTakenAndNamed(t *testing.T) {
|
||||
m, dir, _, ups, logBuf := legManager(t)
|
||||
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 1 || pinOf(t, dir) != ladderB {
|
||||
t.Fatalf("the security_fix step was not taken: ups=%v pin=%s summary=%+v", *ups, pinOf(t, dir), s)
|
||||
}
|
||||
st := legStepFor(s, "nextcloud")
|
||||
if st.Outcome != LegOutcomeDone || strings.Join(st.Security, ",") != "CVE-2026-96652,CVE-2026-96654" {
|
||||
t.Fatalf("the summary step must be done and name the ids, got %+v", st)
|
||||
}
|
||||
want := "[update-leg] nextcloud: security fix CVE-2026-96652,CVE-2026-96654 taken (" + summarisePin(map[string]string{"web": ladderA}) + " → " + summarisePin(map[string]string{"web": ladderB}) + ")"
|
||||
if !strings.Contains(logBuf.String(), want) {
|
||||
t.Fatalf("the log must carry %q; log:\n%s", want, logBuf.String())
|
||||
}
|
||||
if !strings.Contains(s.Line(), "[security fix nextcloud: CVE-2026-96652,CVE-2026-96654 done]") {
|
||||
t.Fatalf("the operator's summary line must name the security fix: %s", s.Line())
|
||||
}
|
||||
}
|
||||
|
||||
func TestD39_SecurityFixWithNeedsPersonIsSkipped(t *testing.T) {
|
||||
m, dir, _, ups, _ := legManager(t)
|
||||
writeLadder(t, m, securityLine(ladderA, ladderB, `{"files_may_change": false, "needs_person": "the admin must sign in again", "memory_tight": false}`),
|
||||
ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 0 || pinOf(t, dir) != ladderA || legStepFor(s, "nextcloud").Reason != LegSkipNeedsPerson {
|
||||
t.Fatalf("security_fix + needs_person: ups=%v pin=%s step=%+v, want skipped %q", *ups, pinOf(t, dir), legStepFor(s, "nextcloud"), LegSkipNeedsPerson)
|
||||
}
|
||||
}
|
||||
|
||||
func TestD39_SecurityFixOnAHeldAppIsSkipped(t *testing.T) {
|
||||
m, _, g, ups, _ := legManager(t)
|
||||
g.held, g.holdWhy = true, "HELD"
|
||||
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipHeld {
|
||||
t.Fatalf("security_fix on a held app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipHeld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestD39_SecurityFixOnAnAppTheHouseholdStoppedIsSkipped(t *testing.T) {
|
||||
m, _, _, ups, _ := legManager(t)
|
||||
writeLadder(t, m, securityLine(ladderA, ladderB, secMarks), ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetDesiredState("nextcloud", DesiredStateStopped); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipStoppedByHousehold {
|
||||
t.Fatalf("security_fix on a stopped app: ups=%v step=%+v, want skipped %q", *ups, legStepFor(s, "nextcloud"), LegSkipStoppedByHousehold)
|
||||
}
|
||||
}
|
||||
|
||||
func TestD39_AnUnknownVerdictIsStillNotProven(t *testing.T) {
|
||||
for _, v := range []string{"security-fix", "failed", "inconclusive", ""} {
|
||||
m, _, _, ups, _ := legManager(t)
|
||||
writeLadder(t, m, ladderLineMarks(ladderA, ladderB, v, secMarks), ladderLine(ladderB, ladderC))
|
||||
legOpts(m, nil)
|
||||
s := mustLeg(t, m)
|
||||
if len(*ups) != 0 || legStepFor(s, "nextcloud").Reason != LegSkipNotProven {
|
||||
t.Fatalf("verdict %q: ups=%v step=%+v, want skipped %q", v, *ups, legStepFor(s, "nextcloud"), LegSkipNotProven)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestD39_ProvenIsUnchangedAndCarriesNoSecurityNames(t *testing.T) {
|
||||
m, dir, _, _, logBuf := legManager(t)
|
||||
legOpts(m, nil)
|
||||
s := mustLeg(t, m)
|
||||
st := legStepFor(s, "nextcloud")
|
||||
if pinOf(t, dir) != ladderB || st.Outcome != LegOutcomeDone || st.Security != nil {
|
||||
t.Fatalf("a proven step: pin=%s step=%+v, want B done with no security names", pinOf(t, dir), st)
|
||||
}
|
||||
if strings.Contains(logBuf.String(), "security fix") || strings.Contains(s.Line(), "security fix") {
|
||||
t.Fatalf("a proven step must not be logged as a security fix")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user