v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s

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-10-05 20:12:56 +02:00
parent 114ff2761a
commit 1453cfc69b
68 changed files with 3953 additions and 116 deletions
+6 -6
View File
@@ -43,12 +43,12 @@ type DeleteResponse struct {
// DeleteResponse, plus the backup half: BackupPathsRefused carries every backup path the removal
// declined to touch and why — until v0.236.0 that refusal existed only as a WARN log line.
type RemoveResponse struct {
Removed string `json:"removed"`
VolumesRemoved []string `json:"volumes_removed"`
HDDPathsRemoved []string `json:"hdd_paths_removed"`
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"`
HDDNote string `json:"hdd_note,omitempty"`
Removed string `json:"removed"`
VolumesRemoved []string `json:"volumes_removed"`
HDDPathsRemoved []string `json:"hdd_paths_removed"`
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
HDDPathsMissing []string `json:"hdd_paths_missing,omitempty"`
HDDNote string `json:"hdd_note,omitempty"`
// UserdataKept: as DeleteResponse.UserdataKept (decision 67, R-800).
UserdataKept []string `json:"userdata_kept"`
BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"`
+1 -1
View File
@@ -36,7 +36,7 @@ func carryLifeRecords(logger *log.Logger, name string, prior, cfg *AppConfig) {
// opened; a gate that was still closed stays closed (its probe opens it if the restored data is set up).
// No prior record (a removed app, kept data, a rebuilt guest) = no gate: the data comes back with its admin.
cfg.SetupGate = prior.SetupGate
cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app
cfg.FamilyGate = prior.FamilyGate // v0.287.0: a restore never un-gates a family app
cfg.InstallHold = prior.InstallHold // R-741: the loop opens it when the restored record says the login was replaced
cfg.DefaultLogin = prior.DefaultLogin
cfg.AfterSetup = prior.AfterSetup
+66
View File
@@ -1210,6 +1210,60 @@ func deepCopyStack(s *Stack) Stack {
cp.Meta.InitialCreds = &icCopy
}
// R-591 follow-up: every other reference in Meta. Pinned by TestDeepCopyStackMetaSharesNoReference,
// which fills every pointer/slice/map reachable from Meta and fails on any the copy still shares —
// so a field added to Metadata later fails there until it is copied here.
cp.Meta.AppInfo.UseCases = cloneStrs(s.Meta.AppInfo.UseCases)
cp.Meta.AppInfo.FirstSteps = cloneStrs(s.Meta.AppInfo.FirstSteps)
cp.Meta.AppInfo.Prerequisites = cloneStrs(s.Meta.AppInfo.Prerequisites)
cp.Meta.FamilyGateExcept = cloneStrs(s.Meta.FamilyGateExcept)
if s.Meta.DataPaths != nil {
cp.Meta.DataPaths = make([]DataPath, len(s.Meta.DataPaths))
copy(cp.Meta.DataPaths, s.Meta.DataPaths)
}
if s.Meta.AfterLoad != nil {
al := *s.Meta.AfterLoad
al.Command = cloneStrs(al.Command)
cp.Meta.AfterLoad = &al
}
if s.Meta.AfterInstall != nil {
ai := *s.Meta.AfterInstall
ai.Env, ai.Command = cloneStrs(ai.Env), cloneStrs(ai.Command)
cp.Meta.AfterInstall = &ai
}
if s.Meta.AfterSetup != nil {
as := *s.Meta.AfterSetup
as.Env = cloneStrMap(as.Env)
as.Args, as.Command = cloneStrs(as.Args), cloneStrs(as.Command)
cp.Meta.AfterSetup = &as
}
if s.Meta.SetupDoneProbe != nil {
sp := *s.Meta.SetupDoneProbe
cp.Meta.SetupDoneProbe = &sp
}
if s.Meta.SMTPMapping != nil {
sm := *s.Meta.SMTPMapping
sm.Extra = cloneStrMap(sm.Extra)
cp.Meta.SMTPMapping = &sm
}
if s.Meta.Backup != nil {
b := *s.Meta.Backup
b.Userdata = append([]appbackup.BindSpec(nil), b.Userdata...)
b.HDD = append([]appbackup.BindSpec(nil), b.HDD...)
b.Import = append([]appbackup.BindSpec(nil), b.Import...)
cp.Meta.Backup = &b
}
// Deep-copy Meta.I18n (R-591): the struct assignment above leaves the map — and every pointer
// and slice inside each overlay — shared with the original. Pinned by
// TestDeepCopyStackI18nIsNotShared.
if s.Meta.I18n != nil {
cp.Meta.I18n = make(map[string]MetadataOverlay, len(s.Meta.I18n))
for lang, ov := range s.Meta.I18n {
cp.Meta.I18n[lang] = ov.Clone()
}
}
return cp
}
@@ -1755,3 +1809,15 @@ func AggregateStateForTest(containers []ContainerInfo) ContainerState {
// SetKeptUnitFinder wires the backup side's removed-app unit lookup (INIT-ONLY; main.go).
func (m *Manager) SetKeptUnitFinder(fn func(app, drive string) string) { m.keptUnitFn = fn }
// cloneStrMap returns a fresh copy of in, keeping nil as nil.
func cloneStrMap(in map[string]string) map[string]string {
if in == nil {
return nil
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = v
}
return out
}
@@ -320,3 +320,93 @@ func LocalizeStackPtr(in *Stack, lang string) *Stack {
out.Meta = out.Meta.For(lang)
return &out
}
// cloneStrPtr returns a fresh pointer to a copy of *p, or nil.
func cloneStrPtr(p *string) *string {
if p == nil {
return nil
}
v := *p
return &v
}
// cloneStrs returns a fresh copy of in, keeping nil as nil and empty as empty.
func cloneStrs(in []string) []string {
if in == nil {
return nil
}
out := make([]string, len(in))
copy(out, in)
return out
}
// Clone returns a deep copy of the overlay: no pointer, slice or nested struct is shared with the
// receiver. It exists for deepCopyStack (R-591) — a Stack copy is "a snapshot the caller may
// mutate", and the overlay must be one too. Pinned by TestDeepCopyStackI18nIsNotShared.
func (o MetadataOverlay) Clone() MetadataOverlay {
c := MetadataOverlay{
Description: cloneStrPtr(o.Description),
}
if o.AppInfo != nil {
ai := AppInfoOverlay{
Tagline: cloneStrPtr(o.AppInfo.Tagline),
UseCases: cloneStrs(o.AppInfo.UseCases),
FirstSteps: cloneStrs(o.AppInfo.FirstSteps),
Prerequisites: cloneStrs(o.AppInfo.Prerequisites),
DefaultCreds: cloneStrPtr(o.AppInfo.DefaultCreds),
AddPeople: cloneStrPtr(o.AppInfo.AddPeople),
}
c.AppInfo = &ai
}
if o.DeployFields != nil {
c.DeployFields = make([]DeployFieldOverlay, len(o.DeployFields))
for i, f := range o.DeployFields {
nf := DeployFieldOverlay{
EnvVar: f.EnvVar,
Label: cloneStrPtr(f.Label),
Description: cloneStrPtr(f.Description),
Placeholder: cloneStrPtr(f.Placeholder),
}
if f.Options != nil {
nf.Options = make([]SelectOptionOverlay, len(f.Options))
for j, op := range f.Options {
nf.Options[j] = SelectOptionOverlay{Value: op.Value, Label: cloneStrPtr(op.Label)}
}
}
c.DeployFields[i] = nf
}
}
if o.OptionalConfig != nil {
c.OptionalConfig = make([]OptionalConfigGroupOverlay, len(o.OptionalConfig))
for i, g := range o.OptionalConfig {
ng := OptionalConfigGroupOverlay{
MatchGroup: g.MatchGroup,
Group: cloneStrPtr(g.Group),
Description: cloneStrPtr(g.Description),
}
if g.Fields != nil {
ng.Fields = make([]OptionalConfigFieldOverlay, len(g.Fields))
for j, f := range g.Fields {
ng.Fields[j] = OptionalConfigFieldOverlay{EnvVar: f.EnvVar, Label: cloneStrPtr(f.Label), HelpText: cloneStrPtr(f.HelpText)}
}
}
c.OptionalConfig[i] = ng
}
}
if o.Integrations != nil {
c.Integrations = make([]IntegrationOverlay, len(o.Integrations))
for i, in := range o.Integrations {
c.Integrations[i] = IntegrationOverlay{Target: in.Target, Label: cloneStrPtr(in.Label), Description: cloneStrPtr(in.Description)}
}
}
if o.DataPaths != nil {
c.DataPaths = make([]DataPathOverlay, len(o.DataPaths))
for i, d := range o.DataPaths {
c.DataPaths[i] = DataPathOverlay{Path: d.Path, Label: cloneStrPtr(d.Label)}
}
}
if o.InitialCreds != nil {
c.InitialCreds = &InitialCredentialsOverlay{Note: cloneStrPtr(o.InitialCreds.Note)}
}
return c
}
@@ -0,0 +1,61 @@
package stacks
import "testing"
// R-591: deepCopyStack is "a snapshot the caller may mutate". Before the fix the Meta.I18n map
// (and every pointer inside each overlay) was shared, so writing through the copy changed the
// original. The test asserts the CONSEQUENCE: after mutating the copy at every depth, the
// original still reads its own values.
func TestDeepCopyStackI18nIsNotShared(t *testing.T) {
s := func(v string) *string { return &v }
orig := &Stack{Name: "demo"}
orig.Meta.I18n = map[string]MetadataOverlay{
"en": {
Description: s("orig desc"),
AppInfo: &AppInfoOverlay{Tagline: s("orig tag"), UseCases: []string{"orig use"}},
DeployFields: []DeployFieldOverlay{{EnvVar: "A", Label: s("orig label"),
Options: []SelectOptionOverlay{{Value: "x", Label: s("orig opt")}}}},
OptionalConfig: []OptionalConfigGroupOverlay{{MatchGroup: "g", Group: s("orig group"),
Fields: []OptionalConfigFieldOverlay{{EnvVar: "B", HelpText: s("orig help")}}}},
Integrations: []IntegrationOverlay{{Target: "t", Label: s("orig int")}},
DataPaths: []DataPathOverlay{{Path: "/p", Label: s("orig path")}},
InitialCreds: &InitialCredentialsOverlay{Note: s("orig note")},
},
}
cp := deepCopyStack(orig)
en := cp.Meta.I18n["en"]
*en.Description = "MUT"
*en.AppInfo.Tagline = "MUT"
en.AppInfo.UseCases[0] = "MUT"
*en.DeployFields[0].Label = "MUT"
*en.DeployFields[0].Options[0].Label = "MUT"
*en.OptionalConfig[0].Group = "MUT"
*en.OptionalConfig[0].Fields[0].HelpText = "MUT"
*en.Integrations[0].Label = "MUT"
*en.DataPaths[0].Label = "MUT"
*en.InitialCreds.Note = "MUT"
cp.Meta.I18n["de"] = MetadataOverlay{Description: s("MUT")}
o := orig.Meta.I18n["en"]
checks := map[string]string{
"description": *o.Description,
"tagline": *o.AppInfo.Tagline,
"use case": o.AppInfo.UseCases[0],
"deploy label": *o.DeployFields[0].Label,
"option label": *o.DeployFields[0].Options[0].Label,
"optional group": *o.OptionalConfig[0].Group,
"optional help": *o.OptionalConfig[0].Fields[0].HelpText,
"integration": *o.Integrations[0].Label,
"data path": *o.DataPaths[0].Label,
"initial creds": *o.InitialCreds.Note,
}
for what, got := range checks {
if got == "MUT" {
t.Errorf("R-591: mutating the copy's %s overlay changed the ORIGINAL — the overlay is shared, not copied", what)
}
}
if _, ok := orig.Meta.I18n["de"]; ok {
t.Errorf("R-591: adding a language to the copy's I18n map added it to the ORIGINAL — the map is shared")
}
}
@@ -0,0 +1,109 @@
package stacks
import (
"reflect"
"testing"
)
// R-591 follow-up: deepCopyStack's contract is "a snapshot the caller may mutate", and the I18n hole
// was not the only one — DataPaths, AfterLoad (and every other reference added to Metadata after the
// copy was written) stayed shared. Instead of one assertion per field, this fills EVERY pointer, slice
// and map reachable from Stack.Meta with a non-empty value and then walks the copy beside the original:
// any reference the two share is a field a caller's write would leak through. A field added to
// Metadata tomorrow is covered the day it is added.
func TestDeepCopyStackMetaSharesNoReference(t *testing.T) {
orig := &Stack{Name: "demo"}
fillAll(reflect.ValueOf(&orig.Meta).Elem(), 0)
cp := deepCopyStack(orig)
var shared []string
findAliases(reflect.ValueOf(orig.Meta), reflect.ValueOf(cp.Meta), "Meta", &shared)
for _, p := range shared {
t.Errorf("R-591: deepCopyStack leaves %s shared with the original — a write through the copy changes the stack", p)
}
}
// fillAll gives every pointer, slice (one element) and map (one entry) under v a non-nil value.
func fillAll(v reflect.Value, depth int) {
if depth > 6 || !v.CanSet() && v.Kind() != reflect.Struct {
return
}
switch v.Kind() {
case reflect.Ptr:
if v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
fillAll(v.Elem(), depth+1)
case reflect.Slice:
if v.Len() == 0 {
v.Set(reflect.MakeSlice(v.Type(), 1, 1))
}
fillAll(v.Index(0), depth+1)
case reflect.Map:
if v.Len() == 0 {
m := reflect.MakeMap(v.Type())
k := reflect.New(v.Type().Key()).Elem()
if k.Kind() == reflect.String {
k.SetString("k")
}
e := reflect.New(v.Type().Elem()).Elem()
fillAll(e, depth+1)
m.SetMapIndex(k, e)
v.Set(m)
}
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if v.Type().Field(i).IsExported() {
fillAll(v.Field(i), depth+1)
}
}
case reflect.String:
if v.String() == "" {
v.SetString("x")
}
}
}
// findAliases records every pointer/slice/map under a and b that points at the same memory.
func findAliases(a, b reflect.Value, path string, out *[]string) {
switch a.Kind() {
case reflect.Ptr:
if a.IsNil() || b.IsNil() {
return
}
if a.Pointer() == b.Pointer() {
*out = append(*out, path)
return
}
findAliases(a.Elem(), b.Elem(), path, out)
case reflect.Slice:
if a.Len() == 0 || b.Len() == 0 {
return
}
if a.Pointer() == b.Pointer() {
*out = append(*out, path)
return
}
for i := 0; i < a.Len() && i < b.Len(); i++ {
findAliases(a.Index(i), b.Index(i), path+"[]", out)
}
case reflect.Map:
if a.Len() == 0 || b.Len() == 0 {
return
}
if a.Pointer() == b.Pointer() {
*out = append(*out, path)
return
}
for _, k := range a.MapKeys() {
if bv := b.MapIndex(k); bv.IsValid() {
findAliases(a.MapIndex(k), bv, path+"[k]", out)
}
}
case reflect.Struct:
for i := 0; i < a.NumField(); i++ {
if a.Type().Field(i).IsExported() {
findAliases(a.Field(i), b.Field(i), path+"."+a.Type().Field(i).Name, out)
}
}
}
}