Files
mesh-controller/internal/inventory/forget_test.go
T
jschoubben 1469f5ff82 A module's own secret rotates when its definition says the module reads it at start (hq 180)
`secret rotate <node> <module> <name>` makes the secret anew the way the first mint did, seals it
to the machine and the operator, and sends the machine, so the module starts again on the new value
— said in the log with who asked and when, never the value. Only for a secret whose definition says
`"taken": "at-start"`: an own secret is now a path, or {path, taken}, and a definition that says
nothing of how a secret is taken is refused with the word to write, because a credential rotated
under software that never reads it again is worse than one left alone (issue 179). `applied` is
refused by name until the staged form ADR 0114 decided is built; a value given to the mesh is
refused as ADR 0113 says. `rotate` is a verb on the controller's seat with two shapes — a pair
credential by provision, an own secret by machine, module and name — so the console can ask.
Registered manifests keep their bytes: a path alone is written back as a path.
2026-10-01 11:41:49 +02:00

234 lines
8.2 KiB
Go

package inventory
import (
"errors"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// What removing a module takes with it, and what re-registering one does not.
//
// Both were reported as one fault — "operator settings do not persist, because re-registering a
// module cascade-deletes them". Only half of it was true, and it was the other half.
// **Registering a module again does not touch what the mesh holds for it.** The upsert is on the
// name, so a manifest changing — a new version, a new requirement, a new resource — leaves the
// settings, the secrets and the ports exactly where they were.
//
// This is here because it was believed not to be. A wrong belief about which command destroys data
// is expensive in both directions: it sends people looking for a fault that is not there, and it
// leaves the command that really does destroy it unexamined.
func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
m := catalogue.Manifest{Module: "step-ca", Version: "1",
Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
t.Fatal(err)
}
if _, err := inv.PortFor(ctx, "anchor", "step-ca", 9000, false); err != nil {
t.Fatal(err)
}
// Re-registered at a new version, which is exactly what somebody bumping one does.
m.Version = "2"
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
t.Fatal(err)
}
layers, err := inv.SettingsFor(ctx, "anchor", "step-ca")
if err != nil {
t.Fatal(err)
}
if len(layers) != 2 {
t.Fatalf("re-registering lost a settings layer: %+v", layers)
}
held, err := inv.HeldFor(ctx, "step-ca")
if err != nil {
t.Fatal(err)
}
if !held.Mesh || len(held.Nodes) != 1 || len(held.Secrets) != 1 || len(held.Ports) != 1 {
t.Fatalf("re-registering lost something the mesh held: %+v", held)
}
// And the new manifest is what resolution sees, which is the point of re-registering at all.
shelf, err := inv.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if shelf["step-ca"].Version != "2" {
t.Fatalf("the new manifest did not replace the old: %+v", shelf["step-ca"])
}
if len(shelf["step-ca"].Provides) != 1 || shelf["step-ca"].Provides[0].Name != "acme-ca" {
// A version bump was reported as un-providing a provision. It does not.
t.Fatalf("a version bump changed what the module provides: %+v", shelf["step-ca"].Provides)
}
}
// **Forgetting a module refuses while the mesh still holds things for it, and says what they are.**
//
// The settings, the module's own secrets and the ports the mesh chose all name the module by a
// foreign key that cascades. So the removal took them, silently, and reported "forgotten" — an
// action succeeding into a state its own verify would reject (novox/hq 04-ISSUES/017). A sealed
// secret is not recoverable afterwards: the mesh discarded the plaintext when it made it.
func TestForgettingAModuleRefusesRatherThanDiscardingWhatTheMeshHolds(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"port": 9000}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
t.Fatal(err)
}
if _, err := inv.PortFor(ctx, "anchor", "step-ca", 9000, false); err != nil {
t.Fatal(err)
}
err = inv.ForgetModule(ctx, "step-ca")
if !errors.Is(err, ErrStillHolds) {
t.Fatalf("forgetting discarded what the mesh held, or refused for another reason: %v", err)
}
// It names them one at a time. "3 things" says there is something to lose and not whether it
// can be afforded; the sealed secret is the one that cannot be made again, and it is named.
for _, want := range []string{"settings, on anchor", "password on anchor",
"the mesh cannot make it again", "the port 9000 on anchor", "--and-what-it-holds"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%s", want, err)
}
}
// And nothing went. A refusal that half-happened would be worse than the cascade.
shelf, err := inv.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if _, still := shelf["step-ca"]; !still {
t.Fatal("the module was removed by a command that refused")
}
layers, err := inv.SettingsFor(ctx, "anchor", "step-ca")
if err != nil {
t.Fatal(err)
}
if len(layers) != 1 {
t.Fatalf("a refusal took the settings anyway: %+v", layers)
}
}
// Having been told, it goes — and what went is reported, because this is the only record that any
// of it existed.
func TestDiscardingAModuleSaysWhatWentWithIt(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
node, err := inv.AddNode(ctx, "anchor")
if err != nil {
t.Fatal(err)
}
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "", "step-ca", map[string]any{"issuer": "the mesh"}); err != nil {
t.Fatal(err)
}
if err := inv.AcceptSecretForModule(ctx, "anchor", "step-ca", "password", "sealed"); err != nil {
t.Fatal(err)
}
held, err := inv.DiscardModule(ctx, "step-ca")
if err != nil {
t.Fatal(err)
}
if !held.Mesh || len(held.Secrets) != 1 {
t.Fatalf("what went was not reported: %+v", held)
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
if _, still := shelf["step-ca"]; still {
t.Fatal("the module is still there")
}
}
// A module the mesh holds nothing for is forgotten without ceremony. The refusal is about loss,
// not about the command.
func TestForgettingAModuleTheMeshHoldsNothingForJustWorks(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, manifest("plain", nil, nil), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.ForgetModule(ctx, "plain"); err != nil {
t.Fatalf("a module holding nothing was refused: %v", err)
}
}
// A module still on a machine refuses first, whatever it holds: that is not a loss an operator can
// consent to on the machine's behalf, and unassign is what "I do not want this" means.
func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T) {
inv := fresh(t)
ctx := t.Context()
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, withOwnSecret(manifest("step-ca", nil, nil), "password"), Source{}); err != nil {
t.Fatal(err)
}
if err := inv.SetSettings(ctx, "anchor", "step-ca", map[string]any{"a": 1}); err != nil {
t.Fatal(err)
}
if _, err := inv.Assign(ctx, "anchor", "step-ca"); err != nil {
t.Fatal(err)
}
for _, forget := range []func() error{
func() error { return inv.ForgetModule(ctx, "step-ca") },
func() error { _, err := inv.DiscardModule(ctx, "step-ca"); return err },
} {
if err := forget(); !errors.Is(err, ErrStillAssigned) {
t.Fatalf("an assigned module was not refused for being assigned: %v", err)
}
}
}
// withOwnSecret gives a fixture manifest an own secret, so a delivery to it is one the module
// declares (novox/hq 04-ISSUES/078).
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
return m
}