A one-shot service that finished is not stopped, and a container is what it reads

Two faults that both reported success while being wrong, found while proving
the firewall module actually delivers.

A unit whose job is to apply something and exit — load a rule set, set a
sysctl — is inactive the instant it succeeds. Reading that as stopped made it
permanently unsatisfiable: the host started it, it worked, the host read back
stopped and reported the machine as not doing what it was told, on every apply,
for ever, with the rules correctly in place the whole time. That is what the
firewall has been doing on every machine it was assigned to, and why the
four-machine bed was red.

And a container took its identity from its own fields, not from the files it
reads. A file written in an earlier apply — or before the container declared it
as a dependency — left a process holding a credential the mesh had already
replaced, with everything reporting success (novox/hq 04-ISSUES/045). What a
container reads is now part of what it is, so the comparison is a standing one
rather than a tripwire that fires during one apply and never again.
This commit is contained in:
2026-09-14 16:51:57 +02:00
parent 6205a93bfe
commit e7f94e0402
7 changed files with 219 additions and 21 deletions
+57 -6
View File
@@ -150,6 +150,20 @@ func Apply(
// restarting for it every time would make a steady machine restart its services for ever.
changed := map[string]bool{}
// **What each resource currently says, so a container can be identified by its inputs.**
//
// `changed` above is edge-triggered and only within one apply, which is right for "restart it
// because this just moved" and wrong for "is this container running the file that is there
// now". A file written in an earlier apply, or written before the container declared it as a
// dependency, leaves a container holding values nothing will ever re-read: it is up, the
// machine reports success, and what is inside is using a credential the mesh has replaced
// (novox/hq 04-ISSUES/045). Folding these into the container's spec makes the comparison a
// standing one instead.
declares := map[string]string{}
for _, resource := range d.Resources {
declares[resource.Identity()] = declaredDigest(resource)
}
// Everything is attempted, and every failure is reported.
//
// **It used to stop at the first one**, and that made one broken resource hold the whole
@@ -169,7 +183,7 @@ func Apply(
var failures []*Error
for _, resource := range d.Resources {
was, _ := known.Find(resource.Identity())
outcome, err := applyOne(ctx, sys, resource, run, changed, was, unseal)
outcome, err := applyOne(ctx, sys, resource, run, changed, declares, was, unseal)
if err != nil {
failed := &Error{Resource: resource.Identity(), Err: err, Done: report}
failures = append(failures, failed)
@@ -239,7 +253,8 @@ func Apply(
type Unseal func(sealed string) ([]byte, error)
func applyOne(ctx context.Context, sys system.System, r declaration.Resource, run Runner,
changed map[string]bool, previous store.Applied, unseal Unseal) (Outcome, error) {
changed map[string]bool, declares map[string]string, previous store.Applied,
unseal Unseal) (Outcome, error) {
switch res := r.(type) {
case *declaration.Directory:
return applyDirectory(res)
@@ -250,7 +265,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
case *declaration.Package:
return applyPackage(ctx, sys, res, run)
case *declaration.Container:
return applyContainer(ctx, res, run, changed, previous)
return applyContainer(ctx, res, run, changed, declares, previous)
case *declaration.User:
return applyUser(ctx, sys, res, run)
case *declaration.Archive:
@@ -853,7 +868,7 @@ const (
// containerSpec is the identity of a declared container: everything that, if changed, means
// the running container is no longer what was asked for.
func containerSpec(r *declaration.Container) string {
func containerSpec(r *declaration.Container, declares map[string]string) string {
keys := make([]string, 0, len(r.Env))
for k := range r.Env {
keys = append(keys, k)
@@ -880,6 +895,19 @@ func containerSpec(r *declaration.Container) string {
if r.Schedule != "" {
b.WriteString("schedule " + r.Schedule + "\n")
}
// **What this container reads is part of what it is.**
//
// A container takes its environment and its mounted files once, at start, and never looks
// again. Comparing only the fields above meant a container whose configuration had since been
// rewritten compared equal and was left alone — running values the machine no longer holds,
// while every check reported success (novox/hq 04-ISSUES/045). Naming what it depends on here
// makes that comparison standing rather than a tripwire that fires during one apply and never
// again. Sorted, so the digest does not move for a reordering nobody made.
depends := append([]string{}, r.RestartOn...)
sort.Strings(depends)
for _, id := range depends {
b.WriteString("reads " + id + "=" + declares[id] + "\n")
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(b.String())))
}
@@ -944,9 +972,10 @@ func applyNetwork(ctx context.Context, r *declaration.Network, run Runner) (Outc
return out, nil
}
func applyContainer(ctx context.Context, r *declaration.Container, run Runner, changed map[string]bool, previous store.Applied) (Outcome, error) {
func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
changed map[string]bool, declares map[string]string, previous store.Applied) (Outcome, error) {
out := begin(r)
want := containerSpec(r)
want := containerSpec(r, declares)
cri, err := containerRuntime(ctx, run)
if err != nil {
@@ -1346,3 +1375,25 @@ func holds(resource declaration.Resource) []int {
}
return out
}
// declaredDigest is what a resource currently says it should be.
//
// **Content, not identity.** It exists so a container can be told apart by what it reads: a file
// whose text changed must produce a different digest, or the container mounting it compares equal
// to one started against the old text. Only the shapes something can read are digested; for
// everything else the identity is enough, because nothing mounts a package.
func declaredDigest(r declaration.Resource) string {
var material string
switch res := r.(type) {
case *declaration.File:
// The content as declared, before any sealing is opened — two machines are given different
// ciphertext for the same secret, and digesting that would make an unchanged file look
// changed on every apply and restart the container reading it for ever.
material = res.Content
case *declaration.Directory:
material = res.Path + "\n" + res.Mode
default:
return ""
}
return fmt.Sprintf("%x", sha256.Sum256([]byte(material)))
}