v0.275.0: a backup's data and its version travel together (R-696, 07 §6.6, D4 option A); R-695, R-691, R-694
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
The unit's data files are stamped with the versions that wrote them; the capture keeps the definition the data belongs to; a restore never starts data under another version's definition (unit restores refuse a mismatch; the off-site restore writes the snapshot's definition); every tier's time is its data's; the conversion-copy release needs a dump on the new engine. File-browser sync single-flight + no empty kept folder (R-695); the kept view joins the folder's owning group, language switch resyncs (R-691); a restore-generated login is not shown as the password (R-694). Red-proofs in felhom.eu/documentation/audits/version-travel-2026-09-26/. 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:
@@ -169,6 +169,11 @@ type AppConfig struct {
|
||||
// ConversionCopy (v0.273.0, `09` §6.4 part 10) is the OLD datadir's copy kept after a successful
|
||||
// PostgreSQL major conversion, until a backup of the converted app is proven (ReleaseConversionCopies).
|
||||
ConversionCopy *ConversionCopy `yaml:"conversion_copy,omitempty" json:"conversion_copy,omitempty"`
|
||||
// RestoredLogins (v0.275.0, R-694) are the `type: password` fields whose stored value was GENERATED by a
|
||||
// restore (the unit never carries an admin login, D5, and the guest had none — a load of kept data, a
|
||||
// removed app, a rebuilt guest) while the app's own login came back with its data. The page then shows
|
||||
// no value for them and says the old password is the one that works (restoredLoginFields).
|
||||
RestoredLogins []string `yaml:"restored_logins,omitempty" json:"restored_logins,omitempty"`
|
||||
}
|
||||
|
||||
// InstalledImage is one compose service's observed image. See AppConfig.InstalledImages.
|
||||
@@ -684,6 +689,9 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
|
||||
// R-694: which admin logins did the restore have to GENERATE? Exactly the `type: password` fields the
|
||||
// guest held no value for before this write (the unit never carries one) — read BEFORE it is replaced.
|
||||
prior := LoadAppConfigDecrypted(stackDir, m.encKey)
|
||||
cfg := &AppConfig{
|
||||
Deployed: true,
|
||||
DeployedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -694,6 +702,10 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
cfg.LockedFields = append(cfg.LockedFields, f.EnvVar)
|
||||
}
|
||||
}
|
||||
cfg.RestoredLogins = restoredLoginFields(name, meta, prior, env)
|
||||
if len(cfg.RestoredLogins) > 0 {
|
||||
m.logger.Printf("[INFO] [stacks] %s: the restore generated %v — the app's own login came back with its data; the page will not show the new value as the password", name, cfg.RestoredLogins)
|
||||
}
|
||||
if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
|
||||
return fmt.Errorf("saving app config: %w", err)
|
||||
}
|
||||
@@ -1332,3 +1344,30 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim
|
||||
}
|
||||
return nil, warning
|
||||
}
|
||||
|
||||
// loginAppliedEveryStart is the register of `type: password` fields whose app APPLIES the env value at
|
||||
// EVERY start, so a value generated at a restore IS the login afterwards (R-694, measured 2026-09-26 from
|
||||
// each image's entrypoint at the catalog's tag, `audits/version-travel-2026-09-26/D4/`): code-server's
|
||||
// s6 run script passes $PASSWORD to `code-server --auth password` on every start and stores none. The six
|
||||
// other apps with such a field (crafty-controller, gokapi, grafana, kimai, nextcloud, paperless-ngx) use it
|
||||
// only at first initialisation — the restored data's login wins. Code, not a catalog flag, like
|
||||
// nonPortableSecrets: it decides what a household is told about how to get into its own app.
|
||||
var loginAppliedEveryStart = map[string]map[string]bool{
|
||||
"code-server": {"PASSWORD": true},
|
||||
}
|
||||
|
||||
// restoredLoginFields names the `type: password` fields a restore GENERATED — no value in the guest's
|
||||
// app.yaml before, a value now — for an app whose login lives in its data. Pinned by TestR694_*.
|
||||
func restoredLoginFields(app string, meta Metadata, prior *AppConfig, env map[string]string) []string {
|
||||
var out []string
|
||||
for _, f := range meta.DeployFields {
|
||||
if f.Type != "password" || env[f.EnvVar] == "" || loginAppliedEveryStart[app][f.EnvVar] {
|
||||
continue
|
||||
}
|
||||
if prior != nil && prior.Env[f.EnvVar] != "" {
|
||||
continue // the guest kept the household's own value — not generated
|
||||
}
|
||||
out = append(out, f.EnvVar)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -304,6 +304,12 @@ func (m *Manager) ListKept(drives []string) []KeptItem {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(d, KeptDirName, a.Name(), s.Name())
|
||||
// R-695 (v0.275.0): an EMPTY dated folder is not kept data — it is what Docker leaves when a
|
||||
// file-browser bind outlives a Delete (it recreates the missing source, empty, as root).
|
||||
// Listed, it was bound again, and the bind recreated it: a loop. Never listed, never bound.
|
||||
if !dirHasEntries(dir) {
|
||||
continue
|
||||
}
|
||||
it := KeptItem{App: a.Name(), DisplayName: a.Name(), Path: dir, Drive: d, Kind: KeptKindDated,
|
||||
Marker: readKeptMarker(dir), SizeBytes: sizeFn(dir)}
|
||||
if st, ok := m.GetStack(a.Name()); ok && st.Meta.DisplayName != "" {
|
||||
|
||||
@@ -295,3 +295,27 @@ func TestKept_OwnerIsNeverTheFileBrowser(t *testing.T) {
|
||||
t.Fatalf("the leftovers must be named by the app that binds them through HDD_PATH, never the file browser: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// R-695 (v0.275.0) — an EMPTY dated kept folder (what Docker recreates when a file-browser bind outlives
|
||||
// a Delete) is never listed, so it is never bound and cannot recreate itself. A dated folder that holds
|
||||
// something is still listed.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): drop the dirHasEntries check in ListKept's dated loop — the empty
|
||||
// folder is then listed and this fails.
|
||||
func TestR695_AnEmptyDatedKeptFolderIsNeverListed(t *testing.T) {
|
||||
m, drive := keptManager(t)
|
||||
m.mu.Lock()
|
||||
s := m.stacks["cloudapp"]
|
||||
s.Deployed = true
|
||||
s.AppConfig = &AppConfig{Deployed: true, Env: map[string]string{"HDD_PATH": drive}}
|
||||
m.mu.Unlock()
|
||||
empty := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-25_141014")
|
||||
must(t, os.MkdirAll(empty, 0o755))
|
||||
full := filepath.Join(drive, KeptDirName, "nextcloud", "2026-09-24_101010")
|
||||
must(t, os.MkdirAll(full, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(full, "f"), []byte("kept"), 0o644))
|
||||
items := m.ListKept([]string{drive})
|
||||
if len(items) != 1 || items[0].Path != full {
|
||||
t.Fatalf("listing = %+v — want only the folder that holds something", items)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,3 +234,27 @@ func loadMetadataFile(path string) (Metadata, error) {
|
||||
}
|
||||
return LoadProbeMetadata(tmp), nil // R-670
|
||||
}
|
||||
|
||||
// RestoredVersionPosition says where an app stands after a restore brought it back at an older version
|
||||
// (v0.275.0, `07` §6.6, the page's first sentence): behind — its pin is not the catalog's; climbable — a
|
||||
// tested ladder step leads on from its pin, so the automatic leg climbs it (legCandidate refuses an app
|
||||
// older than the ladder, LegSkipOlderThanLadder, and a template with no ladder, LegSkipNoTestRecord).
|
||||
// Digests are ignored on both sides: this is about versions, not about which bytes a tag names today.
|
||||
func (m *Manager) RestoredVersionPosition(name string) (behind, climbable bool) {
|
||||
st, ok := m.GetStack(name)
|
||||
if !ok || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 || len(st.CatalogImages) == 0 {
|
||||
return false, false
|
||||
}
|
||||
strip := func(in map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = StripDigest(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if sameRefs(strip(st.AppConfig.PinnedImages), strip(st.CatalogImages)) {
|
||||
return false, false
|
||||
}
|
||||
tpl := filepath.Dir(m.CatalogTemplatePath(name, "docker-compose.yml"))
|
||||
return true, ladderStepsLeft(tpl, st.AppConfig.PinnedImages) > 0
|
||||
}
|
||||
|
||||
@@ -214,3 +214,34 @@ func mustWriteMk(t *testing.T, p, body string) {
|
||||
}
|
||||
mustWrite(t, p, body)
|
||||
}
|
||||
|
||||
// TestA3_RestoredVersionPosition — after a restore brought an app back at an older pin: behind, and
|
||||
// climbable only when a tested ladder step leads on from that pin (the automatic leg's own rule:
|
||||
// LegSkipOlderThanLadder). A pin at the catalog's version is not behind; digests do not count.
|
||||
func TestA3_RestoredVersionPosition(t *testing.T) {
|
||||
m, _, _, _, _ := ladderManager(t, true)
|
||||
set := func(pin string) {
|
||||
m.mu.Lock()
|
||||
st := m.stacks["nextcloud"]
|
||||
st.CatalogImages = map[string]string{"web": ladderC}
|
||||
if st.AppConfig == nil {
|
||||
st.AppConfig = &AppConfig{}
|
||||
}
|
||||
st.AppConfig.PinnedImages = map[string]string{"web": pin}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
for _, c := range []struct {
|
||||
pin string
|
||||
behind, climbable bool
|
||||
}{
|
||||
{ladderA, true, true}, // on the ladder: the leg climbs it
|
||||
{"nextcloud:20.0.0-apache", true, false}, // older than the ladder: the leg will not
|
||||
{ladderC, false, false}, // at the catalog's version
|
||||
{ladderC + "@sha256:0123", false, false}, // a digest is not a version
|
||||
} {
|
||||
set(c.pin)
|
||||
if b, cl := m.RestoredVersionPosition("nextcloud"); b != c.behind || cl != c.climbable {
|
||||
t.Errorf("pin %s: behind=%v climbable=%v, want %v %v", c.pin, b, cl, c.behind, c.climbable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,60 @@ type ConversionCopy struct {
|
||||
At string `yaml:"at" json:"at"` // RFC3339 — a backup proven after this releases the copy
|
||||
From int `yaml:"from" json:"from"`
|
||||
To int `yaml:"to" json:"to"`
|
||||
// Service (v0.275.0) is the converted compose service — the release looks for a dump written by ITS
|
||||
// engine at major To. "" on a record written before v0.275.0: then every Postgres-family image in the
|
||||
// dump's recorded set must be at To.
|
||||
Service string `yaml:"service,omitempty" json:"service,omitempty"`
|
||||
}
|
||||
|
||||
// DataDumpStamp is one database dump in the app's own recovery unit, with what the backup side recorded
|
||||
// when it was WRITTEN (v0.275.0, R-696): its time and the running images (service -> ref@digest).
|
||||
type DataDumpStamp struct {
|
||||
File string
|
||||
At time.Time
|
||||
Images map[string]string
|
||||
}
|
||||
|
||||
// DumpStampSource is the backup side's record of the app's own unit's database dumps (v0.275.0). An
|
||||
// OPTIONAL extension of UpdateGuards: guards without it never release a conversion copy (fail closed —
|
||||
// a copy outliving its backup costs disk, never data).
|
||||
type DumpStampSource interface {
|
||||
DumpStamps(name string) []DataDumpStamp
|
||||
}
|
||||
|
||||
// convertedDumpAt is the release's second condition (A4): a database dump written AFTER the conversion
|
||||
// whose recorded engine is the NEW major. The first condition — a copy of the app proven after the
|
||||
// conversion on any tier — says the DATA is newer; this one says it was written by the converted engine,
|
||||
// which a unit's refresh time or a snapshot's time cannot say (R-696: the demo-hp release cited a unit
|
||||
// re-captured over a PostgreSQL 16 dump).
|
||||
func convertedDumpAt(stamps []DataDumpStamp, cc *ConversionCopy, after time.Time) (DataDumpStamp, bool) {
|
||||
for _, st := range stamps {
|
||||
if !st.At.After(after) || len(st.Images) == 0 {
|
||||
continue
|
||||
}
|
||||
if cc.Service != "" {
|
||||
if ref, ok := st.Images[cc.Service]; ok {
|
||||
if mj, ok := postgresMajor(ref); ok && mj == cc.To {
|
||||
return st, true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen, all := 0, true
|
||||
for _, ref := range st.Images {
|
||||
if !isPostgresImage(ref) {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if mj, ok := postgresMajor(ref); !ok || mj != cc.To {
|
||||
all = false
|
||||
}
|
||||
}
|
||||
if seen > 0 && all {
|
||||
return st, true
|
||||
}
|
||||
}
|
||||
return DataDumpStamp{}, false
|
||||
}
|
||||
|
||||
// conversionDumpMargin is A5's margin on the dump's bound (the DB volume's own size).
|
||||
@@ -576,12 +630,25 @@ func (m *Manager) ReleaseConversionCopies(ctx context.Context) []string {
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// v0.275.0 (A4): AND a dump the converted engine wrote. Without the stamps (older guards, or a unit
|
||||
// whose data is unstamped) the copy is KEPT — logged, retried at the next pass.
|
||||
src, hasStamps := g.(DumpStampSource)
|
||||
var dump DataDumpStamp
|
||||
if hasStamps {
|
||||
dump, ok = convertedDumpAt(src.DumpStamps(st.Name), cc, at)
|
||||
}
|
||||
if !hasStamps || !ok {
|
||||
if m.isDebug() {
|
||||
m.logger.Printf("[DEBUG] [stacks] %s: the pre-conversion copy %s is KEPT — a copy proven at %s exists, but no database dump written after %s by PostgreSQL %d is recorded yet", st.Name, cc.Copy, rp.ProvenAt.UTC().Format(time.RFC3339), cc.At, cc.To)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := m.copier().Remove(cc.Copy); err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] %s: could not remove the pre-conversion copy %s: %v — kept, tried again later", st.Name, cc.Copy, err)
|
||||
continue
|
||||
}
|
||||
m.recordConversionCopy(st.Name, filepath.Dir(st.ComposePath), nil)
|
||||
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339))
|
||||
m.logger.Printf("[INFO] [stacks] %s: REMOVED the pre-conversion datadir copy %s (PostgreSQL %d) — the converted app has a backup proven on %d: %s at %s, its dump %s written %s by %v", st.Name, cc.Copy, cc.From, cc.To, updateTierName(rp.Tier), rp.ProvenAt.UTC().Format(time.RFC3339), dump.File, dump.At.UTC().Format(time.RFC3339), dump.Images)
|
||||
released = append(released, st.Name)
|
||||
}
|
||||
return released
|
||||
|
||||
@@ -423,6 +423,8 @@ func TestConvert_ReleaseAfterAProvenBackup(t *testing.T) {
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: slice4T0.Add(time.Hour)}}
|
||||
// v0.275.0 (A4): and the dump in that backup was written by the converted engine.
|
||||
g.stamps = []DataDumpStamp{{File: "db-dumps/nextcloud-postgres.sql", At: slice4T0.Add(time.Hour), Images: map[string]string{"db": "postgres:18-alpine@sha256:18"}}}
|
||||
g.mu.Unlock()
|
||||
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 1 || fc.nCopies() != 0 {
|
||||
t.Fatalf("released %v after a proven backup; copies=%d", got, fc.nCopies())
|
||||
@@ -505,3 +507,68 @@ func TestConvert_MarkDoesNotChangeOldLadderPrints(t *testing.T) {
|
||||
t.Fatalf("an entry without the mark prints it: %s", b)
|
||||
}
|
||||
}
|
||||
|
||||
// A5 red-proof 2 — the release refuses a PRE-CONVERSION dump (R-696). The measured demo-hp night: a copy
|
||||
// "proven" after the conversion (the unit's refresh time) while its dump was written by PostgreSQL 16
|
||||
// before the conversion; v0.274.0 removed the 16 datadir copy on that. Now the copy stays until a dump
|
||||
// written after the conversion BY THE NEW ENGINE is recorded — and each half of that is needed.
|
||||
func TestA5_TheReleaseRefusesAPreConversionDump(t *testing.T) {
|
||||
m, dir, g, _, fc, _ := convManager(t, goodMark)
|
||||
if err := m.StartGuardedUpdate("nextcloud"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st := waitUpdateDone(t, m, "nextcloud"); st.UpdatePhase != UpdatePhaseDone {
|
||||
t.Fatalf("setup: %q", st.UpdatePhase)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.stacks["nextcloud"].AppConfig = LoadAppConfig(dir)
|
||||
m.mu.Unlock()
|
||||
cc := LoadAppConfig(dir).ConversionCopy
|
||||
if cc == nil || cc.Service != "db" {
|
||||
t.Fatalf("conversion_copy = %+v, want one naming the converted service", cc)
|
||||
}
|
||||
at, _ := time.Parse(time.RFC3339, cc.At)
|
||||
g.mu.Lock()
|
||||
g.points = []UpdateRestorePoint{{Tier: UpdateTierLocal, ProvenAt: at.Add(10 * time.Minute)}} // "proven" after it
|
||||
g.mu.Unlock()
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
stamp DataDumpStamp
|
||||
}{
|
||||
{"a dump written BEFORE the conversion, by 16", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(-5 * time.Minute), Images: map[string]string{"db": "postgres:16-alpine@sha256:16"}}},
|
||||
{"a dump after the conversion, but recorded as 16", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute), Images: map[string]string{"db": "postgres:16-alpine@sha256:16"}}},
|
||||
{"a dump after the conversion with no recorded images", DataDumpStamp{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute)}},
|
||||
} {
|
||||
g.mu.Lock()
|
||||
g.stamps = []DataDumpStamp{c.stamp}
|
||||
g.mu.Unlock()
|
||||
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 0 || fc.nCopies() != 1 {
|
||||
t.Fatalf("%s: released %v; copies=%d — the 16 datadir copy went on a backup that holds no 18 dump", c.why, got, fc.nCopies())
|
||||
}
|
||||
}
|
||||
g.mu.Lock()
|
||||
g.stamps = []DataDumpStamp{{File: "db-dumps/nextcloud-postgres.sql", At: at.Add(5 * time.Minute), Images: map[string]string{"db": "postgres:18-alpine@sha256:18"}}}
|
||||
g.mu.Unlock()
|
||||
if got := m.ReleaseConversionCopies(context.Background()); len(got) != 1 || fc.nCopies() != 0 {
|
||||
t.Fatalf("released %v with an 18 dump after the conversion; copies=%d", got, fc.nCopies())
|
||||
}
|
||||
}
|
||||
|
||||
// A record written before v0.275.0 carries no service: every Postgres-family image in the dump's
|
||||
// recorded set must then be at the new major.
|
||||
func TestA4_AnOldRecordWithoutAServiceChecksEveryPostgresImage(t *testing.T) {
|
||||
cc := &ConversionCopy{From: 16, To: 18}
|
||||
after := slice4T0
|
||||
st := func(imgs map[string]string) []DataDumpStamp {
|
||||
return []DataDumpStamp{{File: "db-dumps/x-postgres.sql", At: after.Add(time.Minute), Images: imgs}}
|
||||
}
|
||||
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1", "db": "postgres:18-alpine"}), cc, after); !ok {
|
||||
t.Fatal("an 18 dump was not accepted")
|
||||
}
|
||||
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1", "db": "postgres:16-alpine"}), cc, after); ok {
|
||||
t.Fatal("a 16 dump was accepted")
|
||||
}
|
||||
if _, ok := convertedDumpAt(st(map[string]string{"app": "x/app:1"}), cc, after); ok {
|
||||
t.Fatal("a dump with no Postgres image recorded was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// R-694 (v0.275.0) — a restore with no guest app.yaml (a load of kept data, a removed app, a rebuilt
|
||||
// guest) GENERATES the withheld admin login (D5: the unit never carries it), while the app's own login
|
||||
// comes back with its data. For six of the seven catalog apps with such a field the old password is the
|
||||
// one that works (measured from each entrypoint, `audits/version-travel-2026-09-26/D4/`); the page used
|
||||
// to show the new value as "the first password set at install".
|
||||
//
|
||||
// Through the PRODUCTION write (PersistUnitRedeployConfig), not the helper alone.
|
||||
//
|
||||
// COMPANION RED-PROOF (REPORT.md): make restoredLoginFields return nil — the nextcloud case then records
|
||||
// nothing and this fails at "restored_logins".
|
||||
func TestR694_ARestoreThatGeneratesTheLoginRecordsIt(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
app, env string
|
||||
guestHad bool
|
||||
wantNoted bool
|
||||
}{
|
||||
{"nextcloud, guest app.yaml gone", "nextcloud", "NEXTCLOUD_ADMIN_PASSWORD", false, true},
|
||||
{"nextcloud, guest kept the household's value", "nextcloud", "NEXTCLOUD_ADMIN_PASSWORD", true, false},
|
||||
{"code-server applies the env at every start", "code-server", "PASSWORD", false, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{}
|
||||
cfg.Paths.StacksDir = filepath.Join(dir, "stacks")
|
||||
cfg.Stacks.ComposeCommand = "docker compose"
|
||||
app := filepath.Join(cfg.Paths.StacksDir, c.app)
|
||||
must(t, os.MkdirAll(app, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(app, "docker-compose.yml"), []byte("services:\n web:\n image: x/y:1\n"), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(app, ".felhom.yml"), []byte("display_name: X\ndeploy_fields:\n - env_var: "+c.env+"\n label: Admin\n type: password\n"), 0o644))
|
||||
if c.guestHad {
|
||||
must(t, os.WriteFile(filepath.Join(app, "app.yaml"), []byte("deployed: true\nenv:\n "+c.env+": households-own\n"), 0o600))
|
||||
}
|
||||
m, err := NewManager(cfg, log.New(io.Discard, "", 0))
|
||||
must(t, err)
|
||||
must(t, m.ScanStacks())
|
||||
must(t, m.PersistUnitRedeployConfig(c.app, map[string]string{c.env: "generated-at-restore"}))
|
||||
got := LoadAppConfig(app)
|
||||
noted := got != nil && len(got.RestoredLogins) == 1 && got.RestoredLogins[0] == c.env
|
||||
if noted != c.wantNoted {
|
||||
t.Fatalf("restored_logins = %v, want noted=%v", got.RestoredLogins, c.wantNoted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1011,7 +1011,7 @@ func (m *Manager) verifyAndConclude(ctx context.Context, name, dir string, env [
|
||||
}
|
||||
m.removeUndoCopies(name, rest)
|
||||
if keep != nil {
|
||||
m.recordConversionCopy(name, dir, &ConversionCopy{Volume: keep.Volume, Copy: keep.Copy, At: m.now().UTC().Format(time.RFC3339), From: entry.Convert.From, To: entry.Convert.To})
|
||||
m.recordConversionCopy(name, dir, &ConversionCopy{Volume: keep.Volume, Copy: keep.Copy, At: m.now().UTC().Format(time.RFC3339), From: entry.Convert.From, To: entry.Convert.To, Service: entry.Convert.Service})
|
||||
m.logger.Printf("[INFO] [stacks] update %s: KEEPING the pre-conversion datadir copy %s (PostgreSQL %d) until a backup of the converted app is proven", name, keep.Copy, entry.Convert.From)
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(dir, preUpdateConvertDir))
|
||||
|
||||
@@ -37,6 +37,14 @@ type fakeGuards struct {
|
||||
pinAtDump string
|
||||
stackDir string
|
||||
undoState string // what the last hold said a failed undo left (v0.263.0)
|
||||
// stamps are the app's own unit's recorded database dumps (v0.275.0, DumpStampSource).
|
||||
stamps []DataDumpStamp
|
||||
}
|
||||
|
||||
func (f *fakeGuards) DumpStamps(string) []DataDumpStamp {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]DataDumpStamp(nil), f.stamps...)
|
||||
}
|
||||
|
||||
func (f *fakeGuards) note(c string) { f.mu.Lock(); f.calls = append(f.calls, c); f.mu.Unlock() }
|
||||
|
||||
Reference in New Issue
Block a user