Keep what an adopted node was found holding until its module is taken, and report it held (hq ADR 0100)
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0100: on an adopted node, what is found is kept until its module is taken.
|
||||
|
||||
// machine is a fake container runtime holding containers by name: id, running, and the host's spec
|
||||
// label when a host made it. Every command it is asked is written down.
|
||||
type machine struct {
|
||||
containers map[string]*fakeContainer
|
||||
asked []string
|
||||
}
|
||||
|
||||
type fakeContainer struct {
|
||||
id string
|
||||
running bool
|
||||
spec string
|
||||
}
|
||||
|
||||
func (m *machine) run(_ context.Context, name string, args ...string) (string, error) {
|
||||
m.asked = append(m.asked, name+" "+strings.Join(args, " "))
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "inspect":
|
||||
c, ok := m.containers[args[len(args)-1]]
|
||||
if !ok {
|
||||
return "", errors.New("no such container")
|
||||
}
|
||||
running := "false"
|
||||
if c.running {
|
||||
running = "true"
|
||||
}
|
||||
if strings.HasPrefix(args[2], "{{.Id}}") {
|
||||
return c.id + "\t" + running + "\t" + c.spec + "\n", nil
|
||||
}
|
||||
return running + "\t" + c.spec + "\n", nil
|
||||
case "rm":
|
||||
delete(m.containers, args[len(args)-1])
|
||||
return "", nil
|
||||
case "run":
|
||||
var name, spec string
|
||||
for i, a := range args {
|
||||
if a == "--name" {
|
||||
name = args[i+1]
|
||||
}
|
||||
if a == "--label" && strings.HasPrefix(args[i+1], specLabel+"=") {
|
||||
spec = strings.TrimPrefix(args[i+1], specLabel+"=")
|
||||
}
|
||||
}
|
||||
m.containers[name] = &fakeContainer{id: "made-by-host", running: true, spec: spec}
|
||||
return "made-by-host\n", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *machine) removed(name string) bool {
|
||||
for _, a := range m.asked {
|
||||
if strings.HasPrefix(a, "docker rm") && strings.HasSuffix(a, " "+name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func adopted(t *testing.T, adoption, resources string) *declaration.Declaration {
|
||||
t.Helper()
|
||||
return parse(t, `{"declaration":1,"adoption":`+adoption+`,"resources":[`+resources+`]}`)
|
||||
}
|
||||
|
||||
const untakenWeb = `{"taken":[],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}}`
|
||||
const takenWeb = `{"taken":["hello-web"]}`
|
||||
|
||||
func webResources(page string) string {
|
||||
return `{"id":"hello-web.page","type":"file","path":"` + page + `","content":"the mesh's page\n"},
|
||||
{"id":"hello-web.server","type":"container","name":"hello-web","image":"` + pinned + `"}`
|
||||
}
|
||||
|
||||
func applyAdopted(t *testing.T, d *declaration.Declaration, known store.State, m *machine, keepDir string) (Report, store.State) {
|
||||
t.Helper()
|
||||
report, state, err := ApplyKeeping(context.Background(), archHost(t), d, known,
|
||||
store.OriginDeclared, m.run, nil, nil, KeepIn(keepDir))
|
||||
if err != nil {
|
||||
t.Fatalf("apply failed: %v", err)
|
||||
}
|
||||
return report, state
|
||||
}
|
||||
|
||||
func outcomeOf(r Report, id string) Outcome {
|
||||
for _, o := range r.Outcomes {
|
||||
if o.ID == id {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return Outcome{}
|
||||
}
|
||||
|
||||
func predecessor(t *testing.T) (dir, page string, m *machine) {
|
||||
t.Helper()
|
||||
dir = t.TempDir()
|
||||
page = filepath.Join(dir, "index.html")
|
||||
if err := os.WriteFile(page, []byte("the predecessor's page\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, page, &machine{containers: map[string]*fakeContainer{
|
||||
"hello-web": {id: "predecessor-id", running: true},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAFoundFileOfAnUntakenModuleIsKeptAsItIs(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
report, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
|
||||
got, _ := os.ReadFile(page)
|
||||
if string(got) != "the predecessor's page\n" {
|
||||
t.Fatalf("a found file was changed: %q", got)
|
||||
}
|
||||
info, _ := os.Stat(page)
|
||||
if info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("a found file's mode was changed to %o", info.Mode().Perm())
|
||||
}
|
||||
if o := outcomeOf(report, "hello-web.page"); o.Action != "held" ||
|
||||
!strings.Contains(o.Detail, "kept until hello-web is taken") {
|
||||
t.Errorf("the found file was not reported held: %+v", o)
|
||||
}
|
||||
h, ok := state.HeldAt("hello-web.page")
|
||||
if !ok || h.Module != "hello-web" || h.Mode != "0640" {
|
||||
t.Fatalf("the hold was not recorded: %+v", h)
|
||||
}
|
||||
kept, err := os.ReadFile(h.Kept)
|
||||
if err != nil || string(kept) != "the predecessor's page\n" {
|
||||
t.Fatalf("the original was not kept: %q %v", kept, err)
|
||||
}
|
||||
if info, _ := os.Stat(h.Kept); info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("the kept original is mode %o", info.Mode().Perm())
|
||||
}
|
||||
if _, recorded := state.Find("hello-web.page"); recorded {
|
||||
t.Error("a held file was recorded as applied, so it would be removed as an orphan")
|
||||
}
|
||||
if report.Changed() {
|
||||
t.Errorf("holding was reported as changing the machine: %+v", report.Outcomes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFoundContainerOfAnUntakenModuleIsNotReplaced(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
report, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
|
||||
if m.removed("hello-web") {
|
||||
t.Fatal("a found container was removed")
|
||||
}
|
||||
for _, a := range m.asked {
|
||||
if strings.HasPrefix(a, "docker run") {
|
||||
t.Fatalf("a container was started over a found one: %s", a)
|
||||
}
|
||||
}
|
||||
if outcomeOf(report, "hello-web.server").Action != "held" {
|
||||
t.Errorf("the found container was not held: %+v", report.Outcomes)
|
||||
}
|
||||
if h, _ := state.HeldAt("hello-web.server"); h.Container != "predecessor-id" || !h.Running {
|
||||
t.Errorf("the container as found was not recorded: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatIsNotFoundIsCreatedWhenAssigned(t *testing.T) {
|
||||
// Assigning prepares: what the module declares that is not there is made.
|
||||
dir := t.TempDir()
|
||||
page := filepath.Join(dir, "index.html")
|
||||
m := &machine{containers: map[string]*fakeContainer{}}
|
||||
report, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
if o := outcomeOf(report, "hello-web.page"); o.Action != "created" {
|
||||
t.Errorf("an absent file of an untaken module was not created: %+v", o)
|
||||
}
|
||||
if o := outcomeOf(report, "hello-web.server"); o.Action != "created" {
|
||||
t.Errorf("an absent container of an untaken module was not created: %+v", o)
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("something was held that was not found: %+v", state.Held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThisHostWroteIsNotFound(t *testing.T) {
|
||||
// Found means present with no record. A record of any origin — carried or declared, this life
|
||||
// of the node or an earlier one — means this host wrote it.
|
||||
for _, origin := range []string{store.OriginCarried, store.OriginDeclared} {
|
||||
dir, page, m := predecessor(t)
|
||||
known := store.State{Resources: []store.Applied{
|
||||
{ID: "earlier-name", Type: "file", Target: page, Origin: origin}}}
|
||||
report, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), known, m, dir)
|
||||
if o := outcomeOf(report, "hello-web.page"); o.Action == "held" {
|
||||
t.Errorf("%s: a file this host has a record of was held: %+v", origin, o)
|
||||
}
|
||||
if _, held := state.HeldAt("hello-web.page"); held {
|
||||
t.Errorf("%s: a recorded file was held", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerAHostMadeIsNotFound(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
m.containers["hello-web"].spec = "some-spec"
|
||||
report, _ := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
if o := outcomeOf(report, "hello-web.server"); o.Action == "held" {
|
||||
t.Errorf("a container carrying the host's spec label was held: %+v", o)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheGenesisStoreAdoptedInPlaceIsNotFound(t *testing.T) {
|
||||
// ADR 0078: the foundation's store, raised from the bundle and recorded as carried, is adopted
|
||||
// as a module by name. It is the mesh's own and must never read as a predecessor's.
|
||||
dir := t.TempDir()
|
||||
m := &machine{containers: map[string]*fakeContainer{"mesh-store": {id: "x", running: true}}}
|
||||
known := store.State{Resources: []store.Applied{{ID: "store", Type: "container", Target: "mesh-store"}}}
|
||||
d := adopted(t, `{"taken":[],"untaken":{"postgres":["postgres.server"]}}`,
|
||||
`{"id":"postgres.server","type":"container","name":"mesh-store","image":"`+pinned+`"}`)
|
||||
report, state := applyAdopted(t, d, known, m, dir)
|
||||
if o := outcomeOf(report, "postgres.server"); o.Action == "held" {
|
||||
t.Errorf("the carried store was held: %+v", o)
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("the carried store was held: %+v", state.Held)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakingAModuleReplacesWhatWasHeldAndTheOriginalSurvives(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
h, _ := state.HeldAt("hello-web.page")
|
||||
|
||||
report, state := applyAdopted(t, adopted(t, takenWeb, webResources(page)), state, m, dir)
|
||||
got, _ := os.ReadFile(page)
|
||||
if string(got) != "the mesh's page\n" {
|
||||
t.Fatalf("taking the module did not converge the file: %q", got)
|
||||
}
|
||||
if !m.removed("hello-web") || m.containers["hello-web"].id != "made-by-host" {
|
||||
t.Fatal("taking the module did not replace the found container")
|
||||
}
|
||||
if o := outcomeOf(report, "hello-web.page"); !strings.Contains(o.Detail, "original kept at "+h.Kept) {
|
||||
t.Errorf("the cutover does not say where the original is: %+v", o)
|
||||
}
|
||||
if len(state.Held) != 0 {
|
||||
t.Errorf("what was taken is still held: %+v", state.Held)
|
||||
}
|
||||
if _, recorded := state.Find("hello-web.page"); !recorded {
|
||||
t.Error("a taken file was not recorded as applied")
|
||||
}
|
||||
kept, err := os.ReadFile(h.Kept)
|
||||
if err != nil || string(kept) != "the predecessor's page\n" {
|
||||
t.Errorf("the kept original did not survive the cutover: %q %v", kept, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldFileIsNeverRemovedWhenItsModuleIsUnassigned(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
_, state := applyAdopted(t, adopted(t, untakenWeb, webResources(page)), store.State{}, m, dir)
|
||||
|
||||
other := filepath.Join(dir, "other")
|
||||
_, state = applyAdopted(t, adopted(t, `{"taken":[]}`,
|
||||
`{"id":"x.other","type":"file","path":"`+other+`","content":"x"}`), state, m, dir)
|
||||
if got, _ := os.ReadFile(page); string(got) != "the predecessor's page\n" {
|
||||
t.Fatalf("a held file was touched when its module left: %q", got)
|
||||
}
|
||||
if m.removed("hello-web") {
|
||||
t.Fatal("a held container was removed when its module left")
|
||||
}
|
||||
if _, still := state.HeldAt("hello-web.page"); !still {
|
||||
t.Error("the hold was forgotten, so a return of the module would read the file as the mesh's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldFileRewrittenIsReportedAndNotReverted(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
d := adopted(t, untakenWeb, webResources(page))
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
|
||||
if err := os.WriteFile(page, []byte("the predecessor wrote again\n"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
report, state := applyAdopted(t, d, state, m, dir)
|
||||
if got, _ := os.ReadFile(page); string(got) != "the predecessor wrote again\n" {
|
||||
t.Fatalf("a held file was reverted: %q", got)
|
||||
}
|
||||
if h, _ := state.HeldAt("hello-web.page"); h.Changed != "rewritten" || h.ChangedAt.IsZero() {
|
||||
t.Errorf("a rewrite was not recorded: %+v", h)
|
||||
}
|
||||
if o := outcomeOf(report, "hello-web.page"); !strings.Contains(o.Detail, "rewritten") {
|
||||
t.Errorf("a rewrite was not reported: %+v", o)
|
||||
}
|
||||
h, _ := state.HeldAt("hello-web.page")
|
||||
if kept, _ := os.ReadFile(h.Kept); string(kept) != "the predecessor's page\n" {
|
||||
t.Errorf("the kept original was overwritten by a later write: %q", kept)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldContainerStoppedOrReplacedIsReportedAndNotRestarted(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
change func(*machine)
|
||||
want string
|
||||
}{
|
||||
{func(m *machine) { m.containers["hello-web"].running = false }, "stopped"},
|
||||
{func(m *machine) { m.containers["hello-web"].id = "another" }, "replaced"},
|
||||
{func(m *machine) { delete(m.containers, "hello-web") }, "gone"},
|
||||
} {
|
||||
dir, page, m := predecessor(t)
|
||||
d := adopted(t, untakenWeb, webResources(page))
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
c.change(m)
|
||||
m.asked = nil
|
||||
_, state = applyAdopted(t, d, state, m, dir)
|
||||
if h, _ := state.HeldAt("hello-web.server"); h.Changed != c.want {
|
||||
t.Errorf("%s: recorded as %q", c.want, h.Changed)
|
||||
}
|
||||
for _, a := range m.asked {
|
||||
if strings.HasPrefix(a, "docker run") || strings.HasPrefix(a, "docker rm") ||
|
||||
strings.HasPrefix(a, "docker start") {
|
||||
t.Errorf("%s: the held container was acted on: %s", c.want, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldFileThatVanishesIsNotCreated(t *testing.T) {
|
||||
dir, page, m := predecessor(t)
|
||||
d := adopted(t, untakenWeb, webResources(page))
|
||||
_, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
if err := os.Remove(page); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, state = applyAdopted(t, d, state, m, dir)
|
||||
if _, err := os.Stat(page); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("a held file that vanished was created before its module was taken")
|
||||
}
|
||||
if h, _ := state.HeldAt("hello-web.page"); h.Changed != "gone" {
|
||||
t.Errorf("a vanished held file was not reported gone: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConvergedNodeStillReplacesWhatItFinds(t *testing.T) {
|
||||
// No adoption, no holds: byte for byte what a converged node did before ADR 0100.
|
||||
dir, page, m := predecessor(t)
|
||||
d := parse(t, `{"declaration":1,"resources":[`+webResources(page)+`]}`)
|
||||
report, state := applyAdopted(t, d, store.State{}, m, dir)
|
||||
if got, _ := os.ReadFile(page); string(got) != "the mesh's page\n" {
|
||||
t.Errorf("a converged node kept a found file: %q", got)
|
||||
}
|
||||
if !m.removed("hello-web") {
|
||||
t.Error("a converged node kept a found container")
|
||||
}
|
||||
if len(state.Held) != 0 || outcomeOf(report, "hello-web.page").Action == "held" {
|
||||
t.Errorf("a converged node held something: %+v", state.Held)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "kept")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Error("a converged node kept originals")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user