Look at what a container mounts directly, accept a pre-upgrade label, and write the genesis secret without a newline
Review of the first cut found four things. A directory mounted into a container is no longer looked inside, not even for the files this host wrote there. The controller records every provider's received and contributions file as a plain file under a mounted directory, so folding those in would have recreated the route proxy — which re-reads its routes live, by design — on every route change, and killed every provisioner sidecar, which polls what it receives, mid-reconcile on every grant. Whether a service reads a file under its directory once or watches it is the service's; restart-on is how a module says "once", and it stays the opt-in. Env-files and files mounted directly remain by content. Genesis wrote the superuser secret as `value\n`; `secret accept` strips the line ending by design, so the postgres module declared `value` — and with a mounted file's content in the spec, phase three would have recreated the store it meant to adopt in place, with the temporary control plane connected to it. Genesis now writes the value alone. readCredentialFile tolerated both endings already. Pinned with the bytes the genesis code path writes, then the module's declaration of the same container: it must reconcile. A container carrying a label from before the host folded in what it reads is accepted rather than recreated, when that label matches the spec as it used to be computed: what it reads is recorded then, a change is caught from that record from the next apply on, and the label is renewed at the next genuine recreate. Recreating them all would have been a restart storm across the mesh in declaration order, the store first. The trade-off is stated in the code: a container already stale at upgrade time is not caught, and could not have been either way. The record of what a container read is looked up by its name when its declared id has none — the bundle's `store` becomes `postgres.server` for the same container — so a change on the day it is adopted still names the file. The by-target lookup takes the most recently applied record, since the bundle's record for the same target is never removed by the mesh's. novox/hq 04-ISSUES/103
This commit is contained in:
+110
-15
@@ -132,24 +132,27 @@ func TestAContainerIsRecreatedWhenAMountedSecretChanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAServiceWritesInAMountedDirectoryIsNotPartOfWhatItIs(t *testing.T) {
|
||||
// A bind-mounted directory is the service's data: it changes while the service runs, and
|
||||
// recreating for it would restart a database for every row it wrote. What the HOST wrote
|
||||
// under that directory — a config it rendered there — is another matter: the service read
|
||||
// that once, at start.
|
||||
func TestAMountedDirectoryIsNotLookedInside(t *testing.T) {
|
||||
// A bind-mounted directory is not part of what a container is — not the data the service
|
||||
// grows in it, and not the files the host itself writes there either. Whether a service reads
|
||||
// a file under its directory once at start or watches it live is the service's business: the
|
||||
// route proxy re-reads its routes live, a provisioner sidecar polls what it receives every few
|
||||
// seconds, and recreating either for a file the host rewrote would kill them for nothing. A
|
||||
// module whose container does read such a file once says so with restart-on, which stays the
|
||||
// opt-in.
|
||||
dir := t.TempDir()
|
||||
state := filepath.Join(dir, "state")
|
||||
config := filepath.Join(state, "config.toml")
|
||||
declare := func(level string) *declaration.Declaration {
|
||||
declare := func(level, restartOn string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"app.state","type":"directory","path":"`+state+`"},
|
||||
{"id":"app.config","type":"file","path":"`+config+`","content":"level = \"`+level+`\"\n"},
|
||||
{"id":"app.server","type":"container","name":"app","image":"`+pinned+`",
|
||||
"volumes":["`+state+`:/var/lib/app"]}
|
||||
"volumes":["`+state+`:/var/lib/app"]`+restartOn+`}
|
||||
]}`)
|
||||
}
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
_, known := applyCarried(t, declare("info"), store.State{}, m, nil)
|
||||
_, known := applyCarried(t, declare("info", ""), store.State{}, m, nil)
|
||||
|
||||
// The service grows its data in the directory it was given.
|
||||
if err := os.WriteFile(filepath.Join(state, "app.db"), []byte("rows"), 0o600); err != nil {
|
||||
@@ -162,20 +165,112 @@ func TestWhatAServiceWritesInAMountedDirectoryIsNotPartOfWhatItIs(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.asked = nil
|
||||
report, known := applyCarried(t, declare("info"), known, m, nil)
|
||||
report, known := applyCarried(t, declare("info", ""), known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Errorf("a container was recreated for data its service wrote in a mounted directory: %v %+v",
|
||||
m.asked, report.Outcomes)
|
||||
}
|
||||
|
||||
// The host's own config under the same directory changes: that, the service read at start.
|
||||
// The host rewrites its own file under the same directory: still not a reason. The container
|
||||
// did not name it.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("debug"), known, m, nil)
|
||||
if !m.removed("app") || !m.did("docker run") {
|
||||
t.Fatalf("a config the host wrote under a mounted directory changed and the container was not recreated: %v", m.asked)
|
||||
report, known = applyCarried(t, declare("debug", ""), known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") {
|
||||
t.Errorf("a container was recreated for a file under a mounted directory it did not name: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.server"); o.Detail != "recreated: "+config+" changed" {
|
||||
t.Errorf("the recreate did not name the config: %+v", o)
|
||||
if o := outcomeOf(report, "app.config"); o.Action != "updated" {
|
||||
t.Fatalf("the config was not rewritten: %+v", o)
|
||||
}
|
||||
|
||||
// Naming it is what makes it a reason, as before this change.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("trace", `,"restart-on":["app.config"]`), known, m, nil)
|
||||
if !m.removed("app") || !m.did("docker run") {
|
||||
t.Fatalf("a container naming a rewritten file under its mount was not recreated: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "app.server"); !strings.Contains(o.Detail, "app.config") {
|
||||
t.Errorf("the recreate did not name why: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerLabelledBeforeTheHostReadItsFilesIsAcceptedNotRecreated(t *testing.T) {
|
||||
// The first apply after the host upgrades finds every container carrying a label computed
|
||||
// without the file lines. Recreating them all would be a restart storm across the mesh in
|
||||
// declaration order, the store first. A label that matches the spec as it used to be computed
|
||||
// is accepted: what the container reads is recorded now, and from then on a change is caught.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "forge.env")
|
||||
declare := func(port string) *declaration.Declaration {
|
||||
return parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"forge.env","type":"file","path":"`+env+`","content":"DATABASE_PORT=`+port+`\n","mode":"0600"},
|
||||
{"id":"forge.server","type":"container","name":"forge","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
}
|
||||
// The machine as the previous host left it: the file written and recorded, the container up
|
||||
// under the label that host computed — the spec with nothing about the file's content.
|
||||
if err := os.WriteFile(env, []byte("DATABASE_PORT=5432\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := declare("5432")
|
||||
legacy := containerSpecReading(d.Resources[1].(*declaration.Container), nil, nil)
|
||||
known := store.State{}
|
||||
known.Record(store.Applied{ID: "forge.env", Type: "file", Origin: store.OriginCarried, Target: env,
|
||||
Wrote: digestOf("DATABASE_PORT=5432\n")})
|
||||
known.Record(store.Applied{ID: "forge.server", Type: "container", Origin: store.OriginCarried, Target: "forge"})
|
||||
m := &machine{containers: map[string]*fakeContainer{"forge": {id: "made-by-host", running: true, spec: legacy}}}
|
||||
|
||||
report, known := applyCarried(t, d, known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Fatalf("a container labelled by the previous host was recreated on upgrade: %v %+v", m.asked, report.Outcomes)
|
||||
}
|
||||
if got, _ := known.Find("forge.server"); got.Reads[env] != digestOf("DATABASE_PORT=5432\n") {
|
||||
t.Fatalf("what the accepted container reads was not recorded: %+v", got)
|
||||
}
|
||||
// Accepted stays accepted: the next pass with nothing moved is quiet too.
|
||||
m.asked = nil
|
||||
report, known = applyCarried(t, d, known, m, nil)
|
||||
if m.did("docker rm") || m.did("docker run") || report.Changed() {
|
||||
t.Fatalf("an accepted container was recreated on the pass after: %v", m.asked)
|
||||
}
|
||||
|
||||
// And a change to the file is caught from the record, and the label is renewed.
|
||||
m.asked = nil
|
||||
report, _ = applyCarried(t, declare("5433"), known, m, nil)
|
||||
if !m.removed("forge") || !m.did("docker run") {
|
||||
t.Fatalf("an accepted container was not recreated when its env-file changed: %v", m.asked)
|
||||
}
|
||||
if o := outcomeOf(report, "forge.server"); o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("the recreate did not name the file: %+v", o)
|
||||
}
|
||||
if m.containers["forge"].spec == legacy {
|
||||
t.Error("the recreated container still carries the legacy label")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerAdoptedUnderANewIdStillSaysWhichFileChanged(t *testing.T) {
|
||||
// The bundle's `store` becomes the postgres module's `postgres.server`: the same container by
|
||||
// name, under a new id with no record of its own. A file change on that day is a real change,
|
||||
// and the record of what it read is under the old id — by name, it is found.
|
||||
dir := t.TempDir()
|
||||
env := filepath.Join(dir, "store.env")
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
raised := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"env","type":"file","path":"`+env+`","content":"PORT=5432\n","mode":"0600"},
|
||||
{"id":"store","type":"container","name":"mesh-store","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
_, known := applyCarried(t, raised, store.State{}, m, nil)
|
||||
|
||||
adopted := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"postgres.env","type":"file","path":"`+env+`","content":"PORT=5433\n","mode":"0600"},
|
||||
{"id":"postgres.server","type":"container","name":"mesh-store","image":"`+pinned+`","env-file":["`+env+`"]}
|
||||
]}`)
|
||||
m.asked = nil
|
||||
report, _, err := Apply(context.Background(), archHost(t), adopted, known, store.OriginDeclared, m.run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o := outcomeOf(report, "postgres.server"); o.Action != "updated" || o.Detail != "recreated: "+env+" changed" {
|
||||
t.Errorf("a container adopted under a new id did not say which file changed: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user