A module names its own resources locally; a declaration names them under the module. restart-on and reload-on are rewritten for exactly that reason and while-stopped was not, so the store's step said it held "store" still while the machine's container is "distribution.store". The host refuses a declaration naming a container it does not have — whole. So novox took nothing at all, on every push, from 04:15 until this. The machine was never damaged: refusing whole is what kept it serving. Both sides' tests passed throughout. The controller's read manifests, the host's read hand-written declarations with bare ids, and nothing composed one and judged the result. That test now exists.
146 lines
5.3 KiB
Go
146 lines
5.3 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
|
|
//
|
|
// The host judges what it receives — whether each id is a container on that machine. What the
|
|
// definition is the only place to see is judged here, near whoever wrote it.
|
|
|
|
func aStoreManifest(step map[string]any) []byte {
|
|
m := map[string]any{
|
|
"module": "distribution", "version": "1",
|
|
"resources": []any{
|
|
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64)},
|
|
step,
|
|
},
|
|
}
|
|
raw, _ := json.Marshal(m)
|
|
return raw
|
|
}
|
|
|
|
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
|
|
raw := aStoreManifest(map[string]any{
|
|
"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
|
|
})
|
|
if _, err := ParseManifest(raw); err != nil {
|
|
t.Fatalf("a step holding its own module's container still was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
step map[string]any
|
|
says string
|
|
}{
|
|
{
|
|
"on a step with no schedule",
|
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"while-stopped": []any{"store"}},
|
|
"gates what is declared after it",
|
|
},
|
|
{
|
|
"on a run-once step, which already has order",
|
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"run-once": true, "while-stopped": []any{"store"}},
|
|
"A maintenance window is for a recurring step",
|
|
},
|
|
{
|
|
"naming a container this module does not declare",
|
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
|
|
"could quiesce a neighbour could stop the mesh",
|
|
},
|
|
{
|
|
"naming itself",
|
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
|
|
"naming itself",
|
|
},
|
|
{
|
|
"written as something that is not a list",
|
|
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"schedule": "30 3 * * *", "while-stopped": "store"},
|
|
"a list of this module's container ids",
|
|
},
|
|
} {
|
|
_, err := ParseManifest(aStoreManifest(c.step))
|
|
if err == nil {
|
|
t.Errorf("%s was accepted", c.name)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A composed declaration names the step's held containers the way the machine knows them.
|
|
//
|
|
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
|
|
// ["store"]`, because a module names its own resources locally; the declaration a machine
|
|
// receives calls that container `distribution.store`, because every resource is composed under
|
|
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
|
|
// `while-stopped` was not — so the host found no container by that id and refused the whole
|
|
// declaration, every push, until it was fixed.
|
|
//
|
|
// It passed every test on both sides: the controller's tests read manifests, the host's read
|
|
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
|
|
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
|
|
store := Manifest{
|
|
Module: "distribution", Version: "1",
|
|
Provides: FromAnywhere("artifact-store"),
|
|
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
|
|
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
|
|
Resources: []map[string]any{
|
|
{"id": "store", "type": "container", "name": "mesh-registry",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
|
|
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
|
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
|
|
},
|
|
}
|
|
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := r.Declaration(Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
collect := fileNamed(out, "distribution.collect")
|
|
if collect == nil {
|
|
for _, res := range out {
|
|
if res["id"] == "distribution.collect" {
|
|
collect = res
|
|
}
|
|
}
|
|
}
|
|
if collect == nil {
|
|
t.Fatalf("the step was not composed at all: %v", out)
|
|
}
|
|
held, _ := collect[WhileStopped].([]any)
|
|
if len(held) != 1 {
|
|
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
|
|
}
|
|
if got := fmt.Sprint(held[0]); got != "distribution.store" {
|
|
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
|
|
"called %q — the host refuses a declaration naming a container it does not have, "+
|
|
"whole, so the machine would take nothing at all", got, "distribution.store")
|
|
}
|
|
}
|