Complete the host's vocabulary: package, container, action
The three shapes the substrate bootstrap needs and the host did not have. Until now tier 1 could not be raised at all -- step 0 is a package, step 1 a container, steps 2 and 3 actions -- so every line of the tier 1 and 2 designs was unbuildable. package -- present, never upgraded, never uninstalled. Removal is "forgotten", not "removed": the host cannot know what else needs the package, uninstalling a container runtime because a declaration changed would stop every container on the node, and the machine may have had it before the mesh saw it. Reporting it removed would claim an effect the host declined to have. container -- identified by a label carrying a digest of the declaration that made it. Comparing every field the runtime reports cannot be done reliably: a runtime normalises, defaults and reorders what it is given, and that is indistinguishable from real drift. There is no in-place update; a container's configuration is fixed at creation, so any change is a replacement, and saying so beats a partial update that leaves the running thing half-declared. This is the one shape the host removes, because it is the one the host created. action -- bundle-only, per ADR 0047. Verify is mandatory and does double duty: it is the idempotency check as well as the read-back. The host does not know what a database is, so "is it already there" is a question only the declaration can ask. `in` runs the action inside a named container, which steps 2 and 3 need. Parse now refuses actions; ParseTrusted permits them. The safe path is the default and the permissive one has to be named. The bundle and a local file handed to a root process use ParseTrusted; the link will use Parse. Also replaced the per-type "fields this type ignores" check with a field-set diff stated as what each type USES. The negative form needs every type revisited whenever a field is added, and the one nobody revisits silently accepts a field it will never read. Images must be pinned by digest (ADR 0046). A bundle naming a tag pins nothing. Verified against a real machine, not only fakes: an action ran and was idempotent on the second apply; an action that exits zero and satisfies nothing fails the apply; a real container was created, labelled, replaced when its declaration changed, exec'd into, and removed; a real package query round- tripped. Each new test was also confirmed to fail on an injected fault -- five injections, each breaking exactly its own test. One existing test changed: a vanished unit is now reported "forgotten" rather than "removed", which is what actually happened.
This commit is contained in:
@@ -156,3 +156,103 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
||||
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
||||
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
||||
}
|
||||
|
||||
// --- the vocabulary the substrate bootstrap needs (novox/hq 07-the-substrate.md) ---
|
||||
|
||||
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
||||
// novox/hq ADR 0047. The link may push declarations of known shape and never a command to
|
||||
// run. This is the boundary the whole security argument rests on, so it is asserted
|
||||
// directly rather than inferred from the type list.
|
||||
raw := []byte(`{"declaration":1,"resources":[
|
||||
{"id":"schema","type":"action","command":["psql","-f","x.sql"],"verify":["psql","-c","select 1"]}
|
||||
]}`)
|
||||
|
||||
if _, err := Parse(raw); err == nil {
|
||||
t.Fatal("an action arriving over the link was accepted")
|
||||
} else if !strings.Contains(err.Error(), "the link may not carry one") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
|
||||
// And the same bytes from the bundle are fine — the asymmetry IS the decision.
|
||||
if _, err := ParseTrusted(raw); err != nil {
|
||||
t.Errorf("the bundle may carry an action, and this one was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
||||
// An action that runs and reports success without reading anything back is the fault this
|
||||
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
||||
// cannot be applied twice safely either.
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"schema","type":"action","command":["psql","-f","x.sql"]}
|
||||
]}`))
|
||||
if err == nil {
|
||||
t.Fatal("an action with no verify was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "needs a verify") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
||||
// novox/hq ADR 0046: reproducibility comes from pinning the identity of a thing. A bundle
|
||||
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
||||
for _, image := range []string{
|
||||
"postgres:17",
|
||||
"postgres",
|
||||
"postgres@sha256:short",
|
||||
"@sha256:0000000000000000000000000000000000000000000000000000000000000000",
|
||||
} {
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"` + image + `"}
|
||||
]}`))
|
||||
if err == nil {
|
||||
t.Errorf("image %q was accepted and is not pinned", image)
|
||||
}
|
||||
}
|
||||
|
||||
good := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"` + good + `"}
|
||||
]}`)); err != nil {
|
||||
t.Errorf("a properly pinned image was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
||||
// The field-set check must cover the types added last, not only the three it was written
|
||||
// for. A package that carries a `content` is a control plane believing it asked for
|
||||
// something that will never happen.
|
||||
for _, body := range []string{
|
||||
`{"id":"p","type":"package","package":"docker","content":"x"}`,
|
||||
`{"id":"p","type":"package","package":"docker","image":"x"}`,
|
||||
`{"id":"c","type":"container","name":"n","image":"i@sha256:` + strings.Repeat("a", 64) + `","unit":"x.service"}`,
|
||||
`{"id":"a","type":"action","command":["x"],"verify":["y"],"path":"/tmp/x"}`,
|
||||
} {
|
||||
_, err := ParseTrusted([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
||||
if err == nil {
|
||||
t.Errorf("a resource carrying a field its type does not use was accepted: %s", body)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Refused rather than ignored") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheVocabularyIsTheSixShapesTheBootstrapNeeds(t *testing.T) {
|
||||
// novox/hq 07-the-substrate.md names six shapes and the bootstrap uses all of them.
|
||||
// Asserted so that removing one is a failing test rather than a discovery during a
|
||||
// first-node install.
|
||||
for _, want := range []Type{
|
||||
TypeDirectory, TypeFile, TypeService, TypePackage, TypeContainer, TypeAction,
|
||||
} {
|
||||
if _, ok := uses[want]; !ok {
|
||||
t.Errorf("the host no longer speaks %q", want)
|
||||
}
|
||||
}
|
||||
if len(uses) != 6 {
|
||||
t.Errorf("the vocabulary is %d shapes; every addition widens what a compromised "+
|
||||
"control plane can express, so a change here is a decision: %s", len(uses), vocabulary())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user