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
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:
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user