Split the unit level from the integration level in cmd, builder, link, catalogue, inventory; cheap fixtures for the archive-entry bound, the registry waits and the catalogue copy (issue 895)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, context.Context) {
|
||||
t.Helper()
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
for _, m := range manifests {
|
||||
if err := inv.RegisterModule(ctx, m, Source{Repository: "/r"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return inv, ctx
|
||||
}
|
||||
|
||||
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
|
||||
// It declares where its account is delivered: a module with no own secret named broker can never
|
||||
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
|
||||
shop := catalogue.Manifest{
|
||||
Module: "shop", Version: "1",
|
||||
Emits: []string{"order.placed"}, Tools: []string{"price"},
|
||||
Uses: []string{"telegram-sender"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
||||
}
|
||||
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
|
||||
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
|
||||
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range []string{"shop", "quiet"} {
|
||||
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var on []broker.Declared
|
||||
for _, d := range records.Assigned["one"] {
|
||||
if d.Module == "shop" {
|
||||
on = append(on, d)
|
||||
}
|
||||
}
|
||||
if len(on) != 1 || on[0].NoAccount {
|
||||
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
|
||||
}
|
||||
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
|
||||
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
|
||||
"seat it was assigned to send to", on[0].Uses)
|
||||
}
|
||||
if len(on[0].Serves) != 1 || on[0].Serves[0] != "price" {
|
||||
t.Fatalf("its tools read as %v, and a module that cannot subscribe its own tool subject "+
|
||||
"serves nothing", on[0].Serves)
|
||||
}
|
||||
|
||||
// And it derives into a user the server would accept.
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, u := range users {
|
||||
if u.Username() != "one.shop" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
perms, err := broker.PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its tools are every one under its own name — the list in the manifest is a person's
|
||||
// vocabulary for asking, not the module's permission to answer.
|
||||
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
|
||||
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
|
||||
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
|
||||
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no user was derived for the assigned module")
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Username() == "one.quiet" {
|
||||
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyAMachineWithALiveTokenHasAnEnrolmentUser(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
for _, name := range []string{"live", "expired", "none"} {
|
||||
if _, err := inv.AddNode(ctx, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.IssueToken(ctx, "live", time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Briefly, then waited out: a token with no lifetime is refused at issue, which is the right
|
||||
// refusal and leaves this as the way to have an expired one.
|
||||
if _, err := inv.IssueToken(ctx, "expired", 10*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(records.Enrolling, ",") != "live" {
|
||||
t.Fatalf("machines with a live token read as %v", records.Enrolling)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, has := users[name]
|
||||
if !has {
|
||||
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
||||
"machine would be refused before the mesh heard of it: %v", name, users)
|
||||
}
|
||||
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
||||
t.Errorf("the account is %+v, not this node's enrolment", user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
||||
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
||||
"token would be refused by the server")
|
||||
}
|
||||
|
||||
// And the composition contains it, which is the thing the server reads.
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
derived, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for n, u := range users {
|
||||
hashes[n] = u.PasswordHash
|
||||
}
|
||||
_, missing := broker.WithPasswords(derived, hashes)
|
||||
for _, m := range missing {
|
||||
if m == name {
|
||||
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) {
|
||||
bus := catalogue.Manifest{
|
||||
Module: "nats", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}},
|
||||
Tools: []string{"nats_streams"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
||||
}
|
||||
shop := catalogue.Manifest{Module: "shop", Version: "1", Emits: []string{"order.placed"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}
|
||||
inv, ctx := aMeshWith(t, bus, shop)
|
||||
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range []string{"nats", "shop"} {
|
||||
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
perms, err := broker.PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshots := granted(perms.Publish, "$JS.API.STREAM.SNAPSHOT.*")
|
||||
switch u.Username() {
|
||||
case "one.nats":
|
||||
seen[u.Username()] = true
|
||||
if !snapshots || len(perms.Publish) != len(broker.BusSnapshotGrants.Publish) {
|
||||
t.Fatalf("the bus's own user is granted %v, not the snapshot API alone", perms.Publish)
|
||||
}
|
||||
case "one.shop":
|
||||
seen[u.Username()] = true
|
||||
if snapshots {
|
||||
t.Fatal("a module that is not the bus may snapshot it")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen["one.nats"] || !seen["one.shop"] {
|
||||
t.Fatalf("users derived: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
|
||||
bar := catalogue.Manifest{Module: "a-bar", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}}
|
||||
power := catalogue.Manifest{Module: "power", Version: "1",
|
||||
State: []catalogue.StateDeclaration{{Name: "draw"}},
|
||||
Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock,
|
||||
Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state",
|
||||
"options": map[string]any{"state": "power.draw"}}}}}
|
||||
inv, ctx := aMeshWith(t, bar, power)
|
||||
for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} {
|
||||
if _, err := inv.AddNode(ctx, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range modules {
|
||||
if _, err := inv.Assign(ctx, node, module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := func(node, module string) []broker.KeyedRead {
|
||||
for _, d := range records.Assigned[node] {
|
||||
if d.Module == module {
|
||||
if len(d.Reads) != 0 {
|
||||
t.Fatalf("%s on %s reads whole buckets: %v", module, node, d.Reads)
|
||||
}
|
||||
return d.KeyedReads
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is not on %s", module, node)
|
||||
return nil
|
||||
}
|
||||
// The laptop's own key, and no other (novox/hq ADR 0260).
|
||||
if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != (broker.KeyedRead{Read: "power.draw", Key: "laptop"}) {
|
||||
t.Fatalf("the laptop's bar reads %v", got)
|
||||
}
|
||||
if got := reads("desk", "a-bar"); len(got) != 0 {
|
||||
t.Fatalf("a bar with no power beside it reads %v", got)
|
||||
}
|
||||
if got := reads("laptop", "power"); len(got) != 0 {
|
||||
t.Fatalf("power reads %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProvisionerClientsMachineHasAnAccountOfItsOwn(t *testing.T) {
|
||||
provisioner := catalogue.Manifest{
|
||||
Module: "mesh-provisioner", Version: "1",
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode,
|
||||
Serves: []catalogue.Verb{{Name: "get"}}}},
|
||||
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"},
|
||||
broker.ProvisionerSecret: {Path: "/run/provisioner-broker"}},
|
||||
}
|
||||
unread := provisioner
|
||||
unread.Module = "provisioner-unread"
|
||||
unread.DefinesSeats = nil
|
||||
unread.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}
|
||||
inv, ctx := aMeshWith(t, provisioner, unread)
|
||||
for node, module := range map[string]string{"one": "mesh-provisioner", "two": "provisioner-unread"} {
|
||||
if _, err := inv.AddNode(ctx, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, node, module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var clients []string
|
||||
for _, u := range users {
|
||||
if u.Kind == broker.KindProvisioner {
|
||||
clients = append(clients, u.Username()+" for "+u.Module)
|
||||
}
|
||||
}
|
||||
if strings.Join(clients, ",") != "provisioner.one for mesh-provisioner" {
|
||||
t.Fatalf("the provisioner clients' accounts are %v, want one for the machine whose module reads it", clients)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/broker"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// Reading the bus's user list out of the mesh's records, against a real store.
|
||||
@@ -17,18 +14,6 @@ import (
|
||||
// the server reads whatever file it is given, and a module whose user is absent fails on its first
|
||||
// publish with an authorisation error that says nothing about a missing assignment.
|
||||
|
||||
func aMeshWith(t *testing.T, manifests ...catalogue.Manifest) (*Inventory, context.Context) {
|
||||
t.Helper()
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
for _, m := range manifests {
|
||||
if err := inv.RegisterModule(ctx, m, Source{Repository: "/r"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return inv, ctx
|
||||
}
|
||||
|
||||
func theSeatDeclarer() catalogue.Manifest {
|
||||
return catalogue.Manifest{
|
||||
Module: "telegram", Version: "1",
|
||||
@@ -41,108 +26,9 @@ func theSeatDeclarer() catalogue.Manifest {
|
||||
|
||||
// A module assigned to a machine becomes a user with the authority its manifest declared — and the
|
||||
// protocol of a seat declared by a *different* module, which is the whole reason a seat exists.
|
||||
func TestAnAssignedModuleBecomesAUserWithWhatItDeclared(t *testing.T) {
|
||||
// It declares where its account is delivered: a module with no own secret named broker can never
|
||||
// be issued one, and is no user at all (novox/hq issue 195) — the case asserted below.
|
||||
shop := catalogue.Manifest{
|
||||
Module: "shop", Version: "1",
|
||||
Emits: []string{"order.placed"}, Tools: []string{"price"},
|
||||
Uses: []string{"telegram-sender"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
||||
}
|
||||
quiet := catalogue.Manifest{Module: "quiet", Version: "1", Emits: []string{"thing.happened"}}
|
||||
inv, ctx := aMeshWith(t, theSeatDeclarer(), shop, quiet)
|
||||
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range []string{"shop", "quiet"} {
|
||||
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var on []broker.Declared
|
||||
for _, d := range records.Assigned["one"] {
|
||||
if d.Module == "shop" {
|
||||
on = append(on, d)
|
||||
}
|
||||
}
|
||||
if len(on) != 1 || on[0].NoAccount {
|
||||
t.Fatalf("the machine's modules read as %+v", records.Assigned["one"])
|
||||
}
|
||||
if len(on[0].Uses) != 1 || on[0].Uses[0].Accepts[0] != "send" {
|
||||
t.Fatalf("the seat it uses carries no protocol: %+v — so it would be granted nothing on a "+
|
||||
"seat it was assigned to send to", on[0].Uses)
|
||||
}
|
||||
if len(on[0].Serves) != 1 || on[0].Serves[0] != "price" {
|
||||
t.Fatalf("its tools read as %v, and a module that cannot subscribe its own tool subject "+
|
||||
"serves nothing", on[0].Serves)
|
||||
}
|
||||
|
||||
// And it derives into a user the server would accept.
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, u := range users {
|
||||
if u.Username() != "one.shop" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
perms, err := broker.PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Its tools are every one under its own name — the list in the manifest is a person's
|
||||
// vocabulary for asking, not the module's permission to answer.
|
||||
if !granted(perms.Publish, "mesh.mod.shop.event.order.placed") ||
|
||||
!granted(perms.Publish, "mesh.seat.telegram-sender.accept.send") ||
|
||||
!granted(perms.Subscribe, "mesh.mod.shop.tool.>") {
|
||||
t.Fatalf("one.shop's authority is not what it declared: %+v", perms)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no user was derived for the assigned module")
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Username() == "one.quiet" {
|
||||
t.Fatal("a module with nowhere to read an account was made a user (novox/hq issue 195)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A machine holding a live token gets an enrolment user; one whose token is spent or expired does
|
||||
// not. **An enrolment user outliving its token is a right to join that nobody issued.**
|
||||
func TestOnlyAMachineWithALiveTokenHasAnEnrolmentUser(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
for _, name := range []string{"live", "expired", "none"} {
|
||||
if _, err := inv.AddNode(ctx, name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := inv.IssueToken(ctx, "live", time.Hour); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Briefly, then waited out: a token with no lifetime is refused at issue, which is the right
|
||||
// refusal and leaves this as the way to have an expired one.
|
||||
if _, err := inv.IssueToken(ctx, "expired", 10*time.Millisecond); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(records.Enrolling, ",") != "live" {
|
||||
t.Fatalf("machines with a live token read as %v", records.Enrolling)
|
||||
}
|
||||
}
|
||||
|
||||
// A module assigned and absent from the catalogue is refused rather than composed with no authority.
|
||||
//
|
||||
@@ -193,199 +79,14 @@ func granted(all []string, one string) bool {
|
||||
// exactly that string. So this checks the two halves that make the account usable — that a row
|
||||
// exists under the name the composer asks for, and that the secret handed out is what that row
|
||||
// accepts.
|
||||
func TestIssuingATokenRecordsTheAccountItIsThePasswordOf(t *testing.T) {
|
||||
inv, ctx := aMeshWith(t)
|
||||
if _, err := inv.AddNode(ctx, "joiner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
issued, err := inv.IssueToken(ctx, "joiner", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
name := broker.Principal{Kind: broker.KindEnrolment, Node: "joiner"}.Username()
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, has := users[name]
|
||||
if !has {
|
||||
t.Fatalf("no bus account for %q; the composer would leave the enrolment out and the "+
|
||||
"machine would be refused before the mesh heard of it: %v", name, users)
|
||||
}
|
||||
if user.Kind != BusEnrolment || user.Node != "joiner" {
|
||||
t.Errorf("the account is %+v, not this node's enrolment", user)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(issued.Secret)); err != nil {
|
||||
t.Error("the account does not accept the secret the token carries, so presenting the " +
|
||||
"token would be refused by the server")
|
||||
}
|
||||
|
||||
// And the composition contains it, which is the thing the server reads.
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
derived, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hashes := map[string]string{}
|
||||
for n, u := range users {
|
||||
hashes[n] = u.PasswordHash
|
||||
}
|
||||
_, missing := broker.WithPasswords(derived, hashes)
|
||||
for _, m := range missing {
|
||||
if m == name {
|
||||
t.Fatal("the enrolment user is composed without a password, which is a user nobody can be")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The module holding mesh-broker is the bus, and its user is the bus's own: granted the snapshot API
|
||||
// for the night's backup and nothing else, whatever tools it declares (novox/hq ADR 0235). Another
|
||||
// module on the same machine is granted none of it.
|
||||
func TestTheBussOwnModuleBecomesTheSnapshotUser(t *testing.T) {
|
||||
bus := catalogue.Manifest{
|
||||
Module: "nats", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BrokerSeat, Scope: catalogue.ScopeMesh}},
|
||||
Tools: []string{"nats_streams"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}},
|
||||
}
|
||||
shop := catalogue.Manifest{Module: "shop", Version: "1", Emits: []string{"order.placed"},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}}
|
||||
inv, ctx := aMeshWith(t, bus, shop)
|
||||
if _, err := inv.AddNode(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range []string{"nats", "shop"} {
|
||||
if _, err := inv.Assign(ctx, "one", module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, u := range users {
|
||||
perms, err := broker.PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snapshots := granted(perms.Publish, "$JS.API.STREAM.SNAPSHOT.*")
|
||||
switch u.Username() {
|
||||
case "one.nats":
|
||||
seen[u.Username()] = true
|
||||
if !snapshots || len(perms.Publish) != len(broker.BusSnapshotGrants.Publish) {
|
||||
t.Fatalf("the bus's own user is granted %v, not the snapshot API alone", perms.Publish)
|
||||
}
|
||||
case "one.shop":
|
||||
seen[u.Username()] = true
|
||||
if snapshots {
|
||||
t.Fatal("a module that is not the bus may snapshot it")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seen["one.nats"] || !seen["one.shop"] {
|
||||
t.Fatalf("users derived: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// The bar's holder reads the state the modules beside it offer in its blocks (novox/hq ADR 0255),
|
||||
// without naming them: granted on the machine where both are, and nowhere else.
|
||||
func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) {
|
||||
bar := catalogue.Manifest{Module: "a-bar", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: catalogue.BarSeat, Scope: catalogue.ScopeNode}}}
|
||||
power := catalogue.Manifest{Module: "power", Version: "1",
|
||||
State: []catalogue.StateDeclaration{{Name: "draw"}},
|
||||
Contributions: []catalogue.SeatContribution{{Seat: catalogue.BarSeat, Kind: catalogue.BarKindBlock,
|
||||
Data: map[string]any{"bar": "bottom", "place": "status", "shows": "state",
|
||||
"options": map[string]any{"state": "power.draw"}}}}}
|
||||
inv, ctx := aMeshWith(t, bar, power)
|
||||
for node, modules := range map[string][]string{"laptop": {"a-bar", "power"}, "desk": {"a-bar"}} {
|
||||
if _, err := inv.AddNode(ctx, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, module := range modules {
|
||||
if _, err := inv.Assign(ctx, node, module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := func(node, module string) []broker.KeyedRead {
|
||||
for _, d := range records.Assigned[node] {
|
||||
if d.Module == module {
|
||||
if len(d.Reads) != 0 {
|
||||
t.Fatalf("%s on %s reads whole buckets: %v", module, node, d.Reads)
|
||||
}
|
||||
return d.KeyedReads
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s is not on %s", module, node)
|
||||
return nil
|
||||
}
|
||||
// The laptop's own key, and no other (novox/hq ADR 0260).
|
||||
if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != (broker.KeyedRead{Read: "power.draw", Key: "laptop"}) {
|
||||
t.Fatalf("the laptop's bar reads %v", got)
|
||||
}
|
||||
if got := reads("desk", "a-bar"); len(got) != 0 {
|
||||
t.Fatalf("a bar with no power beside it reads %v", got)
|
||||
}
|
||||
if got := reads("laptop", "power"); len(got) != 0 {
|
||||
t.Fatalf("power reads %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A module running the machine's provisioner client gives that machine a client account of its own, apart from the
|
||||
// module's and the tool runner's (novox/hq ADR 0349 clause 11, design 57 P3); one with nowhere to read the account
|
||||
// gives none.
|
||||
func TestTheProvisionerClientsMachineHasAnAccountOfItsOwn(t *testing.T) {
|
||||
provisioner := catalogue.Manifest{
|
||||
Module: "mesh-provisioner", Version: "1",
|
||||
DefinesSeats: []catalogue.SeatDeclaration{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode,
|
||||
Serves: []catalogue.Verb{{Name: "get"}}}},
|
||||
Claims: []catalogue.Claim{{Name: broker.NodeProvisionerSeat, Scope: catalogue.ScopeNode}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/run/broker"},
|
||||
broker.ProvisionerSecret: {Path: "/run/provisioner-broker"}},
|
||||
}
|
||||
unread := provisioner
|
||||
unread.Module = "provisioner-unread"
|
||||
unread.DefinesSeats = nil
|
||||
unread.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/run/broker"}}
|
||||
inv, ctx := aMeshWith(t, provisioner, unread)
|
||||
for node, module := range map[string]string{"one": "mesh-provisioner", "two": "provisioner-unread"} {
|
||||
if _, err := inv.AddNode(ctx, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.Assign(ctx, node, module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
records, err := inv.BusRecords(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := broker.Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var clients []string
|
||||
for _, u := range users {
|
||||
if u.Kind == broker.KindProvisioner {
|
||||
clients = append(clients, u.Username()+" for "+u.Module)
|
||||
}
|
||||
}
|
||||
if strings.Join(clients, ",") != "provisioner.one for mesh-provisioner" {
|
||||
t.Fatalf("the provisioner clients' accounts are %v, want one for the machine whose module reads it", clients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password == "" {
|
||||
t.Fatal("no password came back, so nothing can be sealed to the module")
|
||||
}
|
||||
|
||||
hash, known, err := inv.BusUserHash(ctx, "one.shop")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("the user was not recorded: %v %v", known, err)
|
||||
}
|
||||
if hash == password {
|
||||
t.Fatal("the store holds the password itself")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("minting twice produced the same password")
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("%d users after two mints for one name", len(users))
|
||||
}
|
||||
hash := users["one.shop"].PasswordHash
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
|
||||
t.Fatal("the kept hash is not the newest password's")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
|
||||
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, u := range []BusUser{
|
||||
{Username: "node.one", Kind: BusNode, Node: "one"},
|
||||
aBusUser("shop"),
|
||||
{Username: "node.two", Kind: BusNode, Node: "two"},
|
||||
{Username: "controller", Kind: BusController},
|
||||
} {
|
||||
if _, err := inv.MintBusPassword(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := users["node.one"]; still {
|
||||
t.Fatal("a forgotten node's host credential still works")
|
||||
}
|
||||
if _, still := users["one.shop"]; still {
|
||||
t.Fatal("a module on a forgotten node still has a credential")
|
||||
}
|
||||
// And nothing else went with it: the controller has no node, and another machine's user is
|
||||
// another machine's.
|
||||
for _, kept := range []string{"node.two", "controller"} {
|
||||
if _, ok := users[kept]; !ok {
|
||||
t.Fatalf("%s was removed with another node's users", kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
|
||||
t.Fatal("forgetting the bus users of no node was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
|
||||
"bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, known, err := inv.BusUserHash(ctx, "controller")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("the credential was not recorded: %v %v", known, err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil {
|
||||
t.Fatalf("what was recorded does not verify the credential given: %v", err)
|
||||
}
|
||||
|
||||
// Minted since, then seeded again — which is what a restart does. The rotation must stand, or
|
||||
// every restart would put the bootstrap password back over it.
|
||||
minted, err := inv.MintBusPassword(ctx, BusUser{Username: "controller", Kind: BusController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
|
||||
"bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _, err = inv.BusUserHash(ctx, "controller")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(minted)); err != nil {
|
||||
t.Fatal("a restart put the bootstrap credential back over a rotated one")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -17,147 +16,19 @@ func aBusUser(module string) BusUser {
|
||||
// The plaintext comes back once and the store keeps only a hash that verifies against it. **A
|
||||
// credential recoverable from the mesh's store is one whose blast radius is the store's**, so what
|
||||
// is asserted is that the password is not in there.
|
||||
func TestABusPasswordIsReturnedOnceAndOnlyItsHashIsKept(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
password, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if password == "" {
|
||||
t.Fatal("no password came back, so nothing can be sealed to the module")
|
||||
}
|
||||
|
||||
hash, known, err := inv.BusUserHash(ctx, "one.shop")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("the user was not recorded: %v %v", known, err)
|
||||
}
|
||||
if hash == password {
|
||||
t.Fatal("the store holds the password itself")
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||
t.Fatalf("the recorded hash does not verify the password it was made from: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Minting again replaces what was there rather than failing or adding a second row: that is a
|
||||
// rotation, and the old credential stops working at the next composition.
|
||||
func TestMintingAgainRotatesRatherThanAddsAUser(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
first, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := inv.MintBusPassword(ctx, aBusUser("shop"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first == second {
|
||||
t.Fatal("minting twice produced the same password")
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("%d users after two mints for one name", len(users))
|
||||
}
|
||||
hash := users["one.shop"].PasswordHash
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(second)); err != nil {
|
||||
t.Fatal("the kept hash is not the newest password's")
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(hash), []byte(first)) == nil {
|
||||
t.Fatal("the previous password still verifies, so a rotation revoked nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Forgetting a node takes every credential that belonged to it — its host's and every module
|
||||
// assigned to it. What a forgotten node leaves behind otherwise is a working set of credentials for
|
||||
// a machine the mesh no longer knows.
|
||||
func TestForgettingANodeTakesItsBusUsersWithIt(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, u := range []BusUser{
|
||||
{Username: "node.one", Kind: BusNode, Node: "one"},
|
||||
aBusUser("shop"),
|
||||
{Username: "node.two", Kind: BusNode, Node: "two"},
|
||||
{Username: "controller", Kind: BusController},
|
||||
} {
|
||||
if _, err := inv.MintBusPassword(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := inv.ForgetBusUsersOf(ctx, "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, err := inv.BusUsers(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, still := users["node.one"]; still {
|
||||
t.Fatal("a forgotten node's host credential still works")
|
||||
}
|
||||
if _, still := users["one.shop"]; still {
|
||||
t.Fatal("a module on a forgotten node still has a credential")
|
||||
}
|
||||
// And nothing else went with it: the controller has no node, and another machine's user is
|
||||
// another machine's.
|
||||
for _, kept := range []string{"node.two", "controller"} {
|
||||
if _, ok := users[kept]; !ok {
|
||||
t.Fatalf("%s was removed with another node's users", kept)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forgetting the users of no node would forget every user that has none — the controller and every
|
||||
// person — so it is refused rather than run.
|
||||
func TestForgettingTheUsersOfNoNodeIsRefused(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
if err := inv.ForgetBusUsersOf(context.Background(), ""); err == nil {
|
||||
t.Fatal("forgetting the bus users of no node was allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// The controller's own user is created by the installer, so the mesh has to be able to record a
|
||||
// credential it did not mint — or the first composition leaves the writer out of the file it writes.
|
||||
func TestACredentialTheMeshDidNotMintIsRecordedOnceAndNotOverwritten(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
|
||||
"bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, known, err := inv.BusUserHash(ctx, "controller")
|
||||
if err != nil || !known {
|
||||
t.Fatalf("the credential was not recorded: %v %v", known, err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("bootstrap")); err != nil {
|
||||
t.Fatalf("what was recorded does not verify the credential given: %v", err)
|
||||
}
|
||||
|
||||
// Minted since, then seeded again — which is what a restart does. The rotation must stand, or
|
||||
// every restart would put the bootstrap password back over it.
|
||||
minted, err := inv.MintBusPassword(ctx, BusUser{Username: "controller", Kind: BusController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SeedBusUser(ctx, BusUser{Username: "controller", Kind: BusController},
|
||||
"bootstrap"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, _, err = inv.BusUserHash(ctx, "controller")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(minted)); err != nil {
|
||||
t.Fatal("a restart put the bootstrap credential back over a rotated one")
|
||||
}
|
||||
}
|
||||
|
||||
// Outside a test a bus password is hashed at bcrypt's default cost: only ForTest lowers it (novox/hq issue 591).
|
||||
func TestABusPasswordIsHashedAtTheDefaultCostOutsideATest(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
|
||||
inv, node := aNodeWithModules(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
|
||||
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
|
||||
keep := func(busy []BusyWait) bool {
|
||||
t.Helper()
|
||||
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
|
||||
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return news
|
||||
}
|
||||
read := func() []BusyWait {
|
||||
t.Helper()
|
||||
reports, err := inv.LastReports(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range reports {
|
||||
if r.Node == node {
|
||||
return r.Busy
|
||||
}
|
||||
}
|
||||
t.Fatal("the machine's report is not read back")
|
||||
return nil
|
||||
}
|
||||
keep(nil)
|
||||
if got := read(); len(got) != 0 {
|
||||
t.Fatalf("a report with nothing busy reads back %+v", got)
|
||||
}
|
||||
if !keep(waiting) {
|
||||
t.Error("a container starting to wait is not news")
|
||||
}
|
||||
got := read()
|
||||
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
|
||||
t.Fatalf("the wait is not read back: %+v", got)
|
||||
}
|
||||
again := []BusyWait{waiting[0]}
|
||||
again[0].Said = "busy: a check, a minute on"
|
||||
if keep(again) {
|
||||
t.Error("a wait whose output changed is news")
|
||||
}
|
||||
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
|
||||
Since: since, Until: since.Add(15 * time.Minute)}}) {
|
||||
t.Error("a container replaced after it waited is not news")
|
||||
}
|
||||
if _, ok := WaitingFor(read(), "build-agent"); ok {
|
||||
t.Error("a container replaced still reads as waiting")
|
||||
}
|
||||
}
|
||||
@@ -83,63 +83,6 @@ func TestABusyContainerIsItsModules(t *testing.T) {
|
||||
|
||||
// A machine's report of a busy container is kept with its last account and read back beside it, and its
|
||||
// starting and its end are news; its output alone is not (novox/hq ADR 0335). Needs the store.
|
||||
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
|
||||
inv, node := aNodeWithModules(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
|
||||
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
|
||||
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
|
||||
keep := func(busy []BusyWait) bool {
|
||||
t.Helper()
|
||||
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
|
||||
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return news
|
||||
}
|
||||
read := func() []BusyWait {
|
||||
t.Helper()
|
||||
reports, err := inv.LastReports(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range reports {
|
||||
if r.Node == node {
|
||||
return r.Busy
|
||||
}
|
||||
}
|
||||
t.Fatal("the machine's report is not read back")
|
||||
return nil
|
||||
}
|
||||
keep(nil)
|
||||
if got := read(); len(got) != 0 {
|
||||
t.Fatalf("a report with nothing busy reads back %+v", got)
|
||||
}
|
||||
if !keep(waiting) {
|
||||
t.Error("a container starting to wait is not news")
|
||||
}
|
||||
got := read()
|
||||
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
|
||||
t.Fatalf("the wait is not read back: %+v", got)
|
||||
}
|
||||
again := []BusyWait{waiting[0]}
|
||||
again[0].Said = "busy: a check, a minute on"
|
||||
if keep(again) {
|
||||
t.Error("a wait whose output changed is news")
|
||||
}
|
||||
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
|
||||
Since: since, Until: since.Add(15 * time.Minute)}}) {
|
||||
t.Error("a container replaced after it waited is not news")
|
||||
}
|
||||
if _, ok := WaitingFor(read(), "build-agent"); ok {
|
||||
t.Error("a container replaced still reads as waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine of the rest that answered and waits for a busy container is never read as silent, its wait is kept
|
||||
// out of its apply time, and once replaced its report is the end of the wait (novox/hq ADR 0335).
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
// A carried peer is nameable (novox/hq issue 112): the operator states which machine a carried
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func TestAWrongPasswordIsAnAnswerNotAnOutage(t *testing.T) {
|
||||
dsn := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if dsn == "" {
|
||||
t.Skip("MESH_TEST_POSTGRES is not set")
|
||||
}
|
||||
wrong := strings.Replace(dsn, "postgres:check@", "postgres:not-the-password@", 1)
|
||||
if wrong == dsn {
|
||||
t.Skip("the test store's address does not carry the expected credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := pgconn.Connect(ctx, wrong)
|
||||
if err == nil {
|
||||
t.Fatal("a wrong password connected")
|
||||
}
|
||||
if Unreachable(err) {
|
||||
t.Fatalf("a wrong password was taken for an outage and would be retried for ever: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -55,22 +53,3 @@ func TestAStoreThatRefusesTheConnectionIsUnreachable(t *testing.T) {
|
||||
|
||||
// A connection the store refused on a wrong password is an answer, even though it arrives as a
|
||||
// failed connection — asked again for ever, it would hold every message behind it.
|
||||
func TestAWrongPasswordIsAnAnswerNotAnOutage(t *testing.T) {
|
||||
dsn := os.Getenv("MESH_TEST_POSTGRES")
|
||||
if dsn == "" {
|
||||
t.Skip("MESH_TEST_POSTGRES is not set")
|
||||
}
|
||||
wrong := strings.Replace(dsn, "postgres:check@", "postgres:not-the-password@", 1)
|
||||
if wrong == dsn {
|
||||
t.Skip("the test store's address does not carry the expected credential")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := pgconn.Connect(ctx, wrong)
|
||||
if err == nil {
|
||||
t.Fatal("a wrong password connected")
|
||||
}
|
||||
if Unreachable(err) {
|
||||
t.Fatalf("a wrong password was taken for an outage and would be retried for ever: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
var m catalogue.Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number",
|
||||
"description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1,
|
||||
"maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := inv.DeclaredConfigMirror(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got DeclaredConfigMirrored
|
||||
if err := json.Unmarshal(values["proxy"], &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, has := got.Settings["port"]
|
||||
if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" ||
|
||||
port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 ||
|
||||
string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" {
|
||||
t.Errorf("a module in the config form is mirrored as %s", values["proxy"])
|
||||
}
|
||||
}
|
||||
@@ -85,29 +85,3 @@ func TestADeclarationUsingEveryFieldRoundTripsThroughTheMirror(t *testing.T) {
|
||||
// **A module moved to the config form is mirrored from it** (ADR 0348 §1): the store keeps its manifest in the
|
||||
// form it was written in, under `config`, and the declared-config mirror reads its declarations from there with
|
||||
// the config form's fields.
|
||||
func TestAModuleInTheConfigFormIsMirroredWithItsWholeDeclaration(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
var m catalogue.Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module": "proxy", "version": "1", "config": {"port": {"type": "number",
|
||||
"description": "the port it listens on, a TCP port number", "default": 8080, "minimum": 1,
|
||||
"maximum": 65535, "enum": [8080, 8443], "predefined": "http.port"}}}`), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(t.Context(), m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, err := inv.DeclaredConfigMirror(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got DeclaredConfigMirrored
|
||||
if err := json.Unmarshal(values["proxy"], &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port, has := got.Settings["port"]
|
||||
if !has || port.Type != catalogue.ConfigNumber || port.Description == "" || string(port.Default) != "8080" ||
|
||||
port.Minimum == nil || *port.Minimum != 1 || port.Maximum == nil || *port.Maximum != 65535 ||
|
||||
string(port.Enum) != "[8080,8443]" || port.Predefined != "http.port" {
|
||||
t.Errorf("a module in the config form is mirrored as %s", values["proxy"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
manifestOf := func(image string) catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "one", "image": image}}}
|
||||
}
|
||||
record := func(id, image string, asked time.Time) Build {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(manifestOf(image))
|
||||
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
|
||||
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
|
||||
Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
before := record("x", x, time.Now().Add(-2*time.Hour))
|
||||
failed := record("y", y, time.Now().Add(-time.Minute))
|
||||
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
|
||||
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running, err := inv.RegisteredHeld(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if running["one/one"] == y {
|
||||
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
|
||||
}
|
||||
if got := running["one/one"]; got != "" && got != x {
|
||||
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,12 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack is novox/hq issue 721's review: the newest build of a module
|
||||
// failed its gate and the mesh put the build before it back. Held names the newest; what runs, and what a merge's build
|
||||
// is compared with, is the build put back — so a merge whose bytes equal the failed build is never said unchanged.
|
||||
func TestWhatRunsIsTheRegisteredBuildNotANewerOnePutBack(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
manifestOf := func(image string) catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "one", Version: "1", Resources: []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "one", "image": image}}}
|
||||
}
|
||||
record := func(id, image string, asked time.Time) Build {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(manifestOf(image))
|
||||
b := Build{ID: id, Module: "one", Commit: id + "aaaaaaaa", Repository: "novox/one", Manifest: raw,
|
||||
Asked: asked, At: asked, Made: []Artifact{{Name: "one", Kind: "image", Reference: image}}}
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RegisterModule(ctx, manifestOf(image), Source{Repository: "novox/one", BuiltFrom: b.Commit,
|
||||
Asked: asked}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
x := "store/one@sha256:" + "1111111111111111111111111111111111111111111111111111111111111111"
|
||||
y := "store/one@sha256:" + "2222222222222222222222222222222222222222222222222222222222222222"
|
||||
before := record("x", x, time.Now().Add(-2*time.Hour))
|
||||
failed := record("y", y, time.Now().Add(-time.Minute))
|
||||
if err := inv.RecordGate(ctx, GateVerdict{Build: failed.ID, Module: "one", Commit: failed.Commit,
|
||||
Machines: []string{"anchor"}, Verdict: GateFailed}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RestoreModule(ctx, before); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held, err := inv.Held(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running, err := inv.RegisteredHeld(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if running["one/one"] == y {
|
||||
t.Fatalf("what runs is read as the build whose gate failed (%s); Held says %s", y, held["one/one"])
|
||||
}
|
||||
if got := running["one/one"]; got != "" && got != x {
|
||||
t.Fatalf("what runs is read as %s, want the build put back %s or nothing", got, x)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOnlyArtifactsTheRegisteredManifestNamesAreRunning: registeredOnly keeps a held artifact only when its digest is
|
||||
// in its module's registered manifest.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
func TestAMemberGivenIsReadAsGivenAndNothingElseIs(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
node, err := inv.AddNode(ctx, "workstation")
|
||||
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: "mounts", Version: "1", OwnSecrets: catalogue.OwnSecrets{
|
||||
"smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside},
|
||||
"token": {Path: "/s/token", IssuedBy: catalogue.IssuedOutside},
|
||||
}}
|
||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-password-games", "hunter2"); err != nil {
|
||||
t.Fatalf("a member was refused: %v", err)
|
||||
}
|
||||
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-pass", "x"); err == nil {
|
||||
t.Fatal("a name of no family was accepted")
|
||||
}
|
||||
members, err := inv.GivenMembers(ctx, "workstation", "mounts", "smb-password-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(members) != 1 || members[0].Name != "smb-password-games" || !members[0].Current || members[0].Sealed == "" ||
|
||||
strings.Contains(members[0].Sealed, "hunter2") {
|
||||
t.Fatalf("members: %+v", members)
|
||||
}
|
||||
given, err := inv.GivenOwnSecrets(ctx, "workstation", "mounts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, was := given["smb-password-games"]; !was || len(given) != 1 {
|
||||
t.Fatalf("given: %v", given)
|
||||
}
|
||||
// A value the mesh made is not one a person gave.
|
||||
if _, err := inv.SecretForModule(ctx, "workstation", "mounts", "token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given, _ := inv.GivenOwnSecrets(ctx, "workstation", "mounts"); len(given) != 1 {
|
||||
t.Fatalf("a value the mesh made counted as given: %v", given)
|
||||
}
|
||||
}
|
||||
@@ -27,50 +27,3 @@ func TestAMemberIsGivableAtTheDeskAndANameOutsideTheFamilyIsNot(t *testing.T) {
|
||||
}
|
||||
|
||||
// A member given is read back as given, sealed to the machine's key; the mesh makes none, so nothing else is.
|
||||
func TestAMemberGivenIsReadAsGivenAndNothingElseIs(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := t.Context()
|
||||
node, err := inv.AddNode(ctx, "workstation")
|
||||
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: "mounts", Version: "1", OwnSecrets: catalogue.OwnSecrets{
|
||||
"smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: catalogue.IssuedOutside},
|
||||
"token": {Path: "/s/token", IssuedBy: catalogue.IssuedOutside},
|
||||
}}
|
||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-password-games", "hunter2"); err != nil {
|
||||
t.Fatalf("a member was refused: %v", err)
|
||||
}
|
||||
if err := inv.AcceptSecretForModule(ctx, "workstation", "mounts", "smb-pass", "x"); err == nil {
|
||||
t.Fatal("a name of no family was accepted")
|
||||
}
|
||||
members, err := inv.GivenMembers(ctx, "workstation", "mounts", "smb-password-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(members) != 1 || members[0].Name != "smb-password-games" || !members[0].Current || members[0].Sealed == "" ||
|
||||
strings.Contains(members[0].Sealed, "hunter2") {
|
||||
t.Fatalf("members: %+v", members)
|
||||
}
|
||||
given, err := inv.GivenOwnSecrets(ctx, "workstation", "mounts")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, was := given["smb-password-games"]; !was || len(given) != 1 {
|
||||
t.Fatalf("given: %v", given)
|
||||
}
|
||||
// A value the mesh made is not one a person gave.
|
||||
if _, err := inv.SecretForModule(ctx, "workstation", "mounts", "token"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given, _ := inv.GivenOwnSecrets(ctx, "workstation", "mounts"); len(given) != 1 {
|
||||
t.Fatalf("a value the mesh made counted as given: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
@@ -6,12 +8,13 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAWalksRestIsReadFromEachMachinesOwnSend(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.AddNode(ctx, "g14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.NextSequence(ctx, record.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-582", map[string]string{"build-agent": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent, found, err := inv.SentTo(ctx, "g14")
|
||||
if err != nil || !found || sent.At.IsZero() || sent.Digest != "d-582" {
|
||||
t.Fatalf("the machine's own send time: %+v %v %v", sent, found, err)
|
||||
}
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second)); err != nil || ok {
|
||||
t.Fatalf("no report yet: %v %v", ok, err)
|
||||
}
|
||||
if err := inv.RecordApplyDuration(ctx, "g14", "d-582", OutcomeApplied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The walk's record of its send, 20 s after the machine's own: still found, by the machine's own send.
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
|
||||
if err != nil || !ok || r.Outcome != OutcomeApplied || r.At.Before(sent.At) {
|
||||
t.Fatalf("the machine's own report of its send: %+v %v %v", r, ok, err)
|
||||
}
|
||||
// A later send, reported: never this walk's.
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-next", map[string]string{"build-agent": "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordApplyDuration(ctx, "g14", "d-next", OutcomeApplied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _, _ := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
|
||||
if !again.At.Equal(r.At) {
|
||||
t.Fatalf("a later send's report read as this walk's: %+v, was %+v", again, r)
|
||||
}
|
||||
}
|
||||
@@ -146,46 +146,6 @@ func TestAMachineSentTwiceByOneWalkIsHeardByItsLaterSend(t *testing.T) {
|
||||
|
||||
// The store: a machine's own send time is read with what it was sent, and its `apply` duration is found by it —
|
||||
// the send and the duration start at one moment, the node's sent_at.
|
||||
func TestAWalksRestIsReadFromEachMachinesOwnSend(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
record, err := inv.AddNode(ctx, "g14")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.NextSequence(ctx, record.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-582", map[string]string{"build-agent": "c1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent, found, err := inv.SentTo(ctx, "g14")
|
||||
if err != nil || !found || sent.At.IsZero() || sent.Digest != "d-582" {
|
||||
t.Fatalf("the machine's own send time: %+v %v %v", sent, found, err)
|
||||
}
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second)); err != nil || ok {
|
||||
t.Fatalf("no report yet: %v %v", ok, err)
|
||||
}
|
||||
if err := inv.RecordApplyDuration(ctx, "g14", "d-582", OutcomeApplied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The walk's record of its send, 20 s after the machine's own: still found, by the machine's own send.
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
|
||||
if err != nil || !ok || r.Outcome != OutcomeApplied || r.At.Before(sent.At) {
|
||||
t.Fatalf("the machine's own report of its send: %+v %v %v", r, ok, err)
|
||||
}
|
||||
// A later send, reported: never this walk's.
|
||||
if err := inv.RecordSent(ctx, record.ID, "d-next", map[string]string{"build-agent": "c2"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.RecordApplyDuration(ctx, "g14", "d-next", OutcomeApplied); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, _, _ := inv.FirstAppliedAfter(ctx, "g14", sent, sent.At.Add(20*time.Second))
|
||||
if !again.At.Equal(r.At) {
|
||||
t.Fatalf("a later send's report read as this walk's: %+v, was %+v", again, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 840 (ADR 0362): the walk's report of a machine is asked for with only the provisioned modules the walk sent
|
||||
// there, never every module the machine's declaration hands to the provisioner.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAWalksMomentsAreKeptAndItsRestsReportRead(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Revision, p.Epoch = 0, 0
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Class != ClassCore || back.Times.WindowClosed == nil ||
|
||||
!back.Times.WindowClosed.Equal(*p.Times.WindowClosed) {
|
||||
t.Fatalf("the walk's moments were not kept: %v %+v", err, back.Times)
|
||||
}
|
||||
if back.Delivery.Merges[0].Merged.IsZero() {
|
||||
t.Fatal("a merge's time was not kept")
|
||||
}
|
||||
sent := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent); err != nil || ok {
|
||||
t.Fatalf("no report yet: %v %v", ok, err)
|
||||
}
|
||||
for _, d := range []Duration{
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "old@1", Started: sent.Add(-time.Hour), Took: time.Second, Detail: OutcomeApplied},
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "d1@2", Started: sent.Add(-time.Second), Took: 12 * time.Second, Detail: OutcomeApplied},
|
||||
} {
|
||||
if err := inv.RecordDuration(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent)
|
||||
if err != nil || !ok || r.Outcome != OutcomeApplied || !r.At.Equal(sent.Add(11*time.Second)) {
|
||||
t.Fatalf("the report after the send: %+v %v %v", r, ok, err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || anyKept || total {
|
||||
t.Fatalf("nothing kept yet: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationWalkPhase, Subject: "core/total", Ref: p.ID + "/total",
|
||||
Started: sent, Took: time.Minute}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || !anyKept || !total {
|
||||
t.Fatalf("the total kept: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALateReportIsSilentNotTheEnd(t *testing.T) {
|
||||
sent := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Modules["mesh-delivery"].SentAt = &sent
|
||||
p.Modules["mesh-delivery"].Rest = map[string]SentDeclaration{"laptop": {Digest: "d"}, "server": {Digest: "e"}}
|
||||
w := p.WalkPhases(sent.Add(3*time.Hour), func(node string, _ SentDeclaration, _ time.Time) (AppliedReport, bool) {
|
||||
if node == "server" {
|
||||
return AppliedReport{At: sent.Add(20 * time.Second), Outcome: OutcomeApplied}, true
|
||||
}
|
||||
return AppliedReport{At: sent.Add(2 * time.Hour), Outcome: OutcomeApplied}, true
|
||||
})
|
||||
if len(w.Silent) != 1 || w.Silent[0] != "laptop" || w.End == nil || !w.End.Equal(sent.Add(20*time.Second)) {
|
||||
t.Fatalf("a late report: silent %v, end %v", w.Silent, w.End)
|
||||
}
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationApply, Subject: "laptop", Node: "laptop", Ref: "late@1",
|
||||
Started: sent.Add(time.Hour), Took: time.Second, Detail: OutcomeApplied}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "laptop", SentDeclaration{}, sent); err != nil || ok {
|
||||
t.Fatalf("a send past the bound read as the walk's: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
@@ -230,76 +230,9 @@ func TestAGateKeepsItsReadings(t *testing.T) {
|
||||
|
||||
// A walk's moments are kept and read back with it; a machine's first report after a send is read from the
|
||||
// controller's apply durations.
|
||||
func TestAWalksMomentsAreKeptAndItsRestsReportRead(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Revision, p.Epoch = 0, 0
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Class != ClassCore || back.Times.WindowClosed == nil ||
|
||||
!back.Times.WindowClosed.Equal(*p.Times.WindowClosed) {
|
||||
t.Fatalf("the walk's moments were not kept: %v %+v", err, back.Times)
|
||||
}
|
||||
if back.Delivery.Merges[0].Merged.IsZero() {
|
||||
t.Fatal("a merge's time was not kept")
|
||||
}
|
||||
sent := time.Now().UTC().Add(-time.Minute).Truncate(time.Millisecond)
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent); err != nil || ok {
|
||||
t.Fatalf("no report yet: %v %v", ok, err)
|
||||
}
|
||||
for _, d := range []Duration{
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "old@1", Started: sent.Add(-time.Hour), Took: time.Second, Detail: OutcomeApplied},
|
||||
{Kind: DurationApply, Subject: "ace", Node: "ace", Ref: "d1@2", Started: sent.Add(-time.Second), Took: 12 * time.Second, Detail: OutcomeApplied},
|
||||
} {
|
||||
if err := inv.RecordDuration(ctx, d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r, ok, err := inv.FirstAppliedAfter(ctx, "ace", SentDeclaration{}, sent)
|
||||
if err != nil || !ok || r.Outcome != OutcomeApplied || !r.At.Equal(sent.Add(11*time.Second)) {
|
||||
t.Fatalf("the report after the send: %+v %v %v", r, ok, err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || anyKept || total {
|
||||
t.Fatalf("nothing kept yet: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationWalkPhase, Subject: "core/total", Ref: p.ID + "/total",
|
||||
Started: sent, Took: time.Minute}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyKept, total, err := inv.WalkPhasesKept(ctx, p.ID); err != nil || !anyKept || !total {
|
||||
t.Fatalf("the total kept: %v %v %v", anyKept, total, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine that slept past the bound and reported later is listed silent: its late report never moves the
|
||||
// walk's end; and a report sent past the bound is not read as the walk's.
|
||||
func TestALateReportIsSilentNotTheEnd(t *testing.T) {
|
||||
sent := time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC)
|
||||
p := walkOf(t, recordedWalk)
|
||||
p.Modules["mesh-delivery"].SentAt = &sent
|
||||
p.Modules["mesh-delivery"].Rest = map[string]SentDeclaration{"laptop": {Digest: "d"}, "server": {Digest: "e"}}
|
||||
w := p.WalkPhases(sent.Add(3*time.Hour), func(node string, _ SentDeclaration, _ time.Time) (AppliedReport, bool) {
|
||||
if node == "server" {
|
||||
return AppliedReport{At: sent.Add(20 * time.Second), Outcome: OutcomeApplied}, true
|
||||
}
|
||||
return AppliedReport{At: sent.Add(2 * time.Hour), Outcome: OutcomeApplied}, true
|
||||
})
|
||||
if len(w.Silent) != 1 || w.Silent[0] != "laptop" || w.End == nil || !w.End.Equal(sent.Add(20*time.Second)) {
|
||||
t.Fatalf("a late report: silent %v, end %v", w.Silent, w.End)
|
||||
}
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
if err := inv.RecordDuration(ctx, Duration{Kind: DurationApply, Subject: "laptop", Node: "laptop", Ref: "late@1",
|
||||
Started: sent.Add(time.Hour), Took: time.Second, Detail: OutcomeApplied}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok, err := inv.FirstAppliedAfter(ctx, "laptop", SentDeclaration{}, sent); err != nil || ok {
|
||||
t.Fatalf("a send past the bound read as the walk's: %v %v", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Moments out of order — #206's own walk on 2026-10-10 kept its first send 783 ms before its build — are said
|
||||
// unknown, never a negative phase: the phase before is unknown and its time joins the unknown time, the phase
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build integration
|
||||
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
cut := time.Now().UTC().Add(-time.Minute)
|
||||
p := Plan{ID: "plan-308", Repository: "novox/mesh-catalog", Commit: "abc", Created: cut, State: PlanBuilding,
|
||||
Tiers: [][]string{{"messenger"}}, Modules: map[string]*PlanModule{"messenger": {}},
|
||||
Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
|
||||
var said []string
|
||||
was := PlanSaved
|
||||
PlanSaved = func(_ *Inventory, p Plan) { said = append(said, p.WalkSaid) }
|
||||
t.Cleanup(func() { PlanSaved = was })
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Started == nil || back.Times.Class != ClassLeaf {
|
||||
t.Fatalf("the start is kept with the walk: %v %+v", err, back.Times)
|
||||
}
|
||||
// Read back by another controller and saved: not said again.
|
||||
back.State = PlanRolling
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A stale copy ending it is refused, and unmarked.
|
||||
stale := p
|
||||
stale.Revision = 1
|
||||
stale.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &stale); err == nil {
|
||||
t.Fatal("a stale save is refused")
|
||||
}
|
||||
if stale.WalkSaid != "" || stale.Times.Ended != nil {
|
||||
t.Fatalf("a refused save is unmarked: %q %+v", stale.WalkSaid, stale.Times)
|
||||
}
|
||||
back.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 3 || said[0] != WalkStarted || said[1] != "" || said[2] != WalkEnded {
|
||||
t.Fatalf("said: %q, want started, nothing, ended", said)
|
||||
}
|
||||
}
|
||||
@@ -87,44 +87,3 @@ func TestAWalksOutcome(t *testing.T) {
|
||||
|
||||
// The marks are kept with the walk, so a controller replaced mid-walk neither says its start again nor misses
|
||||
// its end; and a save refused leaves the walk unmarked, so the save that is made says it.
|
||||
func TestAWalksStartIsKeptAndARefusedSaveLeavesItUnsaid(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := t.Context()
|
||||
cut := time.Now().UTC().Add(-time.Minute)
|
||||
p := Plan{ID: "plan-308", Repository: "novox/mesh-catalog", Commit: "abc", Created: cut, State: PlanBuilding,
|
||||
Tiers: [][]string{{"messenger"}}, Modules: map[string]*PlanModule{"messenger": {}},
|
||||
Times: &PlanTimes{Cut: &cut, Class: ClassLeaf}}
|
||||
var said []string
|
||||
was := PlanSaved
|
||||
PlanSaved = func(_ *Inventory, p Plan) { said = append(said, p.WalkSaid) }
|
||||
t.Cleanup(func() { PlanSaved = was })
|
||||
if err := inv.SavePlan(ctx, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := inv.PlanByID(ctx, p.ID)
|
||||
if err != nil || back.Times == nil || back.Times.Started == nil || back.Times.Class != ClassLeaf {
|
||||
t.Fatalf("the start is kept with the walk: %v %+v", err, back.Times)
|
||||
}
|
||||
// Read back by another controller and saved: not said again.
|
||||
back.State = PlanRolling
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A stale copy ending it is refused, and unmarked.
|
||||
stale := p
|
||||
stale.Revision = 1
|
||||
stale.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &stale); err == nil {
|
||||
t.Fatal("a stale save is refused")
|
||||
}
|
||||
if stale.WalkSaid != "" || stale.Times.Ended != nil {
|
||||
t.Fatalf("a refused save is unmarked: %q %+v", stale.WalkSaid, stale.Times)
|
||||
}
|
||||
back.State = PlanDone
|
||||
if err := inv.SavePlan(ctx, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(said) != 3 || said[0] != WalkStarted || said[1] != "" || said[2] != WalkEnded {
|
||||
t.Fatalf("said: %q, want started, nothing, ended", said)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user