Land feature 57's controller work on its release branch
The controller plans config, templates and moved providers' provisions, hands bus accounts to the vault and judges the new manifest words, on the feature's SDK and node-engine validator (feature 57, config and provisioning).
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The bus module's watch speaks through the runtime that carries it** (novox/hq ADR 0357): the node's
|
||||
// runtime is granted the events the bus module declares, under the module's name, while the bus's own
|
||||
// account keeps the snapshot API and nothing else (ADR 0235).
|
||||
func TestTheRuntimeCarryingTheBusModuleMayPublishItsEventsAndTheBussAccountMayNot(t *testing.T) {
|
||||
events := []string{"bus.slow-consumer", "bus.connection-closed"}
|
||||
runtime := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule,
|
||||
Carries: []Declared{{Module: "nats", Emits: events}}}
|
||||
p, err := PermissionsFor(runtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range events {
|
||||
if !slices.Contains(p.Publish, "mesh.mod.nats.event."+e) {
|
||||
t.Errorf("the runtime carrying the bus module may not publish %s: %v", e, p.Publish)
|
||||
}
|
||||
}
|
||||
account := Principal{Kind: KindModule, Node: "anchor", Module: "nats", Emits: events, SnapshotsTheBus: true}
|
||||
a, err := PermissionsFor(account)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range a.Publish {
|
||||
if s == "mesh.mod.nats.event.bus.slow-consumer" || s == "mesh.mod.nats.event.bus.connection-closed" {
|
||||
t.Errorf("the bus's own account is granted %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **Only mesh-config's account writes mesh-config's buckets** (novox/hq ADR 0348 §9 and How it is checked, design
|
||||
// 57 K3, issue 662): on a real server whose users are composed as the controller composes them. mesh-config runs on
|
||||
// an account of its own (`runs-as`, ADR 0259 §8), so the machine's runtime does not carry it and its own principal
|
||||
// alone is granted the writes of its four buckets. A put from any other account is refused by the server: a
|
||||
// module reading the resolved values through `reads`, the runtime of the machine mesh-config runs on, and the
|
||||
// controller.
|
||||
func TestNatsOnlyMeshConfigsAccountPutsToItsBuckets(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
states := []string{"values", "resolved", "predefined", "proposals"}
|
||||
var buckets []Bucket
|
||||
for _, s := range states {
|
||||
buckets = append(buckets, Bucket{Module: "mesh-config", Name: s})
|
||||
}
|
||||
seat := Seat{Name: "mesh-config", Scope: "mesh", Serves: []string{"config"}}
|
||||
users, err := Users(Records{Nodes: []string{"novox", "g14"}, Assigned: map[string][]Declared{
|
||||
"novox": {
|
||||
{Module: RuntimeModule},
|
||||
{Module: "mesh-config", RunsAs: "mesh-config", Holds: []Seat{seat}, State: buckets,
|
||||
Emits: []string{"config-changed"},
|
||||
Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config", "mesh-controller.predefined"}},
|
||||
{Module: "mesh-issues", State: []Bucket{{Module: "mesh-issues", Name: "issues"}}},
|
||||
},
|
||||
"g14": {{Module: "dunst", Reads: []string{"mesh-config.resolved"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
t.Cleanup(s.Shutdown)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
connect := func(user string) (*nats.Conn, chan error) {
|
||||
t.Helper()
|
||||
refused := make(chan error, 8)
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refused <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("%s cannot connect: %v", user, err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc, refused
|
||||
}
|
||||
// The controller makes the buckets, as it does from a manifest's `state`.
|
||||
controller, controllerRefused := connect(ControllerName)
|
||||
cjs, err := jetstream.New(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
if _, err := cjs.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b.Bucket()}); err != nil {
|
||||
t.Fatalf("the controller cannot make %s: %v", b.Bucket(), err)
|
||||
}
|
||||
}
|
||||
|
||||
owner, _ := connect("novox.mesh-config")
|
||||
ojs, err := jetstream.New(owner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
kv, err := ojs.KeyValue(ctx, b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatalf("mesh-config cannot bind its own %s: %v", b.Bucket(), err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, "mesh.font-size", []byte(`{"value":12}`)); err != nil {
|
||||
t.Errorf("mesh-config cannot write its own %s: %v", b.Bucket(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every other account: a publish on the bucket's subject is refused by the server, and the bucket holds
|
||||
// what mesh-config wrote.
|
||||
reader, readerRefused := connect("g14.dunst")
|
||||
runtime, runtimeRefused := connect("novox." + RuntimeModule)
|
||||
for _, other := range []struct {
|
||||
name string
|
||||
nc *nats.Conn
|
||||
refused chan error
|
||||
}{{"a module reading the resolved values", reader, readerRefused},
|
||||
{"the runtime of mesh-config's machine", runtime, runtimeRefused},
|
||||
{"the controller", controller, controllerRefused}} {
|
||||
for _, b := range buckets {
|
||||
if err := other.nc.Publish("$KV."+b.Bucket()+".mesh.font-size", []byte(`{"value":99}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = other.nc.Flush()
|
||||
select {
|
||||
case err := <-other.refused:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s putting to %s: the server said %v, want a permissions violation", other.name, b.Bucket(), err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse %s a put to %s", other.name, b.Bucket())
|
||||
}
|
||||
}
|
||||
}
|
||||
// The reader still reads what it was granted.
|
||||
rjs, err := jetstream.New(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := rjs.KeyValue(ctx, "mesh-config_resolved")
|
||||
if err != nil {
|
||||
t.Fatalf("a module reading mesh-config.resolved cannot bind it: %v", err)
|
||||
}
|
||||
e, err := resolved.Get(ctx, "mesh.font-size")
|
||||
if err != nil || string(e.Value()) != `{"value":12}` {
|
||||
t.Errorf("the resolved value reads %v (%v), want mesh-config's own write", e, err)
|
||||
}
|
||||
for _, b := range buckets {
|
||||
kv, err := ojs.KeyValue(ctx, b.Bucket())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, err := kv.Get(ctx, "mesh.font-size"); err != nil || string(e.Value()) != `{"value":12}` {
|
||||
t.Errorf("%s holds %v (%v) after the refused puts", b.Bucket(), e, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,45 @@ var (
|
||||
AskedBucket = BucketName(ControllerSeat, "asked")
|
||||
)
|
||||
|
||||
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
|
||||
// record, one key per record, written from the store by the lease holder only when the record changed, with
|
||||
// one event on the controller's seat per write. Read by whatever names them in `reads` as
|
||||
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
|
||||
// assignment's value names its module and node; a module's declared config is what its manifest, which is
|
||||
// public, declares.
|
||||
//
|
||||
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
|
||||
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
|
||||
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
|
||||
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
|
||||
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
|
||||
var (
|
||||
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
|
||||
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
|
||||
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
|
||||
)
|
||||
|
||||
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
|
||||
const (
|
||||
MirrorAssignments = "assignments"
|
||||
MirrorDeclaredConfig = "declared-config"
|
||||
MirrorPredefined = "predefined"
|
||||
)
|
||||
|
||||
// ControllerMirrors are the local names of the controller's record mirrors, which a module may read.
|
||||
func ControllerMirrors() []string {
|
||||
return []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined}
|
||||
}
|
||||
|
||||
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
|
||||
const (
|
||||
MirrorValueBytes = 64 << 10
|
||||
MirrorBytes = 64 << 20
|
||||
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
|
||||
// per-message age for it, and the writer gives each marker a day.
|
||||
MirrorMarkerTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
|
||||
const AskedKeptFor = 30 * 24 * time.Hour
|
||||
|
||||
@@ -66,12 +105,14 @@ const (
|
||||
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
|
||||
func IsControllerBucket(bucket string) bool {
|
||||
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket
|
||||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
|
||||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket
|
||||
}
|
||||
|
||||
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
|
||||
func ControllerBuckets() []string {
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket}
|
||||
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
|
||||
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket}
|
||||
}
|
||||
|
||||
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
|
||||
@@ -168,6 +209,30 @@ func (j *JetStream) EnsureControllerBuckets() error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", AskedBucket, err)
|
||||
}
|
||||
// **The record mirrors: no age** (design 54 §5). A key is removed when its record is, by a purge whose
|
||||
// marker lives a day; a key that expired would be a record the bus forgot while the store still holds it.
|
||||
for _, m := range []struct{ bucket, why string }{
|
||||
{AssignmentsBucket, "which module is assigned to which node, keyed <node>.<module> (novox/hq ADR 0289, " +
|
||||
"design 54): written by the controller alone from its store when an assignment changes, read through `reads`"},
|
||||
{DeclaredConfigBucket, "each registered module's config declarations as its manifest holds them, keyed by " +
|
||||
"the module (novox/hq ADR 0348, design 57 F3): written by the controller alone from its store when a " +
|
||||
"registration changes them, read through `reads`"},
|
||||
{PredefinedBucket, "the list of predefined keys the mesh published, the newest trunk build's of the " +
|
||||
"catalogue, one key `list` (novox/hq ADR 0348 §7, design 57 K3): written by the controller alone from " +
|
||||
"its store when a newer list is kept, read through `reads`"},
|
||||
} {
|
||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
||||
Bucket: m.bucket,
|
||||
Description: m.why,
|
||||
History: 1,
|
||||
MaxValueSize: MirrorValueBytes,
|
||||
MaxBytes: MirrorBytes,
|
||||
Storage: jetstream.FileStorage,
|
||||
LimitMarkerTTL: MirrorMarkerTTL,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asserting bucket %s: %w", m.bucket, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>"}
|
||||
want := []string{"$KV." + CallsBucket + ".>", "$KV." + HandActsBucket + ".>",
|
||||
"$KV." + AssignmentsBucket + ".>", "$KV." + DeclaredConfigBucket + ".>",
|
||||
"$KV." + PredefinedBucket + ".>"}
|
||||
for _, seat := range seatsTheControllerAsks {
|
||||
if hasCancelledSet(seat) {
|
||||
want = append(want, "$KV."+CancelledSetName(seat)+".>")
|
||||
@@ -37,6 +39,35 @@ func TestTheControllerMayWriteEveryBucketItWrites(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **A module reads a record mirror through `reads`, and never writes it** (ADR 0201 §1, design 54 §5,
|
||||
// design 57 F3): granted the direct reads and watches of the bucket, and refused at composition a grant to
|
||||
// write it, whose writer is the controller.
|
||||
func TestAModuleReadsTheControllersMirrorsAndWritesNone(t *testing.T) {
|
||||
reader := Principal{Kind: KindModule, Node: "one", Module: "mesh-config", PasswordHash: "x",
|
||||
Reads: []string{"mesh-controller." + MirrorAssignments, "mesh-controller." + MirrorDeclaredConfig,
|
||||
"mesh-controller." + MirrorPredefined}}
|
||||
p, err := PermissionsFor(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bucket := range []string{AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket} {
|
||||
for _, subject := range []string{"$JS.API.STREAM.INFO.KV_" + bucket, "$JS.API.DIRECT.GET.KV_" + bucket + ".>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_" + bucket + ".>"} {
|
||||
if !slices.Contains(p.Publish, subject) {
|
||||
t.Errorf("a module reading %s may not publish %s", bucket, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range p.Publish {
|
||||
if SubjectsOverlap(subject, "$KV."+bucket+".>") {
|
||||
t.Errorf("a module reading %s may write it through %s", bucket, subject)
|
||||
}
|
||||
}
|
||||
if err := CheckWriters(reader, []string{"$KV." + bucket + ".>"}); err == nil {
|
||||
t.Errorf("a module granted a write of %s would compose", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A machine's node tools may say they are there, as that machine and no other** (novox/hq to-be 45
|
||||
// S11), and the controller may hear the bus's advisories and ask who answers — read-only, named.
|
||||
func TestTheWatchedSignalsMayBeSaidAndHeard(t *testing.T) {
|
||||
@@ -96,3 +127,102 @@ func TestWhatTheControllerAskedIsBounded(t *testing.T) {
|
||||
t.Errorf("history %d, age %s, bytes %d", status.History(), status.TTL(), info.Config.MaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// **A listed mirror is no undeclared bucket** (ADR 0356 §3, ADR 0201 §16): the controller's list of its mirrors
|
||||
// stands for their declaration, so the report of state nothing declares never names one.
|
||||
func TestAListedMirrorIsNotReportedAsUndeclared(t *testing.T) {
|
||||
names := []string{"gone_state"}
|
||||
for _, m := range ControllerMirrors() {
|
||||
names = append(names, BucketName(ControllerSeat, m))
|
||||
}
|
||||
undeclared, err := RaiseBuckets(fakeBuckets{names: names}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(undeclared, []string{"gone_state"}) {
|
||||
t.Fatalf("the report of undeclared state names %v", undeclared)
|
||||
}
|
||||
}
|
||||
|
||||
// **The mirrors' list and the writers table agree** (ADR 0356 §3, to-be 45 §1): every listed mirror is a row of
|
||||
// the table whose one writer is the controller, on its bucket alone; and every row that writes a bucket of the
|
||||
// controller's named as a mirror is on the list.
|
||||
func TestTheMirrorsListAndTheWritersTableAgree(t *testing.T) {
|
||||
controller := Principal{Kind: KindController}
|
||||
module := Principal{Kind: KindModule, Module: ControllerSeat, Node: "one"}
|
||||
rowed := map[string]bool{}
|
||||
for _, row := range WritersTable {
|
||||
if !strings.HasSuffix(row.State, " mirror") {
|
||||
continue
|
||||
}
|
||||
for _, subject := range row.Subjects {
|
||||
bucket := strings.TrimSuffix(strings.TrimPrefix(subject, "$KV."), ".>")
|
||||
rowed[bucket] = true
|
||||
if row.Writes == nil || !row.Writes(controller, subject) || row.Writes(module, subject) {
|
||||
t.Errorf("%q is not written by the controller alone", row.State)
|
||||
}
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, m := range ControllerMirrors() {
|
||||
bucket := BucketName(ControllerSeat, m)
|
||||
listed[bucket] = true
|
||||
if !rowed[bucket] {
|
||||
t.Errorf("the mirror %s is listed and no row of the writers table writes it", bucket)
|
||||
}
|
||||
if !IsControllerBucket(bucket) || !slices.Contains(ControllerBuckets(), bucket) {
|
||||
t.Errorf("the mirror %s is listed and not asserted as the controller's own", bucket)
|
||||
}
|
||||
}
|
||||
for bucket := range rowed {
|
||||
if !listed[bucket] {
|
||||
t.Errorf("the writers table writes the mirror %s, which the list does not name", bucket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller may ask the vault's intake verbs in its own name** (design 57's V2, issue 675): the vault
|
||||
// takes a value only from the controller, which it knows from the subject that names its caller — so the
|
||||
// controller is granted that subject for the verbs the vault's holder serves, and no more of the vault's seat.
|
||||
// `get` among them since issue 755 (ADR 0349 §16): the vault answers the controller a core credential sealed to its
|
||||
// machine alone, which the controller carries and cannot open.
|
||||
// Composed from the records, as the issued tools are: the installer's first user list carries none.
|
||||
func TestTheControllerMayAskTheVaultInItsOwnName(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller := users[0]
|
||||
if strings.Join(controller.AsksTheVault, ",") != "accept,get,issued,rotate,secret-ask" {
|
||||
t.Fatalf("composed %v", controller.AsksTheVault)
|
||||
}
|
||||
p, err := PermissionsFor(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, verb := range []string{"accept", "secret-ask", "issued", "rotate", "get"} {
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, ControllerName)) {
|
||||
t.Errorf("the controller may not ask the vault's %s in its own name", verb)
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool."+verb, "anchor.node-tools")) {
|
||||
t.Errorf("the controller may ask the vault's %s in another's name", verb)
|
||||
}
|
||||
}
|
||||
if MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName)) {
|
||||
t.Error("the controller may move a value in: that is a node-engine's alone")
|
||||
}
|
||||
// A vault that serves none of them yet composes no grant, nor does genesis.
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"move-in"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"novox"}, Assigned: map[string][]Declared{"novox": {older}}})
|
||||
if len(users[0].AsksTheVault) != 0 {
|
||||
t.Errorf("a vault serving none composed %v", users[0].AsksTheVault)
|
||||
}
|
||||
genesis, _ := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
|
||||
for _, s := range genesis.Publish {
|
||||
if strings.Contains(s, "mesh-vault") {
|
||||
t.Errorf("the installer's controller may publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,13 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// connected, and its graph stayed empty with nothing anywhere reporting why.
|
||||
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
|
||||
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn) {
|
||||
hearsAStream := false
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) > 0 {
|
||||
hearsAStream = true
|
||||
}
|
||||
}
|
||||
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
|
||||
return Consumer{}, false
|
||||
}
|
||||
perms, err := PermissionsFor(p)
|
||||
@@ -140,11 +146,14 @@ func ConsumerFor(p Principal) (Consumer, bool) {
|
||||
// business, which is why this is a filter and not the whole list.
|
||||
var filters []string
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.Contains(s, ".event.") {
|
||||
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
|
||||
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
|
||||
filters = append(filters, s)
|
||||
}
|
||||
}
|
||||
if len(filters) == 0 {
|
||||
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
|
||||
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
|
||||
if len(filters) == 0 && !hearsAStream {
|
||||
return Consumer{}, false
|
||||
}
|
||||
sort.Strings(filters)
|
||||
@@ -181,6 +190,13 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
}
|
||||
seen[p.Node+"/"+p.Module] = true
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
|
||||
// And one on each module stream it hears (novox/hq ADR 0363 §5).
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
|
||||
}
|
||||
}
|
||||
for _, p := range users {
|
||||
if p.Kind == KindModule {
|
||||
@@ -193,7 +209,8 @@ func ConsumersOf(users []Principal) []ModuleConsumer {
|
||||
}
|
||||
for _, d := range p.Carries {
|
||||
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches})
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: ComposedHears(d)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
carriedPub := subjectsIn(accounts, "publish")
|
||||
carriedSub := subjectsIn(accounts, "subscribe")
|
||||
|
||||
if diff := missing(want.Publish, carriedPub); len(diff) > 0 {
|
||||
if diff := requiredPublishGap(t, want.Publish, carriedPub); len(diff) > 0 {
|
||||
t.Errorf("the installer's user list does not let the controller publish %v — it would come up "+
|
||||
"and be refused on the first thing it tried", diff)
|
||||
}
|
||||
@@ -62,6 +62,59 @@ func TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// requiredPublishGap is what in want is missing from carried, leaving out a grant DegradableGrants names
|
||||
// and saying why in the test log instead (novox/hq issue 835, the forge check of mesh-controller#345): the
|
||||
// merge check reads mesh-host at the commit the machines run, which a change to mesh-host's examples alone
|
||||
// never moves, so the carried list can lag the controller's derived one without the controller refusing to
|
||||
// come up.
|
||||
func requiredPublishGap(t *testing.T, want, carried []string) []string {
|
||||
t.Helper()
|
||||
diff := missing(want, carried)
|
||||
var required []string
|
||||
for _, subject := range diff {
|
||||
if reason, ok := degradableReason(subject); ok {
|
||||
t.Logf("the installer's user list does not let the controller publish %s, but that falls back "+
|
||||
"rather than refuses: %s", subject, reason)
|
||||
continue
|
||||
}
|
||||
required = append(required, subject)
|
||||
}
|
||||
return required
|
||||
}
|
||||
|
||||
// degradableReason says whether a subject is one of DegradableGrants, and why.
|
||||
func degradableReason(subject string) (string, bool) {
|
||||
for _, g := range DegradableGrants {
|
||||
if g.Subject == subject {
|
||||
return g.Reason, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TestADegradableGrantMissingFromTheInstallersListPasses is the forge check's own case (mesh-controller#345,
|
||||
// novox/hq issue 835): an installer list that lacks operator-channel.tell alone, because mesh-host has not
|
||||
// yet built with the grant, still passes — the controller falls back to the secret-given condition rather
|
||||
// than coming up refused.
|
||||
func TestADegradableGrantMissingFromTheInstallersListPasses(t *testing.T) {
|
||||
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
||||
carried := []string{"mesh.seat.node-service-manager.tool.restart.*"}
|
||||
if diff := requiredPublishGap(t, want, carried); len(diff) > 0 {
|
||||
t.Errorf("a degradable grant missing from the installer's list must not fail the genesis test, got %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// TestANonDegradableGrantMissingFromTheInstallersListStillFails proves the exemption is named, not general:
|
||||
// every other grant the controller derives stays required.
|
||||
func TestANonDegradableGrantMissingFromTheInstallersListStillFails(t *testing.T) {
|
||||
want := []string{"mesh.seat.operator-channel.tool.tell", "mesh.seat.node-service-manager.tool.restart.*"}
|
||||
carried := []string{"mesh.seat.operator-channel.tool.tell"}
|
||||
diff := requiredPublishGap(t, want, carried)
|
||||
if len(diff) != 1 || diff[0] != "mesh.seat.node-service-manager.tool.restart.*" {
|
||||
t.Errorf("a missing grant that is not degradable must still fail the genesis test, got %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
// theCarriedAccounts is the accounts file the installer's template writes at genesis.
|
||||
func theCarriedAccounts(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -173,6 +173,10 @@ func (j *JetStream) EnsureStream(s Stream) error {
|
||||
Description: s.Why,
|
||||
}
|
||||
want.AllowDirect = s.Direct
|
||||
want.AllowMsgTTL = s.AllowMsgTTL
|
||||
if s.Restricted {
|
||||
want.Metadata = map[string]string{RestrictedMetadata: "true"}
|
||||
}
|
||||
if s.MaxBytes > 0 {
|
||||
want.MaxBytes = s.MaxBytes
|
||||
}
|
||||
@@ -486,3 +490,25 @@ func (j *JetStream) LogStreams() ([]string, error) {
|
||||
}
|
||||
return out, lister.Err()
|
||||
}
|
||||
|
||||
// ConsumerNames is every durable consumer on a stream; none for a stream that does not exist yet.
|
||||
func (j *JetStream) ConsumerNames(stream string) ([]string, error) {
|
||||
if _, err := j.js.StreamInfo(stream); errors.Is(err, nats.ErrStreamNotFound) {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for name := range j.js.ConsumerNames(stream) {
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoveConsumer deletes one durable consumer (novox/hq ADR 0363 §6): one the composition no longer asks for.
|
||||
func (j *JetStream) RemoveConsumer(stream, name string) error {
|
||||
if err := j.js.DeleteConsumer(stream, name); err != nil && !errors.Is(err, nats.ErrConsumerNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,7 +172,9 @@ func TestRaisingLogsReportsWhatNothingDeclares(t *testing.T) {
|
||||
// **No path of the mesh deletes or purges a log or a bucket, or recreates one** (novox/hq ADR 0297 §2,
|
||||
// ADR 0201 §15–16): no code of this repository outside its tests calls the client's stream or bucket
|
||||
// delete, or purges a stream, but for the controller lease's own stream, which purges its own history
|
||||
// below a sequence (internal/lease). A new call is a decision, not a refactor.
|
||||
// below a sequence (internal/lease), and the controller's record mirrors, which purge the key of a record
|
||||
// the store no longer holds (internal/recordmirror; novox/hq ADR 0289, design 54 §2). A new call is a decision,
|
||||
// not a refactor.
|
||||
func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||
removers := map[string]bool{"DeleteStream": true, "DeleteKeyValue": true, "PurgeStream": true,
|
||||
"DeleteObjectStore": true}
|
||||
@@ -197,7 +199,9 @@ func TestNoPathDeletesALogOrABucket(t *testing.T) {
|
||||
return true
|
||||
}
|
||||
name := sel.Sel.Name
|
||||
if removers[name] || (name == "Purge" && !strings.Contains(filepath.ToSlash(path), "internal/lease/")) {
|
||||
decided := strings.Contains(filepath.ToSlash(path), "internal/lease/") ||
|
||||
strings.Contains(filepath.ToSlash(path), "internal/recordmirror/")
|
||||
if removers[name] || (name == "Purge" && !decided) {
|
||||
found = append(found, path+": "+name)
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -61,6 +61,15 @@ type Membership struct {
|
||||
// over every module on the machine, so one module's code reaching another's name or kind through
|
||||
// the runtime is the runtime's to refuse.
|
||||
SeatTraffic *SeatTraffic `json:"seat-traffic,omitempty"`
|
||||
// Streams are this module's own streams (novox/hq ADR 0363 §2): an event one of them carries is published
|
||||
// on that stream's subject, never on Emits. Hears are the module streams it hears, each through a durable
|
||||
// consumer on that stream the runtime binds beside the one on EVENTS (§8).
|
||||
Streams []StreamIssued `json:"streams,omitempty"`
|
||||
Hears []HeardIssued `json:"hears,omitempty"`
|
||||
// Waits are the event families a session may wait on through the mesh MCP server, the tool runner's
|
||||
// alone (novox/hq ADR 0367 §2): the subjects its account is granted, under the names a session asks for.
|
||||
// The tool runner refuses any family not listed here.
|
||||
Waits []WaitIssued `json:"waits,omitempty"`
|
||||
}
|
||||
|
||||
// Served is one address a tool is answered on.
|
||||
@@ -126,6 +135,9 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
||||
}
|
||||
m.State = stateIssuedFor(d, node)
|
||||
m.Logs = logsIssuedFor(d)
|
||||
m.Streams = streamsIssuedFor(d)
|
||||
m.Hears = HeardIssuedFor(node, d)
|
||||
m.Waits = waitsIssuedFor(d)
|
||||
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
|
||||
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
|
||||
if !s.Kinded {
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module's own stream on the bus (novox/hq ADR 0363).
|
||||
//
|
||||
// A module declares a stream as it declares a log: named locally, created by the controller from the
|
||||
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
|
||||
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
|
||||
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
|
||||
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
|
||||
// EVENTS. Pure; the controller's raise asserts what is composed here.
|
||||
|
||||
// moduleStreamPrefix starts every subject a module's own stream takes.
|
||||
const moduleStreamPrefix = "mesh.stream."
|
||||
|
||||
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
|
||||
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
|
||||
func ModuleStreamSubject(module, event string) string {
|
||||
return moduleStreamPrefix + module + ".event." + event
|
||||
}
|
||||
|
||||
// OnAModuleStream says a subject is one a module's own stream takes.
|
||||
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
|
||||
|
||||
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
|
||||
func WirePattern(local string) string {
|
||||
parts := strings.Split(local, ".")
|
||||
for i, p := range parts {
|
||||
if p == "**" {
|
||||
parts[i] = ">"
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
// A ModuleStream is one module's declared stream as the bus holds it.
|
||||
type ModuleStream struct {
|
||||
Module string
|
||||
// Name is its name on the bus, `MAIL`.
|
||||
Name string
|
||||
// Events are the events it carries, as the bus spells them (WirePattern).
|
||||
Events []string
|
||||
MaxAgeDays int
|
||||
MaxMiB int
|
||||
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// Subjects are the subjects the stream takes.
|
||||
func (s ModuleStream) Subjects() []string {
|
||||
var out []string
|
||||
for _, e := range s.Events {
|
||||
out = append(out, ModuleStreamSubject(s.Module, e))
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
|
||||
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
|
||||
// answers for anything older.
|
||||
func (s ModuleStream) Stream() Stream {
|
||||
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
|
||||
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
|
||||
if s.Restricted {
|
||||
why += "; heard only by the consumers its trusted config value grants"
|
||||
}
|
||||
return Stream{
|
||||
Name: s.Name,
|
||||
Subjects: s.Subjects(),
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
|
||||
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
|
||||
Why: why,
|
||||
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
|
||||
Restricted: s.Restricted,
|
||||
}
|
||||
}
|
||||
|
||||
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
|
||||
func ModuleStreams(declared []ModuleStream) []Stream {
|
||||
sorted := append([]ModuleStream(nil), declared...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
out := make([]Stream, 0, len(sorted))
|
||||
for _, s := range sorted {
|
||||
out = append(out, s.Stream())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
|
||||
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
|
||||
// (ADR 0363 §5). Composed only with at least one subject.
|
||||
type Heard struct {
|
||||
Stream string
|
||||
Emitter string
|
||||
Filters []string
|
||||
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
|
||||
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
|
||||
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
|
||||
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
|
||||
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
|
||||
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
|
||||
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
|
||||
// the mesh keeps from agents.
|
||||
func ComposedHears(d Declared) []Heard {
|
||||
if d.RunsAs != "" {
|
||||
return d.Hears
|
||||
}
|
||||
var out []Heard
|
||||
for _, h := range d.Hears {
|
||||
if !h.Restricted {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
|
||||
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
|
||||
// silent.
|
||||
type Withheld struct {
|
||||
Node, Module, Stream, Emitter string
|
||||
}
|
||||
|
||||
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
|
||||
func (w Withheld) Said() string {
|
||||
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
|
||||
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
|
||||
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
|
||||
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
|
||||
}
|
||||
|
||||
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
|
||||
func WithheldOf(r Records) []Withheld {
|
||||
var out []Withheld
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs != "" {
|
||||
continue
|
||||
}
|
||||
for _, h := range d.Hears {
|
||||
if h.Restricted && len(h.Filters) > 0 {
|
||||
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
|
||||
// on EVENTS, on that stream, with the subjects it was granted as its filters.
|
||||
func heardConsumer(p Principal, h Heard) Consumer {
|
||||
filters := append([]string(nil), h.Filters...)
|
||||
sort.Strings(filters)
|
||||
return Consumer{
|
||||
Name: consumerDurable(p),
|
||||
Stream: h.Stream,
|
||||
Filters: filters,
|
||||
AckWaitSeconds: 30,
|
||||
MaxDeliver: 5,
|
||||
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
|
||||
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
|
||||
"DEAD_LETTERS until a person delivers it again or drops it",
|
||||
}
|
||||
}
|
||||
|
||||
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
|
||||
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
|
||||
func heardGrants(p Principal, hears []Heard) []string {
|
||||
var out []string
|
||||
durable := consumerDurable(p)
|
||||
for _, h := range hears {
|
||||
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out,
|
||||
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
|
||||
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
|
||||
"$JS.ACK."+h.Stream+"."+durable+".>")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
|
||||
func ownStreamGrants(module string, streams []ModuleStream) []string {
|
||||
var out []string
|
||||
for _, s := range streams {
|
||||
if s.Module != module {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.Subjects()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StreamIssued is one of a module's own streams as its membership names it (ADR 0363 §2): the events it
|
||||
// carries, as the bus spells them, and the subject an event of them is published on, `{event}` the event.
|
||||
type StreamIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Events []string `json:"events"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// HeardIssued is one module stream an assignment hears, as its membership names it (ADR 0363 §8): the
|
||||
// stream and the durable consumer on it the runtime binds.
|
||||
type HeardIssued struct {
|
||||
Stream string `json:"stream"`
|
||||
Consumer string `json:"consumer"`
|
||||
}
|
||||
|
||||
// streamsIssuedFor is every stream of its own a module's membership names.
|
||||
func streamsIssuedFor(d Declared) []StreamIssued {
|
||||
var out []StreamIssued
|
||||
for _, s := range d.Streams {
|
||||
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
|
||||
continue
|
||||
}
|
||||
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
|
||||
Subject: ModuleStreamSubject(d.Module, "{event}")})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
|
||||
func HeardIssuedFor(node string, d Declared) []HeardIssued {
|
||||
var out []HeardIssued
|
||||
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
|
||||
for _, h := range ComposedHears(d) {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
|
||||
return out
|
||||
}
|
||||
|
||||
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
|
||||
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
|
||||
type Pruner interface {
|
||||
ConsumerNames(stream string) ([]string, error)
|
||||
RemoveConsumer(stream, name string) error
|
||||
}
|
||||
|
||||
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
|
||||
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
|
||||
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
|
||||
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
|
||||
keep := map[string]bool{}
|
||||
for _, c := range wanted {
|
||||
keep[c.Stream+"/"+c.Name] = true
|
||||
}
|
||||
var removed []string
|
||||
for _, s := range streams {
|
||||
names, err := p.ConsumerNames(s.Name)
|
||||
if err != nil {
|
||||
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
|
||||
}
|
||||
for _, n := range names {
|
||||
if keep[s.Name+"/"+n] {
|
||||
continue
|
||||
}
|
||||
if err := p.RemoveConsumer(s.Name, n); err != nil {
|
||||
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
|
||||
}
|
||||
removed = append(removed, s.Name+"/"+n)
|
||||
}
|
||||
}
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
|
||||
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
|
||||
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
|
||||
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
|
||||
// returned either way, saying what was done, so the send says it and raises it.
|
||||
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
|
||||
err := r.EnsureConsumer(c)
|
||||
if err == nil || !restricted {
|
||||
return err
|
||||
}
|
||||
p, prunes := r.(Pruner)
|
||||
if !prunes {
|
||||
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
|
||||
}
|
||||
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
|
||||
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
|
||||
"again with its granted filters (novox/hq ADR 0363 §6)", err)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// On a real bus: a module's stream is made beside EVENTS without a clash, keeps what the module publishes on
|
||||
// its own subjects, a granted consumer hears only its part, a narrowed grant narrows it, and a withdrawn one
|
||||
// is removed (novox/hq ADR 0363).
|
||||
//
|
||||
// go test ./internal/broker/ -run TestAModuleStreamOnARealBus
|
||||
func TestAModuleStreamOnARealBus(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
for _, s := range []Stream{MeshStreams()[3], mailStream.Stream()} {
|
||||
_ = js.js.DeleteStream(s.Name)
|
||||
if err := js.EnsureStream(s); err != nil {
|
||||
t.Fatalf("%s: %v", s.Name, err)
|
||||
}
|
||||
defer func(name string) { _ = js.js.DeleteStream(name) }(s.Name)
|
||||
}
|
||||
for _, account := range []string{"mesh", "work"} {
|
||||
if _, err := js.js.Publish(ModuleStreamSubject("mesh-mail", "mail."+account+".message.added"), []byte("{}")); err != nil {
|
||||
t.Fatalf("publishing %s's mail: %v", account, err)
|
||||
}
|
||||
}
|
||||
if info, _ := js.js.StreamInfo(EventsStream); info.State.Msgs != 0 {
|
||||
t.Fatalf("EVENTS kept %d of the module stream's events", info.State.Msgs)
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
c := heardConsumer(reader, mailHeard)
|
||||
if err := js.EnsureConsumer(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 1 {
|
||||
t.Fatalf("the reader granted `mesh` has %d pending, want 1", info.NumPending)
|
||||
}
|
||||
narrowed := c
|
||||
narrowed.Filters = []string{"mesh.stream.mesh-mail.event.mail.mesh.message.changed"}
|
||||
if err := EnsureHeard(js, narrowed, true); err != nil {
|
||||
t.Fatalf("narrowing the grant: %v", err)
|
||||
}
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.Config.FilterSubject != narrowed.Filters[0] || len(info.Config.FilterSubjects) != 0 {
|
||||
t.Fatalf("narrowed to %q %v, want only %s", info.Config.FilterSubject, info.Config.FilterSubjects, narrowed.Filters[0])
|
||||
}
|
||||
if info, _ := js.js.ConsumerInfo("MAIL", c.Name); info.NumPending != 0 {
|
||||
t.Fatalf("after narrowing, %d of the reader's former grant are still pending", info.NumPending)
|
||||
}
|
||||
removed, err := PruneHeard(js, []ModuleStream{mailStream}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_reader"}) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
if names, _ := js.ConsumerNames("MAIL"); len(names) != 0 {
|
||||
t.Fatalf("still on MAIL: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailStream is mesh-mail's own stream as the bus holds it (novox/hq ADR 0363).
|
||||
var mailStream = ModuleStream{Module: "mesh-mail", Name: "MAIL",
|
||||
Events: []string{"mail.*.message.added", "mail.*.message.changed", "mail.*.message.removed"},
|
||||
MaxAgeDays: 7, MaxMiB: 1024, Restricted: true}
|
||||
|
||||
// mailHeard is what a reader granted the account `mesh` hears of it.
|
||||
var mailHeard = Heard{Stream: "MAIL", Emitter: "mesh-mail", Restricted: true, Filters: []string{
|
||||
"mesh.stream.mesh-mail.event.mail.mesh.message.added", "mesh.stream.mesh-mail.event.mail.mesh.message.changed"}}
|
||||
|
||||
// newsStream is a module's stream without a grant: heard by whoever asks (ADR 0363 §5), the tool runner included.
|
||||
var newsStream = ModuleStream{Module: "news", Name: "NEWS", Events: []string{"story.**"}, MaxAgeDays: 7, MaxMiB: 16}
|
||||
|
||||
var newsHeard = Heard{Stream: "NEWS", Emitter: "news", Filters: []string{"mesh.stream.news.event.story.>"}}
|
||||
|
||||
// A module's stream is its own: subjects EVENTS does not claim, its age and size, kept by limits (ADR 0363 §1, §2).
|
||||
func TestAModuleStreamTakesSubjectsEventsDoesNot(t *testing.T) {
|
||||
s := mailStream.Stream()
|
||||
if s.Name != "MAIL" || s.Retention != RetentionLimits || s.MaxAge != 7*24*60*60 || s.MaxBytes != 1024<<20 || s.DiscardNew {
|
||||
t.Fatalf("composed as %+v", s)
|
||||
}
|
||||
want := []string{"mesh.stream.mesh-mail.event.mail.*.message.added", "mesh.stream.mesh-mail.event.mail.*.message.changed",
|
||||
"mesh.stream.mesh-mail.event.mail.*.message.removed"}
|
||||
if !reflect.DeepEqual(s.Subjects, want) {
|
||||
t.Fatalf("subjects %v", s.Subjects)
|
||||
}
|
||||
for _, own := range MeshStreams() {
|
||||
for _, a := range own.Subjects {
|
||||
for _, b := range s.Subjects {
|
||||
if SubjectsOverlap(a, b) {
|
||||
t.Fatalf("%s claims %s, which overlaps %s of %s: the bus would refuse the stream", s.Name, b, a, own.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if WirePattern("mail.**") != "mail.>" {
|
||||
t.Fatal("`**` is spelled `>` on the wire")
|
||||
}
|
||||
}
|
||||
|
||||
// The provider publishes its stream's events on their subjects and nowhere on EVENTS for them; a consumer
|
||||
// granted a part hears it through its own consumer on the stream, and one not granted has nothing there
|
||||
// (ADR 0363 §2, §5).
|
||||
func TestTheProviderPublishesItsStreamAndOnlyAGrantedConsumerHearsIt(t *testing.T) {
|
||||
provider, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "mesh-mail",
|
||||
Streams: []ModuleStream{mailStream}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(provider, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may not publish its own stream's event")
|
||||
}
|
||||
if MayPublish(provider, "mesh.mod.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish its stream's event on EVENTS")
|
||||
}
|
||||
if MayPublish(provider, "mesh.stream.other.event.mail.work.message.added") {
|
||||
t.Fatal("the provider may publish on another module's stream")
|
||||
}
|
||||
|
||||
reader := Principal{Kind: KindModule, Node: "ace", Module: "reader", Hears: []Heard{mailHeard}}
|
||||
perms, err := PermissionsFor(reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, s := range []string{"$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader", "$JS.API.CONSUMER.INFO.MAIL.ace_reader",
|
||||
"$JS.ACK.MAIL.ace_reader.1"} {
|
||||
if !MayPublish(perms, s) {
|
||||
t.Errorf("the granted reader may not publish %s", s)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.mesh.message.added") ||
|
||||
MaySubscribe(perms, "mesh.stream.mesh-mail.event.mail.work.message.added") {
|
||||
t.Fatal("the reader's subscription is not what it was granted")
|
||||
}
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_other") {
|
||||
t.Fatal("the reader may pull another's consumer")
|
||||
}
|
||||
|
||||
stranger, err := PermissionsFor(Principal{Kind: KindModule, Node: "ace", Module: "stranger",
|
||||
Consumes: []string{"billing.paid"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if MayPublish(stranger, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") ||
|
||||
MaySubscribe(stranger, "mesh.stream.mesh-mail.event.mail.mesh.message.added") {
|
||||
t.Fatal("a module not granted reaches the stream")
|
||||
}
|
||||
}
|
||||
|
||||
// A granted consumer of its own account has a durable consumer on the stream, with its granted subjects as
|
||||
// filters, and an EVENTS consumer that hears what it gives up on delivered again (ADR 0363 §5, §7); a module of its
|
||||
// own account is no module the runtime carries, so the runtime reads neither (ADR 0376 §1).
|
||||
func TestAGrantedConsumerHasAConsumerOnTheStream(t *testing.T) {
|
||||
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
|
||||
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}},
|
||||
"ace": {{Module: RuntimeModule}, {Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}, HearsAStream: true},
|
||||
{Module: "stranger", HearsAStream: true}},
|
||||
}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onMail, onEvents []Consumer
|
||||
for _, c := range ConsumersOf(users) {
|
||||
switch c.Consumer.Stream {
|
||||
case "MAIL":
|
||||
onMail = append(onMail, c.Consumer)
|
||||
case EventsStream:
|
||||
onEvents = append(onEvents, c.Consumer)
|
||||
}
|
||||
}
|
||||
if len(onMail) != 1 || onMail[0].Name != "ace_reader" || !slices.Equal(onMail[0].Filters, mailHeard.Filters) {
|
||||
t.Fatalf("on MAIL: %+v", onMail)
|
||||
}
|
||||
if len(onEvents) != 1 || onEvents[0].Name != "ace_reader" || !slices.Equal(onEvents[0].Filters, []string{AgainFilter("ace_reader")}) {
|
||||
t.Fatalf("on EVENTS: %+v", onEvents)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Node != "ace" || (u.Kind != KindNodeTools && u.Module != "reader") {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reads := MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_reader") && MayPublish(perms, "$JS.ACK.MAIL.ace_reader.1")
|
||||
if u.Kind == KindNodeTools && reads {
|
||||
t.Fatal("the runtime may read the consumer on MAIL of a module of its own account")
|
||||
}
|
||||
if u.Kind == KindModule && !reads {
|
||||
t.Fatal("the reader's own account may not read its consumer on MAIL")
|
||||
}
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_stranger") {
|
||||
t.Fatal("a consumer no module is granted may be read")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The tool runner never hears a restricted stream** (ADR 0376 §1, §2): a module it carries, granted part of
|
||||
// MAIL, gets no consumer on MAIL, the tool runner's account no grant to ask about, pull or acknowledge one there,
|
||||
// and its membership no word to bind one; a module of its own account granted the same part gets all three, on
|
||||
// its own account. The grant that composes nothing is said. A stream without a grant is heard through the tool
|
||||
// runner as before.
|
||||
func TestTheToolRunnerNeverHearsARestrictedStream(t *testing.T) {
|
||||
records := Records{Nodes: []string{"ace", "novox"}, Assigned: map[string][]Declared{
|
||||
"novox": {{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, {Module: "news", Streams: []ModuleStream{newsStream}}},
|
||||
"ace": {{Module: RuntimeModule},
|
||||
{Module: "carried", Hears: []Heard{mailHeard, newsHeard}, HearsAStream: true},
|
||||
{Module: "mailer", RunsAs: "mailer", Hears: []Heard{mailHeard}, HearsAStream: true}},
|
||||
}}
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var onMail, onNews []string
|
||||
for _, c := range ConsumersOf(users) {
|
||||
switch c.Consumer.Stream {
|
||||
case "MAIL":
|
||||
onMail = append(onMail, c.Consumer.Name)
|
||||
case "NEWS":
|
||||
onNews = append(onNews, c.Consumer.Name)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(onMail, []string{"ace_mailer"}) {
|
||||
t.Fatalf("consumers on MAIL: %v, want only the mailer's", onMail)
|
||||
}
|
||||
if !slices.Equal(onNews, []string{"ace_carried"}) {
|
||||
t.Fatalf("consumers on NEWS: %v, want the carried module's as before", onNews)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Node != "ace" {
|
||||
continue
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch {
|
||||
case u.Kind == KindNodeTools:
|
||||
for _, s := range perms.Publish {
|
||||
if strings.Contains(s, ".MAIL.") {
|
||||
t.Errorf("the tool runner's account may publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range perms.Subscribe {
|
||||
if strings.HasPrefix(s, "mesh.stream.mesh-mail.") {
|
||||
t.Errorf("the tool runner's account may subscribe %s", s)
|
||||
}
|
||||
}
|
||||
if !MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.NEWS.ace_carried") {
|
||||
t.Error("the tool runner may no longer read the carried module's consumer on a stream without a grant")
|
||||
}
|
||||
case u.Kind == KindModule && u.Module == "carried":
|
||||
if MayPublish(perms, "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_carried") {
|
||||
t.Error("a module without an account of its own may pull a consumer on MAIL")
|
||||
}
|
||||
case u.Kind == KindModule && u.Module == "mailer":
|
||||
for _, s := range []string{"$JS.API.CONSUMER.INFO.MAIL.ace_mailer", "$JS.API.CONSUMER.MSG.NEXT.MAIL.ace_mailer",
|
||||
"$JS.ACK.MAIL.ace_mailer.1"} {
|
||||
if !MayPublish(perms, s) {
|
||||
t.Errorf("the mailer's own account may not publish %s", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
where := Placements{Nodes: map[string][]string{}}
|
||||
carried := MembershipFor("ace", records.Assigned["ace"][1], where)
|
||||
if !reflect.DeepEqual(carried.Hears, []HeardIssued{{Stream: "NEWS", Consumer: "ace_carried"}}) {
|
||||
t.Fatalf("the carried module is told to bind %+v", carried.Hears)
|
||||
}
|
||||
mailer := MembershipFor("ace", records.Assigned["ace"][2], where)
|
||||
if !reflect.DeepEqual(mailer.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_mailer"}}) {
|
||||
t.Fatalf("the mailer is told to bind %+v", mailer.Hears)
|
||||
}
|
||||
withheld := WithheldOf(records)
|
||||
if len(withheld) != 1 || withheld[0] != (Withheld{Node: "ace", Module: "carried", Stream: "MAIL", Emitter: "mesh-mail"}) {
|
||||
t.Fatalf("withheld %+v", withheld)
|
||||
}
|
||||
if said := withheld[0].Said(); !strings.Contains(said, "carried on ace") || !strings.Contains(said, "MAIL") ||
|
||||
!strings.Contains(said, "runs-as") {
|
||||
t.Fatalf("said %q", said)
|
||||
}
|
||||
}
|
||||
|
||||
// The membership says where a module's stream events go and which consumers on module streams to bind
|
||||
// (ADR 0363 §2, §8).
|
||||
func TestTheMembershipNamesTheStreamAndWhatIsHeard(t *testing.T) {
|
||||
where := Placements{Nodes: map[string][]string{}}
|
||||
provider := MembershipFor("novox", Declared{Module: "mesh-mail", Streams: []ModuleStream{mailStream}}, where)
|
||||
if len(provider.Streams) != 1 || provider.Streams[0].Stream != "MAIL" ||
|
||||
provider.Streams[0].Subject != "mesh.stream.mesh-mail.event.{event}" ||
|
||||
!slices.Equal(provider.Streams[0].Events, mailStream.Events) {
|
||||
t.Fatalf("the provider's membership: %+v", provider.Streams)
|
||||
}
|
||||
reader := MembershipFor("ace", Declared{Module: "reader", RunsAs: "reader", Hears: []Heard{mailHeard}}, where)
|
||||
if !reflect.DeepEqual(reader.Hears, []HeardIssued{{Stream: "MAIL", Consumer: "ace_reader"}}) {
|
||||
t.Fatalf("the reader's membership: %+v", reader.Hears)
|
||||
}
|
||||
if none := MembershipFor("ace", Declared{Module: "stranger", HearsAStream: true}, where); none.Hears != nil {
|
||||
t.Fatalf("a module not granted is told to bind %+v", none.Hears)
|
||||
}
|
||||
}
|
||||
|
||||
type fakePruner struct {
|
||||
on map[string][]string
|
||||
removed []string
|
||||
}
|
||||
|
||||
func (f *fakePruner) ConsumerNames(stream string) ([]string, error) { return f.on[stream], nil }
|
||||
func (f *fakePruner) RemoveConsumer(stream, name string) error {
|
||||
f.removed = append(f.removed, stream+"/"+name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// A consumer that lost its grant has its consumer on the stream removed by the next assertion (ADR 0363 §6).
|
||||
func TestAConsumerThatLostItsGrantIsRemoved(t *testing.T) {
|
||||
f := &fakePruner{on: map[string][]string{"MAIL": {"ace_reader", "ace_former"}}}
|
||||
wanted := []Consumer{{Name: "ace_reader", Stream: "MAIL"}, {Name: "ace_former", Stream: EventsStream}}
|
||||
removed, err := PruneHeard(f, []ModuleStream{mailStream}, wanted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(removed, []string{"MAIL/ace_former"}) || !slices.Equal(f.removed, removed) {
|
||||
t.Fatalf("removed %v", removed)
|
||||
}
|
||||
}
|
||||
|
||||
type refusingRaiser struct {
|
||||
fakePruner
|
||||
refuse bool
|
||||
}
|
||||
|
||||
func (r *refusingRaiser) EnsureStream(Stream) error { return nil }
|
||||
func (r *refusingRaiser) EnsureConsumer(Consumer) error {
|
||||
if r.refuse {
|
||||
return errors.New("consumer update refused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A narrowing the bus refuses on a restricted stream removes the consumer, so its wider filters deliver nothing
|
||||
// more; on an unrestricted stream the refusal is only said (review of PR 338, ADR 0363 §6).
|
||||
func TestARefusedNarrowingOnARestrictedStreamRemovesTheConsumer(t *testing.T) {
|
||||
c := Consumer{Name: "ace_reader", Stream: "MAIL", Filters: mailHeard.Filters[:1]}
|
||||
r := &refusingRaiser{refuse: true}
|
||||
err := EnsureHeard(r, c, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "REMOVED") || !slices.Equal(r.removed, []string{"MAIL/ace_reader"}) {
|
||||
t.Fatalf("restricted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
r = &refusingRaiser{refuse: true}
|
||||
if err := EnsureHeard(r, c, false); err == nil || len(r.removed) != 0 {
|
||||
t.Fatalf("unrestricted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
r = &refusingRaiser{}
|
||||
if err := EnsureHeard(r, c, true); err != nil || len(r.removed) != 0 {
|
||||
t.Fatalf("accepted: %v, removed %v", err, r.removed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The move of the secrets into the vault (design 57's V3, issue 676): each machine's node-engine may ask the
|
||||
// vault's move-in in its own name — the subject that names it, and no other machine's — once the vault's holder
|
||||
// serves it; it hears the controller's ask to move a value on its own subject; and the controller may copy its
|
||||
// record of asks with the vault's move-asks. Composed from the records: a vault that serves neither grants nothing.
|
||||
func TestANodeEngineMayMoveItsSecretsIntoTheVaultInItsOwnName(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault",
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"anchor", "shanks"}, Assigned: map[string][]Declared{"anchor": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var anchor, controller Principal
|
||||
for _, u := range users {
|
||||
switch {
|
||||
case u.Kind == KindNode && u.Node == "anchor":
|
||||
anchor = u
|
||||
case u.Kind == KindController:
|
||||
controller = u
|
||||
}
|
||||
}
|
||||
p, err := PermissionsFor(anchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor")) {
|
||||
t.Error("anchor's node-engine may not ask the vault's move-in in its own name")
|
||||
}
|
||||
for _, s := range []string{CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.shanks"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.get", "node.anchor"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.accept", "node.anchor"),
|
||||
"mesh.seat.mesh-vault.tool.move-in"} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("anchor's node-engine may publish %s", s)
|
||||
}
|
||||
}
|
||||
if !MaySubscribe(p, AskMoveSecretSubject("anchor")) || MaySubscribe(p, AskMoveSecretSubject("shanks")) {
|
||||
t.Error("anchor's node-engine does not hear its own move asks alone")
|
||||
}
|
||||
// The vault's holder hears the call naming the node-engine.
|
||||
held := false
|
||||
for _, u := range users {
|
||||
if u.Kind == KindModule && u.Module == "mesh-vault" {
|
||||
held = true
|
||||
h, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MaySubscribe(h, CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor")) {
|
||||
t.Error("the vault's holder does not hear move-in asked by a node-engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
t.Fatal("no principal holds the vault's seat")
|
||||
}
|
||||
c, err := PermissionsFor(controller)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(c, CalledSubject("mesh.seat.mesh-vault.tool.move-asks", ControllerName)) {
|
||||
t.Error("the controller may not copy its record of asks into the vault")
|
||||
}
|
||||
if MayPublish(c, CalledSubject("mesh.seat.mesh-vault.tool.move-in", ControllerName)) {
|
||||
t.Error("the controller may move a value in itself: it opens none")
|
||||
}
|
||||
if !MayPublish(c, AskMoveSecretSubject("anchor")) {
|
||||
t.Error("the controller may not ask a machine's node-engine to move a value")
|
||||
}
|
||||
// A vault that serves neither, the running one: no grant for either.
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {older}}})
|
||||
for _, u := range users {
|
||||
p, _ := PermissionsFor(u)
|
||||
for _, s := range p.Publish {
|
||||
if strings.Contains(s, "move-in") || strings.Contains(s, "move-asks") {
|
||||
t.Errorf("%s may publish %s while the vault serves neither", u.Username(), s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/testbus"
|
||||
)
|
||||
|
||||
// **DEAD_LETTERS and EVENTS take a message's own time to live once updated in place** (novox/hq ADR 0376 §3, §5),
|
||||
// on the pinned server: each made as it was before, without one, is brought to its definition by the controller's
|
||||
// assertion, keeps what it held, and removes a message once its time to live passes. A restricted module stream says
|
||||
// so in its metadata; one without a grant does not.
|
||||
//
|
||||
// go test ./internal/broker/ -run TestTheStreamsTakeAMessagesTimeToLive
|
||||
func TestTheStreamsTakeAMessagesTimeToLive(t *testing.T) {
|
||||
js, err := Dial(testbus.URL(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer js.Close()
|
||||
for _, s := range MeshStreams() {
|
||||
if s.Name != EventsStream && s.Name != DeadLettersStream {
|
||||
if s.AllowMsgTTL {
|
||||
t.Errorf("%s takes a time to live of a message's own, which nothing asks of it", s.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !s.AllowMsgTTL {
|
||||
t.Fatalf("%s is not defined to take a message's time to live", s.Name)
|
||||
}
|
||||
_ = js.js.DeleteStream(s.Name)
|
||||
before := s
|
||||
before.AllowMsgTTL = false
|
||||
if err := js.EnsureStream(before); err != nil {
|
||||
t.Fatalf("%s as it was: %v", s.Name, err)
|
||||
}
|
||||
subject := DeadLetterSubject("EVENTS", "ace_reader")
|
||||
if s.Name == EventsStream {
|
||||
subject = "mesh.mod.gitea.event.pull.merged"
|
||||
}
|
||||
if _, err := js.js.Publish(subject, []byte("held")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := js.EnsureStream(s); err != nil {
|
||||
t.Fatalf("%s updated in place: %v", s.Name, err)
|
||||
}
|
||||
info, err := js.js.StreamInfo(s.Name)
|
||||
if err != nil || !info.Config.AllowMsgTTL || info.State.Msgs != 1 {
|
||||
t.Fatalf("%s after the update: %+v (%v)", s.Name, info, err)
|
||||
}
|
||||
msg := &nats.Msg{Subject: subject, Data: []byte("brief"), Header: nats.Header{}}
|
||||
msg.Header.Set(nats.MsgTTLHdr, "1s")
|
||||
ack, err := js.js.PublishMsg(msg)
|
||||
if err != nil {
|
||||
t.Fatalf("%s refused a message's time to live: %v", s.Name, err)
|
||||
}
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
if _, err := js.js.GetMsg(s.Name, ack.Sequence); err != nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s still holds a message past its time to live", s.Name)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if info, _ := js.js.StreamInfo(s.Name); info.State.Msgs != 1 {
|
||||
t.Fatalf("%s holds %d, want only the message without an age", s.Name, info.State.Msgs)
|
||||
}
|
||||
_ = js.js.PurgeStream(s.Name)
|
||||
}
|
||||
if !mailStream.Stream().Restricted || newsStream.Stream().Restricted {
|
||||
t.Fatal("a module stream does not say whether it is restricted")
|
||||
}
|
||||
_ = js.js.DeleteStream("MAIL")
|
||||
if err := js.EnsureStream(mailStream.Stream()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = js.js.DeleteStream("MAIL") }()
|
||||
if info, _ := js.js.StreamInfo("MAIL"); info.Config.Metadata[RestrictedMetadata] != "true" {
|
||||
t.Fatalf("MAIL's metadata: %v", info.Config.Metadata)
|
||||
}
|
||||
}
|
||||
+194
-5
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -42,6 +43,10 @@ const (
|
||||
// Its authority is the union of what the modules it carries would each have had for their
|
||||
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
|
||||
KindNodeTools Kind = "node-tools"
|
||||
// KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a
|
||||
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so
|
||||
// the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
|
||||
KindProvisioner Kind = "provisioner"
|
||||
)
|
||||
|
||||
// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is
|
||||
@@ -125,11 +130,24 @@ type Principal struct {
|
||||
|
||||
// AsksIssued are the tools the controller asks in its own name for a credential a provider's software
|
||||
// issued, each `<module>.<tool>` (novox/hq ADR 0305): on the subject that names its caller, so the tool
|
||||
// knows the controller asks. Composed from the records, so the installer's first user list — a controller
|
||||
// knows the controller asks. The vault's holder is granted the same tools in its own name (issue 723,
|
||||
// vaultCalls); the controller's grant goes with its path in R1 (issue 682). Composed from the records, so the installer's first user list — a controller
|
||||
// with no records — carries none, and moves with issue 464 like the rest of the controller's caller-named
|
||||
// grants.
|
||||
AsksIssued []string
|
||||
|
||||
// AsksTheVault are the vault's verbs the controller asks in its own name (VerbsTheControllerAsksTheVault,
|
||||
// issue 675), those the vault's holder serves: on the subject that names its caller, so the vault knows the
|
||||
// controller asks. Composed from the records like AsksIssued, so the installer's first user list carries none.
|
||||
AsksTheVault []string
|
||||
|
||||
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
|
||||
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
|
||||
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
|
||||
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10). The controller
|
||||
// keeps its own grant to both until R1 (issue 682), for the modules still filled the old way (clause 17).
|
||||
AsksTheDesk bool
|
||||
|
||||
// State is the local names of the state this principal's module keeps, and Reads the state of
|
||||
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
|
||||
// instances and read by whoever declares it.
|
||||
@@ -141,6 +159,10 @@ type Principal struct {
|
||||
KeyedReads []KeyedRead
|
||||
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
|
||||
Logs []string
|
||||
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
|
||||
// subjects it was granted (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
Hears []Heard
|
||||
|
||||
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
|
||||
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
|
||||
@@ -156,6 +178,16 @@ type Principal struct {
|
||||
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
|
||||
// nowhere else.
|
||||
Checks []string
|
||||
// MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design
|
||||
// 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its
|
||||
// machine, which nothing else can open.
|
||||
MovesIntoTheVault bool
|
||||
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
|
||||
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
|
||||
StatesItsKeyToTheVault bool
|
||||
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
|
||||
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
|
||||
BoundBuckets []string
|
||||
|
||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
||||
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||
@@ -269,12 +301,63 @@ var VerbsTheControllerAsksTheDeliveryOwner = []SeatVerb{{Seat: "mesh-delivery",
|
||||
{Seat: "mesh-delivery", Verb: "close"}, {Seat: "mesh-delivery", Verb: "release"}, {Seat: "mesh-delivery", Verb: "stop"},
|
||||
{Seat: "mesh-delivery", Verb: "times"}, {Seat: "mesh-delivery", Verb: "groups"}}
|
||||
|
||||
// VerbsTheControllerAsksTheVault are the mesh-vault seat's verbs the controller calls in its own name (novox/hq
|
||||
// ADR 0349 clauses 8 to 10, design 57's V2, issue 675): it carries a value given at its terminal, typed at a desk
|
||||
// or issued by a provider's software into the vault, sealed to a key the vault made for the call, and asks a
|
||||
// rotation. The vault takes these from the controller alone, which it knows from the subject that names its
|
||||
// caller; granted as Principal.AsksTheVault says.
|
||||
//
|
||||
// And `move-asks` (design 57's V3, issue 676): the controller copies its record of asks at a desk, which holds no
|
||||
// value, into the vault's.
|
||||
//
|
||||
// And `get` (novox/hq ADR 0349 §16, issue 755): the controller asks for a core module's own credential sealed to its
|
||||
// machine's key, which it carries in that machine's declaration and cannot open (mesh-vault's core.go).
|
||||
var VerbsTheControllerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "accept"},
|
||||
{Seat: "mesh-vault", Verb: "secret-ask"}, {Seat: "mesh-vault", Verb: "issued"}, {Seat: "mesh-vault", Verb: "rotate"},
|
||||
{Seat: "mesh-vault", Verb: "move-asks"}, {Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VaultMoveIn is the vault's verb a machine's node-engine asks in its own name to hand in a value sealed to its
|
||||
// machine (design 57's V3, issue 676); granted as Principal.MovesIntoTheVault says.
|
||||
var VaultMoveIn = SeatVerb{Seat: "mesh-vault", Verb: "move-in"}
|
||||
|
||||
// VaultNodeKey is the vault's verb a machine's node-engine asks in its own name to state its sealing key (issue 786,
|
||||
// novox/hq ADR 0372); granted as Principal.StatesItsKeyToTheVault says.
|
||||
var VaultNodeKey = SeatVerb{Seat: "mesh-vault", Verb: "node-key"}
|
||||
|
||||
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
|
||||
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
|
||||
// controller's own `plans`. Named one by one; a node seat's on any machine, a mesh seat's flat.
|
||||
var VerbsTheControllerActsOnAWarrant = []SeatVerb{{Seat: "node-service-manager", Verb: "restart"},
|
||||
{Seat: ControllerSeat, Verb: "plans"}}
|
||||
|
||||
// VerbsTheControllerAsksTheOperatorChannel are the operator channel's verbs the controller calls (novox/hq
|
||||
// ADR 0380): `tell`, one plain message with no condition behind it — a module's own secret given, named,
|
||||
// where and when and by whom, asking nothing. A mesh seat's verb is flat: no machine in the subject.
|
||||
var VerbsTheControllerAsksTheOperatorChannel = []SeatVerb{{Seat: "operator-channel", Verb: "tell"}}
|
||||
|
||||
// DegradableGrant is a publish grant whose absence from the installer's carried first-user list is not a
|
||||
// bug in the template: a fallback covers the refusal.
|
||||
type DegradableGrant struct {
|
||||
Subject string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// DegradableGrants are the only publish grants TestTheInstallersFirstUserListIsWhatTheControllerWouldCompose
|
||||
// may find missing from the installer's carried list without failing (novox/hq issue 835, the forge check
|
||||
// of mesh-controller#345): a change that only edits mesh-host's examples can never satisfy a check that
|
||||
// reads the mesh-host commit the machines actually run, so the carried list lags behind the controller's
|
||||
// derived one until mesh-host's own module next builds. operator-channel.tell is the one grant the
|
||||
// controller already has a fallback for: refused (no holder, or the bus refuses it), the controller raises
|
||||
// the secret-given condition exactly as before (ADR 0380 §9), so an older carried list still comes up and
|
||||
// works. Every other grant stays required — missing one of those is a controller that comes up and is
|
||||
// refused on the first thing it tries, with no fallback that covers it.
|
||||
var DegradableGrants = []DegradableGrant{
|
||||
{
|
||||
Subject: "mesh.seat." + VerbsTheControllerAsksTheOperatorChannel[0].Seat + ".tool." + VerbsTheControllerAsksTheOperatorChannel[0].Verb,
|
||||
Reason: "refused: the controller raises the secret-given condition as before (ADR 0380 §9)",
|
||||
},
|
||||
}
|
||||
|
||||
// perMachineEvents are a node-scoped seat's events about the holder itself, whose last token is the
|
||||
// holder's machine (novox/hq ADR 0219): `paused.<node>`, the build agent saying whether it takes work.
|
||||
var perMachineEvents = map[string]bool{"paused.*": true}
|
||||
@@ -304,6 +387,10 @@ func (p Principal) Username() string {
|
||||
return "node." + p.Node
|
||||
case KindController:
|
||||
return "controller"
|
||||
case KindProvisioner:
|
||||
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
|
||||
// `<node>.<module>`, which is the module's own account or the tool runner's.
|
||||
return "provisioner." + p.Node
|
||||
case KindEnrolment:
|
||||
// Per token, not one shared user. **The inbox is the reason**: with a single `enrolment`
|
||||
// user every machine enrolling at once could read every other's answer, and an answer
|
||||
@@ -343,6 +430,11 @@ func AskJudgeDirectoriesSubject(node string) string {
|
||||
return "mesh.node." + node + ".ask.judge-directories"
|
||||
}
|
||||
|
||||
// AskMoveSecretSubject is where the controller asks one machine's node-engine to move one value sealed to it into
|
||||
// the vault (design 57's V3, issue 676): a request answered once, signed as a hand-over is (link.MoveSecretContext),
|
||||
// for the same reason.
|
||||
func AskMoveSecretSubject(node string) string { return "mesh.node." + node + ".ask.move-secret" }
|
||||
|
||||
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
|
||||
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
|
||||
// inbox is derived from its own identity and its permissions name that prefix and no other.
|
||||
@@ -496,6 +588,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb+".*")
|
||||
}
|
||||
// And the operator channel, a mesh seat, asked flat: one message when a secret is given (ADR 0380).
|
||||
for _, v := range VerbsTheControllerAsksTheOperatorChannel {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
}
|
||||
// And the delivery's owner, a mesh seat, asked on its flat subjects (ADR 0239).
|
||||
for _, v := range VerbsTheControllerAsksTheDeliveryOwner {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+".tool."+v.Verb)
|
||||
@@ -562,6 +658,12 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
pub = append(pub, "$JS.ACK."+DeadLetterNoticesStream+"."+ControllerName+".>", deadLetterPrefix+">",
|
||||
againPrefix+">")
|
||||
|
||||
case KindProvisioner:
|
||||
var err error
|
||||
if pub, sub, err = provisionerGrants(p); err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
|
||||
case KindPerson:
|
||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
||||
// who could publish an event would be able to claim a module said something.
|
||||
@@ -623,11 +725,24 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// And asking it for a fresh search for setuid programs (novox/hq issue 361), answered the same way.
|
||||
AskSetuidSearchSubject(p.Node),
|
||||
// And asking it to judge its declared directories, read-only (novox/hq issue 358), answered the same way.
|
||||
AskJudgeDirectoriesSubject(p.Node)}
|
||||
AskJudgeDirectoriesSubject(p.Node),
|
||||
// And asking it to move a value sealed to it into the vault (design 57's V3, issue 676), answered the
|
||||
// same way.
|
||||
AskMoveSecretSubject(p.Node)}
|
||||
// The node-engine witnesses the core builds it places (novox/hq to-be 45 §8, ADR 0236; the
|
||||
// contract is lease/witness.go): it asks its own machine's node tools PING, and, where the
|
||||
// machine runs the controller, reads the lease's one key — read, never written.
|
||||
pub = append(pub, WitnessSubjects(p)...)
|
||||
// And, while the vault serves it, the vault's move-in in its own name and nobody else's (issue 676): the
|
||||
// values sealed to this machine, which only it opens, handed in sealed to the vault's key for the call.
|
||||
if p.MovesIntoTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultMoveIn.Seat+"."+CallKind+"."+VaultMoveIn.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
// And, while the vault serves it, its node-key in its own name (issue 786): the machine's sealing key, stated
|
||||
// by the engine that holds it, which the vault takes from no other caller.
|
||||
if p.StatesItsKeyToTheVault {
|
||||
pub = append(pub, "mesh.seat."+VaultNodeKey.Seat+"."+CallKind+"."+VaultNodeKey.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
|
||||
case KindModule:
|
||||
// 1. Its own namespace: it publishes its events there and serves its tools there. Nothing
|
||||
@@ -637,6 +752,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range p.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events, on their own subjects (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(p.Module, p.Streams)...)
|
||||
// Every tool under its own name, not a list: the tools a module serves are what its code
|
||||
// answers, and a second copy of that list in the manifest would be a second source of
|
||||
// truth for the mesh to keep in step (2026-09-28: every module that served a tool was
|
||||
@@ -678,6 +795,16 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
sub = append(sub, subject)
|
||||
}
|
||||
|
||||
// 2a. What it hears of a module's own stream, as it was granted (novox/hq ADR 0363 §5): the subjects,
|
||||
// and its own consumer on that stream, asked about, pulled from and acknowledged.
|
||||
for _, h := range p.Hears {
|
||||
if len(h.Filters) == 0 {
|
||||
continue
|
||||
}
|
||||
sub = append(sub, h.Filters...)
|
||||
}
|
||||
pub = append(pub, heardGrants(p, p.Hears)...)
|
||||
|
||||
// 2b. Events of a role it watches, under the seat's own namespace. Subscribe only: watching a
|
||||
// role is hearing what it announced, not taking part in it.
|
||||
for _, w := range p.Watches {
|
||||
@@ -800,6 +927,8 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, e := range d.Emits {
|
||||
pub = append(pub, own+".event."+e)
|
||||
}
|
||||
// And its own streams' events (novox/hq ADR 0363 §2).
|
||||
pub = append(pub, ownStreamGrants(d.Module, d.Streams)...)
|
||||
for _, s := range d.Holds {
|
||||
if servedOnlyByTheController(s) {
|
||||
continue
|
||||
@@ -824,6 +953,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
return Permissions{}, err
|
||||
}
|
||||
pub = append(pub, invoked...)
|
||||
// And its own machine's provisioner client, for a carried module's config and non-secret bound values,
|
||||
// naming the module from the bundle that asks (novox/hq ADR 0370 §3, §4): the ask on its own machine's
|
||||
// subject alone, and what the client says moved, so a module's code's watches are asked again.
|
||||
pub = append(pub, ProvisionerAskSubject(p.Node))
|
||||
sub = append(sub, "prov.changed."+p.Node+".>")
|
||||
// It says what it serves and may ask what answers (novox/hq ADR 0197): the runtime answers
|
||||
// discovery for each module and seat it carries, and the console it is asks the bus.
|
||||
// One service per runtime process, named for the runtime: the bus lets a principal answer each
|
||||
@@ -838,7 +972,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// consumer is still the controller's to make, from the module's own principal.
|
||||
for _, d := range p.Carries {
|
||||
own := Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches}
|
||||
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
|
||||
Streams: d.Streams, Hears: ComposedHears(d)}
|
||||
// And the module streams it hears, each through its own consumer there (novox/hq ADR 0363 §8) — never
|
||||
// a restricted one, which only a module on an account of its own hears (ADR 0376 §1).
|
||||
pub = append(pub, heardGrants(own, own.Hears)...)
|
||||
if _, consumes := ConsumerFor(own); !consumes {
|
||||
continue
|
||||
}
|
||||
@@ -862,6 +1000,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
for _, d := range p.Carries {
|
||||
pub = append(pub, logGrants(d.Module, logNames(d.Logs))...)
|
||||
}
|
||||
// **And the event families a session may wait on** (novox/hq ADR 0367 §2): the tool runner's module's own,
|
||||
// named one by one in its manifest, as a plain subscription — no consumer is made for them, and no
|
||||
// call or setting adds one.
|
||||
waits, err := waitGrants(p.Carries)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
sub = append(sub, waits...)
|
||||
// **Never the traffic of a trusted holder** (novox/hq ADR 0259 §8): the machine's runtime runs as the
|
||||
// operator's account, which every agent runs as, so a module saying warrants or speaking for a kind
|
||||
// that proves its sender is never composed into it — refused here, naming it, whatever registration
|
||||
@@ -917,8 +1063,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// runs, so a change to them waits on a node-engine delivery. It calls and serves on the subjects that name
|
||||
// no caller meanwhile — a caller-named call to its seat reaches nobody and is asked again on those at once —
|
||||
// and moves with issue 464.
|
||||
// The vault's calls in its own name (issue 723), kept apart so the desk's grant is not denied below.
|
||||
var vaultsOwn []string
|
||||
if p.Kind != KindController {
|
||||
pub, sub = callerNamed(p.Username(), pub, sub)
|
||||
if p.Kind == KindModule {
|
||||
vaultsOwn = vaultCalls(p)
|
||||
pub = append(pub, vaultsOwn...)
|
||||
}
|
||||
} else {
|
||||
// **But the tools that answer a software-issued credential** (novox/hq ADR 0305 §2): those answer the
|
||||
// controller alone, and know it only from the subject's last token, so it asks them in its own name —
|
||||
@@ -930,6 +1082,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
}
|
||||
pub = append(pub, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+CallerToken(p.Username()))
|
||||
}
|
||||
// **And the vault's intake verbs** (issue 675): the vault takes a value from the controller alone, known
|
||||
// from the subject's last token — the verbs its holder serves, flat, and nothing else of its seat.
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if slices.Contains(p.AsksTheVault, v.Verb) {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+CallerToken(p.Username()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(pub)
|
||||
@@ -944,6 +1103,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
if p.Kind != KindController {
|
||||
for _, only := range ControllerOnly() {
|
||||
for _, a := range pub {
|
||||
if slices.Contains(vaultsOwn, a) {
|
||||
continue
|
||||
}
|
||||
if SubjectsOverlap(a, only) {
|
||||
deny = append(deny, only)
|
||||
break
|
||||
@@ -960,11 +1122,38 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// thing, a hand-over on its own subject (novox/hq issue 356), and answers that: the node is its
|
||||
// machine's engine, root there already, and it is delivered only its own subjects. What it answers is
|
||||
// never trusted for being an answer — the controller reads the engine's words and records nothing. A
|
||||
// person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode,
|
||||
// provisioner client is asked by its own machine's tool runner alone, on its own machine's subject (novox/hq
|
||||
// ADR 0370 §3), and answers that. A person is never asked anything, and is granted nothing here.
|
||||
AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools || p.Kind == KindNode ||
|
||||
p.Kind == KindProvisioner,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// vaultCalls are the calls the vault's holder makes in its own name (novox/hq ADR 0349 clauses 9 and 10, issue
|
||||
// 723): the desks' secret prompt on any machine, and each tool that answers a credential its provider's software
|
||||
// issued, on any machine — each on the subject whose last token is the vault's own name, which the bus lets it
|
||||
// alone publish, so the prompt and the tool know the vault asks. Nothing else outside its seat.
|
||||
func vaultCalls(p Principal) []string {
|
||||
token := CallerToken(p.Username())
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if p.AsksTheDesk {
|
||||
for _, v := range VerbsTheControllerAsksForASecret {
|
||||
out = append(out, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".*."+token)
|
||||
}
|
||||
}
|
||||
for _, key := range p.AsksIssued {
|
||||
module, tool, ok := strings.Cut(key, ".")
|
||||
if !ok || module == "" || tool == "" || strings.ContainsAny(key, "*> ") {
|
||||
continue
|
||||
}
|
||||
out = append(out, "mesh.mod."+module+"."+CallKind+"."+tool+".*."+token)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatSubject places a seat's verb under the kind of traffic it is.
|
||||
//
|
||||
// **The kind token is load-bearing, not decoration.** A stream is defined by a subject filter, so
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package broker
|
||||
|
||||
import "testing"
|
||||
|
||||
// Issue 786 (novox/hq ADR 0372): each machine's node-engine may ask the vault's node-key in its own name, the
|
||||
// subject that names it and no other machine's, once the vault's holder serves it; and nothing else of the vault's
|
||||
// is granted by it. A vault that does not serve it grants nothing.
|
||||
func TestANodeEngineMayStateItsSealingKeyToTheVaultInItsOwnNameAlone(t *testing.T) {
|
||||
vault := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get", "node-key"}}}}
|
||||
users, err := Users(Records{Nodes: []string{"anchor", "shanks"}, Assigned: map[string][]Declared{"anchor": {vault}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var anchor Principal
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNode && u.Node == "anchor" {
|
||||
anchor = u
|
||||
}
|
||||
}
|
||||
p, err := PermissionsFor(anchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MayPublish(p, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("anchor's node-engine may not state its key to the vault in its own name")
|
||||
}
|
||||
for _, s := range []string{CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.shanks"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.node-key", ControllerName),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.get", "node.anchor"),
|
||||
CalledSubject("mesh.seat.mesh-vault.tool.move-in", "node.anchor"),
|
||||
"mesh.seat.mesh-vault.tool.node-key"} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("anchor's node-engine may publish %s", s)
|
||||
}
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindModule && u.Module == "mesh-vault" {
|
||||
h, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !MaySubscribe(h, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("the vault's holder does not hear node-key asked by a node-engine")
|
||||
}
|
||||
}
|
||||
}
|
||||
older := Declared{Module: "mesh-vault", Holds: []Seat{{Name: "mesh-vault", Serves: []string{"get", "move-in"}}}}
|
||||
users, _ = Users(Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": {older}}})
|
||||
for _, u := range users {
|
||||
if u.Kind != KindNode {
|
||||
continue
|
||||
}
|
||||
q, _ := PermissionsFor(u)
|
||||
if MayPublish(q, CalledSubject("mesh.seat.mesh-vault.tool.node-key", "node.anchor")) {
|
||||
t.Error("a node-engine may state its key while the vault does not serve node-key")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 P6, issue 672, ADR 0362): a machine's provisioner client's word on a
|
||||
// module's files is kept by the mesh's own stream, newest per module, and the controller reads it by subject; the
|
||||
// client cannot say it of another machine, and nothing but a machine's own client is granted to say it.
|
||||
func TestTheProvisionersReportIsKeptAndReadByTheController(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner-client", PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: "mesh-provisioner-client", PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
connect := func(user string) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
control := OnConn(connect("controller"))
|
||||
if err := AssertMeshStreams(control); err != nil {
|
||||
t.Fatalf("the mesh's streams cannot be asserted: %v", err)
|
||||
}
|
||||
|
||||
g14 := connect("provisioner.g14")
|
||||
for _, said := range []string{`{"status":"waiting"}`, `{"status":"applied","templates":"sha256:new"}`} {
|
||||
if err := g14.Publish(ProvisionerAppliedSubject("g14", "dunst"), []byte(said)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g14.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another machine's client says nothing of g14: refused by the server, and nothing kept.
|
||||
shanks := connect("provisioner.shanks")
|
||||
if err := shanks.Publish(ProvisionerAppliedSubject("g14", "dunst"), []byte(`{"status":"failed"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = shanks.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Fatalf("the server said %v, want a permissions violation", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the server did not refuse shanks's client a report on g14")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
js, err := control.Conn().JetStream(nats.Context(ctx))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A core publish is kept by the stream once the server has stored it: read until it has, a few seconds at most.
|
||||
var got []byte
|
||||
var said bool
|
||||
for deadline := time.Now().Add(5 * time.Second); ; {
|
||||
got, said, err = LastProvApplied(js, "g14", "dunst")
|
||||
if (err == nil && said && strings.Contains(string(got), "applied")) || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if err != nil || !said || string(got) != `{"status":"applied","templates":"sha256:new"}` {
|
||||
t.Fatalf("the controller read %q %v %v, want g14's newest report", got, said, err)
|
||||
}
|
||||
if _, said, err := LastProvApplied(js, "shanks", "dunst"); err != nil || said {
|
||||
t.Fatalf("a module nothing was said of reads as said: %v %v", said, err)
|
||||
}
|
||||
all, err := ProvAppliedReports(js)
|
||||
if err != nil || len(all) != 1 || string(all["prov.applied.g14.dunst"]) != `{"status":"applied","templates":"sha256:new"}` {
|
||||
t.Fatalf("every report, newest per module: %q %v", all, err)
|
||||
}
|
||||
info, err := js.StreamInfo(ProvAppliedStream)
|
||||
if err != nil || info.State.Msgs != 1 {
|
||||
t.Fatalf("the stream keeps the newest report per module only: %+v %v", info, err)
|
||||
}
|
||||
|
||||
// The writers table: a machine's own client alone is granted to say it.
|
||||
if err := CheckWriters(Principal{Kind: KindProvisioner, Node: "g14"}, []string{"prov.applied.g14.>"}); err != nil {
|
||||
t.Fatalf("g14's client may not say its own: %v", err)
|
||||
}
|
||||
for _, p := range []Principal{{Kind: KindProvisioner, Node: "shanks"}, {Kind: KindNode, Node: "g14"},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst"}} {
|
||||
err := CheckWriters(p, []string{"prov.applied.g14.>"})
|
||||
if err == nil || !strings.Contains(err.Error(), "a module's files placed on a machine") {
|
||||
t.Errorf("%s may say g14's provisioner's report: %v", p.Username(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3).
|
||||
//
|
||||
// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that
|
||||
// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the
|
||||
// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that
|
||||
// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client
|
||||
// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the
|
||||
// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it
|
||||
// applied on its own machine, and ask the vault in its own name.
|
||||
|
||||
// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3).
|
||||
const NodeProvisionerSeat = "node-provisioner"
|
||||
|
||||
// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as
|
||||
// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own
|
||||
// secret of this name has nowhere to read an account, and is given none.
|
||||
const ProvisionerSecret = "provisioner-broker"
|
||||
|
||||
// ConfigResolvedBucket is the config domain's resolved values, keyed `<node>.<module>.<key>` (ADR 0348, research
|
||||
// 051): mesh-config's state `resolved`, written by mesh-config alone.
|
||||
var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
|
||||
|
||||
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
|
||||
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
|
||||
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
|
||||
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
|
||||
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
|
||||
var ProvisionerReads = []string{ConfigResolvedBucket}
|
||||
|
||||
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
|
||||
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
|
||||
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
|
||||
// and plans, never a secret's value; the client writes none of it.
|
||||
var ProvisionerReadsWhole = []string{AssignmentsBucket}
|
||||
|
||||
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
|
||||
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
|
||||
// ${bound:<provision>:<key>} (issue 679).
|
||||
const BoundState = "bound"
|
||||
|
||||
// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3,
|
||||
// design 57 P4), whose buckets the clients write their own machine's keys of.
|
||||
const ProvisionerServerModule = "mesh-provisioner"
|
||||
|
||||
// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest
|
||||
// like any module's, and written by its own account whole.
|
||||
var (
|
||||
// ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the
|
||||
// server's alone, read whole by every client.
|
||||
ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry")
|
||||
// ProvisionerNeedsBucket is what each module on each machine needs, `<node>.<module>.<kind>.<name>`.
|
||||
ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs")
|
||||
// ProvisionerAppliedBucket is what is applied where, `<node>.<module>`.
|
||||
ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied")
|
||||
// ProvisionerCollectBucket is the items of every collection, `<node>.<collection>.<contributor>`.
|
||||
ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect")
|
||||
)
|
||||
|
||||
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
|
||||
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
|
||||
// client cannot write another machine's needs, or say another machine applied a file.
|
||||
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
|
||||
|
||||
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
|
||||
// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject
|
||||
// that names its caller, so the vault knows which machine's client asks from the bus's word.
|
||||
var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}}
|
||||
|
||||
// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom,
|
||||
// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A
|
||||
// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4):
|
||||
// it asks the vault again, in its own name, for a secret its machine's declaration names.
|
||||
const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
|
||||
|
||||
// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed
|
||||
// with the reason (ADR 0349 clauses 4 and 15): `prov.applied.<node>.<module>`.
|
||||
func ProvisionerAppliedSubject(node, module string) string {
|
||||
return "prov.applied." + node + "." + module
|
||||
}
|
||||
|
||||
// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value
|
||||
// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR
|
||||
// 0370 §1, §3, §4; issue 685): `prov.ask.<node>`, which the client subscribes and the tool runner's account of the
|
||||
// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word,
|
||||
// so the client answers no secret here (ADR 0370 §2).
|
||||
func ProvisionerAskSubject(node string) string { return "prov.ask." + node }
|
||||
|
||||
// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the
|
||||
// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed.<node>.<module>`,
|
||||
// carrying which value and never the value itself, heard by that machine's tool runner alone.
|
||||
func ProvisionerChangedSubject(node, module string) string {
|
||||
return "prov.changed." + node + "." + module
|
||||
}
|
||||
|
||||
// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe.
|
||||
func provisionerGrants(p Principal) (pub, sub []string, err error) {
|
||||
if p.Node == "" {
|
||||
// Refused rather than composed into `$KV.<bucket>..>`, a pattern no machine's keys are under, and a
|
||||
// name every nameless client would share.
|
||||
return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " +
|
||||
"to read and an account it shared with every other")
|
||||
}
|
||||
for _, bucket := range ProvisionerReads {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
for _, bucket := range ProvisionerReadsWhole {
|
||||
pub = append(pub, wholeBucketRead(bucket)...)
|
||||
}
|
||||
for _, bucket := range p.BoundBuckets {
|
||||
module, name, ok := strings.Cut(bucket, "_")
|
||||
if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") {
|
||||
return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, <module>_%s", bucket, BoundState)
|
||||
}
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
}
|
||||
// The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the
|
||||
// collections, read and written, `$KV.<bucket>.<node>.>` and nothing wider; and the registry, read whole,
|
||||
// which says which domain answers which kind and is the same for every machine.
|
||||
for _, bucket := range ProvisionerWrites {
|
||||
pub = append(pub, machineKeysRead(bucket, p.Node)...)
|
||||
pub = append(pub, "$KV."+bucket+"."+p.Node+".>")
|
||||
}
|
||||
pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...)
|
||||
pub = append(pub, "prov.applied."+p.Node+".>")
|
||||
token := CallerToken(p.Username())
|
||||
if token == "" {
|
||||
return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username())
|
||||
}
|
||||
for _, v := range VerbsTheProvisionerAsksTheVault {
|
||||
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token)
|
||||
}
|
||||
// What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name.
|
||||
pub = append(pub, "prov.changed."+p.Node+".>")
|
||||
// Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says
|
||||
// changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR
|
||||
// 0370 §3), which it answers on the ask's reply and nowhere else.
|
||||
return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil
|
||||
}
|
||||
|
||||
// machineKeysRead is reading every key of a bucket under one machine's name, `<node>.>`, and no other key: a
|
||||
// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter
|
||||
// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260).
|
||||
// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value.
|
||||
func machineKeysRead(bucket, node string) []string {
|
||||
stream := "KV_" + bucket
|
||||
keys := "$KV." + bucket + "." + node + ".>"
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + "." + keys,
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".*." + keys,
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a
|
||||
// bucket it `reads`.
|
||||
func wholeBucketRead(bucket string) []string {
|
||||
stream := "KV_" + bucket
|
||||
return []string{
|
||||
"$JS.API.STREAM.INFO." + stream,
|
||||
"$JS.API.DIRECT.GET." + stream + ".>",
|
||||
"$JS.API.CONSUMER.CREATE." + stream + ".>",
|
||||
"$JS.API.CONSUMER.DELETE." + stream + ".>",
|
||||
"$JS.FC." + stream + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on
|
||||
// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in
|
||||
// the stream's week.
|
||||
func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module))
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err)
|
||||
}
|
||||
return msg.Data, true, nil
|
||||
}
|
||||
|
||||
// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by
|
||||
// ProvAppliedStream, by subject (`prov.applied.<node>.<module>`).
|
||||
func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) {
|
||||
info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err)
|
||||
}
|
||||
out := map[string][]byte{}
|
||||
for subject := range info.State.Subjects {
|
||||
msg, err := js.GetLastMsg(ProvAppliedStream, subject)
|
||||
if errors.Is(err, nats.ErrMsgNotFound) {
|
||||
continue // aged out between the two reads
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err)
|
||||
}
|
||||
out[subject] = msg.Data
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 package P3, issue 669): a machine's provisioner client reads and
|
||||
// watches its own machine's keys in a domain's bucket, and the server refuses it another machine's keys, a write,
|
||||
// and a report or a vault ask in another's name.
|
||||
func TestAServerComposedFromTheGrantsRefusesTheProvisionerClientAnotherMachinesKeys(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault := Seat{Name: "mesh-vault", Scope: "mesh", Serves: []string{"get"}}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "mesh-config", State: []string{"resolved"}, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "mesh-vault", Holds: []Seat{vault}, PasswordHash: string(hash)},
|
||||
{Kind: KindModule, Node: "novox", Module: "postgres", State: []string{"bound"}, PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner", BoundBuckets: []string{"postgres_bound"},
|
||||
PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
connect := func(user string, refusals chan error) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
|
||||
if refusals != nil {
|
||||
refusals <- err
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
|
||||
// The controller makes the buckets, as it does from registration; the controller writes its mirror and
|
||||
// mesh-config writes its own bucket.
|
||||
control, err := jetstream.New(connect("controller", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put := func(js jetstream.JetStream, bucket string, values map[string]string) {
|
||||
t.Helper()
|
||||
kv, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range values {
|
||||
if _, err := kv.PutString(ctx, k, v); err != nil {
|
||||
t.Fatalf("writing %s in %s: %v", k, bucket, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, bucket := range append(append([]string{"postgres_bound"}, ProvisionerReads...), ProvisionerReadsWhole...) {
|
||||
if _, err := control.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put(control, AssignmentsBucket, map[string]string{"g14.dunst": "{}", "shanks.dunst": "{}"})
|
||||
provider, err := jetstream.New(connect("novox.postgres", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(provider, "postgres_bound", map[string]string{"g14.gitea.postgres-database": "{}",
|
||||
"shanks.photos.postgres-database": "{}"})
|
||||
config, err := jetstream.New(connect("novox.mesh-config", nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
put(config, ConfigResolvedBucket, map[string]string{"g14.dunst.font-size": "13", "shanks.dunst.font-size": "11"})
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
clientConn := connect("provisioner.g14", refusals)
|
||||
client, err := jetstream.New(clientConn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := client.KeyValue(ctx, ConfigResolvedBucket)
|
||||
if err != nil {
|
||||
t.Fatalf("the client cannot bind to the config domain's bucket: %v", err)
|
||||
}
|
||||
|
||||
// Its own machine's key, read and watched; the watch delivers the current value first.
|
||||
if e, err := resolved.Get(ctx, "g14.dunst.font-size"); err != nil || string(e.Value()) != "13" {
|
||||
t.Fatalf("its own machine's key: %v %v", e, err)
|
||||
}
|
||||
w, err := resolved.Watch(ctx, "g14.>")
|
||||
if err != nil {
|
||||
t.Fatalf("watching its own machine's keys: %v", err)
|
||||
}
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Key() != "g14.dunst.font-size" {
|
||||
t.Fatalf("the watch delivered %v first", e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the watch of its own machine's keys delivered nothing")
|
||||
}
|
||||
_ = w.Stop()
|
||||
assignments, err := client.KeyValue(ctx, AssignmentsBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := assignments.Get(ctx, "g14.dunst"); err != nil {
|
||||
t.Fatalf("its own machine's placement: %v", err)
|
||||
}
|
||||
|
||||
// Another machine's key: refused by the server, read or watched, and every key at once too; and a write of
|
||||
// a domain's bucket, which is the domain's alone. Each refusal is the server's word, not a missing key.
|
||||
drain := func() {
|
||||
for {
|
||||
select {
|
||||
case <-refusals:
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
serverRefuses := func(what string, act func(context.Context) error) {
|
||||
t.Helper()
|
||||
drain()
|
||||
short, done := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer done()
|
||||
if err := act(short); err == nil {
|
||||
t.Errorf("the client may %s", what)
|
||||
}
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", what, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("%s: the server did not refuse it", what)
|
||||
}
|
||||
}
|
||||
serverRefuses("read another machine's config", func(c context.Context) error {
|
||||
_, err := resolved.Get(c, "shanks.dunst.font-size")
|
||||
return err
|
||||
})
|
||||
// Another machine's placement is read, since a provider's consumers are bound to it in their own machines'
|
||||
// plans (ADR 0373, issue 679), and never written.
|
||||
if _, err := assignments.Get(ctx, "shanks.dunst"); err != nil {
|
||||
t.Fatalf("another machine's plan, which binds its consumers to a provider here: %v", err)
|
||||
}
|
||||
serverRefuses("write the assignments mirror", func(c context.Context) error {
|
||||
_, err := assignments.PutString(c, "shanks.dunst", "{}")
|
||||
return err
|
||||
})
|
||||
// A provider's entries: its own machine's consumers' keys alone (issue 679).
|
||||
entries, err := client.KeyValue(ctx, "postgres_bound")
|
||||
if err != nil {
|
||||
t.Fatalf("the client cannot bind to a provider's bucket of entries: %v", err)
|
||||
}
|
||||
if _, err := entries.Get(ctx, "g14.gitea.postgres-database"); err != nil {
|
||||
t.Fatalf("its own machine's consumer's entry: %v", err)
|
||||
}
|
||||
serverRefuses("read another machine's consumer's entry", func(c context.Context) error {
|
||||
_, err := entries.Get(c, "shanks.photos.postgres-database")
|
||||
return err
|
||||
})
|
||||
serverRefuses("write a provider's entry", func(c context.Context) error {
|
||||
_, err := entries.PutString(c, "g14.gitea.postgres-database", "{}")
|
||||
return err
|
||||
})
|
||||
for _, keys := range []string{"shanks.>", ">"} {
|
||||
serverRefuses("watch "+keys, func(c context.Context) error {
|
||||
w, err := resolved.Watch(c, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("nothing delivered")
|
||||
case <-c.Done():
|
||||
return c.Err()
|
||||
}
|
||||
})
|
||||
}
|
||||
serverRefuses("write the config domain's bucket", func(c context.Context) error {
|
||||
_, err := resolved.PutString(c, "g14.dunst.font-size", "40")
|
||||
return err
|
||||
})
|
||||
|
||||
// What it applied, on its own machine and no other's; the vault asked in its own name and no other's.
|
||||
refused := func(subject string, want bool) {
|
||||
t.Helper()
|
||||
drain()
|
||||
if err := clientConn.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = clientConn.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !want {
|
||||
t.Errorf("%s was refused: %v", subject, err)
|
||||
} else if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
if want {
|
||||
t.Errorf("the server did not refuse %s", subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
refused("prov.applied.g14.dunst", false)
|
||||
refused("prov.applied.shanks.dunst", true)
|
||||
refused("mesh.seat.mesh-vault.call.get.provisioner~g14", false)
|
||||
refused("mesh.seat.mesh-vault.call.get.g14~node-tools", true)
|
||||
refused("mesh.seat.mesh-vault.call.accept.provisioner~g14", true)
|
||||
refused("mesh.mod.dunst.tool.anything", true)
|
||||
}
|
||||
|
||||
// **On a real server, as composed** (novox/hq ADR 0370 §3, issue 685): a machine's tool runner asks its own
|
||||
// machine's provisioner client and is answered; the server refuses the ask from another machine's tool runner, from
|
||||
// a module's own account and from the client of another machine, and refuses the client what another machine's
|
||||
// tool runner would hear.
|
||||
func TestAServerComposedFromTheGrantsLetsOnlyItsOwnToolRunnerAskTheProvisionerClient(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: string(hash)},
|
||||
{Kind: KindNodeTools, Node: "g14", Module: RuntimeModule, PasswordHash: string(hash),
|
||||
Carries: []Declared{{Module: "claude-code"}}},
|
||||
{Kind: KindNodeTools, Node: "shanks", Module: RuntimeModule, PasswordHash: string(hash),
|
||||
Carries: []Declared{{Module: "claude-code"}}},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst", PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner", BoundBuckets: []string{"postgres_bound"},
|
||||
PasswordHash: string(hash)},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: "mesh-provisioner", PasswordHash: string(hash)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
connect := func(user string, refusals chan error) *nats.Conn {
|
||||
t.Helper()
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
|
||||
if refusals != nil {
|
||||
refusals <- err
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
return nc
|
||||
}
|
||||
|
||||
clientRefusals := make(chan error, 8)
|
||||
client := connect("provisioner.g14", clientRefusals)
|
||||
asked := make(chan string, 8)
|
||||
if _, err := client.Subscribe(ProvisionerAskSubject("g14"), func(m *nats.Msg) {
|
||||
asked <- string(m.Data)
|
||||
_ = m.Respond([]byte(`{"value":"answered"}`))
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := client.Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case err := <-clientRefusals:
|
||||
t.Fatalf("the client may not hear its own machine's asks: %v", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
|
||||
// Its own machine's tool runner asks and is answered.
|
||||
runner := connect("g14.node-tools", nil)
|
||||
reply, err := runner.Request(ProvisionerAskSubject("g14"), []byte(`{"module":"claude-code"}`), 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("g14's tool runner cannot ask g14's client: %v", err)
|
||||
}
|
||||
if string(reply.Data) != `{"value":"answered"}` {
|
||||
t.Fatalf("answered %s", reply.Data)
|
||||
}
|
||||
<-asked
|
||||
|
||||
// Anybody else's ask is refused by the server, and never reaches the client.
|
||||
for _, user := range []string{"shanks.node-tools", "g14.dunst", "provisioner.shanks", "controller"} {
|
||||
refusals := make(chan error, 4)
|
||||
nc := connect(user, refusals)
|
||||
if err := nc.Publish(ProvisionerAskSubject("g14"), []byte(`{"module":"claude-code"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = nc.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", user, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("the server did not refuse %s an ask of g14's client", user)
|
||||
}
|
||||
select {
|
||||
case body := <-asked:
|
||||
t.Errorf("g14's client heard %s from %s", body, user)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// What moved is said under its own machine's name, heard by that machine's tool runner; another machine's name
|
||||
// is refused it.
|
||||
told := make(chan string, 4)
|
||||
if _, err := runner.Subscribe("prov.changed.g14.>", func(m *nats.Msg) { told <- m.Subject }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = runner.Flush()
|
||||
if err := client.Publish(ProvisionerChangedSubject("g14", "claude-code"), []byte(`{"kind":"config"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case subject := <-told:
|
||||
if subject != "prov.changed.g14.claude-code" {
|
||||
t.Errorf("heard %s", subject)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("g14's tool runner did not hear what g14's client said moved")
|
||||
}
|
||||
if err := client.Publish(ProvisionerChangedSubject("shanks", "claude-code"), []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = client.Flush()
|
||||
select {
|
||||
case err := <-clientRefusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("the server said %v, want a permissions violation", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Error("the server did not refuse g14's client a word in shanks's name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// **On a real server, as composed** (design 57 package P4, issue 670): the provisioner server's buckets are
|
||||
// written by the server's own account whole, and by each machine's provisioner client for its own machine's keys
|
||||
// alone (ADR 0349 clause 3). The server refuses a client another machine's needs, applied or items, and the
|
||||
// registry; and refuses every other account a write, a provider reading the needs included.
|
||||
func TestOnlyTheServerAndEachClientForItsOwnMachineWriteTheProvisionersBuckets(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pw := string(hash)
|
||||
accounts, err := ComposeAccounts([]Principal{
|
||||
{Kind: KindController, PasswordHash: pw},
|
||||
{Kind: KindModule, Node: "novox", Module: ProvisionerServerModule,
|
||||
State: []string{"registry", "needs", "applied", "collect"}, PasswordHash: pw},
|
||||
// A provider learns its consumers from the needs (ADR 0349 clause 12): it reads them, and writes none.
|
||||
{Kind: KindModule, Node: "novox", Module: "postgres", Reads: []string{"mesh-provisioner.needs"}, PasswordHash: pw},
|
||||
{Kind: KindModule, Node: "g14", Module: "dunst", PasswordHash: pw},
|
||||
{Kind: KindProvisioner, Node: "g14", Module: ProvisionerServerModule, PasswordHash: pw},
|
||||
{Kind: KindProvisioner, Node: "shanks", Module: ProvisionerServerModule, PasswordHash: pw},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
type account struct {
|
||||
js jetstream.JetStream
|
||||
refusals chan error
|
||||
}
|
||||
connect := func(user string) account {
|
||||
t.Helper()
|
||||
refusals := make(chan error, 16)
|
||||
nc, err := nats.Connect(s.ClientURL(), nats.UserInfo(user, "pw"), nats.CustomInboxPrefix("_INBOX."+user),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return account{js: js, refusals: refusals}
|
||||
}
|
||||
bucket := func(a account, name string) jetstream.KeyValue {
|
||||
t.Helper()
|
||||
kv, err := a.js.KeyValue(ctx, name)
|
||||
if err != nil {
|
||||
t.Fatalf("binding to %s: %v", name, err)
|
||||
}
|
||||
return kv
|
||||
}
|
||||
|
||||
// The controller makes the buckets from the server's manifest, as it does for every module's state.
|
||||
control := connect("controller")
|
||||
for _, name := range append([]string{ProvisionerRegistryBucket}, ProvisionerWrites...) {
|
||||
if _, err := control.js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The server writes all four, every machine's keys.
|
||||
srv := connect("novox." + ProvisionerServerModule)
|
||||
for name, key := range map[string]string{ProvisionerRegistryBucket: "config", ProvisionerNeedsBucket: "shanks.dunst.config.font-size",
|
||||
ProvisionerAppliedBucket: "shanks.dunst", ProvisionerCollectBucket: "shanks.zsh-snippets.git"} {
|
||||
if _, err := bucket(srv, name).PutString(ctx, key, `{}`); err != nil {
|
||||
t.Fatalf("the server writing %s in %s: %v", key, name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A client writes, reads and watches its own machine's keys, and reads the registry whole.
|
||||
g14 := connect("provisioner.g14")
|
||||
own := map[string]string{ProvisionerNeedsBucket: "g14.dunst.config.font-size", ProvisionerAppliedBucket: "g14.dunst",
|
||||
ProvisionerCollectBucket: "g14.zsh-snippets.git"}
|
||||
for name, key := range own {
|
||||
kv := bucket(g14, name)
|
||||
if _, err := kv.PutString(ctx, key, `{}`); err != nil {
|
||||
t.Fatalf("the client writing its own machine's %s in %s: %v", key, name, err)
|
||||
}
|
||||
if e, err := kv.Get(ctx, key); err != nil || string(e.Value()) != `{}` {
|
||||
t.Fatalf("the client reading back its own %s in %s: %v %v", key, name, e, err)
|
||||
}
|
||||
w, err := kv.Watch(ctx, "g14.>")
|
||||
if err != nil {
|
||||
t.Fatalf("the client watching its own machine's keys in %s: %v", name, err)
|
||||
}
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Key() != key {
|
||||
t.Fatalf("the watch of %s delivered %v first", name, e)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("the watch of its own machine's keys in %s delivered nothing", name)
|
||||
}
|
||||
_ = w.Stop()
|
||||
if err := kv.Delete(ctx, key); err != nil {
|
||||
t.Fatalf("the client deleting its own %s in %s (a module taken off its machine): %v", key, name, err)
|
||||
}
|
||||
}
|
||||
registry := bucket(g14, ProvisionerRegistryBucket)
|
||||
if e, err := registry.Get(ctx, "config"); err != nil || e == nil {
|
||||
t.Fatalf("the client reading the registry: %v %v", e, err)
|
||||
}
|
||||
|
||||
// Each refusal is the server's word, not a missing key.
|
||||
serverRefuses := func(a account, what string, act func(context.Context) error) {
|
||||
t.Helper()
|
||||
for drained := false; !drained; {
|
||||
select {
|
||||
case <-a.refusals:
|
||||
default:
|
||||
drained = true
|
||||
}
|
||||
}
|
||||
short, done := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer done()
|
||||
if err := act(short); err == nil {
|
||||
t.Errorf("%s was let through", what)
|
||||
}
|
||||
select {
|
||||
case err := <-a.refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", what, err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Errorf("%s: the server did not refuse it", what)
|
||||
}
|
||||
}
|
||||
put := func(kv jetstream.KeyValue, key string) func(context.Context) error {
|
||||
return func(c context.Context) error { _, err := kv.PutString(c, key, `{}`); return err }
|
||||
}
|
||||
for name, key := range map[string]string{ProvisionerNeedsBucket: "shanks.dunst.config.font-size",
|
||||
ProvisionerAppliedBucket: "shanks.dunst", ProvisionerCollectBucket: "shanks.zsh-snippets.git"} {
|
||||
kv := bucket(g14, name)
|
||||
serverRefuses(g14, "g14's client writing shanks's "+key+" in "+name, put(kv, key))
|
||||
serverRefuses(g14, "g14's client reading shanks's "+key+" in "+name, func(c context.Context) error {
|
||||
_, err := kv.Get(c, key)
|
||||
return err
|
||||
})
|
||||
}
|
||||
serverRefuses(g14, "g14's client watching every machine's needs", func(c context.Context) error {
|
||||
w, err := bucket(g14, ProvisionerNeedsBucket).Watch(c, ">")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
select {
|
||||
case e := <-w.Updates():
|
||||
if e != nil {
|
||||
return nil
|
||||
}
|
||||
return errors.New("nothing delivered")
|
||||
case <-c.Done():
|
||||
return c.Err()
|
||||
}
|
||||
})
|
||||
serverRefuses(g14, "a client writing the registry", put(registry, "config"))
|
||||
|
||||
// Shanks's client is held to shanks's keys the same way: its write of g14's applied is refused.
|
||||
shanks := connect("provisioner.shanks")
|
||||
if _, err := bucket(shanks, ProvisionerAppliedBucket).PutString(ctx, "shanks.dunst", `{}`); err != nil {
|
||||
t.Fatalf("shanks's client writing its own applied: %v", err)
|
||||
}
|
||||
serverRefuses(shanks, "shanks's client writing g14's applied", put(bucket(shanks, ProvisionerAppliedBucket), "g14.dunst"))
|
||||
|
||||
// A provider reads every machine's needs and writes none; a module on a client's machine writes none.
|
||||
provider := connect("novox.postgres")
|
||||
needs := bucket(provider, ProvisionerNeedsBucket)
|
||||
if e, err := needs.Get(ctx, "shanks.dunst.config.font-size"); err != nil || e == nil {
|
||||
t.Fatalf("the provider reading the needs: %v %v", e, err)
|
||||
}
|
||||
serverRefuses(provider, "a provider writing the needs", put(needs, "shanks.postgres.bound.db"))
|
||||
dunst := connect("g14.dunst")
|
||||
for _, name := range append([]string{ProvisionerRegistryBucket}, ProvisionerWrites...) {
|
||||
serverRefuses(dunst, "a module on g14 writing "+name, func(c context.Context) error {
|
||||
// Bound by the stream's own subject: the module is granted no bind, so its put is published raw.
|
||||
_, err := dunst.js.Publish(c, "$KV."+name+".g14.dunst", []byte(`{}`))
|
||||
return err
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3, issue
|
||||
// 669): one per machine running it, apart from the machine's tool runner, granted to read its own machine's keys
|
||||
// in each domain's bucket, to say what it applied on that machine, and to ask the vault in its own name; nothing
|
||||
// else.
|
||||
|
||||
func provisionerRecords() Records {
|
||||
client := Declared{Module: "mesh-provisioner", ProvisionsNode: true}
|
||||
return Records{
|
||||
Nodes: []string{"g14", "shanks", "ace"},
|
||||
Assigned: map[string][]Declared{
|
||||
"g14": {client, {Module: RuntimeModule}},
|
||||
"shanks": {client},
|
||||
"ace": {{Module: "dunst"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// One account per machine that runs the client, named for the machine and apart from its tool runner's: the
|
||||
// vault tells the client from an agent on that machine by the name the bus proves.
|
||||
func TestEachMachineRunningTheProvisionerClientHasAnAccountOfItsOwn(t *testing.T) {
|
||||
users, err := Users(provisionerRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, u := range users {
|
||||
if u.Kind == KindProvisioner {
|
||||
got = append(got, u.Username())
|
||||
if u.Module != "mesh-provisioner" {
|
||||
t.Errorf("%s is delivered to %q, want the module that runs it", u.Username(), u.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []string{"provisioner.g14", "provisioner.shanks"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("the provisioner clients' accounts are %v, want %v (none for a machine that does not run it)", got, want)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind == KindNodeTools && u.Username() == "provisioner.g14" {
|
||||
t.Fatal("the client shares the tool runner's account")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Its whole authority, subject by subject: a read of its own machine's keys in each domain's bucket, a read and a
|
||||
// write of its own machine's keys in the provisioner server's needs, applied and collections, a read of the
|
||||
// server's registry (design 57 P4), a publish of what it applied on its own machine, the vault's `get` in its own
|
||||
// name, its own inbox and the vault's secret-changed.
|
||||
func TestTheProvisionerClientIsGrantedItsMachinesKeysAndNothingElse(t *testing.T) {
|
||||
perms, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner",
|
||||
BoundBuckets: []string{"postgres_bound"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wantPub []string
|
||||
for _, bucket := range []string{"mesh-config_resolved", "postgres_bound"} {
|
||||
stream := "KV_" + bucket
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>")
|
||||
}
|
||||
// The assignments mirror whole, read and never written: a provider's consumers are bound to it in their own
|
||||
// machines' plans, which its machine's client reads to fill ${consumers:<provision>} (novox/hq ADR 0373, issue
|
||||
// 679). The mirror holds placement and plans, never a value of a secret.
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO.KV_mesh-controller_assignments",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-controller_assignments.>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-controller_assignments.>",
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-controller_assignments.>",
|
||||
"$JS.FC.KV_mesh-controller_assignments.>")
|
||||
for _, bucket := range []string{"mesh-provisioner_needs", "mesh-provisioner_applied", "mesh-provisioner_collect"} {
|
||||
stream := "KV_" + bucket
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO."+stream,
|
||||
"$JS.API.DIRECT.GET."+stream+".$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+".g14.>",
|
||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
||||
"$JS.FC."+stream+".>",
|
||||
"$KV."+bucket+".g14.>")
|
||||
}
|
||||
wantPub = append(wantPub,
|
||||
"$JS.API.STREAM.INFO.KV_mesh-provisioner_registry",
|
||||
"$JS.API.DIRECT.GET.KV_mesh-provisioner_registry.>",
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-provisioner_registry.>",
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-provisioner_registry.>",
|
||||
"$JS.FC.KV_mesh-provisioner_registry.>")
|
||||
wantPub = append(wantPub, "prov.applied.g14.>", "mesh.seat.mesh-vault.call.get.provisioner~g14",
|
||||
"prov.changed.g14.>")
|
||||
slices.Sort(wantPub)
|
||||
if !slices.Equal(perms.Publish, wantPub) {
|
||||
t.Errorf("publish:\n have %q\n want %q", perms.Publish, wantPub)
|
||||
}
|
||||
// Its own inbox, what the vault says changed (`secret-changed`: which, by whom, never a value), so a rotated
|
||||
// secret reaches its file without a new declaration (design 57 V4), and its own machine's tool runner asking
|
||||
// it for a carried module's config (ADR 0370 §3), which it answers on the ask's reply.
|
||||
if want := []string{"_INBOX.provisioner.g14.>", "mesh.mod.mesh-vault.event.secret-changed", "prov.ask.g14"}; !slices.Equal(perms.Subscribe, want) {
|
||||
t.Errorf("subscribe:\n have %q\n want %q", perms.Subscribe, want)
|
||||
}
|
||||
if !perms.AllowResponses || len(perms.PublishDeny) > 0 {
|
||||
t.Errorf("it answers what it is asked and needs no deny: %+v", perms)
|
||||
}
|
||||
// No write of any bucket but its own machine's keys of the server's needs, applied and collections: a domain
|
||||
// writes its own (ADR 0349 clause 3, research 051), and the registry is the server's alone.
|
||||
for _, p := range perms.Publish {
|
||||
if !SubjectsOverlap(p, "$KV.>") {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains([]string{"$KV.mesh-provisioner_needs.g14.>", "$KV.mesh-provisioner_applied.g14.>",
|
||||
"$KV.mesh-provisioner_collect.g14.>"}, p) {
|
||||
t.Errorf("it may write a bucket: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678), is read by every machine's
|
||||
// client for that machine's consumers' keys alone (`<node>.>`), to fill ${bound:<provision>:<key>} (issue 679): the
|
||||
// buckets of every module the mesh places that keeps state named bound, wherever it runs, and no other.
|
||||
func TestTheProvisionerClientReadsItsMachinesKeysOfEachProvidersBoundBucket(t *testing.T) {
|
||||
records := provisionerRecords()
|
||||
records.Assigned["ace"] = append(records.Assigned["ace"],
|
||||
Declared{Module: "postgres", State: []Bucket{{Module: "postgres", Name: "bound"}, {Module: "postgres", Name: "kept"}}},
|
||||
Declared{Module: "keycloak", State: []Bucket{{Module: "keycloak", Name: "bound"}}})
|
||||
records.Assigned["shanks"] = append(records.Assigned["shanks"],
|
||||
Declared{Module: "postgres", State: []Bucket{{Module: "postgres", Name: "bound"}}})
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Kind != KindProvisioner {
|
||||
continue
|
||||
}
|
||||
if want := []string{"keycloak_bound", "postgres_bound"}; !slices.Equal(u.BoundBuckets, want) {
|
||||
t.Errorf("%s reads the bound buckets %v, want %v", u.Username(), u.BoundBuckets, want)
|
||||
}
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mine := "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound." + u.Node + ".>"
|
||||
if !slices.Contains(perms.Publish, mine) {
|
||||
t.Errorf("%s may not read its machine's keys of postgres_bound", u.Username())
|
||||
}
|
||||
for _, p := range perms.Publish {
|
||||
if SubjectsOverlap(p, "$JS.API.DIRECT.GET.KV_postgres_kept.>") || SubjectsOverlap(p, "$KV.postgres_bound.>") ||
|
||||
SubjectsOverlap(p, "$JS.API.DIRECT.GET.KV_postgres_bound.$KV.postgres_bound.ace.>") {
|
||||
t.Errorf("%s is granted %s: another bucket, a write, or another machine's consumers", u.Username(), p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: "g14", Module: "mesh-provisioner",
|
||||
BoundBuckets: []string{"postgres_bound.>"}}); err == nil {
|
||||
t.Error("a bound bucket that is a pattern composed")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with no usable name reaches nothing: its grants would otherwise be a pattern over every machine.
|
||||
func TestAProvisionerClientNamingNoMachineIsRefused(t *testing.T) {
|
||||
for _, node := range []string{"", "g14.>", "*"} {
|
||||
if _, err := PermissionsFor(Principal{Kind: KindProvisioner, Node: node, Module: "mesh-provisioner"}); err == nil {
|
||||
t.Errorf("a provisioner client on %q composed", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A whole mesh composes with the clients beside every other kind: nobody is granted a second writer's subject.
|
||||
func TestAMeshWithProvisionerClientsComposes(t *testing.T) {
|
||||
users, err := Users(provisionerRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = "x"
|
||||
}
|
||||
if _, err := ComposeAccounts(users); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The client is asked on a subject its own machine's tool runner alone may publish (novox/hq ADR 0370 §3, issue
|
||||
// 685): across a whole mesh, no principal but g14's tool runner may ask g14's client, none but g14's client hears
|
||||
// it, and the client's word of what moved is heard by g14's tool runner alone.
|
||||
func TestTheProvisionerClientIsAskedByItsOwnMachinesToolRunnerAlone(t *testing.T) {
|
||||
records := provisionerRecords()
|
||||
records.Assigned["shanks"] = append(records.Assigned["shanks"], Declared{Module: RuntimeModule},
|
||||
Declared{Module: "dunst"})
|
||||
records.Assigned["g14"] = append(records.Assigned["g14"], Declared{Module: "claude-code"})
|
||||
users, err := Users(records)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users = append(users, Principal{Kind: KindPerson, Module: "jochen", Invokes: []string{"*"}})
|
||||
var asks, hears, told []string
|
||||
for _, u := range users {
|
||||
perms, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", u.Username(), err)
|
||||
}
|
||||
if slices.ContainsFunc(perms.Publish, func(p string) bool { return SubjectsOverlap(p, ProvisionerAskSubject("g14")) }) {
|
||||
asks = append(asks, u.Username())
|
||||
}
|
||||
if slices.ContainsFunc(perms.Subscribe, func(p string) bool { return SubjectsOverlap(p, ProvisionerAskSubject("g14")) }) {
|
||||
hears = append(hears, u.Username())
|
||||
}
|
||||
if slices.ContainsFunc(perms.Subscribe, func(p string) bool {
|
||||
return SubjectsOverlap(p, ProvisionerChangedSubject("g14", "claude-code"))
|
||||
}) {
|
||||
told = append(told, u.Username())
|
||||
}
|
||||
}
|
||||
if want := []string{"g14.node-tools"}; !slices.Equal(asks, want) {
|
||||
t.Errorf("g14's client may be asked by %v, want %v alone", asks, want)
|
||||
}
|
||||
if want := []string{"provisioner.g14"}; !slices.Equal(hears, want) {
|
||||
t.Errorf("g14's client's asks are heard by %v, want %v alone", hears, want)
|
||||
}
|
||||
if want := []string{"g14.node-tools"}; !slices.Equal(told, want) {
|
||||
t.Errorf("what g14's client says moved is heard by %v, want %v alone", told, want)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||
states = append(states, link.KeyChecked)
|
||||
// And a walk kept in a new state, for the delivery it walks (novox/hq ADR 0239).
|
||||
states = append(states, link.KeyPlanMoved)
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3; design 57 F3).
|
||||
states = append(states, link.KeyAssignmentChanged, link.KeyDeclaredConfigChanged, link.KeyPredefinedChanged)
|
||||
for _, event := range states {
|
||||
if !slices.Contains(broker.ControllerStates, event) {
|
||||
t.Errorf("the mesh states %q and its account may not publish it", event)
|
||||
@@ -42,6 +44,11 @@ func TestTheFactsTheGrantPermitsAreTheFactsTheMeshStates(t *testing.T) {
|
||||
if len(broker.ControllerStates) != len(states) {
|
||||
t.Errorf("the grant permits %v, which is more than the mesh states", broker.ControllerStates)
|
||||
}
|
||||
// The record mirrors a module may read are the ones the bus holds (design 54 §5, design 57 F3).
|
||||
if !slices.Equal(catalogue.ControllerMirrors, broker.ControllerMirrors()) {
|
||||
t.Errorf("the catalogue lets a module read the controller's mirrors %v and the bus holds %v",
|
||||
catalogue.ControllerMirrors, broker.ControllerMirrors())
|
||||
}
|
||||
// **And the seat says it.** A seat carries the protocol of its role (novox/hq ADR 0129), so the
|
||||
// facts the control plane states are the seat's `emits` — which is what lets anything else declare
|
||||
// that it consumes them, and what the subject-agreement check reads to know they have an owner.
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// The catalogue's list of the mesh's own stream names, which a module's stream may not take, is the broker's
|
||||
// own list (novox/hq ADR 0363 §1): read from MeshStreams, so a stream added there cannot be forgotten there.
|
||||
func TestTheCataloguesReservedStreamNamesAreTheMeshsOwn(t *testing.T) {
|
||||
var own []string
|
||||
for _, s := range MeshStreams() {
|
||||
own = append(own, s.Name)
|
||||
}
|
||||
reserved := append([]string(nil), catalogue.MeshStreamNames...)
|
||||
slices.Sort(own)
|
||||
slices.Sort(reserved)
|
||||
if !slices.Equal(own, reserved) {
|
||||
t.Fatalf("the broker's own streams are %v, the catalogue reserves %v", own, reserved)
|
||||
}
|
||||
}
|
||||
@@ -59,8 +59,17 @@ type Stream struct {
|
||||
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
|
||||
// server's default (two minutes).
|
||||
DuplicatesSeconds int
|
||||
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
|
||||
// stream it came from (novox/hq ADR 0376 §3, §5).
|
||||
AllowMsgTTL bool
|
||||
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
|
||||
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
|
||||
Restricted bool
|
||||
}
|
||||
|
||||
// RestrictedMetadata is the key a restricted stream's metadata says it under.
|
||||
const RestrictedMetadata = "mesh-restricted"
|
||||
|
||||
// AssignmentsStream holds every assignment's membership, the newest per subject.
|
||||
const AssignmentsStream = "ASSIGNMENTS"
|
||||
|
||||
@@ -174,6 +183,8 @@ func MeshStreams() []Stream {
|
||||
Retention: RetentionLimits,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
MaxMsgsPerSubject: 10000,
|
||||
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
|
||||
AllowMsgTTL: true,
|
||||
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
|
||||
"excluded by the event token; per-subject caps keep a noisy emitter from " +
|
||||
"evicting a quiet one without splitting the stream",
|
||||
@@ -194,13 +205,34 @@ func MeshStreams() []Stream {
|
||||
MaxBytes: DeadLettersBytes,
|
||||
DiscardNew: true,
|
||||
DuplicatesSeconds: 24 * 60 * 60,
|
||||
AllowMsgTTL: true,
|
||||
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age, and full " +
|
||||
"it refuses the next copy rather than drop one it holds",
|
||||
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
|
||||
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
|
||||
"rather than drop one it holds",
|
||||
},
|
||||
{
|
||||
Name: ProvAppliedStream,
|
||||
Subjects: []string{ProvAppliedSubjects},
|
||||
Retention: RetentionLastPerSubject,
|
||||
Direct: true,
|
||||
MaxAge: 7 * 24 * 60 * 60,
|
||||
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
|
||||
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
|
||||
"ADR 0349 §15), read by subject; a week, as the events are",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
|
||||
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
|
||||
// no other stream captures it, and a report said while no controller listened is still there to be read.
|
||||
const ProvAppliedStream = "PROV_APPLIED"
|
||||
|
||||
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
|
||||
// all (ProvisionerAppliedSubject).
|
||||
const ProvAppliedSubjects = "prov.applied.>"
|
||||
|
||||
// DeliverSubjectFor is where a push consumer's messages land.
|
||||
//
|
||||
// **Per consumer, which means per stream as well as per name** (novox/hq 04-ISSUES/146). A push
|
||||
@@ -313,7 +345,10 @@ var ControllerStates = []string{"applied", "refused", "built-before",
|
||||
// the pull request's status, and anybody else may read.
|
||||
"checked",
|
||||
// And a walk kept in a new state (novox/hq ADR 0239): what the delivery it walks reads its steps from.
|
||||
"plan-moved"}
|
||||
"plan-moved",
|
||||
// And a key of a record mirror written or removed (novox/hq ADR 0289, design 54 §3): the assignments and
|
||||
// each registered module's declared config (design 57 F3).
|
||||
"assignment-changed", "declared-config-changed", "predefined-changed"}
|
||||
|
||||
// BusAdvisories are what the bus server says about the mesh's own account that the controller
|
||||
// reads (novox/hq to-be 45 §3, S9): a durable consumer that handed a message over as often as it
|
||||
|
||||
@@ -131,6 +131,9 @@ func TestEachStreamCarriesTheRetentionItsShapeNeeds(t *testing.T) {
|
||||
// kept until somebody acts.
|
||||
"DEAD_LETTER_NOTICES": RetentionWorkQueue,
|
||||
"DEAD_LETTERS": RetentionLimits,
|
||||
// What each machine's provisioner client last said of each module's files (design 57 P6): the newest
|
||||
// word per module, what a walk's end reads.
|
||||
"PROV_APPLIED": RetentionLastPerSubject,
|
||||
}
|
||||
got := map[string]Retention{}
|
||||
for _, s := range MeshStreams() {
|
||||
|
||||
+2
-2
@@ -24,7 +24,7 @@ accounts {
|
||||
jetstream: enabled
|
||||
users = [
|
||||
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*"] }
|
||||
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.DEAD_LETTER_NOTICES.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "$KV.SEAT_MESH_BUILD_MACHINE_cancelled.>", "$KV.SEAT_NODE_BUILD_AGENT_cancelled.>", "$KV.mesh-controller_asked.>", "$KV.mesh-controller_assignments.>", "$KV.mesh-controller_calls.>", "$KV.mesh-controller_condition-history.>", "$KV.mesh-controller_conditions.>", "$KV.mesh-controller_declared-config.>", "$KV.mesh-controller_hand-acts.>", "$KV.mesh-controller_lease.>", "$KV.mesh-controller_predefined.>", "$SRV.INFO", "_INBOX.enrol.>", "mesh.again.>", "mesh.assignment.>", "mesh.events.dead.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-build-machine.tool.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.assignment-changed", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.checked", "mesh.seat.mesh-controller.event.condition-changed", "mesh.seat.mesh-controller.event.condition-cleared", "mesh.seat.mesh-controller.event.condition-raised", "mesh.seat.mesh-controller.event.declared-config-changed", "mesh.seat.mesh-controller.event.doctor-heartbeat", "mesh.seat.mesh-controller.event.healer-acted", "mesh.seat.mesh-controller.event.plan-moved", "mesh.seat.mesh-controller.event.predefined-changed", "mesh.seat.mesh-controller.event.refused", "mesh.seat.mesh-controller.event.rolled-back", "mesh.seat.mesh-controller.event.secret-replaced", "mesh.seat.mesh-controller.tool.plans", "mesh.seat.mesh-delivery.tool.close", "mesh.seat.mesh-delivery.tool.groups", "mesh.seat.mesh-delivery.tool.release", "mesh.seat.mesh-delivery.tool.stalled", "mesh.seat.mesh-delivery.tool.stop", "mesh.seat.mesh-delivery.tool.times", "mesh.seat.node-backup.tool.backed-up.*", "mesh.seat.node-backup.tool.now.*", "mesh.seat.node-build-agent.accept.>", "mesh.seat.node-build-agent.tool.>", "mesh.seat.node-intrusion-prevention.tool.banned.*", "mesh.seat.node-launcher.tool.secret.*", "mesh.seat.node-service-manager.tool.restart.*", "mesh.seat.operator-channel.tool.tell"] }
|
||||
subscribe: { allow: ["$JS.API.>", "$JS.EVENT.ADVISORY.CONSUMER.DELETED.>", "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>", "$SRV.INFO", "$SRV.INFO.mesh-controller", "$SRV.INFO.mesh-controller.>", "$SRV.PING", "$SRV.PING.mesh-controller", "$SRV.PING.mesh-controller.>", "$SRV.STATS", "$SRV.STATS.mesh-controller", "$SRV.STATS.mesh-controller.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.*.event.provisioner.failing", "mesh.mod.*.event.provisioner.recovered", "mesh.mod.*.event.provisioner.retirement", "mesh.mod.gitea.event.pull.merged", "mesh.mod.gitea.event.pull.updated", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>", "mesh.seat.node-build-agent.event.built", "mesh.seat.operator-channel.event.decided.mesh-controller"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
@@ -34,7 +34,7 @@ accounts {
|
||||
} }
|
||||
{ user: "node.one", password: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn", permissions: {
|
||||
publish: { allow: ["$JS.ACK.NODES.one.>", "$JS.API.CONSUMER.INFO.NODES.one", "$SRV.PING.node-tools.one", "mesh.control.one.>"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
subscribe: { allow: ["_DELIVER.one", "_DELIVER.one.>", "_INBOX.node.one.>", "mesh.node.one.ask.hand-over", "mesh.node.one.ask.judge-directories", "mesh.node.one.ask.move-secret", "mesh.node.one.ask.report", "mesh.node.one.ask.setuid-search", "mesh.node.one.declare"] }
|
||||
allow_responses: { max: 1, ttl: "1m" }
|
||||
} }
|
||||
{ user: "one.nats", password: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb", permissions: {
|
||||
|
||||
+109
-3
@@ -2,6 +2,7 @@ package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
)
|
||||
|
||||
@@ -36,10 +37,20 @@ type Declared struct {
|
||||
Watches []Seat
|
||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||
Invokes []string
|
||||
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
|
||||
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
|
||||
Waits []Wait
|
||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
||||
State []Bucket
|
||||
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
|
||||
Logs []Log
|
||||
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
|
||||
Streams []ModuleStream
|
||||
// Hears are the module streams it hears on this machine, each with the subjects it was granted
|
||||
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
|
||||
// or not, so its membership is issued again when a grant changes.
|
||||
Hears []Heard
|
||||
HearsAStream bool
|
||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
||||
Reads []string
|
||||
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
|
||||
@@ -58,6 +69,10 @@ type Declared struct {
|
||||
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
|
||||
// carried by the machine's runtime, and reaches the bus on its own account.
|
||||
RunsAs string
|
||||
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
|
||||
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
|
||||
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
|
||||
ProvisionsNode bool
|
||||
}
|
||||
|
||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||
@@ -87,7 +102,7 @@ type Records struct {
|
||||
// is a mesh that cannot be told anything, and there is no state of the records in which that is
|
||||
// correct.
|
||||
func Users(r Records) ([]Principal, error) {
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r)}}
|
||||
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r)}}
|
||||
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
witness := false
|
||||
@@ -98,7 +113,17 @@ func Users(r Records) ([]Principal, error) {
|
||||
}
|
||||
checks = append(checks, d.Checks...)
|
||||
}
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks})
|
||||
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
|
||||
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey)})
|
||||
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
|
||||
// per machine that runs it, delivered to the module running it.
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.ProvisionsNode {
|
||||
out = append(out, Principal{Kind: KindProvisioner, Node: node, Module: d.Module,
|
||||
BoundBuckets: boundBucketsOf(r)})
|
||||
break
|
||||
}
|
||||
}
|
||||
// **Where the runtime is assigned, the machine gets one runtime principal in place of the
|
||||
// runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the
|
||||
// node: its serving grants are the union of theirs. Every other module keeps its own
|
||||
@@ -129,13 +154,21 @@ func Users(r Records) ([]Principal, error) {
|
||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||
State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus,
|
||||
PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads,
|
||||
Logs: logNames(d.Logs),
|
||||
Logs: logNames(d.Logs), Streams: d.Streams, Hears: ComposedHears(d),
|
||||
})
|
||||
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
|
||||
// account its own runtime runs on (ADR 0259 §8's runs-as): the one module granted it.
|
||||
if holdsSeat(d, VaultSeat) {
|
||||
out[len(out)-1].AsksTheDesk = true
|
||||
out[len(out)-1].AsksIssued = issuedOf(r)
|
||||
}
|
||||
}
|
||||
if runtimeHere {
|
||||
var carried []Declared
|
||||
for _, d := range r.Assigned[node] {
|
||||
if d.RunsAs == "" {
|
||||
// Never a restricted stream for what the tool runner carries (novox/hq ADR 0376 §1).
|
||||
d.Hears = ComposedHears(d)
|
||||
carried = append(carried, d)
|
||||
}
|
||||
}
|
||||
@@ -242,3 +275,76 @@ func asksSeatOf(r Records) []Seat {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||
const VaultSeat = "mesh-vault"
|
||||
|
||||
func holdsSeat(d Declared, seat string) bool {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// vaultVerbsOf are the vault's intake verbs its holder serves, as the records say (issue 675): what the controller
|
||||
// is granted to ask it in its own name. None while no holder serves them.
|
||||
func vaultVerbsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, node := range sortedCopy(r.Nodes) {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
for _, v := range VerbsTheControllerAsksTheVault {
|
||||
if s.Name == v.Seat && slices.Contains(s.Serves, v.Verb) && !seen[v.Verb] {
|
||||
seen[v.Verb] = true
|
||||
out = append(out, v.Verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// servesMoveIn says the vault's holder serves move-in, as the records say (issue 676): what lets each machine's
|
||||
// node-engine ask it in its own name.
|
||||
func servesMoveIn(r Records) bool { return servesVerb(r, VaultMoveIn) }
|
||||
|
||||
// servesVerb says a holder of verb's seat serves it, as the records say.
|
||||
func servesVerb(r Records, verb SeatVerb) bool {
|
||||
for _, node := range r.Nodes {
|
||||
for _, d := range r.Assigned[node] {
|
||||
for _, s := range d.Holds {
|
||||
if s.Name == verb.Seat && slices.Contains(s.Serves, verb.Verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// boundBucketsOf is every provider's bucket of entries, `<module>_bound` (mesh-sdk go/provider, issue 678): the state
|
||||
// named bound of every module placed anywhere, once each, in name order. A consumer on any machine may be bound to
|
||||
// it, so each machine's provisioner client reads its own keys of each (issue 679).
|
||||
func boundBucketsOf(r Records) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, mods := range r.Assigned {
|
||||
for _, d := range mods {
|
||||
for _, b := range d.State {
|
||||
if b.Name == BoundState {
|
||||
seen[b.Bucket()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for b := range seen {
|
||||
out = append(out, b)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// vaultRecords is a mesh of two machines: anchor, a desk whose runtime carries the desk's prompt (rofi holding
|
||||
// node-launcher) beside an issuing provider (plex) and a module with a tool of its own; and novox, where the vault
|
||||
// runs in a runtime of its own on its own account (ADR 0259 §8's runs-as).
|
||||
func vaultRecords() Records {
|
||||
launcher := Seat{Name: "node-launcher", Scope: "node", Serves: []string{"menu", "secret"}}
|
||||
vault := Seat{Name: VaultSeat, Scope: "mesh", Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}
|
||||
return Records{Nodes: []string{"anchor", "novox"}, Assigned: map[string][]Declared{
|
||||
"anchor": {{Module: RuntimeModule}, {Module: "rofi", Holds: []Seat{launcher}, NoAccount: true},
|
||||
{Module: "plex", Serves: []string{"plex_issued_credential", "plex_status"},
|
||||
Issued: []string{"plex_issued_credential"}, NoAccount: true},
|
||||
{Module: "other", Serves: []string{"tool"}, NoAccount: true}},
|
||||
"novox": {{Module: RuntimeModule}, {Module: "mesh-vault", Holds: []Seat{vault}, RunsAs: "mesh-vault"}},
|
||||
}}
|
||||
}
|
||||
|
||||
// The vault's holder may ask the desk's prompt and each issuing tool in its own name, and nothing else outside its
|
||||
// seat; no other module and no runtime is granted it (issue 723).
|
||||
func TestTheVaultAsksTheDeskAndTheIssuingToolsInItsOwnNameAlone(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms := map[string]Permissions{}
|
||||
for _, u := range users {
|
||||
p, err := PermissionsFor(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
perms[u.Username()] = p
|
||||
}
|
||||
vault, ok := perms["novox.mesh-vault"]
|
||||
if !ok {
|
||||
t.Fatal("the vault has no account of its own")
|
||||
}
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, s := range []string{
|
||||
"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token,
|
||||
} {
|
||||
if !MayPublish(vault, s) {
|
||||
t.Errorf("the vault may not ask %s", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.rofi.call.node-launcher.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_status.anchor." + token,
|
||||
"mesh.mod.plex.tool.plex_issued_credential.anchor",
|
||||
"mesh.mod.other.call.tool.anchor." + token,
|
||||
"mesh.mod.other.tool.tool.anchor",
|
||||
"mesh.seat.mesh-controller.call.status." + token,
|
||||
} {
|
||||
if MayPublish(vault, s) {
|
||||
t.Errorf("the vault may publish %s", s)
|
||||
}
|
||||
}
|
||||
// Outside its own namespace and its own seat, the vault publishes nothing but those two calls, its inbox's
|
||||
// answers and the stream API's reads of what it keeps.
|
||||
for _, s := range vault.Publish {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "mesh.mod.mesh-vault."), strings.HasPrefix(s, "mesh.seat."+VaultSeat+"."),
|
||||
s == "mesh.seat.node-launcher.call.secret.*."+token,
|
||||
s == "mesh.mod.plex.call.plex_issued_credential.*."+token,
|
||||
strings.HasPrefix(s, "$JS."), strings.HasPrefix(s, "$KV."), strings.HasPrefix(s, "_INBOX."),
|
||||
strings.HasPrefix(s, "mesh.discover."), strings.HasPrefix(s, "$SRV."):
|
||||
default:
|
||||
t.Errorf("the vault is granted %s, outside its seat", s)
|
||||
}
|
||||
}
|
||||
// Nobody else is granted the vault's calls; the controller keeps today's path until R1 (issue 682).
|
||||
for name, p := range perms {
|
||||
if name == "novox.mesh-vault" {
|
||||
continue
|
||||
}
|
||||
for _, s := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if MayPublish(p, s) {
|
||||
t.Errorf("%s may call in the vault's name: %s", name, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
controller := perms["controller"]
|
||||
for _, s := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor.controller"} {
|
||||
if !MayPublish(controller, s) {
|
||||
t.Errorf("the controller lost today's path %s before R1", s)
|
||||
}
|
||||
}
|
||||
if MayPublish(perms["anchor.node-tools"], "mesh.seat.node-launcher.call.secret.anchor."+CallerToken("anchor.node-tools")) {
|
||||
t.Error("a machine's runtime may ask the desk's prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// **On a real server, as composed** (issue 723): the vault's account asks the desk's prompt and an issuing tool
|
||||
// and is answered, naming itself; the server refuses it any other call; and the controller is still answered on
|
||||
// today's path. That only sealed values cross is the vault's test (mesh-catalog, mesh-vault intake_test.go).
|
||||
func TestOnARealBusTheVaultsAccountIsAnsweredByTheDeskAndTheProviderAndNothingElse(t *testing.T) {
|
||||
users, err := Users(vaultRecords())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].PasswordHash = string(hash)
|
||||
}
|
||||
accounts, err := ComposeAccounts(users)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := filepath.Join(t.TempDir(), "bus.conf")
|
||||
if err := os.WriteFile(conf, []byte("listen: 127.0.0.1:-1\njetstream { store_dir: "+
|
||||
`"`+t.TempDir()+`"`+" }\n"+accounts), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts, err := server.ProcessConfigFile(conf)
|
||||
if err != nil {
|
||||
t.Fatalf("the composed accounts do not parse: %v", err)
|
||||
}
|
||||
opts.NoLog, opts.NoSigs = true, true
|
||||
s, err := server.NewServer(opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go s.Start()
|
||||
if !s.ReadyForConnections(10 * time.Second) {
|
||||
t.Fatal("the bus did not come up")
|
||||
}
|
||||
defer s.Shutdown()
|
||||
|
||||
// anchor's runtime serves the desk's prompt and plex's issuing tool, on every subject it is granted, and says
|
||||
// which subject it heard.
|
||||
holder, err := nats.Connect(s.ClientURL(), nats.UserInfo("anchor.node-tools", "pw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer holder.Close()
|
||||
heard := make(chan string, 8)
|
||||
answer := func(m *nats.Msg) {
|
||||
heard <- m.Subject
|
||||
_ = m.Respond([]byte(`{"result":{"sealed":"c2VhbGVkLXRvLXRoZS1jYWxs"}}`))
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor.*",
|
||||
"mesh.seat.node-launcher.tool.secret.anchor", "mesh.mod.plex.call.plex_issued_credential.anchor.*",
|
||||
"mesh.mod.other.call.tool.anchor.*", "mesh.seat.node-launcher.call.menu.anchor.*"} {
|
||||
sub, err := holder.Subscribe(subject, answer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = holder.Flush()
|
||||
if !sub.IsValid() {
|
||||
t.Fatalf("the desk's runtime may not hear %s", subject)
|
||||
}
|
||||
}
|
||||
|
||||
refusals := make(chan error, 16)
|
||||
vault, err := nats.Connect(s.ClientURL(), nats.UserInfo("novox.mesh-vault", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.novox.mesh-vault"),
|
||||
nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) { refusals <- err }))
|
||||
if err != nil {
|
||||
t.Fatalf("the vault's own account cannot connect: %v", err)
|
||||
}
|
||||
defer vault.Close()
|
||||
token := CallerToken("novox.mesh-vault")
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.call.secret.anchor." + token,
|
||||
"mesh.mod.plex.call.plex_issued_credential.anchor." + token} {
|
||||
if _, err := vault.Request(subject, []byte(`{"seal_to":"a2V5LW9mLXRoZS1jYWxs"}`), 3*time.Second); err != nil {
|
||||
t.Fatalf("the vault's call %s was not answered: %v", subject, err)
|
||||
}
|
||||
if got := <-heard; got != subject {
|
||||
t.Fatalf("the holder heard %s, want %s", got, subject)
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-launcher.tool.secret.anchor",
|
||||
"mesh.seat.node-launcher.call.secret.anchor.controller",
|
||||
"mesh.seat.node-launcher.call.menu.anchor." + token,
|
||||
"mesh.mod.other.call.tool.anchor." + token} {
|
||||
if err := vault.Publish(subject, []byte(`{}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = vault.Flush()
|
||||
select {
|
||||
case err := <-refusals:
|
||||
if !errors.Is(err, nats.ErrPermissionViolation) {
|
||||
t.Errorf("%s: the server said %v, want a permissions violation", subject, err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Errorf("the server did not refuse the vault %s", subject)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case got := <-heard:
|
||||
t.Errorf("a call the vault may not make reached the holder: %s", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
|
||||
// The controller is still answered on today's path, for the modules filled the old way (until R1).
|
||||
controller, err := nats.Connect(s.ClientURL(), nats.UserInfo("controller", "pw"),
|
||||
nats.CustomInboxPrefix("_INBOX.controller"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer controller.Close()
|
||||
if _, err := controller.Request("mesh.seat.node-launcher.tool.secret.anchor", []byte(`{}`), 3*time.Second); err != nil {
|
||||
t.Errorf("the controller's call to the desk on today's path: %v", err)
|
||||
}
|
||||
<-heard
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a session may wait on through the mesh MCP server (novox/hq ADR 0367).
|
||||
//
|
||||
// **A grant apart from `consumes`, and the tool runner's alone.** The tool runner's module names in its
|
||||
// manifest the event families a session may wait on, one by one; the runtime's account is granted a plain
|
||||
// subscription on those subjects and nothing wider — no durable consumer, no queue of its own — and its
|
||||
// membership names the same list, from which `mesh_wait` refuses any other. Both are read from this one
|
||||
// composition, so the grant and what the tool runner offers cannot disagree, and nothing but the manifest
|
||||
// adds a family (§6).
|
||||
|
||||
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
|
||||
// emitter — a seat or a module — and the event, as the inventory resolved them.
|
||||
type Wait struct {
|
||||
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
|
||||
Name string
|
||||
Emitter string
|
||||
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
|
||||
Seat bool
|
||||
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
|
||||
// seat that names it last (ADR 0259 §3).
|
||||
Event string
|
||||
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
|
||||
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
|
||||
// on with it given, and refused without.
|
||||
Last string
|
||||
}
|
||||
|
||||
// WaitIssued is one family as a membership issues it: the name a session asks for, and its subject.
|
||||
type WaitIssued struct {
|
||||
Event string `json:"event"`
|
||||
Subject string `json:"subject"`
|
||||
}
|
||||
|
||||
// WaitSubject is where a family's events land. Each token is checked before it becomes a subject: a
|
||||
// wildcard, a dot inside a name or an empty token would widen the grant.
|
||||
func WaitSubject(w Wait) (string, error) {
|
||||
if !safeSubject.MatchString(w.Emitter) {
|
||||
return "", fmt.Errorf("%q cannot name an emitter of a waited event: it would widen the subject", w.Emitter)
|
||||
}
|
||||
for _, t := range strings.Split(w.Event, ".") {
|
||||
if !safeSubject.MatchString(t) {
|
||||
return "", fmt.Errorf("%q cannot name a waited event: each of its names is plain, never a wildcard "+
|
||||
"(novox/hq ADR 0367 §2)", w.Event)
|
||||
}
|
||||
}
|
||||
if w.Last != "" && !strings.Contains(w.Event, ".") {
|
||||
return "", fmt.Errorf("%s names no %s: %s's events are said for each %s, so a family is %s.%s.<%s>; "+
|
||||
"without it no event would ever be heard (novox/hq ADR 0367 §5)",
|
||||
w.Name, w.Last, w.Emitter, w.Last, w.Emitter, w.Event, w.Last)
|
||||
}
|
||||
if w.Seat {
|
||||
return "mesh.seat." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
return "mesh.mod." + w.Emitter + ".event." + w.Event, nil
|
||||
}
|
||||
|
||||
// waitGrants is the subscription the tool runner's account is granted for its module's families, and an
|
||||
// error for a carried module other than the tool runner's that declares any (ADR 0367 §2).
|
||||
func waitGrants(carries []Declared) ([]string, error) {
|
||||
var sub []string
|
||||
for _, d := range carries {
|
||||
if len(d.Waits) == 0 {
|
||||
continue
|
||||
}
|
||||
if d.Module != RuntimeModule {
|
||||
return nil, fmt.Errorf("%s declares what a session waits on; only %s, the tool runner's module, "+
|
||||
"does (novox/hq ADR 0367 §2)", d.Module, RuntimeModule)
|
||||
}
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub = append(sub, s)
|
||||
}
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// waitsIssuedFor is the families a membership names: the tool runner's module's, and nobody else's.
|
||||
func waitsIssuedFor(d Declared) []WaitIssued {
|
||||
if d.Module != RuntimeModule {
|
||||
return nil
|
||||
}
|
||||
var out []WaitIssued
|
||||
for _, w := range d.Waits {
|
||||
s, err := WaitSubject(w)
|
||||
if err != nil {
|
||||
continue // refused where the account is composed, which names it
|
||||
}
|
||||
out = append(out, WaitIssued{Event: w.Name, Subject: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The families ADR 0367 §5 starts the grant with, resolved as the inventory resolves a manifest: a seat's
|
||||
// event under the seat's namespace, a module's under the module's.
|
||||
var firstFamilies = []Wait{
|
||||
{Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"},
|
||||
{Name: "mesh-delivery.transition", Emitter: "mesh-delivery", Event: "transition"},
|
||||
{Name: "gitea.forge.status", Emitter: "gitea", Event: "forge.status"},
|
||||
{Name: "mesh-controller.checked", Emitter: "mesh-controller", Seat: true, Event: "checked"},
|
||||
{Name: "operator-channel.decided.claude-code", Emitter: "operator-channel", Seat: true, Event: "decided.claude-code"},
|
||||
}
|
||||
|
||||
var firstSubjects = []string{
|
||||
"mesh.mod.gitea.event.forge.status",
|
||||
"mesh.mod.mesh-delivery.event.transition",
|
||||
"mesh.mod.mesh-knowledge.event.reviewed",
|
||||
"mesh.seat.mesh-controller.event.checked",
|
||||
"mesh.seat.operator-channel.event.decided.claude-code",
|
||||
}
|
||||
|
||||
func runtimeCarrying(waits []Wait) Principal {
|
||||
return Principal{Kind: KindNodeTools, Node: "desk", Module: RuntimeModule, PasswordHash: "x", Carries: []Declared{
|
||||
{Module: "shop", Emits: []string{"order.placed"}, Consumes: []string{"billing.paid"}, Serves: []string{"price"}},
|
||||
{Module: RuntimeModule, Invokes: []string{"*"}, Waits: waits},
|
||||
}}
|
||||
}
|
||||
|
||||
func without(all, some []string) []string {
|
||||
drop := map[string]bool{}
|
||||
for _, s := range some {
|
||||
drop[s] = true
|
||||
}
|
||||
var out []string
|
||||
for _, s := range all {
|
||||
if !drop[s] {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// novox/hq ADR 0367 "How it is checked": the account's wait grant equals the manifest's families. The tool
|
||||
// runner's account subscribes exactly the families' subjects beyond what it had without them — nothing
|
||||
// published, nothing wider — and the membership it is issued names the same subjects.
|
||||
func TestTheToolRunnersWaitGrantIsTheManifestsFamiliesAlone(t *testing.T) {
|
||||
before, err := PermissionsFor(runtimeCarrying(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := PermissionsFor(runtimeCarrying(firstFamilies))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
added := without(after.Subscribe, before.Subscribe)
|
||||
sort.Strings(added)
|
||||
if !reflect.DeepEqual(added, firstSubjects) {
|
||||
t.Errorf("the wait grant is %v, want the families' subjects %v", added, firstSubjects)
|
||||
}
|
||||
if extra := without(after.Publish, before.Publish); len(extra) > 0 {
|
||||
t.Errorf("waiting granted a publish: %v", extra)
|
||||
}
|
||||
for _, s := range after.Subscribe {
|
||||
if strings.HasPrefix(s, "mesh.mod.*.event") || s == "mesh.>" || s == ">" || strings.HasSuffix(s, ".event.>") {
|
||||
t.Errorf("the runtime may subscribe every event: %s", s)
|
||||
}
|
||||
}
|
||||
for _, other := range []string{"mesh.seat.operator-channel.event.decided.shop",
|
||||
"mesh.mod.mesh-knowledge.event.proposed", "mesh.mod.gitea.event.forge.push"} {
|
||||
if MaySubscribe(after, other) {
|
||||
t.Errorf("the runtime may subscribe %s, which no family names", other)
|
||||
}
|
||||
}
|
||||
|
||||
issued := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[1], Placements{})
|
||||
var subjects []string
|
||||
for _, w := range issued.Waits {
|
||||
subjects = append(subjects, w.Subject)
|
||||
}
|
||||
sort.Strings(subjects)
|
||||
if !reflect.DeepEqual(subjects, firstSubjects) {
|
||||
t.Errorf("the membership names %v, want the grant %v", subjects, firstSubjects)
|
||||
}
|
||||
if issued.Waits[0].Event != "mesh-knowledge.reviewed" {
|
||||
t.Errorf("a family is issued under its manifest name: %+v", issued.Waits[0])
|
||||
}
|
||||
if shop := MembershipFor("desk", runtimeCarrying(firstFamilies).Carries[0], Placements{}); len(shop.Waits) > 0 {
|
||||
t.Errorf("a module beside the runtime is issued waits: %+v", shop.Waits)
|
||||
}
|
||||
}
|
||||
|
||||
// **A subscription for the call, never a durable consumer** (ADR 0367 §2): composing the users makes no
|
||||
// consumer that filters a family, and none for the runtime's module at all.
|
||||
func TestAWaitMakesNoDurableConsumer(t *testing.T) {
|
||||
for _, c := range ConsumersOf([]Principal{runtimeCarrying(firstFamilies)}) {
|
||||
if c.Module == RuntimeModule {
|
||||
t.Errorf("the runtime's module has a durable consumer: %+v", c.Consumer)
|
||||
}
|
||||
for _, f := range c.Consumer.Filters {
|
||||
for _, s := range firstSubjects {
|
||||
if f == s {
|
||||
t.Errorf("%s's consumer filters the family %s", c.Module, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the tool runner's module waits (ADR 0367 §2): a carried module declaring waits is refused at
|
||||
// composition, naming it, and no name that would widen a subject is composed.
|
||||
func TestAWaitIsComposedOnlyForTheToolRunnerAndOnlyWhole(t *testing.T) {
|
||||
p := runtimeCarrying(nil)
|
||||
p.Carries[0].Waits = []Wait{{Name: "mesh-knowledge.reviewed", Emitter: "mesh-knowledge", Event: "reviewed"}}
|
||||
if _, err := PermissionsFor(p); err == nil || !strings.Contains(err.Error(), "shop") {
|
||||
t.Errorf("a carried module's waits were composed: %v", err)
|
||||
}
|
||||
for _, w := range []Wait{
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "*"},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "a.>"},
|
||||
{Name: "x", Emitter: "*", Event: "reviewed"},
|
||||
{Name: "x", Emitter: "mesh.knowledge", Event: "reviewed"},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: ""},
|
||||
{Name: "x", Emitter: "mesh-knowledge", Event: "a..b"},
|
||||
} {
|
||||
if _, err := PermissionsFor(runtimeCarrying([]Wait{w})); err == nil {
|
||||
t.Errorf("%+v was composed", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,13 @@ func ownModule(p Principal, pattern string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// ownProvisioner is a machine's provisioner client saying a report about its own machine: the third token of
|
||||
// `prov.applied.<node>.…` is its machine (novox/hq ADR 0349 §4, design 57 P3).
|
||||
func ownProvisioner(p Principal, pattern string) bool {
|
||||
tokens := strings.Split(pattern, ".")
|
||||
return p.Kind == KindProvisioner && len(tokens) > 3 && tokens[2] == p.Node
|
||||
}
|
||||
|
||||
// kvOf is a bucket's write subjects.
|
||||
func kvOf(bucket string) []string { return []string{"$KV." + bucket + ".>"} }
|
||||
|
||||
@@ -158,6 +165,26 @@ var WritersTable = []WriterRow{
|
||||
{State: "a message a consumer gave up on", Writer: "controller", KeptIn: "the bus, the stream " + DeadLettersStream,
|
||||
Others: "read, delivered again or dropped through the controller's dead-letters verb",
|
||||
Subjects: []string{deadLetterPrefix + ">", againPrefix + ">"}, Writes: isController},
|
||||
// The record mirrors (novox/hq ADR 0289 §9, design 54 §2): rows of this table, so a grant that lets
|
||||
// anything else write them is refused at composition. Built so far: the assignments and each registered
|
||||
// module's declared config (design 57 F3), and the list of predefined keys kept (design 57 K3).
|
||||
{State: "the assignments mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + AssignmentsBucket, Others: "read through `reads` as mesh-controller." + MirrorAssignments,
|
||||
Subjects: kvOf(AssignmentsBucket), Writes: isController},
|
||||
{State: "the declared-config mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + DeclaredConfigBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredConfig,
|
||||
Subjects: kvOf(DeclaredConfigBucket), Writes: isController},
|
||||
{State: "the predefined-keys mirror", Writer: "controller (lease holder), from its store, only on a change",
|
||||
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
|
||||
Subjects: kvOf(PredefinedBucket), Writes: isController},
|
||||
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
|
||||
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
|
||||
// in the stream ProvAppliedStream, and by the client in the provisioner server's `applied` bucket.
|
||||
{State: "a module's files placed on a machine", Writer: "that machine's provisioner client",
|
||||
KeptIn: "the bus, the stream " + ProvAppliedStream + ", newest per module; and key-value " +
|
||||
ProvisionerAppliedBucket,
|
||||
Others: "the controller reads it at a walk's end and a gate; a failure is that machine's failed apply",
|
||||
Subjects: []string{"prov.applied.*.>"}, Writes: ownProvisioner},
|
||||
}
|
||||
|
||||
// CheckWriters refuses a grant that lets a principal publish on a subject the writers table gives
|
||||
|
||||
@@ -34,6 +34,12 @@ var designRows = []string{
|
||||
"the operator-channel's open messages",
|
||||
"the facts snapshot",
|
||||
"a message a consumer gave up on",
|
||||
// The record mirrors, rows of to-be 45 §1's table by design 54 §2 (ADR 0289 §9).
|
||||
"the assignments mirror",
|
||||
"the declared-config mirror",
|
||||
"the predefined-keys mirror",
|
||||
// The provisioner client's word on what it placed (novox/hq ADR 0349 §4, design 57 P6).
|
||||
"a module's files placed on a machine",
|
||||
}
|
||||
|
||||
func TestTheWritersTableIsTheDesigns(t *testing.T) {
|
||||
|
||||
@@ -173,6 +173,10 @@ type Reuse struct {
|
||||
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
|
||||
// built at all — nothing could ever take it.
|
||||
keyFirst bool
|
||||
// pull and head are the check's own pull request and the head it judged (novox/hq issue 703): it keeps only a
|
||||
// build an earlier check of the same pull request at the same head made.
|
||||
pull PullRef
|
||||
head string
|
||||
}
|
||||
|
||||
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
|
||||
@@ -414,7 +418,7 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
||||
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
|
||||
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
|
||||
if reuse != nil && reuse.keyFirst {
|
||||
_, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
||||
keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
|
||||
held, npmrcPath, npmrc, seatBases, src, say)
|
||||
if err == nil && key.Key == "" {
|
||||
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
|
||||
@@ -423,6 +427,11 @@ func build(ctx context.Context, run Runner, publish Publisher,
|
||||
say("reuse", "not built for the merge: %v", err)
|
||||
return Result{Commit: commit, NoKey: err.Error()}, err
|
||||
}
|
||||
// **A check keeps a build of the same key a verdict names** (novox/hq issue 703): asked again when only
|
||||
// the trunk moved, it builds nothing the earlier check built of an unchanged source.
|
||||
if kept := keptCheckBuild(ctx, *reuse, manifest.Module, key.Key, len(artifacts), say); kept != nil {
|
||||
built, src, reused = kept.Made, keyed, kept
|
||||
}
|
||||
} else if reuse != nil && reuse.Scratch != nil {
|
||||
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
|
||||
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
|
||||
@@ -553,6 +562,47 @@ func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse R
|
||||
return taken, keyed, ""
|
||||
}
|
||||
|
||||
// keptCheckBuild is the build a check keeps instead of building (novox/hq issue 703): the one kept under the
|
||||
// module's key by an earlier check of the same pull request at the same head — only the trunk moved — when a verdict
|
||||
// of the build seat names its key and every digest — the record alone is never trusted (ADR 0350 §2) — and it holds
|
||||
// every artifact the module declares. Another pull request's build, or an earlier head's, is never kept. Nil, and
|
||||
// the check builds, otherwise.
|
||||
func keptCheckBuild(ctx context.Context, reuse Reuse, module, key string, artifacts int,
|
||||
say func(step, format string, args ...any)) *Reused {
|
||||
if reuse.Scratch == nil || reuse.head == "" {
|
||||
return nil
|
||||
}
|
||||
var same []NamedBuild
|
||||
for _, n := range reuse.Named {
|
||||
if n.Pull == reuse.pull {
|
||||
same = append(same, n)
|
||||
}
|
||||
}
|
||||
if len(same) == 0 {
|
||||
return nil
|
||||
}
|
||||
found, ok, err := reuse.Scratch.Find(ctx, module, key)
|
||||
switch {
|
||||
case err != nil:
|
||||
say("reuse", "the scratch namespace could not be read, so the check builds: %v", err)
|
||||
return nil
|
||||
case !ok:
|
||||
return nil
|
||||
case found.Pull != reuse.pull || found.Head == "" || twelve(found.Head) != twelve(reuse.head):
|
||||
say("reuse", "the build kept under %s is %s, not this pull request's at this head: built", twelve(key[3:]),
|
||||
found.Said())
|
||||
return nil
|
||||
case unnamed(same, module, key, found) != "":
|
||||
say("reuse", "a build is kept under %s and no verdict names it with its digests: built", twelve(key[3:]))
|
||||
return nil
|
||||
case len(found.Made) != artifacts:
|
||||
return nil
|
||||
}
|
||||
kept := &Reused{From: found, Made: found.Made}
|
||||
say("reuse", "kept, not built: %s", strings.Replace(kept.Words(), "reused from", "the build of", 1))
|
||||
return kept
|
||||
}
|
||||
|
||||
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
|
||||
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
|
||||
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
|
||||
|
||||
+616
-40
@@ -13,7 +13,9 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/artifacts"
|
||||
@@ -87,6 +89,19 @@ type CheckSpec struct {
|
||||
// Judge is who judges the gate: "" the controller the mesh runs, "self" the change's own, "validator"
|
||||
// the running one with the change's validator.
|
||||
Judge string
|
||||
// Release is the release branch the pull request merges into (novox/hq ADR 0358), empty for any other base. The
|
||||
// gate is then judged by the controller beside it as mesh-controller — mesh-controller's release branch, never
|
||||
// the controller the mesh runs nor its main — with mesh-host's validator from beside it when mesh-host was read at
|
||||
// the release branch too; a change to the controller still judges itself and a change to the node-engine brings
|
||||
// its own validator. The verdict names the branch, the judge's commit and the branch each beside was read at.
|
||||
Release string
|
||||
// JudgeCache is the judge cache (novox/hq ADR 0358 §6): a directory of the build agent's own, outside the
|
||||
// workspace, that only judge builds mount — never a check's containers, so no pull request's script can write it.
|
||||
// It holds the Go build cache every judge is built with (judges stay warm), the release-branch judges kept by the
|
||||
// commits they are built from (judges/, one per release-branch head), and each judge build's fresh home (homes/,
|
||||
// removed after the build). Empty: each judge is built cold under the check's own root, which every check empties
|
||||
// before it clones, and nothing is kept.
|
||||
JudgeCache string
|
||||
// Toolchain is the image a check's Go runs in: the mesh's own Go toolchain, as it holds it.
|
||||
// Toolchains is every toolchain the mesh holds, by language, for a script that declares another.
|
||||
Toolchain string
|
||||
@@ -153,10 +168,12 @@ func ToolchainsOf(held map[string]string) map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// Beside is one repository cloned next to the one checked.
|
||||
// Beside is one repository cloned next to the one checked: at Ref, or at Else when Else is said and the repository
|
||||
// has no branch Ref (novox/hq ADR 0358 §3).
|
||||
type Beside struct {
|
||||
Repository string
|
||||
Ref string
|
||||
Else string
|
||||
}
|
||||
|
||||
// CheckVerdict is what came of one check. Verdict and Summary are the gate's; Gate and Repo each layer.
|
||||
@@ -179,6 +196,16 @@ type CheckVerdict struct {
|
||||
Members []JudgedHead
|
||||
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
|
||||
Built []CheckBuilt
|
||||
// Release is the release branch it was judged for (novox/hq ADR 0358 §4), JudgedBy the commit of the controller
|
||||
// that judged its gate (twelve characters), and BesideAt the branch each repository beside it was read at, by
|
||||
// directory. Empty for a check of any other base.
|
||||
Release string
|
||||
JudgedBy string
|
||||
BesideAt map[string]string
|
||||
// Validator says which node-engine validator the judge of a release-branch check was built with (ADR 0358 §2):
|
||||
// mesh-host's of the release branch with its commit, the one the release branch's controller vendors, the
|
||||
// change's own, or the one the change's own controller vendors. Empty for a check of any other base.
|
||||
Validator string
|
||||
}
|
||||
|
||||
// Layer is one layer of a check, judged.
|
||||
@@ -274,7 +301,17 @@ func ScriptToolchain(script []byte) string {
|
||||
// Check runs one merge check. An error is that it could not run; the verdict is then "error".
|
||||
func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry string, forge GitCredential,
|
||||
log Log) (CheckVerdict, error) {
|
||||
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462).
|
||||
// Everything a check says goes to the build's log, which the bus keeps: said redacted (novox/hq issue 462). A line
|
||||
// at a time: its builds for the merge say theirs while its tests run (novox/hq issue 716).
|
||||
if log != nil {
|
||||
var saying sync.Mutex
|
||||
inner := log
|
||||
log = func(step, message string) {
|
||||
saying.Lock()
|
||||
defer saying.Unlock()
|
||||
inner(step, message)
|
||||
}
|
||||
}
|
||||
redact := redactorFor(forge)
|
||||
say := logging(func(step, message string) {
|
||||
if log != nil {
|
||||
@@ -303,13 +340,32 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
if err := forgetLeftCredentials(workspace); err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
clone := func(repository, ref, dir string) error {
|
||||
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the
|
||||
// container runtime's socket, so their sources, home, Go cache and module cache are none of the workspace's.
|
||||
replay, err := replayDirs(spec.JudgeCache, workspace, root, spec.ID)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
if replay.base != "" {
|
||||
defer os.RemoveAll(replay.base)
|
||||
}
|
||||
// cloneAt clones a repository and checks out ref — or, when the repository has no branch ref and orElse is said,
|
||||
// orElse (novox/hq ADR 0358 §3); readAt keeps which, by directory.
|
||||
readAt := map[string]string{}
|
||||
cloneAt := func(repository, ref, orElse, dir string) error {
|
||||
if _, err := run(ctx, root, "git", cloneWith(credentials, "clone", "--quiet", repository, dir)...); err != nil {
|
||||
return fmt.Errorf("cannot clone %s: %s", redact.redact(repository), redact.redact(err.Error()))
|
||||
}
|
||||
if err := recordedWithoutUserinfo(ctx, run, filepath.Join(root, dir), repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if orElse != "" && ref != "" {
|
||||
if _, err := run(ctx, filepath.Join(root, dir), "git", "rev-parse", "--verify", "--quiet",
|
||||
"refs/remotes/origin/"+ref); err != nil {
|
||||
ref = orElse
|
||||
}
|
||||
}
|
||||
readAt[dir] = ref
|
||||
if ref != "" {
|
||||
if _, err := run(ctx, filepath.Join(root, dir), "git", "checkout", "--quiet", ref); err != nil {
|
||||
return fmt.Errorf("%s has no %s: %w", redact.redact(repository), ref, err)
|
||||
@@ -317,6 +373,9 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
}
|
||||
return nil
|
||||
}
|
||||
clone := func(repository, ref, dir string) error {
|
||||
return cloneAt(repository, ref, "", dir)
|
||||
}
|
||||
name := spec.Repo
|
||||
if name == "" || !safeName.MatchString(name) {
|
||||
name = "checked"
|
||||
@@ -352,8 +411,28 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
}
|
||||
named := func(v CheckVerdict) CheckVerdict {
|
||||
v.Head, v.Trunk, v.Merge, v.Beside, v.Members = judged.Head, judged.Trunk, judged.Merge, judged.Beside, judged.Members
|
||||
v.Release, v.JudgedBy, v.BesideAt, v.Validator = spec.Release, judged.JudgedBy, judged.BesideAt, judged.Validator
|
||||
return v
|
||||
}
|
||||
// **What the merge would move is built from a checkout nothing of the check touches** (novox/hq issue 703): the
|
||||
// gate and the repository's own check run in containers that mount the workspace read-write, so they can change
|
||||
// its tree, its objects and its caches. The builds are made from a copy of the merge's objects, cloned before
|
||||
// anything of the change runs, outside the workspace — so what is kept under a key is what the key describes. Each
|
||||
// build's working copy is cloned from it into the workspace only after the check has ended (forTheMerge): the
|
||||
// host's container engine sees no other directory of the holder's (novox/hq issue 713).
|
||||
buildingTree := ""
|
||||
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
|
||||
at := judged.Merge
|
||||
if at == "" {
|
||||
at = spec.Ref
|
||||
}
|
||||
var building string
|
||||
building, buildingTree, err = checkoutForTheBuilds(ctx, run, tree, at, workspace)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
defer os.RemoveAll(building)
|
||||
}
|
||||
// **The base branch's check judges the change** (novox/hq issue 310), never the one the change brings.
|
||||
judging, err := TheCheckThatJudges(ctx, tree, spec.Base)
|
||||
if err != nil {
|
||||
@@ -376,9 +455,35 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
if dir == name || !safeName.MatchString(dir) {
|
||||
continue
|
||||
}
|
||||
if err := clone(b.Repository, b.Ref, dir); err != nil {
|
||||
if dir == "mesh-lab" {
|
||||
// **mesh-lab is never read at a release branch** (novox/hq ADR 0358): its replays run with the container
|
||||
// runtime's socket, so a branch of a release's name — which anyone may create — is never what runs there,
|
||||
// whether it is asked with a fallback or without.
|
||||
if b.Else != "" || (spec.Release != "" && b.Ref == spec.Release) {
|
||||
b.Ref = b.Else
|
||||
if b.Ref == "" {
|
||||
b.Ref = "main"
|
||||
}
|
||||
}
|
||||
b.Else = ""
|
||||
}
|
||||
if err := cloneAt(b.Repository, b.Ref, b.Else, dir); err != nil {
|
||||
return CheckVerdict{}, fmt.Errorf("beside it, %w", err)
|
||||
}
|
||||
if dir == "mesh-lab" {
|
||||
// The copy the replays run from, made before anything of the change runs, outside every check container
|
||||
// (novox/hq issue 709) — and whether it holds replays, read now, so nothing written later decides it.
|
||||
if err := replay.copyLab(ctx, run, filepath.Join(root, dir)); err != nil {
|
||||
return CheckVerdict{}, fmt.Errorf("mesh-lab could not be copied for its replays: %w", err)
|
||||
}
|
||||
}
|
||||
if spec.Release != "" {
|
||||
// The branch each was read at is named with its commit (ADR 0358 §4).
|
||||
if judged.BesideAt == nil {
|
||||
judged.BesideAt = map[string]string{}
|
||||
}
|
||||
judged.BesideAt[dir] = readAt[dir]
|
||||
}
|
||||
// The commit each one read is named in the verdict (ADR 0323 §3): its move is issue 568's question.
|
||||
if at := headAt(ctx, run, filepath.Join(root, dir)); at != "" {
|
||||
if judged.Beside == nil {
|
||||
@@ -386,7 +491,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
}
|
||||
judged.Beside[dir] = at
|
||||
}
|
||||
say("check", "beside it %s at %s", dir, short(b.Ref))
|
||||
say("check", "beside it %s at %s", dir, refWords(readAt[dir]))
|
||||
}
|
||||
|
||||
// A delivery group's other heads (novox/hq ADR 0239), each at its head, for the gate to compose with this.
|
||||
@@ -438,6 +543,25 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
// Every clone is made: the credential is gone before anything of the check runs (novox/hq issue 462).
|
||||
forget()
|
||||
|
||||
// **What the merge moves is built beside the tests** (novox/hq issue 716) when the build agent has a judge cache to
|
||||
// build it in, where no container of the check reaches; without one, after the check has judged.
|
||||
logged := func(step, message string) {
|
||||
if log != nil {
|
||||
log(step, redact.redact(message))
|
||||
}
|
||||
}
|
||||
var beside *besideTheTests
|
||||
if buildingTree != "" {
|
||||
place, err := buildsBesideTheTests(spec.JudgeCache, workspace, spec.ID)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
if place != "" {
|
||||
beside = startBesideTheTests(ctx, run, spec, place, buildingTree, judged, forge, logged, say)
|
||||
defer beside.abandon()
|
||||
}
|
||||
}
|
||||
|
||||
// The facts, and the versions they say the mesh runs.
|
||||
if registry == "" {
|
||||
return CheckVerdict{}, errors.New("no artifact store to read the facts snapshot from")
|
||||
@@ -500,21 +624,56 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
user = fmt.Sprintf("%d:%d", os.Getuid(), os.Getgid())
|
||||
}
|
||||
in := func(image, dir string, env []string, command ...string) []string {
|
||||
home := workspace
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "HOME=") {
|
||||
home = strings.TrimPrefix(e, "HOME=")
|
||||
}
|
||||
}
|
||||
// As the builder itself: what a check writes into the workspace is the builder's to remove.
|
||||
args := []string{"run", "--rm", "--network", "host", "--volume", workspace + ":" + workspace, "--workdir", dir,
|
||||
"--user", user, "--env", "HOME=" + workspace,
|
||||
"--user", user, "--env", "HOME=" + home,
|
||||
// The check's own checkouts, whoever cloned them: git in a container of another user than the
|
||||
// one that cloned refuses a repository it does not own ("dubious ownership"), and Go's build
|
||||
// stamps the version from git — a judge that would not build for want of it.
|
||||
"--env", "GIT_CONFIG_COUNT=1", "--env", "GIT_CONFIG_KEY_0=safe.directory", "--env", "GIT_CONFIG_VALUE_0=*"}
|
||||
for _, e := range env {
|
||||
args = append(args, "--env", e)
|
||||
if !strings.HasPrefix(e, "HOME=") {
|
||||
args = append(args, "--env", e)
|
||||
}
|
||||
}
|
||||
return append(append(args, image), command...)
|
||||
}
|
||||
inToolchain := func(dir string, env []string, command ...string) []string {
|
||||
return in(spec.Toolchain, dir, env, command...)
|
||||
}
|
||||
// **Every judge is built where no pull request can reach** (novox/hq issue 482, ADR 0358 review): the workspace's
|
||||
// Go cache, HOME and git configuration are written by pull requests' own scripts, and git runs during a Go build
|
||||
// to stamp its version — a planted hook would rewrite the judge's sources before they compile. So a judge is built
|
||||
// with a HOME and configuration made fresh for this check and a Go cache only judge builds mount (judgeBuildDirs),
|
||||
// no global or system git configuration, no Go environment file, and no version stamping from git.
|
||||
judgeHome, judgeGoCache, err := judgeBuildDirs(spec.JudgeCache, workspace, root, spec.ID)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
defer os.RemoveAll(judgeHome)
|
||||
inJudgeBuild := func(dir string, _ []string, command ...string) []string {
|
||||
args := in(spec.Toolchain, dir, JudgeBuildEnv(judgeHome, judgeGoCache), command...)
|
||||
if spec.JudgeCache == "" {
|
||||
return args // under the check's root, in the workspace mounted already
|
||||
}
|
||||
// The judge cache's parts this build needs, mounted into judge builds alone (ADR 0358 §6).
|
||||
return append([]string{args[0], "--volume", judgeHome + ":" + judgeHome, "--volume",
|
||||
judgeGoCache + ":" + judgeGoCache}, args[1:]...)
|
||||
}
|
||||
inReplays := func(env []string, command ...string) []string {
|
||||
args := in(spec.Toolchain, filepath.Join(replay.lab, "replays"), append(ReplayEnv(replay.home, replay.goCache,
|
||||
replay.modCache), env...), command...)
|
||||
// The socket goes to the replays alone, never to the change's own code.
|
||||
mounts := []string{"--volume", "/var/run/docker.sock:/var/run/docker.sock", "--volume", replay.base + ":" +
|
||||
replay.base, "--volume", replay.goCache + ":" + replay.goCache, "--volume", replay.modCache + ":" + replay.modCache}
|
||||
return append(append([]string{args[0]}, mounts...), args[1:]...)
|
||||
}
|
||||
var out tail
|
||||
// running runs one container of the check, its output into the report, in a process group of its own.
|
||||
running := func(args []string) error {
|
||||
@@ -526,10 +685,23 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
|
||||
// The judge. Its failing to build is the change's fault when the change is the judge or its validator,
|
||||
// and the check's when it is the controller the mesh runs.
|
||||
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, inToolchain, say)
|
||||
gate, judgeFault, err := judgeFor(ctx, labelled, run, spec, root, tree, judged.BesideAt, inJudgeBuild, say)
|
||||
if err != nil {
|
||||
return CheckVerdict{}, err
|
||||
}
|
||||
if spec.Release != "" {
|
||||
// **A release-branch judgement says so** (novox/hq ADR 0358 §4): the branch, the controller's commit that
|
||||
// judged, and each repository read beside it at its branch and commit.
|
||||
judgedBy := filepath.Join(root, "mesh-controller")
|
||||
if spec.Judge == "self" {
|
||||
judgedBy = tree
|
||||
}
|
||||
judged.JudgedBy = headAt(ctx, run, judgedBy)
|
||||
judged.Validator = releaseValidator(spec, judged.BesideAt, judged.Beside)
|
||||
said := releaseWords(spec.Release, judged.JudgedBy, judged.Validator, judged.BesideAt, judged.Beside)
|
||||
fmt.Fprintf(&out, "--- %s\n", said)
|
||||
say("check", "%s", said)
|
||||
}
|
||||
verdictFile := filepath.Join(root, "verdict.json")
|
||||
env := []string{EnvFacts + "=" + factsFile, EnvGate + "=" + gate, EnvGateStore + "=" + storeURL,
|
||||
EnvTestStore + "=" + storeURL, EnvTestBus + "=nats://" + bus, EnvRepository + "=" + spec.Owner + "/" + spec.Repo,
|
||||
@@ -555,7 +727,7 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
say("check", "the gate: %d module(s) of the graph touched — %s", len(modules), strings.Join(modules, ", "))
|
||||
fmt.Fprintf(&out, "--- the gate: %s\n", strings.Join(modules, ", "))
|
||||
v.Gate.Verdict, v.Gate.Summary = gateLayer(ctx, spec, tree, root, gate, verdictFile, env, inToolchain,
|
||||
running, &out, bus, workspace, name, say)
|
||||
running, &out, bus, name, replay.hasReplays, replay.refused(), inReplays, say)
|
||||
}
|
||||
if timedOut() {
|
||||
v.Gate.Verdict, v.Gate.Summary = "error", fmt.Sprintf("the check ran past %s and was ended", CheckTimeout)
|
||||
@@ -595,6 +767,11 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
}
|
||||
}
|
||||
|
||||
if spec.Release != "" {
|
||||
// The statuses name the release branch and the judge first, so the forge's short description keeps them.
|
||||
mark := releaseMark(spec.Release, judged.JudgedBy)
|
||||
v.Gate.Summary, v.Repo.Summary = mark+v.Gate.Summary, mark+v.Repo.Summary
|
||||
}
|
||||
// What the check printed travels in the verdict to the forge and the controller: redacted as the log is.
|
||||
v.Gate.Summary, v.Repo.Summary, v.Repo.Failed = redact.redact(v.Gate.Summary), redact.redact(v.Repo.Summary),
|
||||
redact.redact(v.Repo.Failed)
|
||||
@@ -605,24 +782,68 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
|
||||
|
||||
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
|
||||
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
|
||||
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
|
||||
if why := skipsTheBuilds(v); why != "" {
|
||||
say("check", "%s", why)
|
||||
} else {
|
||||
began := time.Now()
|
||||
v.Built = buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, tree, judged.Merge, judged, forge,
|
||||
func(step, message string) {
|
||||
if log != nil {
|
||||
log(step, redact.redact(message))
|
||||
}
|
||||
}, say)
|
||||
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(v.Built), len(v.Built),
|
||||
time.Since(began).Round(time.Second))
|
||||
}
|
||||
switch {
|
||||
case beside != nil:
|
||||
v.Built = beside.settle(ctx, v)
|
||||
case len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0:
|
||||
v.Built = forTheMerge(ctx, run, spec, v, workspace, buildingTree, judged, forge, logged, say)
|
||||
}
|
||||
return named(v), nil
|
||||
}
|
||||
|
||||
// forTheMerge builds what the merge moves once the check has judged it, from merged — the copy of the merge
|
||||
// checkoutForTheBuilds made outside the workspace — **in the builder's own workspace** (novox/hq issue 713). A build's
|
||||
// containers are run by the host's container engine, which mounts a path as the host names it: the build seat's
|
||||
// holder runs in a container that shares only its workspace with the host, at the same path, so a working copy in
|
||||
// its own temporary directory reaches the toolchain as an empty directory ("go.mod file not found"), every check
|
||||
// build failed, and every walk built again. The copy stays outside the workspace, so nothing the tests ran reaches it;
|
||||
// the working copy cloned from it is made only now, after every container of the check has ended.
|
||||
func forTheMerge(ctx context.Context, run Runner, spec CheckSpec, verdict CheckVerdict, workspace, merged string,
|
||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
||||
if why := skipsTheBuilds(verdict); why != "" {
|
||||
say("check", "%s", why)
|
||||
return nil
|
||||
}
|
||||
began := time.Now()
|
||||
built := buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, merged, judged.Merge, judged, forge, log, say)
|
||||
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(built), len(built),
|
||||
time.Since(began).Round(time.Second))
|
||||
return built
|
||||
}
|
||||
|
||||
// checkoutForTheBuilds is a clone of the commit a check builds for its merge, made before anything of the check runs,
|
||||
// outside the workspace its containers mount (novox/hq issue 703): the directory to remove, and the clone in it.
|
||||
// Cloned over git's own transport, never by linking the objects: what the check's containers later write into the
|
||||
// workspace's objects cannot reach it.
|
||||
func checkoutForTheBuilds(ctx context.Context, run Runner, tree, at, workspace string) (string, string, error) {
|
||||
dir, err := os.MkdirTemp("", "mesh-check-builds-")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if withinDir(workspace, dir) {
|
||||
os.RemoveAll(dir)
|
||||
return "", "", fmt.Errorf("the temporary directory %s is inside the workspace %s, which a check's containers "+
|
||||
"mount: the merge is not built from where its tests could change it", dir, workspace)
|
||||
}
|
||||
commit, err := run(ctx, tree, "git", "rev-parse", "--verify", at+"^{commit}")
|
||||
if err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", "", fmt.Errorf("the commit to build for the merge, %s: %w", at, err)
|
||||
}
|
||||
commit = strings.TrimSpace(commit)
|
||||
if _, err := run(ctx, tree, "git", "update-ref", "refs/heads/mesh-check-builds", commit); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", "", err
|
||||
}
|
||||
cloned := filepath.Join(dir, "merged")
|
||||
if _, err := run(ctx, dir, "git", "clone", "--quiet", "--no-local", "--no-checkout", "--branch",
|
||||
"mesh-check-builds", tree, cloned); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", "", fmt.Errorf("cannot copy the merge to build it: %w", err)
|
||||
}
|
||||
return dir, cloned, nil
|
||||
}
|
||||
|
||||
// builtCount is how many of a check's builds for its merge were built.
|
||||
func builtCount(built []CheckBuilt) int {
|
||||
n := 0
|
||||
@@ -794,8 +1015,9 @@ const SomeManifestsEnv = "MESH_MODULE_CHECK_SOME=1"
|
||||
// gateLayer runs the gate: the touched manifests through `module check`, every machine composed with the
|
||||
// change, and the replays of what the mesh runs. It answers the gate's verdict and summary.
|
||||
func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFile string, env []string,
|
||||
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus, workspace,
|
||||
name string, say func(step, format string, args ...any)) (string, string) {
|
||||
inToolchain func(string, []string, ...string) []string, running func([]string) error, out *tail, bus,
|
||||
name string, hasReplays bool, replaysRefused string, inReplays func([]string, ...string) []string,
|
||||
say func(step, format string, args ...any)) (string, string) {
|
||||
// 1. The manifests the change touches, as the judge reads them: a manifest it cannot read, or one with a
|
||||
// problem the change brings, fails here. **What was already so on the base branch is said and fails
|
||||
// nothing** — the gate's own rule: a module whose manifest the running controller already finds fault
|
||||
@@ -887,24 +1109,37 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
||||
// 3. **The replays of what the mesh runs** (to-be 45 §9, M9): mesh-lab's, from its main — reviewed code,
|
||||
// so given the container runtime the resolver replay raises containers with — against the bus of the
|
||||
// release the mesh runs and the change's own catalogue when the change is to the catalogue.
|
||||
if lab := filepath.Join(root, "mesh-lab", "replays"); ctx.Err() == nil {
|
||||
if _, err := os.Stat(lab); err == nil {
|
||||
if ctx.Err() == nil && hasReplays && replaysRefused != "" {
|
||||
// **Never from the workspace** (novox/hq issue 709): without the judge cache there is no place outside it.
|
||||
say("check", "%s", replaysRefused)
|
||||
fmt.Fprintf(out, "--- the replays: %s\n", replaysRefused)
|
||||
return "error", replaysRefused
|
||||
}
|
||||
if ctx.Err() == nil {
|
||||
if hasReplays {
|
||||
catalogue := filepath.Join(root, "mesh-catalog")
|
||||
if name == "mesh-catalog" {
|
||||
catalogue = tree
|
||||
}
|
||||
say("check", "the replays of what the mesh runs, from mesh-lab")
|
||||
fmt.Fprintln(out, "--- the replays (mesh-lab replays/)")
|
||||
args := inToolchain(lab, []string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue,
|
||||
"GOCACHE=" + filepath.Join(workspace, "go-cache"), "GOMODCACHE=" + filepath.Join(workspace, "go-modules")},
|
||||
// In the replays' own environment (inReplays): reviewed code, the socket, and nothing the workspace holds
|
||||
// but the catalogue they read.
|
||||
args := inReplays([]string{EnvTestBus + "=nats://" + bus, "MESH_REPLAY_CATALOGUE=" + catalogue},
|
||||
"go", "test", "-count=1", "./...")
|
||||
// The socket goes to the replays alone, never to the change's own code above.
|
||||
args = append([]string{args[0], "--volume", "/var/run/docker.sock:/var/run/docker.sock"}, args[1:]...)
|
||||
if err := running(args); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return "error", "the check was ended during the replays"
|
||||
}
|
||||
return "fail", "a replay of a core incident fails with this change: " + lastLine(out.String())
|
||||
if replaysNotStarted(err) {
|
||||
// The container runtime could not start them (a mount, an image): the check's fault, never the change's.
|
||||
said := "the replays could not be started, so the change is not judged by them: " + lastLine(out.String())
|
||||
say("check", "%s", said)
|
||||
return "error", said
|
||||
}
|
||||
said := "a replay of a core incident fails with this change: " + lastLine(out.String())
|
||||
say("check", "%s", said)
|
||||
return "fail", said
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -919,21 +1154,32 @@ func gateLayer(ctx context.Context, spec CheckSpec, tree, root, gate, verdictFil
|
||||
// the mesh runs. It answers the judge's path; or, when the change makes its own judge unbuildable, why —
|
||||
// the change's fault, a failing gate; or an error when the check cannot build a judge at all. A check
|
||||
// whose gate does not run builds a judge only to hand its scripts one, and goes on without when it cannot.
|
||||
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string,
|
||||
//
|
||||
// **A pull request into a release branch is judged by that branch's controller** (novox/hq ADR 0358 §2): the one
|
||||
// beside it as mesh-controller, read at the release branch — with mesh-host's validator from beside it when mesh-host
|
||||
// has that branch too, or with the change's own validator for a change to the node-engine — never the controller the
|
||||
// mesh runs. besideAt is the branch each repository beside it was read at.
|
||||
func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, tree string, besideAt map[string]string,
|
||||
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
||||
gated := spec.Gated()
|
||||
switch spec.Judge {
|
||||
case "self":
|
||||
bin := filepath.Join(root, "bin", "judge-of-itself")
|
||||
if _, err := labelled(ctx, root, "docker", inToolchain(tree,
|
||||
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||
if _, err := labelled(ctx, root, "docker", inToolchain(tree, nil,
|
||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
||||
return "", "the change's controller, which judges itself, does not build: " + firstLine(err.Error()), nil
|
||||
}
|
||||
say("check", "judged by the change's own controller")
|
||||
return bin, "", nil
|
||||
}
|
||||
bin, judgeTree, err := judge(ctx, labelled, run, root, inToolchain, say)
|
||||
var bin, judgeTree string
|
||||
var err error
|
||||
if spec.Release != "" {
|
||||
withHost := spec.Judge != "validator" && besideAt["mesh-host"] == spec.Release
|
||||
bin, judgeTree, err = releaseJudge(ctx, labelled, run, spec, root, withHost, inToolchain, say)
|
||||
} else {
|
||||
bin, judgeTree, err = judge(ctx, labelled, run, root, inToolchain, say)
|
||||
}
|
||||
if err != nil {
|
||||
if !gated {
|
||||
say("check", "no judge for its script: %v", err)
|
||||
@@ -952,15 +1198,346 @@ func judgeFor(ctx context.Context, labelled, run Runner, spec CheckSpec, root, t
|
||||
}
|
||||
}
|
||||
withValidator := filepath.Join(root, "bin", "judge-with-this-validator")
|
||||
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree),
|
||||
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||
if _, err := labelled(ctx, root, "docker", inToolchain(filepath.Join(root, judgeTree), nil,
|
||||
"go", "build", "-o", withValidator, "./cmd/mesh-controller")...); err != nil {
|
||||
if spec.Release != "" {
|
||||
return "", spec.Release + "'s controller does not build with this change's validator: " +
|
||||
firstLine(err.Error()), nil
|
||||
}
|
||||
return "", "the controller the mesh runs does not build with this change's validator: " + firstLine(err.Error()), nil
|
||||
}
|
||||
if spec.Release != "" {
|
||||
say("check", "judged by %s's controller, with this change's validator", spec.Release)
|
||||
return withValidator, "", nil
|
||||
}
|
||||
say("check", "judged by the controller the mesh runs, with this change's validator")
|
||||
return withValidator, "", nil
|
||||
}
|
||||
|
||||
// releaseJudge builds the controller that judges a pull request into a release branch (novox/hq ADR 0358 §2): the
|
||||
// one beside the check as mesh-controller, read at that branch — with mesh-host's validator from beside it in place
|
||||
// of the one it vendors when withHost. **Built once per release-branch head**: kept in the spec's judge cache by the
|
||||
// commits it is built from and taken from there while they stand. It answers the binary and the directory it was
|
||||
// built from.
|
||||
func releaseJudge(ctx context.Context, run, plain Runner, spec CheckSpec, root string, withHost bool,
|
||||
inToolchain func(string, []string, ...string) []string, say func(step, format string, args ...any)) (string, string, error) {
|
||||
const dir = "mesh-controller"
|
||||
if _, err := os.Stat(filepath.Join(root, dir)); err != nil {
|
||||
return "", "", fmt.Errorf("no controller of %s beside the check to judge it with", spec.Release)
|
||||
}
|
||||
key := fullHeadAt(ctx, plain, filepath.Join(root, dir))
|
||||
if key == "" {
|
||||
return "", "", fmt.Errorf("the controller of %s beside the check names no commit", spec.Release)
|
||||
}
|
||||
with := ""
|
||||
if withHost {
|
||||
host := fullHeadAt(ctx, plain, filepath.Join(root, "mesh-host"))
|
||||
if host == "" {
|
||||
return "", "", fmt.Errorf("mesh-host of %s beside the check names no commit", spec.Release)
|
||||
}
|
||||
key += "-with-mesh-host-" + host
|
||||
with = ", with mesh-host's validator of " + spec.Release + " at " + twelve(host)
|
||||
}
|
||||
bin := filepath.Join(root, "bin", "mesh-controller")
|
||||
if took := judgeKept(keptJudges(spec.JudgeCache), key, bin); took {
|
||||
say("check", "judged by %s's controller at %s%s, built for an earlier check of that head", spec.Release,
|
||||
twelve(key), with)
|
||||
return bin, dir, nil
|
||||
}
|
||||
if withHost {
|
||||
vendored := filepath.Join(root, dir, "vendor", "github.com", "novox", "mesh-host")
|
||||
for _, pkg := range []string{"validate", filepath.Join("internal", "declaration")} {
|
||||
if err := replaceGoFiles(filepath.Join(root, "mesh-host", pkg), filepath.Join(vendored, pkg)); err != nil {
|
||||
return "", "", fmt.Errorf("mesh-host's validator of %s could not be put in its controller: %w", spec.Release, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Built where no pull request reaches (JudgeBuildEnv): a judge kept for this head is the head's, nothing else.
|
||||
if _, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
|
||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...); err != nil {
|
||||
return "", "", fmt.Errorf("the controller of %s does not build%s: %w", spec.Release, with, err)
|
||||
}
|
||||
// Kept before anything of the change runs: nothing a pull request's script writes can reach the cache.
|
||||
keepJudge(keptJudges(spec.JudgeCache), key, bin, say)
|
||||
say("check", "judged by %s's controller at %s%s", spec.Release, twelve(key), with)
|
||||
return bin, dir, nil
|
||||
}
|
||||
|
||||
// JudgeBuildEnv is the environment every judge is built in (novox/hq ADR 0358 §6, issue 482): home, made fresh for the
|
||||
// build, as HOME and configuration; goCache, the judge cache's Go cache that no check's container mounts; no global
|
||||
// or system git configuration; no Go environment file; and no version stamped from git, so no git runs at all.
|
||||
func JudgeBuildEnv(home, goCache string) []string {
|
||||
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
|
||||
"GOCACHE=" + goCache, "GOPATH=" + filepath.Join(home, "go"),
|
||||
"CGO_ENABLED=0", "GOFLAGS=-mod=vendor -buildvcs=false", "GOPROXY=off"}
|
||||
}
|
||||
|
||||
// judgeBuildDirs are the home and the Go cache a check's judge builds use (ADR 0358 §6). With a judge cache: a home
|
||||
// made fresh under its homes/ for this check, and its go-cache/, kept warm between checks — both on the host path the
|
||||
// build agent declares, so the container runtime mounts what the agent sees, and nothing is left in a temporary
|
||||
// directory of the host. Without one: both under the check's root, which every check empties before it clones. A
|
||||
// judge cache inside the workspace is refused: a check's containers mount the workspace.
|
||||
func judgeBuildDirs(cache, workspace, root, id string) (string, string, error) {
|
||||
if cache == "" {
|
||||
home, goCache := filepath.Join(root, "judge-home"), filepath.Join(root, "judge-go-cache")
|
||||
for _, d := range []string{home, goCache} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return home, goCache, nil
|
||||
}
|
||||
if withinDir(workspace, cache) {
|
||||
return "", "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
|
||||
"no judge is built where a pull request could reach it", cache, workspace)
|
||||
}
|
||||
if !safeName.MatchString(id) {
|
||||
return "", "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
||||
}
|
||||
home, goCache := filepath.Join(cache, "homes", id), filepath.Join(cache, "go-cache")
|
||||
if err := os.RemoveAll(home); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, d := range []string{home, goCache} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return home, goCache, nil
|
||||
}
|
||||
|
||||
// replayPlace is where mesh-lab's replays run from and with (novox/hq issue 709), never anything a pull request can
|
||||
// write: base, the directory made for this check under the judge cache's homes/ (the same path on the host and in the
|
||||
// build agent), removed after it; lab, the copy of mesh-lab they run; home, their HOME and configuration; and the judge
|
||||
// cache's Go build and module caches. Empty without a judge cache: the replays are then refused. hasReplays says whether
|
||||
// mesh-lab held replays when it was cloned, before anything of the change ran.
|
||||
type replayPlace struct {
|
||||
base, lab, home, goCache, modCache string
|
||||
hasReplays bool
|
||||
}
|
||||
|
||||
// replaysNeedTheJudgeCache is the gate's error when mesh-lab holds replays and the build agent has no judge cache.
|
||||
const replaysNeedTheJudgeCache = "the replays need the build agent's judge cache (mesh-catalog#297); refused rather " +
|
||||
"than run from the workspace"
|
||||
|
||||
// refused is why the replays cannot run here, empty when they can.
|
||||
func (p replayPlace) refused() string {
|
||||
if p.base == "" {
|
||||
return replaysNeedTheJudgeCache
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// replayDirs makes the replays' place (issue 709, ADR 0358 §6). The replays' build is as trusted as a judge's: it
|
||||
// compiles mesh-lab's trunk, reviewed code, and nothing of the pull request, which hands them only its catalogue, as
|
||||
// data. Never the workspace: the gate runs before the replays, a pull request's own code among its containers. Without
|
||||
// a judge cache there is no place: the build agent declares no other host path, and the replays are refused.
|
||||
func replayDirs(cache, workspace, root, id string) (replayPlace, error) {
|
||||
if cache == "" {
|
||||
return replayPlace{}, nil
|
||||
}
|
||||
if withinDir(workspace, cache) {
|
||||
return replayPlace{}, fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers "+
|
||||
"mount: no replay is run where a pull request could reach it", cache, workspace)
|
||||
}
|
||||
if !safeName.MatchString(id) {
|
||||
return replayPlace{}, fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
||||
}
|
||||
base := filepath.Join(cache, "homes", id+"-replays")
|
||||
p := replayPlace{base: base, lab: filepath.Join(base, "mesh-lab"), home: filepath.Join(base, "home"),
|
||||
goCache: filepath.Join(cache, "go-cache"), modCache: filepath.Join(cache, "go-modules")}
|
||||
if err := os.RemoveAll(base); err != nil {
|
||||
return replayPlace{}, err
|
||||
}
|
||||
for _, d := range []string{p.home, p.goCache, p.modCache} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return replayPlace{}, err
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// copyLab reads whether mesh-lab's checkout, freshly cloned and before anything of the change runs, holds replays, and
|
||||
// copies it over git's own transport to where the replays run it — when there is such a place.
|
||||
func (p *replayPlace) copyLab(ctx context.Context, run Runner, from string) error {
|
||||
_, err := os.Stat(filepath.Join(from, "replays"))
|
||||
p.hasReplays = err == nil
|
||||
if p.lab == "" {
|
||||
return nil
|
||||
}
|
||||
return copyCheckout(ctx, run, from, p.lab)
|
||||
}
|
||||
|
||||
// replaysNotStarted says the container runtime could not start the replays' container — its own exit status 125, or
|
||||
// 126 and 127 for a command it could not run — rather than that a replay failed.
|
||||
func replaysNotStarted(err error) bool {
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) {
|
||||
return true
|
||||
}
|
||||
code := exit.ExitCode()
|
||||
return code == 125 || code == 126 || code == 127
|
||||
}
|
||||
|
||||
// ReplayEnv is the environment mesh-lab's replays run in (novox/hq issue 709): home as HOME and configuration, the Go
|
||||
// build and module caches given, no global or system git configuration, no Go environment file, and no version
|
||||
// stamped from git — as a judge is built (JudgeBuildEnv), with the modules the replays fetch.
|
||||
func ReplayEnv(home, goCache, modCache string) []string {
|
||||
return []string{"HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_NOSYSTEM=1", "GOENV=off",
|
||||
"GOCACHE=" + goCache, "GOMODCACHE=" + modCache, "GOPATH=" + filepath.Join(home, "go"),
|
||||
"GOFLAGS=-buildvcs=false", "GOWORK=off"}
|
||||
}
|
||||
|
||||
// copyCheckout copies a checkout at the commit it stands at into dir, over git's own transport — never by linking
|
||||
// objects — so what is written into the source afterwards does not reach the copy.
|
||||
func copyCheckout(ctx context.Context, run Runner, from, dir string) error {
|
||||
commit := fullHeadAt(ctx, run, from)
|
||||
if commit == "" {
|
||||
return fmt.Errorf("%s names no commit", from)
|
||||
}
|
||||
if _, err := run(ctx, filepath.Dir(dir), "git", "clone", "--quiet", "--no-local", "--no-checkout", from, dir); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := run(ctx, dir, "git", "checkout", "--quiet", "--detach", commit)
|
||||
return err
|
||||
}
|
||||
|
||||
// keptJudges is where a judge cache keeps the release-branch judges, empty when there is no judge cache.
|
||||
func keptJudges(cache string) string {
|
||||
if cache == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cache, "judges")
|
||||
}
|
||||
|
||||
// judgesKept is how many release-branch judges a judge cache keeps: a head moved past is not built from again.
|
||||
const judgesKept = 4
|
||||
|
||||
// judgeKept puts the judge kept under key in the cache at bin, and says whether there was one.
|
||||
func judgeKept(cache, key, bin string) bool {
|
||||
if cache == "" {
|
||||
return false
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(cache, key, "mesh-controller"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(bin), 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := os.WriteFile(bin, body, 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
_ = os.Chtimes(filepath.Join(cache, key), now, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// keepJudge keeps a judge just built under key, and lets go of all but the judgesKept most recently used. A judge
|
||||
// that cannot be kept is said and built again next time: the cache only saves time.
|
||||
func keepJudge(cache, key, bin string, say func(step, format string, args ...any)) {
|
||||
if cache == "" {
|
||||
return
|
||||
}
|
||||
body, err := os.ReadFile(bin)
|
||||
if err == nil {
|
||||
err = os.MkdirAll(filepath.Join(cache, key), 0o755)
|
||||
}
|
||||
if err == nil {
|
||||
kept := filepath.Join(cache, key, "mesh-controller")
|
||||
if err = os.WriteFile(kept+".part", body, 0o755); err == nil {
|
||||
err = os.Rename(kept+".part", kept)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
say("check", "the judge could not be kept for the next check, which builds it again: %v", err)
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(cache)
|
||||
if err != nil || len(entries) <= judgesKept {
|
||||
return
|
||||
}
|
||||
type used struct {
|
||||
name string
|
||||
at time.Time
|
||||
}
|
||||
var all []used
|
||||
for _, e := range entries {
|
||||
if info, err := e.Info(); err == nil && e.IsDir() {
|
||||
all = append(all, used{e.Name(), info.ModTime()})
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].at.After(all[j].at) })
|
||||
for _, u := range all[min(judgesKept, len(all)):] {
|
||||
if u.name != key {
|
||||
_ = os.RemoveAll(filepath.Join(cache, u.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fullHeadAt is the whole commit a checkout stands at, empty when it names none.
|
||||
func fullHeadAt(ctx context.Context, run Runner, tree string) string {
|
||||
out, err := run(ctx, tree, "git", "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// releaseValidator says which node-engine validator a release-branch check's judge is built with (novox/hq ADR 0358
|
||||
// §2), by the rule judgeFor builds it by.
|
||||
func releaseValidator(spec CheckSpec, at, commits map[string]string) string {
|
||||
switch {
|
||||
case spec.Judge == "self":
|
||||
return "the validator the change's own controller vendors"
|
||||
case spec.Judge == "validator":
|
||||
return "the change's own validator"
|
||||
case at["mesh-host"] == spec.Release:
|
||||
return fmt.Sprintf("mesh-host's validator of %s at %s", spec.Release, commits["mesh-host"])
|
||||
}
|
||||
return fmt.Sprintf("the validator %s's controller vendors (mesh-host has no branch %s)", spec.Release, spec.Release)
|
||||
}
|
||||
|
||||
// releaseWords is how a release-branch check says what judged it and what it read (novox/hq ADR 0358 §4).
|
||||
func releaseWords(release, judgedBy, validator string, at, commits map[string]string) string {
|
||||
var read []string
|
||||
for _, dir := range sortedKeys(at) {
|
||||
read = append(read, fmt.Sprintf("%s %s at %s", dir, refWords(at[dir]), commits[dir]))
|
||||
}
|
||||
said := fmt.Sprintf("judged for the release branch %s by its controller at %s, with %s", release, judgedBy, validator)
|
||||
if len(read) > 0 {
|
||||
said += "; read beside it: " + strings.Join(read, ", ")
|
||||
}
|
||||
return said
|
||||
}
|
||||
|
||||
// releaseMark opens each layer's summary of a release-branch check, so a status says it first.
|
||||
func releaseMark(release, judgedBy string) string {
|
||||
return fmt.Sprintf("[release %s, judged by its controller %s] ", release, judgedBy)
|
||||
}
|
||||
|
||||
// refWords is a ref as a line says it: a commit by its first eight characters, a branch whole.
|
||||
func refWords(ref string) string {
|
||||
if commitLike.MatchString(ref) {
|
||||
return short(ref)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// commitLike is a ref that names a commit.
|
||||
var commitLike = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// replaceGoFiles puts a package's Go files — never its tests — in place of another copy's.
|
||||
func replaceGoFiles(from, into string) error {
|
||||
old, err := filepath.Glob(filepath.Join(into, "*.go"))
|
||||
@@ -1195,8 +1772,7 @@ func judge(ctx context.Context, run, plain Runner, root string, inToolchain func
|
||||
say func(step, format string, args ...any)) (string, string, error) {
|
||||
bin := filepath.Join(root, "bin", "mesh-controller")
|
||||
build := func(dir string) error {
|
||||
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir),
|
||||
[]string{"CGO_ENABLED=0", "GOFLAGS=-mod=vendor", "GOPROXY=off", "GOCACHE=" + filepath.Join(filepath.Dir(root), "go-cache")},
|
||||
_, err := run(ctx, root, "docker", inToolchain(filepath.Join(root, dir), nil,
|
||||
"go", "build", "-o", bin, "./cmd/mesh-controller")...)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -35,8 +36,17 @@ type CheckBuilding struct {
|
||||
Publish Publisher
|
||||
Scratch Scratch
|
||||
Held map[string]string
|
||||
// Running is each artifact of the build the mesh has registered for its module, keyed "<module>/<artifact>"
|
||||
// (novox/hq issue 721): what a build for the merge is compared with — never Held's newest build, which may be one
|
||||
// its gate failed and the mesh put back.
|
||||
Running map[string]string
|
||||
Npmrc Npmrc
|
||||
Seats map[string]string
|
||||
// Named are the builds the build seat's verdicts of earlier checks of this pull request at this head name
|
||||
// (novox/hq issue 703): a build kept under a module's key is kept again, instead of built, only when one of them
|
||||
// names its key and every digest, and the build is this pull request's at this head. The record alone is never
|
||||
// enough.
|
||||
Named []NamedBuild
|
||||
}
|
||||
|
||||
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
|
||||
@@ -48,6 +58,14 @@ type CheckBuilt struct {
|
||||
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
|
||||
NotBuilt string
|
||||
Took time.Duration
|
||||
// Kept is the check whose build this check kept instead of building, its key unchanged (novox/hq issue 703):
|
||||
// the verdict names that build, by key and digest, as that check's. Empty when this check built it.
|
||||
Kept string
|
||||
// Same says every artifact came out the same as the build the mesh holds of the module (novox/hq issue 721), and
|
||||
// SameAs names, per artifact, the held reference it was compared with: the walk leaves the module where it is
|
||||
// while the mesh still holds that build. Empty when it differs, or could not be compared.
|
||||
Same string
|
||||
SameAs map[string]string
|
||||
}
|
||||
|
||||
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
|
||||
@@ -60,13 +78,45 @@ func skipsTheBuilds(v CheckVerdict) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace, and keeps what it
|
||||
// built under its keys. A module standing after one not built is not built either: what it stands on in the walk
|
||||
// is not what it would stand on here. tree is the check's clone and merge the merge commit it made there.
|
||||
// buildForTheMerge builds each module the plan moves into the scratch namespace (makeForTheMerge), and keeps what it
|
||||
// built under its keys (keepForTheMerge): the order of a check that builds after it has judged.
|
||||
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
|
||||
out, kept := makeForTheMerge(ctx, run, spec, with, workspace, tree, merge, judged, forge, log, say)
|
||||
return keepForTheMerge(ctx, spec, with, out, kept, say)
|
||||
}
|
||||
|
||||
// makeForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace — **the modules of a
|
||||
// tier at the same time** (novox/hq issue 716): none of them stands on another, and a tier waits only for the ones
|
||||
// before it, whose builds it is handed. A module standing after one not built is not built either: what it stands on
|
||||
// in the walk is not what it would stand on here. tree is the check's copy of the merge and merge the merge commit it
|
||||
// made there; workspace is where each build's working copy is made, one directory per module, which the host's
|
||||
// container engine sees at the same path (novox/hq issue 713). Answers what each module came to, in the plan's order,
|
||||
// and the builds to keep — nothing is kept here.
|
||||
func makeForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
|
||||
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) ([]CheckBuilt, []CheckBuild) {
|
||||
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
// What an earlier check left, ended before it could remove it, is not built on.
|
||||
place := filepath.Join(workspace, "for-the-merge")
|
||||
_ = os.RemoveAll(place)
|
||||
defer os.RemoveAll(place)
|
||||
// One log, said to by builds running at once: a line at a time.
|
||||
var saying sync.Mutex
|
||||
if log != nil {
|
||||
inner := log
|
||||
log = func(step, message string) {
|
||||
saying.Lock()
|
||||
defer saying.Unlock()
|
||||
inner(step, message)
|
||||
}
|
||||
}
|
||||
inner := say
|
||||
say = func(step, format string, args ...any) {
|
||||
saying.Lock()
|
||||
defer saying.Unlock()
|
||||
inner(step, format, args...)
|
||||
}
|
||||
ref := merge
|
||||
if ref == "" {
|
||||
@@ -76,76 +126,230 @@ func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with Chec
|
||||
for k, v := range with.Held {
|
||||
held[k] = v
|
||||
}
|
||||
var out []CheckBuilt
|
||||
out := make([]CheckBuilt, len(spec.Builds))
|
||||
results := make([]Result, len(spec.Builds))
|
||||
var kept []CheckBuild
|
||||
stopAt, stopWhy := -1, ""
|
||||
for _, b := range spec.Builds {
|
||||
began := time.Now()
|
||||
if stopAt >= 0 && b.Tier > stopAt {
|
||||
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: stopWhy})
|
||||
continue
|
||||
for start := 0; start < len(spec.Builds); {
|
||||
tier := spec.Builds[start].Tier
|
||||
end := start
|
||||
for end < len(spec.Builds) && spec.Builds[end].Tier == tier {
|
||||
end++
|
||||
}
|
||||
if b.Skip != "" {
|
||||
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
|
||||
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: b.Skip})
|
||||
if stopAt < 0 || b.Tier < stopAt {
|
||||
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||
// What this tier's builds are handed: the builds of the tiers before it, read alone while they run.
|
||||
handed := map[string]string{}
|
||||
for k, v := range held {
|
||||
handed[k] = v
|
||||
}
|
||||
var building sync.WaitGroup
|
||||
for i := start; i < end; i++ {
|
||||
b := spec.Builds[i]
|
||||
if stopAt >= 0 && b.Tier > stopAt {
|
||||
out[i] = CheckBuilt{Module: b.Module, NotBuilt: stopWhy}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
repository, at := b.Repository, b.Ref
|
||||
if b.Merged {
|
||||
repository, at = tree, ref
|
||||
}
|
||||
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
|
||||
var mirrored []string
|
||||
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at,
|
||||
filepath.Join(workspace, "for-the-merge"), held, with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true},
|
||||
with.Seats)
|
||||
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
|
||||
switch {
|
||||
case errors.Is(err, errNoKey):
|
||||
built.NotBuilt = err.Error()
|
||||
case err != nil:
|
||||
built.NotBuilt = "its build failed: " + err.Error()
|
||||
case result.ReuseKey == "":
|
||||
built.NotBuilt = "it has no reuse key: " + result.NoKey
|
||||
}
|
||||
if built.NotBuilt != "" {
|
||||
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
|
||||
out = append(out, built)
|
||||
if stopAt < 0 || b.Tier < stopAt {
|
||||
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||
if b.Skip != "" {
|
||||
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
|
||||
out[i] = CheckBuilt{Module: b.Module, NotBuilt: b.Skip}
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
built.Made = result.Built
|
||||
for _, m := range result.Built {
|
||||
held[b.Module+"/"+m.Name] = m.Reference
|
||||
}
|
||||
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]), built.Took.Round(time.Second))
|
||||
out = append(out, built)
|
||||
if len(result.Built) > 0 {
|
||||
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
|
||||
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
|
||||
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
|
||||
}
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(workspace, "for-the-merge"))
|
||||
if len(kept) > 0 {
|
||||
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
|
||||
time.Now().UTC(), kept); err != nil {
|
||||
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
|
||||
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
|
||||
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
||||
say("build", "and what they made could not all be let go of: %v", err)
|
||||
repository, at := b.Repository, b.Ref
|
||||
if b.Merged {
|
||||
repository, at = tree, ref
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].NotBuilt == "" {
|
||||
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
|
||||
name := b.Module
|
||||
if !safeName.MatchString(name) {
|
||||
name = fmt.Sprintf("module-%d", i)
|
||||
}
|
||||
building.Add(1)
|
||||
go func(i int, b CheckBuildSpec, repository, at, dir string) {
|
||||
defer building.Done()
|
||||
began := time.Now()
|
||||
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
|
||||
var mirrored []string
|
||||
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at, dir, handed,
|
||||
with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true, Scratch: with.Scratch, Named: with.Named,
|
||||
pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, head: judged.Head},
|
||||
with.Seats)
|
||||
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
|
||||
switch {
|
||||
case errors.Is(err, errNoKey):
|
||||
built.NotBuilt = err.Error()
|
||||
case err != nil:
|
||||
built.NotBuilt = "its build failed: " + err.Error()
|
||||
case result.ReuseKey == "":
|
||||
built.NotBuilt = "it has no reuse key: " + result.NoKey
|
||||
}
|
||||
if built.NotBuilt == "" {
|
||||
// **Compared with what runs** (novox/hq issue 721): a failed comparison says nothing, and the
|
||||
// module moves as before.
|
||||
same, against, err := sameAsHeld(ctx, run, dir, b.Module, result.Built, with.Running)
|
||||
switch {
|
||||
case err != nil:
|
||||
say("build", "%s could not be compared with the build the mesh holds, so it moves: %v", b.Module, err)
|
||||
case same != "":
|
||||
built.Same, built.SameAs = same, against
|
||||
say("build", "%s %s", b.Module, same)
|
||||
}
|
||||
}
|
||||
out[i], results[i] = built, result
|
||||
}(i, b, repository, at, filepath.Join(place, name))
|
||||
}
|
||||
building.Wait()
|
||||
for i := start; i < end; i++ {
|
||||
b, built, result := spec.Builds[i], &out[i], results[i]
|
||||
if built.NotBuilt != "" {
|
||||
if built.Key != "" || built.Took > 0 {
|
||||
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
|
||||
}
|
||||
if stopAt < 0 || b.Tier < stopAt {
|
||||
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
|
||||
}
|
||||
continue
|
||||
}
|
||||
built.Made = result.Built
|
||||
for _, m := range result.Built {
|
||||
held[b.Module+"/"+m.Name] = m.Reference
|
||||
}
|
||||
if result.Reused != nil {
|
||||
// **Kept, not built** (novox/hq issue 703): the earlier check's build, named as that check's, so the
|
||||
// walk takes it as the verdict names it; this check's document names it too, so it stays while this
|
||||
// check does (ADR 0350 §5).
|
||||
built.Kept = result.Reused.From.Check
|
||||
say("build", "%s kept for the merge under %s: %s", b.Module, twelve(result.ReuseKey[3:]),
|
||||
result.Reused.From.Said())
|
||||
kept = append(kept, result.Reused.From)
|
||||
continue
|
||||
}
|
||||
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]),
|
||||
built.Took.Round(time.Second))
|
||||
if len(result.Built) > 0 {
|
||||
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
|
||||
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
|
||||
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
|
||||
}
|
||||
}
|
||||
start = end
|
||||
}
|
||||
return out, kept
|
||||
}
|
||||
|
||||
// keepForTheMerge keeps a check's builds under their keys, its own document naming them, so the walk of its merge
|
||||
// finds them; what cannot be kept is let go of, said, and each module says it — never the check's verdict.
|
||||
func keepForTheMerge(ctx context.Context, spec CheckSpec, with CheckBuilding, out []CheckBuilt, kept []CheckBuild,
|
||||
say func(step, format string, args ...any)) []CheckBuilt {
|
||||
if len(kept) == 0 {
|
||||
return out
|
||||
}
|
||||
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
|
||||
time.Now().UTC(), kept); err != nil {
|
||||
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
|
||||
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
|
||||
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
||||
say("build", "and what they made could not all be let go of: %v", err)
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].NotBuilt == "" {
|
||||
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// discardForTheMerge lets go of builds a check made beside its tests when the check did not pass (novox/hq issue 716):
|
||||
// a change that fails is not merged, and nothing it built is kept. A build another check names stays (Discard).
|
||||
func discardForTheMerge(ctx context.Context, with CheckBuilding, kept []CheckBuild, why string,
|
||||
say func(step, format string, args ...any)) {
|
||||
say("check", "%s: the %d build(s) made beside it are let go of", why, len(kept))
|
||||
if len(kept) == 0 {
|
||||
return
|
||||
}
|
||||
if err := with.Scratch.Discard(ctx, kept); err != nil {
|
||||
say("build", "what they made could not all be let go of, and goes with the week's sweep: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// besideTheTests are a check's builds for its merge running while its gate and its own check run (novox/hq issue
|
||||
// 716): started once every clone is made, waited for when the check has judged, kept when it passed and let go of
|
||||
// when it did not.
|
||||
type besideTheTests struct {
|
||||
spec CheckSpec
|
||||
with CheckBuilding
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
place string
|
||||
out []CheckBuilt
|
||||
kept []CheckBuild
|
||||
began time.Time
|
||||
settled bool
|
||||
say func(step, format string, args ...any)
|
||||
}
|
||||
|
||||
// buildsBesideTheTests is where a check's builds run beside its tests: a directory made for this check under the judge
|
||||
// cache's homes/ (ADR 0358 §6), on the host path the build agent declares — so the host's container engine mounts what
|
||||
// the agent sees (issue 713) — and never mounted by a check's container, so nothing a pull request's script runs can
|
||||
// change a build while it is made (issue 703). Empty, and the builds wait for the check, without a judge cache.
|
||||
func buildsBesideTheTests(cache, workspace, id string) (string, error) {
|
||||
if cache == "" {
|
||||
return "", nil
|
||||
}
|
||||
if withinDir(workspace, cache) {
|
||||
return "", fmt.Errorf("the judge cache %s is inside the workspace %s, which a check's containers mount: "+
|
||||
"nothing is built for the merge where a pull request could reach it", cache, workspace)
|
||||
}
|
||||
if !safeName.MatchString(id) {
|
||||
return "", fmt.Errorf("the check's id %q cannot name its place in the judge cache", id)
|
||||
}
|
||||
place := filepath.Join(cache, "homes", id+"-builds")
|
||||
if err := os.RemoveAll(place); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(place, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return place, nil
|
||||
}
|
||||
|
||||
// startBesideTheTests starts the builds for the merge in place, from merged, the check's copy of the merge.
|
||||
func startBesideTheTests(ctx context.Context, run Runner, spec CheckSpec, place, merged string, judged CheckVerdict,
|
||||
forge GitCredential, log Log, say func(step, format string, args ...any)) *besideTheTests {
|
||||
building, cancel := context.WithCancel(ctx)
|
||||
b := &besideTheTests{spec: spec, with: *spec.BuildWith, cancel: cancel, done: make(chan struct{}), place: place,
|
||||
began: time.Now(), say: say}
|
||||
say("check", "building what the merge moves (%d module(s)) beside its tests", len(spec.Builds))
|
||||
go func() {
|
||||
defer close(b.done)
|
||||
b.out, b.kept = makeForTheMerge(building, run, spec, b.with, place, merged, judged.Merge, judged, forge, log, say)
|
||||
}()
|
||||
return b
|
||||
}
|
||||
|
||||
// settle waits for the builds and keeps them when the check passed; a check that did not pass lets go of them, and
|
||||
// its verdict names none.
|
||||
func (b *besideTheTests) settle(ctx context.Context, verdict CheckVerdict) []CheckBuilt {
|
||||
<-b.done
|
||||
b.settled = true
|
||||
defer os.RemoveAll(b.place)
|
||||
if why := skipsTheBuilds(verdict); why != "" {
|
||||
discardForTheMerge(ctx, b.with, b.kept, why, b.say)
|
||||
return nil
|
||||
}
|
||||
built := keepForTheMerge(ctx, b.spec, b.with, b.out, b.kept, b.say)
|
||||
b.say("check", "built for the merge: %d of %d module(s), beside the tests (%s)", builtCount(built), len(built),
|
||||
time.Since(b.began).Round(time.Second))
|
||||
return built
|
||||
}
|
||||
|
||||
// abandon ends builds a check that ended without a verdict leaves, and lets go of what they made.
|
||||
func (b *besideTheTests) abandon() {
|
||||
if b.settled {
|
||||
return
|
||||
}
|
||||
b.cancel()
|
||||
<-b.done
|
||||
defer os.RemoveAll(b.place)
|
||||
letGo, done := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer done()
|
||||
discardForTheMerge(letGo, b.with, b.kept, "the check ended without a verdict", b.say)
|
||||
}
|
||||
|
||||
@@ -210,3 +210,53 @@ func TestACheckMergeRunsNoHook(t *testing.T) {
|
||||
t.Fatalf("a hook ran when the check made its merge: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// What a check's tests change in the workspace never reaches what it builds for the merge (novox/hq issue 703): the
|
||||
// builds are made from a copy of the merge, outside the workspace, cloned before anything of the change runs.
|
||||
func TestWhatTheTestsChangeInTheWorkspaceNeverReachesTheBuild(t *testing.T) {
|
||||
repo, head, _ := aTrunkAndABranch(t, map[string]string{"main.go": "package main // the merge\n"},
|
||||
map[string]string{"trunk-only": "t"}, map[string]string{"head-only": "h"})
|
||||
workspace := t.TempDir()
|
||||
tree := filepath.Join(workspace, "check", "checked")
|
||||
if out, err := exec.Command("git", "clone", "--quiet", repo, tree).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
dir, cloned, err := checkoutForTheBuilds(t.Context(), Command, tree, head, workspace)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if withinDir(workspace, cloned) {
|
||||
t.Fatalf("the builds' checkout %s is inside the workspace %s the tests mount", cloned, workspace)
|
||||
}
|
||||
|
||||
// The tests rewrite the tree, its branch and every object it holds.
|
||||
if err := os.WriteFile(filepath.Join(tree, "main.go"), []byte("package main // planted\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := filepath.WalkDir(filepath.Join(tree, ".git", "objects"), func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
_ = os.Chmod(path, 0o644)
|
||||
return os.WriteFile(path, []byte("planted"), 0o644)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The build clones the merge from its own checkout, as buildForTheMerge does, and reads what the merge holds.
|
||||
build := filepath.Join(t.TempDir(), "source")
|
||||
if out, err := exec.Command("git", "clone", "--quiet", cloned, build).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if out, err := exec.Command("git", "-C", build, "checkout", "--quiet", head).CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(build, "main.go"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(body) != "package main // the merge\n" {
|
||||
t.Fatalf("the build read %q: what the tests wrote reached it", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A pull request into a release branch is judged by the controller of that branch** (novox/hq ADR 0358, issue
|
||||
// 704): the build seat's check builds the judge from mesh-controller read at the release branch — with mesh-host's
|
||||
// validator of that branch when mesh-host has one — reads every repository beside it at that branch where it has one
|
||||
// and at its trunk otherwise, and names the branch, the judge's commit and each repository read with its commit.
|
||||
|
||||
const releaseBranch = "feat/57-config-and-provisioning"
|
||||
|
||||
// gitInRepo runs git in a repository made for a test.
|
||||
func gitInRepo(t *testing.T, dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.org",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.org")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v: %v\n%s", args, err, out)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// commitFiles writes files into a repository and commits them on the branch checked out.
|
||||
func commitFiles(t *testing.T, dir string, files map[string]string) string {
|
||||
t.Helper()
|
||||
for name, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
gitInRepo(t, dir, "add", "-A")
|
||||
gitInRepo(t, dir, "commit", "--quiet", "-m", "x")
|
||||
return gitInRepo(t, dir, "rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
// aRepositoryWithARelease is a repository whose main holds mainFiles and, when releaseFiles is not nil, a release
|
||||
// branch over them holding releaseFiles. It answers the repository, main's head and the release branch's head.
|
||||
func aRepositoryWithARelease(t *testing.T, mainFiles, releaseFiles map[string]string) (string, string, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
gitInRepo(t, dir, "init", "--quiet", "-b", "main")
|
||||
main := commitFiles(t, dir, mainFiles)
|
||||
if releaseFiles == nil {
|
||||
return dir, main, ""
|
||||
}
|
||||
gitInRepo(t, dir, "checkout", "--quiet", "-b", releaseBranch)
|
||||
head := commitFiles(t, dir, releaseFiles)
|
||||
gitInRepo(t, dir, "checkout", "--quiet", "main")
|
||||
return dir, main, head
|
||||
}
|
||||
|
||||
// aJudgeOf is a controller that judges as told: its gate passes, saying who it is and the word of the node-engine
|
||||
// validator it vendors — the one it was built with.
|
||||
func aJudgeOf(who string) map[string]string {
|
||||
return map[string]string{
|
||||
"go.mod": "module example.org/judge\n\ngo 1.22\n\nrequire github.com/novox/mesh-host v0.0.0\n",
|
||||
"vendor/modules.txt": "# github.com/novox/mesh-host v0.0.0\n## explicit; go 1.22\ngithub.com/novox/mesh-host/validate\n",
|
||||
"vendor/github.com/novox/mesh-host/validate/v.go": "package validate\n\nconst Word = \"the vendored validator\"\n",
|
||||
"vendor/github.com/novox/mesh-host/internal/declaration/d.go": "package declaration\n",
|
||||
"cmd/mesh-controller/main.go": `package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) == 2 && os.Args[1] == "merge-gate" {
|
||||
fmt.Println("usage: merge-gate --facts <file> --store <postgres>")
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Printf("{\"verdict\":\"pass\",\"summary\":\"%s with %s\"}\n", "` + who + `", validate.Word)
|
||||
}
|
||||
`,
|
||||
}
|
||||
}
|
||||
|
||||
// aHostSaying is the node-engine's validator, saying a word.
|
||||
func aHostSaying(word string) map[string]string {
|
||||
return map[string]string{
|
||||
"validate/v.go": "package validate\n\nconst Word = \"" + word + "\"\n",
|
||||
"internal/declaration/d.go": "package declaration\n",
|
||||
"validate/v_test.go": "package validate\n",
|
||||
"internal/declaration/x_test.go": "package declaration\n",
|
||||
}
|
||||
}
|
||||
|
||||
// aRuntimeThatBuildsTheJudge puts a container runtime on PATH that runs every container's command on this machine,
|
||||
// in the directory and with the environment the check gives it — the judge's build too — and a store and a bus that
|
||||
// answer. It answers the file each `go` the check runs is written to.
|
||||
func aRuntimeThatBuildsTheJudge(t *testing.T) string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("no Go toolchain on this machine to build a judge with")
|
||||
}
|
||||
bus, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { bus.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := bus.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
dir := t.TempDir()
|
||||
built := filepath.Join(dir, "built")
|
||||
runtime := `#!/bin/sh
|
||||
cmd="$1"; shift
|
||||
case "$cmd" in
|
||||
ps|rm|exec) exit 0 ;;
|
||||
volume)
|
||||
if [ -n "$VOLUMES" ]; then echo "$*" >> "$VOLUMES"; fi
|
||||
exit 0 ;;
|
||||
port) echo "` + bus.Addr().String() + `"; exit 0 ;;
|
||||
run)
|
||||
if [ -n "$RUNS" ]; then echo "$*" >> "$RUNS"; fi
|
||||
workdir=""; detached=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-d) detached=1; shift ;;
|
||||
--rm) shift ;;
|
||||
--workdir) workdir="$2"; shift 2 ;;
|
||||
--env) export "$2"; shift 2 ;;
|
||||
--network|--volume|--user|--label|--name|-p|-e) shift 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$detached" ]; then echo a-container; exit 0; fi
|
||||
shift
|
||||
if [ "$1" = go ]; then echo "GOCACHE=$GOCACHE GOENV=$GOENV $*" >> "` + built + `"; fi
|
||||
if [ "$1" = go ] && [ "$2" = build ] && [ -n "$JUDGE_HOMES" ]; then echo "$HOME" >> "$JUDGE_HOMES"; fi
|
||||
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS_CANNOT_START" ]; then
|
||||
echo "docker: Error response from daemon: invalid mount config for type bind" >&2; exit 125
|
||||
fi
|
||||
if [ "$1" = go ] && [ "$2" = test ] && [ -n "$REPLAYS" ]; then
|
||||
echo "HOME=$HOME GOCACHE=$GOCACHE GOMODCACHE=$GOMODCACHE GOENV=$GOENV GOFLAGS=$GOFLAGS DIR=$workdir" >> "$REPLAYS"
|
||||
fi
|
||||
if [ "$1" = go ] && [ -n "$WARM_GO_CACHE" ] && [ -n "$GOCACHE" ] && [ -z "$(ls -A "$GOCACHE" 2>/dev/null)" ]; then
|
||||
mkdir -p "$GOCACHE" && cp -R "$WARM_GO_CACHE/." "$GOCACHE/"
|
||||
fi
|
||||
cd "$workdir" && exec "$@" ;;
|
||||
esac
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(runtime), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
t.Setenv("GOTOOLCHAIN", "local")
|
||||
// An empty Go cache the check names is filled from a warm one before `go` runs (warm_go_cache_test.go).
|
||||
t.Setenv("WARM_GO_CACHE", warmGoCache(t))
|
||||
return built
|
||||
}
|
||||
|
||||
// builds is how many times the check ran go to build a judge.
|
||||
func builds(t *testing.T, file string) int {
|
||||
t.Helper()
|
||||
body, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(string(body), "go build")
|
||||
}
|
||||
|
||||
// theReleasedMesh is the core beside a release-branch check: a controller whose main and release branch judge
|
||||
// differently, a node-engine with a validator of each, and a lab with no release branch.
|
||||
func theReleasedMesh(t *testing.T) (map[string]Beside, string, string, string) {
|
||||
t.Helper()
|
||||
controller, _, controllerRelease := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
|
||||
aJudgeOf("the release branch's controller"))
|
||||
host, _, hostRelease := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
||||
aHostSaying("the release branch's validator"))
|
||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"}, nil)
|
||||
return map[string]Beside{
|
||||
"mesh-controller": {Repository: controller, Ref: releaseBranch},
|
||||
"mesh-controller-main": {Repository: controller, Ref: "main"},
|
||||
"mesh-host": {Repository: host, Ref: releaseBranch, Else: "main"},
|
||||
"mesh-lab": {Repository: lab, Ref: releaseBranch, Else: "main"},
|
||||
}, controllerRelease, hostRelease, labMain
|
||||
}
|
||||
|
||||
// aPullRequestInto is a repository whose pull request's head is cut from base, with a change.
|
||||
func aPullRequestInto(t *testing.T, files map[string]string, base string, change map[string]string) (string, string) {
|
||||
t.Helper()
|
||||
repo, _, _ := aRepositoryWithARelease(t, files, map[string]string{"released": "r"})
|
||||
gitInRepo(t, repo, "checkout", "--quiet", "-b", "change", base)
|
||||
head := commitFiles(t, repo, change)
|
||||
gitInRepo(t, repo, "checkout", "--quiet", "main")
|
||||
return repo, head
|
||||
}
|
||||
|
||||
func TestACataloguePullRequestIntoAReleaseBranchIsJudgedByThatBranchsController(t *testing.T) {
|
||||
built := aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
beside, controllerRelease, hostRelease, labMain := theReleasedMesh(t)
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
||||
cache := t.TempDir()
|
||||
check := func(id string) CheckVerdict {
|
||||
t.Helper()
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: id, Repository: catalogue, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch, JudgeCache: cache,
|
||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside, Toolchain: "golang"},
|
||||
t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
v := check("check-704-catalogue")
|
||||
// The release branch's controller judged, with mesh-host's validator of the release branch.
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" ||
|
||||
!strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
|
||||
t.Fatalf("the gate was not judged by the release branch's controller with its node-engine's validator: %+v\n%s",
|
||||
v.Gate, v.Report)
|
||||
}
|
||||
// It names the release branch, the judge's commit, and each repository read beside it at its branch and commit.
|
||||
mark := "[release " + releaseBranch + ", judged by its controller " + controllerRelease[:12] + "] "
|
||||
if !strings.HasPrefix(v.Gate.Summary, mark) || !strings.HasPrefix(v.Repo.Summary, mark) {
|
||||
t.Fatalf("the statuses do not name the release branch and its judge first:\n%s\n%s", v.Gate.Summary, v.Repo.Summary)
|
||||
}
|
||||
if v.Release != releaseBranch || v.JudgedBy != controllerRelease[:12] {
|
||||
t.Fatalf("the verdict names release %q judged by %q, not %s by %s", v.Release, v.JudgedBy, releaseBranch,
|
||||
controllerRelease[:12])
|
||||
}
|
||||
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
|
||||
t.Fatalf("mesh-host was read at %q (%s), not its release branch at %s", v.BesideAt["mesh-host"],
|
||||
v.Beside["mesh-host"], hostRelease[:12])
|
||||
}
|
||||
// A repository with no release branch is read at its trunk, and says so.
|
||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
|
||||
t.Fatalf("the lab, with no release branch, was read at %q (%s), not main at %s", v.BesideAt["mesh-lab"],
|
||||
v.Beside["mesh-lab"], labMain[:12])
|
||||
}
|
||||
if !strings.Contains(v.Report, "read beside it: ") || !strings.Contains(v.Report, "mesh-host "+releaseBranch+" at "+hostRelease[:12]) {
|
||||
t.Fatalf("the report does not say what was read beside it:\n%s", v.Report)
|
||||
}
|
||||
// It says which validator judged: mesh-host's of the release branch, with its commit.
|
||||
if want := "mesh-host's validator of " + releaseBranch + " at " + hostRelease[:12]; v.Validator != want ||
|
||||
!strings.Contains(v.Report, want) {
|
||||
t.Fatalf("the verdict says the validator was %q, not %q:\n%s", v.Validator, want, v.Report)
|
||||
}
|
||||
// Each repository read beside it is named with the branch and the commit read.
|
||||
for dir := range beside {
|
||||
if v.BesideAt[dir] == "" || v.Beside[dir] == "" ||
|
||||
!strings.Contains(v.Report, dir+" "+v.BesideAt[dir]+" at "+v.Beside[dir]) {
|
||||
t.Fatalf("%s is not named with its branch and commit: at %q, commit %q\n%s", dir, v.BesideAt[dir],
|
||||
v.Beside[dir], v.Report)
|
||||
}
|
||||
}
|
||||
if n := builds(t, built); n != 1 {
|
||||
t.Fatalf("the judge was built %d times for one check", n)
|
||||
}
|
||||
// **The judge is built with the judge cache's Go cache** (ADR 0358 §6), which no check's container mounts, and no
|
||||
// Go environment file.
|
||||
said, _ := os.ReadFile(built)
|
||||
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build") {
|
||||
t.Fatalf("the release judge was not built with the judge cache's Go cache:\n%s", said)
|
||||
}
|
||||
// **Clause 5**: the head was merged onto the release branch's head, which the verdict names as its trunk commit,
|
||||
// so the release branch's move puts it out of date as a trunk's move does (ADR 0323 §4, the forge's holder).
|
||||
if releaseHead := gitInRepo(t, catalogue, "rev-parse", releaseBranch); v.Trunk != releaseHead[:12] {
|
||||
t.Fatalf("the verdict names trunk %q, not the release branch's head %s", v.Trunk, releaseHead[:12])
|
||||
}
|
||||
|
||||
// **Built once per release-branch head**: a second check while that head stands takes the judge kept.
|
||||
v = check("check-704-catalogue-again")
|
||||
if !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the release branch's validator") {
|
||||
t.Fatalf("the kept judge did not judge: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if n := builds(t, built); n != 1 {
|
||||
t.Fatalf("the judge of the same release-branch head was built again: %d builds", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAControllerPullRequestIntoAReleaseBranchReadsTheNodeEngineAtThatBranch(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
beside, _, hostRelease, _ := theReleasedMesh(t)
|
||||
delete(beside, "mesh-controller")
|
||||
controller, head := aPullRequestInto(t, aJudgeOf("the change's controller"), releaseBranch,
|
||||
map[string]string{"cmd/x.txt": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-controller", Repository: controller, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-controller", Number: 315, Base: releaseBranch, Release: releaseBranch,
|
||||
JudgeCache: t.TempDir(), Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Judge: "self",
|
||||
Beside: beside, Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the change's controller with the vendored validator") {
|
||||
t.Fatalf("a controller change into a release branch did not judge itself: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if v.BesideAt["mesh-host"] != releaseBranch || v.Beside["mesh-host"] != hostRelease[:12] {
|
||||
t.Fatalf("mesh-host was read beside it at %q (%s), not at the release branch's %s", v.BesideAt["mesh-host"],
|
||||
v.Beside["mesh-host"], hostRelease[:12])
|
||||
}
|
||||
if v.Release != releaseBranch || v.JudgedBy == "" || v.JudgedBy != v.Merge[:12] {
|
||||
t.Fatalf("the verdict names release %q judged by %q, not its own merge %s", v.Release, v.JudgedBy, v.Merge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPullRequestIntoTheTrunkIsJudgedAsBefore(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("main's controller"),
|
||||
aJudgeOf("the release branch's controller"))
|
||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
||||
aHostSaying("the release branch's validator"))
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-trunk", Repository: catalogue, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: t.TempDir(),
|
||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
|
||||
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
|
||||
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Summary != "main's controller with the vendored validator" {
|
||||
t.Fatalf("a trunk pull request was not judged by the controller beside it as before: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || strings.Contains(v.Report, "release branch") {
|
||||
t.Fatalf("a trunk pull request's verdict speaks of a release branch: %q %q %v", v.Release, v.JudgedBy, v.BesideAt)
|
||||
}
|
||||
if v.Beside["mesh-host"] != hostMain[:12] {
|
||||
t.Fatalf("mesh-host was read at %s, not the commit asked %s", v.Beside["mesh-host"], hostMain[:12])
|
||||
}
|
||||
}
|
||||
|
||||
// **Without mesh-host's release branch, the release branch's controller keeps the validator it vendors** (ADR 0358
|
||||
// §2), and the verdict says so.
|
||||
func TestWithoutTheNodeEnginesReleaseBranchTheJudgeKeepsItsVendoredValidator(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
beside, controllerRelease, _, _ := theReleasedMesh(t)
|
||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"), nil)
|
||||
beside["mesh-host"] = Beside{Repository: host, Ref: releaseBranch, Else: "main"}
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-vendored", Repository: catalogue, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with the vendored validator") {
|
||||
t.Fatalf("without mesh-host's release branch the judge did not keep its vendored validator: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if v.JudgedBy != controllerRelease[:12] || v.BesideAt["mesh-host"] != "main" || v.Beside["mesh-host"] != hostMain[:12] {
|
||||
t.Fatalf("judged by %q, mesh-host read at %q (%s)", v.JudgedBy, v.BesideAt["mesh-host"], v.Beside["mesh-host"])
|
||||
}
|
||||
if !strings.Contains(v.Validator, "controller vendors") || !strings.Contains(v.Report, v.Validator) {
|
||||
t.Fatalf("the verdict does not say the vendored validator judged: %q\n%s", v.Validator, v.Report)
|
||||
}
|
||||
}
|
||||
|
||||
// **Clause 7** (ADR 0358 §7): a release branch's own pull request into its trunk is checked as any pull request into a
|
||||
// trunk — by the controller beside it as the mesh runs it, the repositories at what a trunk check reads — and its
|
||||
// verdict names no release branch.
|
||||
func TestAReleaseBranchsPullRequestIntoItsTrunkIsJudgedByTheControllerTheMeshRuns(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"),
|
||||
aJudgeOf("the release branch's controller"))
|
||||
host, hostMain, _ := aRepositoryWithARelease(t, aHostSaying("main's validator"),
|
||||
aHostSaying("the release branch's validator"))
|
||||
catalogue, _, releaseHead := aRepositoryWithARelease(t, map[string]string{
|
||||
"modules/gitea/module.json": `{"module":"gitea"}`, CheckScript: "true\n"},
|
||||
map[string]string{"modules/gitea/index.ts": "released"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-release-into-trunk", Repository: catalogue,
|
||||
Ref: releaseHead, Owner: "novox", Repo: "mesh-catalog", Number: 300, Base: "main", JudgeCache: t.TempDir(),
|
||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang",
|
||||
Beside: map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain},
|
||||
"mesh-host": {Repository: host, Ref: hostMain}}}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
|
||||
t.Fatalf("the release branch's pull request into its trunk was not judged by the controller the mesh runs: %+v\n%s",
|
||||
v.Gate, v.Report)
|
||||
}
|
||||
if v.Release != "" || v.JudgedBy != "" || v.BesideAt != nil || v.Validator != "" ||
|
||||
strings.Contains(v.Report, "release branch") || strings.Contains(v.Gate.Summary, "[release") {
|
||||
t.Fatalf("its verdict names a release branch: %q %q %v %q", v.Release, v.JudgedBy, v.BesideAt, v.Validator)
|
||||
}
|
||||
if v.Beside["mesh-host"] != hostMain[:12] || v.Beside["mesh-controller"] != controllerMain[:12] {
|
||||
t.Fatalf("beside it were read %v, not the trunk's %s and %s", v.Beside, controllerMain[:12], hostMain[:12])
|
||||
}
|
||||
}
|
||||
|
||||
// **mesh-lab is read at its trunk, always** (ADR 0358): its replays run with the container runtime's socket, so a
|
||||
// branch of the release's name in mesh-lab — which anyone may create — is never what a check reads.
|
||||
func TestAReleaseBranchCheckReadsMeshLabAtItsTrunkEvenWhenItHasThatBranch(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
beside, _, _, _ := theReleasedMesh(t)
|
||||
lab, labMain, labRelease := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
|
||||
map[string]string{"README": "a branch anyone could push"})
|
||||
beside["mesh-lab"] = Beside{Repository: lab, Ref: releaseBranch, Else: "main"}
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab", Repository: catalogue, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] || v.Beside["mesh-lab"] == labRelease[:12] {
|
||||
t.Fatalf("mesh-lab was read at %q (%s), not its trunk main at %s", v.BesideAt["mesh-lab"], v.Beside["mesh-lab"],
|
||||
labMain[:12])
|
||||
}
|
||||
}
|
||||
|
||||
// aPlantedWorkspace is a workspace a pull request's own script has written into, as it can: a git configuration in its
|
||||
// HOME whose fsmonitor hook says each directory git runs in, a git configuration under XDG_CONFIG_HOME doing the same,
|
||||
// and a Go environment file that would hand the judge's build a tool of its own. It answers the workspace and the
|
||||
// file the hook writes to.
|
||||
func aPlantedWorkspace(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
workspace, logs := t.TempDir(), t.TempDir()
|
||||
ran := filepath.Join(logs, "hook-ran")
|
||||
hook := filepath.Join(workspace, "hook.sh")
|
||||
files := map[string]string{
|
||||
hook: "#!/bin/sh\necho \"$PWD\" >> " + ran + "\n",
|
||||
filepath.Join(workspace, ".gitconfig"): "[core]\n\tfsmonitor = " + hook + "\n",
|
||||
filepath.Join(workspace, ".config", "git", "config"): "[core]\n\tfsmonitor = " + hook + "\n",
|
||||
filepath.Join(workspace, ".config", "go", "env"): "GOFLAGS=-toolexec=" + hook + "\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(name, []byte(body), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return workspace, ran
|
||||
}
|
||||
|
||||
// judgeRanTheHook says whether the planted hook ran in a judge's tree: the controller beside the check, or the
|
||||
// change's own when it judges itself.
|
||||
func judgeRanTheHook(t *testing.T, ran string) string {
|
||||
t.Helper()
|
||||
body, _ := os.ReadFile(ran)
|
||||
for _, line := range strings.Split(string(body), "\n") {
|
||||
if strings.HasSuffix(line, "/check/mesh-controller") {
|
||||
return string(body)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// **Every judge is built where no pull request reaches** (issue 482, ADR 0358 review): a git configuration with a
|
||||
// hook, and a Go environment file, planted in the workspace by an earlier pull request's script, do not run during
|
||||
// the release judge's build — while the pull request's own script, in the workspace, still meets them.
|
||||
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheReleaseJudgesBuild(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
homes := filepath.Join(t.TempDir(), "homes")
|
||||
t.Setenv("JUDGE_HOMES", homes)
|
||||
registry := aFactsRegistry(t)
|
||||
workspace, ran := aPlantedWorkspace(t)
|
||||
beside, _, _, _ := theReleasedMesh(t)
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "git status >/dev/null 2>&1; true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted", Repository: catalogue, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
||||
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" {
|
||||
t.Fatalf("the check did not judge: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-catalog") {
|
||||
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
|
||||
}
|
||||
if said := judgeRanTheHook(t, ran); said != "" {
|
||||
t.Fatalf("the hook planted in the workspace ran during the judge's build:\n%s", said)
|
||||
}
|
||||
if body, _ := os.ReadFile(homes); len(body) == 0 || strings.Contains(string(body), workspace) {
|
||||
t.Fatalf("the judge was built with a HOME in the workspace: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The same for the judge built with a node-engine change's own validator, into a release branch (ADR 0358 §2).
|
||||
func TestAHookPlantedInTheWorkspaceDoesNotRunDuringTheValidatorJudgesBuild(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
homes := filepath.Join(t.TempDir(), "homes")
|
||||
t.Setenv("JUDGE_HOMES", homes)
|
||||
registry := aFactsRegistry(t)
|
||||
workspace, ran := aPlantedWorkspace(t)
|
||||
beside, _, _, _ := theReleasedMesh(t)
|
||||
delete(beside, "mesh-host")
|
||||
files := aHostSaying("the release branch's validator")
|
||||
files["module.json"] = `{"module":"mesh-host"}`
|
||||
files[CheckScript] = "git status >/dev/null 2>&1; true\n"
|
||||
host, head := aPullRequestInto(t, files, releaseBranch, aHostSaying("this change's validator"))
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-planted-validator", Repository: host, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-host", Number: 111, Base: releaseBranch, Release: releaseBranch, Judge: "validator",
|
||||
JudgeCache: t.TempDir(), Paths: []string{"validate/v.go"}, Modules: []string{"mesh-host"}, Beside: beside,
|
||||
Toolchain: "golang"}, workspace, registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || !strings.HasSuffix(v.Gate.Summary, "the release branch's controller with this change's validator") {
|
||||
t.Fatalf("a node-engine change into a release branch was not judged by that branch's controller with its own "+
|
||||
"validator: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
if body, _ := os.ReadFile(ran); !strings.Contains(string(body), "/check/mesh-host") {
|
||||
t.Fatalf("the planted hook never ran, so this test proves nothing: %q", body)
|
||||
}
|
||||
if said := judgeRanTheHook(t, ran); said != "" {
|
||||
t.Fatalf("the hook planted in the workspace ran during a judge's build:\n%s", said)
|
||||
}
|
||||
if body, _ := os.ReadFile(homes); strings.Count(string(body), "\n") != 2 || strings.Contains(string(body), workspace) {
|
||||
t.Fatalf("the judges were not both built with a HOME outside the workspace: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
// **mesh-lab is never read at a release branch**, asked with a fallback or without (ADR 0358).
|
||||
func TestTheBuildSeatNeverReadsMeshLabAtAReleaseBranch(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
registry := aFactsRegistry(t)
|
||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{"README": "the lab"},
|
||||
map[string]string{"README": "a branch anyone could push"})
|
||||
for _, asked := range []Beside{{Repository: lab, Ref: releaseBranch}, {Repository: lab, Ref: releaseBranch, Else: "main"}} {
|
||||
beside, _, _, _ := theReleasedMesh(t)
|
||||
beside["mesh-lab"] = asked
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, releaseBranch, map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-704-lab-asked", Repository: catalogue, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-catalog", Number: 286, Base: releaseBranch, Release: releaseBranch,
|
||||
JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Beside: beside,
|
||||
Toolchain: "golang"}, t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.BesideAt["mesh-lab"] != "main" || v.Beside["mesh-lab"] != labMain[:12] {
|
||||
t.Fatalf("mesh-lab asked as %+v was read at %q (%s), not main at %s", asked, v.BesideAt["mesh-lab"],
|
||||
v.Beside["mesh-lab"], labMain[:12])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Judges are built warm and where no pull request reaches** (ADR 0358 §6): every judge build uses the judge cache's
|
||||
// one Go cache, kept between checks; its home is made fresh under the judge cache and removed after; nothing is left in
|
||||
// a temporary directory of the host; and no container a check runs for the pull request mounts the judge cache.
|
||||
func TestJudgesAreBuiltWarmFromAJudgeCacheNoCheckContainerMounts(t *testing.T) {
|
||||
built := aRuntimeThatBuildsTheJudge(t)
|
||||
// Not filled from the test's warm cache: what the first judge's build leaves in the judge cache is what is proved.
|
||||
t.Setenv("WARM_GO_CACHE", "")
|
||||
runs := filepath.Join(t.TempDir(), "runs")
|
||||
t.Setenv("RUNS", runs)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
registry := aFactsRegistry(t)
|
||||
cache := t.TempDir()
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
||||
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
|
||||
for i := range 2 {
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": fmt.Sprint(i)})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: fmt.Sprintf("check-704-warm-%d", i), Repository: catalogue,
|
||||
Ref: head, Owner: "novox", Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache,
|
||||
Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"}, Toolchain: "golang", Beside: beside},
|
||||
t.TempDir(), registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Summary != "the controller the mesh runs with the vendored validator" {
|
||||
t.Fatalf("check %d was not judged: %+v\n%s", i, v.Gate, v.Report)
|
||||
}
|
||||
if i == 0 {
|
||||
if entries, _ := os.ReadDir(filepath.Join(cache, "go-cache")); len(entries) == 0 {
|
||||
t.Fatal("the judge's build left nothing in the judge cache's Go cache to build the next judge warm")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both judges built with the one Go cache of the judge cache.
|
||||
said, _ := os.ReadFile(built)
|
||||
if n := strings.Count(string(said), "GOCACHE="+filepath.Join(cache, "go-cache")+" GOENV=off go build"); n != 2 {
|
||||
t.Fatalf("%d of 2 judge builds used the judge cache's Go cache:\n%s", n, said)
|
||||
}
|
||||
// Nothing left behind: no home under the judge cache, nothing in the host's temporary directory.
|
||||
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
|
||||
t.Fatalf("judge builds left %d home(s) under the judge cache", len(left))
|
||||
}
|
||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
||||
t.Fatalf("a check left %d entr(ies) in the temporary directory, e.g. %s", len(left), left[0].Name())
|
||||
}
|
||||
// Only a judge build mounts the judge cache: no container the check runs for the pull request does.
|
||||
body, _ := os.ReadFile(runs)
|
||||
judgeBuilds := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
judging := strings.Contains(line, " go build ")
|
||||
if judging {
|
||||
judgeBuilds++
|
||||
if !strings.Contains(line, "--volume "+filepath.Join(cache, "go-cache")+":") {
|
||||
t.Fatalf("a judge build did not mount the judge cache's Go cache: %s", line)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, cache) {
|
||||
t.Fatalf("a container the check runs for the pull request names the judge cache: %s", line)
|
||||
}
|
||||
}
|
||||
if judgeBuilds != 2 {
|
||||
t.Fatalf("%d judge builds were run, not 2", judgeBuilds)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a judge cache, a judge is built cold under the check's own root — never in a temporary directory of the
|
||||
// host — and a judge cache inside the workspace is refused.
|
||||
func TestWithoutAJudgeCacheAJudgeIsBuiltColdUnderTheChecksRoot(t *testing.T) {
|
||||
built := aRuntimeThatBuildsTheJudge(t)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
registry := aFactsRegistry(t)
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
||||
beside := map[string]Beside{"mesh-controller": {Repository: controller, Ref: controllerMain}}
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
||||
workspace := t.TempDir()
|
||||
spec := CheckSpec{ID: "check-704-cold", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
|
||||
Number: 1, Base: "main", Paths: []string{"modules/gitea/index.ts"}, Modules: []string{"gitea"},
|
||||
Toolchain: "golang", Beside: beside}
|
||||
v, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" {
|
||||
t.Fatalf("the check was not judged: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
said, _ := os.ReadFile(built)
|
||||
if !strings.Contains(string(said), "GOCACHE="+filepath.Join(workspace, "check", "judge-go-cache")+" GOENV=off go build") {
|
||||
t.Fatalf("without a judge cache the judge was not built under the check's root:\n%s", said)
|
||||
}
|
||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
||||
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
|
||||
}
|
||||
spec.JudgeCache = filepath.Join(workspace, "judge-cache")
|
||||
if _, err := Check(t.Context(), Command, spec, workspace, registry, GitCredential{}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "inside the workspace") {
|
||||
t.Fatalf("a judge cache inside the workspace was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// aLabWithReplays is mesh-lab with replays: a Go module of its own whose one test passes, needing nothing fetched.
|
||||
func aLabWithReplays(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
lab, labMain, _ := aRepositoryWithARelease(t, map[string]string{
|
||||
"replays/go.mod": "module example.org/replays\n\ngo 1.22\n",
|
||||
"replays/replay_test.go": "package replays\n\nimport \"testing\"\n\nfunc TestAReplay(t *testing.T) {}\n",
|
||||
}, nil)
|
||||
return lab, labMain
|
||||
}
|
||||
|
||||
// **mesh-lab's replays run where no pull request reaches** (novox/hq issue 709, ADR 0358 §6): they hold the container
|
||||
// runtime's socket, so a Go environment file with a -toolexec hook and a Go cache and module cache planted in the
|
||||
// workspace by a pull request's script are neither run nor read — the replays run from a copy of mesh-lab outside
|
||||
// the workspace with a fresh home and the judge cache's caches, all removed or kept outside every check container.
|
||||
func TestTheReplaysRunWhereNoPullRequestReaches(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
replays := filepath.Join(t.TempDir(), "replays")
|
||||
t.Setenv("REPLAYS", replays)
|
||||
registry := aFactsRegistry(t)
|
||||
workspace, ran := aPlantedWorkspace(t)
|
||||
for _, poisoned := range []string{filepath.Join(workspace, "go-cache", "README"),
|
||||
filepath.Join(workspace, "go-modules", "cache", "download", "planted")} {
|
||||
if err := os.MkdirAll(filepath.Dir(poisoned), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(poisoned, []byte("planted by a pull request"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cache := t.TempDir()
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
||||
lab, labMain := aLabWithReplays(t)
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709", Repository: catalogue, Ref: head, Owner: "novox",
|
||||
Repo: "mesh-catalog", Number: 1, Base: "main", JudgeCache: cache, Paths: []string{"modules/gitea/index.ts"},
|
||||
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
|
||||
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}},
|
||||
workspace, registry, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
|
||||
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
body, _ := os.ReadFile(replays)
|
||||
said := strings.TrimSpace(string(body))
|
||||
if said == "" || strings.Count(said, "\n") != 0 {
|
||||
t.Fatalf("the replays ran %q, not once", said)
|
||||
}
|
||||
for _, want := range []string{"GOCACHE=" + filepath.Join(cache, "go-cache") + " ",
|
||||
"GOMODCACHE=" + filepath.Join(cache, "go-modules") + " ", "GOENV=off ", "GOFLAGS=-buildvcs=false ",
|
||||
"HOME=" + filepath.Join(cache, "homes", "check-709-replays", "home") + " ",
|
||||
"DIR=" + filepath.Join(cache, "homes", "check-709-replays", "mesh-lab", "replays")} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Fatalf("the replays did not run with %q: %s", want, said)
|
||||
}
|
||||
}
|
||||
if strings.Contains(said, workspace) {
|
||||
t.Fatalf("the replays ran with something of the workspace: %s", said)
|
||||
}
|
||||
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
|
||||
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
|
||||
}
|
||||
if left, _ := os.ReadDir(filepath.Join(cache, "homes")); len(left) != 0 {
|
||||
t.Fatalf("the replays left %d entr(ies) under the judge cache's homes", len(left))
|
||||
}
|
||||
}
|
||||
|
||||
// aSelfJudgingControllerThatWritesIntoTheReplays is a controller pull request whose own judge, while the gate runs it,
|
||||
// writes a test into the replays beside the check — the reviewer's case for issue 709: code of the pull request's,
|
||||
// run before the replays, reaching what they run.
|
||||
func aSelfJudgingControllerThatWritesIntoTheReplays(t *testing.T, marker string) (string, string) {
|
||||
t.Helper()
|
||||
files := aJudgeOf("the change's controller")
|
||||
evil := "package replays\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestEvil(t *testing.T) {\n\t_ = os.WriteFile(" +
|
||||
fmt.Sprintf("%q", marker) + ", []byte(\"ran\"), 0o644)\n}\n"
|
||||
files["cmd/mesh-controller/main.go"] = strings.Replace(files["cmd/mesh-controller/main.go"], "func main() {",
|
||||
"func main() {\n\tif len(os.Args) > 2 && os.Args[1] == \"merge-gate\" {\n\t\t_ = os.WriteFile(os.Getenv(\"MESH_CHECK_BESIDE\")+"+
|
||||
"\"/mesh-lab/replays/evil_test.go\", []byte("+fmt.Sprintf("%q", evil)+"), 0o644)\n\t\t_ = os.WriteFile("+
|
||||
fmt.Sprintf("%q", marker+".wrote")+", nil, 0o644)\n\t}", 1)
|
||||
return aPullRequestInto(t, files, "main", map[string]string{"cmd/x.txt": "x"})
|
||||
}
|
||||
|
||||
// **The replays never read what a pull request wrote** (issue 709, reviews 12303 and 12308): with a judge cache they
|
||||
// run from a copy under it, and a self-judging controller pull request writing a test into the replays beside the
|
||||
// check, a planted GOFLAGS hook and a poisoned cache entry in the workspace neither run nor are read. Without one the
|
||||
// replays are refused — the gate an error that says why, never a pass, never the workspace — they never start, and
|
||||
// nothing is made on the host.
|
||||
func TestTheReplaysNeverReadWhatAPullRequestWroteAndNeedAJudgeCache(t *testing.T) {
|
||||
for _, withCache := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("judge cache %v", withCache), func(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
replays, volumes := filepath.Join(t.TempDir(), "replays"), filepath.Join(t.TempDir(), "volumes")
|
||||
t.Setenv("REPLAYS", replays)
|
||||
t.Setenv("VOLUMES", volumes)
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
registry := aFactsRegistry(t)
|
||||
workspace, ran := aPlantedWorkspace(t)
|
||||
if err := os.MkdirAll(filepath.Join(workspace, "go-cache"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspace, "go-cache", "README"), []byte("planted"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evilRan := filepath.Join(t.TempDir(), "evil-ran")
|
||||
controller, head := aSelfJudgingControllerThatWritesIntoTheReplays(t, evilRan)
|
||||
lab, labMain := aLabWithReplays(t)
|
||||
cache := ""
|
||||
if withCache {
|
||||
cache = t.TempDir()
|
||||
}
|
||||
v, err := Check(t.Context(), Command, CheckSpec{ID: "check-709-self", Repository: controller, Ref: head,
|
||||
Owner: "novox", Repo: "mesh-controller", Number: 330, Base: "main", Judge: "self", JudgeCache: cache,
|
||||
Paths: []string{"cmd/x.txt"}, Modules: []string{"mesh-controller"}, Toolchain: "golang",
|
||||
Beside: map[string]Beside{"mesh-lab": {Repository: lab, Ref: labMain}}}, workspace, registry,
|
||||
GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(evilRan + ".wrote"); err != nil {
|
||||
t.Fatalf("the self-judging pull request did not write into the replays beside it, so this proves nothing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(evilRan); err == nil {
|
||||
t.Fatal("the test the pull request wrote into the replays ran with the socket")
|
||||
}
|
||||
if body, _ := os.ReadFile(ran); strings.Contains(string(body), "replays") || strings.Contains(string(body), "mesh-lab") {
|
||||
t.Fatalf("a hook planted in the workspace ran during the replays:\n%s", body)
|
||||
}
|
||||
body, _ := os.ReadFile(replays)
|
||||
said := strings.TrimSpace(string(body))
|
||||
if vols, _ := os.ReadFile(volumes); len(vols) != 0 {
|
||||
t.Fatalf("the check made a container volume: %q", vols)
|
||||
}
|
||||
if left, _ := os.ReadDir(tmp); len(left) != 0 {
|
||||
t.Fatalf("a check left %d entr(ies) in the temporary directory", len(left))
|
||||
}
|
||||
if !withCache {
|
||||
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, replaysNeedTheJudgeCache) ||
|
||||
v.Verdict != "error" {
|
||||
t.Fatalf("without a judge cache the gate said %+v, not an error saying the replays need it", v.Gate)
|
||||
}
|
||||
if said != "" {
|
||||
t.Fatalf("without a judge cache the replays started: %s", said)
|
||||
}
|
||||
return
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Verdict != "pass" || !strings.Contains(v.Report, "--- the replays") {
|
||||
t.Fatalf("the replays did not run and pass: %+v\n%s", v.Gate, v.Report)
|
||||
}
|
||||
base := filepath.Join(cache, "homes", "check-709-self-replays")
|
||||
if said == "" || strings.Contains(said, workspace) || !strings.Contains(said, "GOENV=off ") ||
|
||||
!strings.Contains(said, "DIR="+filepath.Join(base, "mesh-lab", "replays")) {
|
||||
t.Fatalf("the replays did not run from the copy under %s, apart from the workspace: %q", base, said)
|
||||
}
|
||||
if _, err := os.Stat(base); err == nil {
|
||||
t.Fatalf("the replays' place %s was left after the check", base)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// **Replays the container runtime could not start are the check's error, never the change's failure**, said in the
|
||||
// build's log (review 12308); and a check whose id cannot name a place in the judge cache is refused, never given a
|
||||
// shared one.
|
||||
func TestReplaysThatCannotStartAreAnErrorAndAnUnsafeIDIsRefused(t *testing.T) {
|
||||
aRuntimeThatBuildsTheJudge(t)
|
||||
t.Setenv("REPLAYS_CANNOT_START", "1")
|
||||
registry := aFactsRegistry(t)
|
||||
controller, controllerMain, _ := aRepositoryWithARelease(t, aJudgeOf("the controller the mesh runs"), nil)
|
||||
lab, labMain := aLabWithReplays(t)
|
||||
catalogue, head := aPullRequestInto(t, map[string]string{"modules/gitea/module.json": `{"module":"gitea"}`,
|
||||
CheckScript: "true\n"}, "main", map[string]string{"modules/gitea/index.ts": "x"})
|
||||
spec := CheckSpec{ID: "check-709-start", Repository: catalogue, Ref: head, Owner: "novox", Repo: "mesh-catalog",
|
||||
Number: 1, Base: "main", JudgeCache: t.TempDir(), Paths: []string{"modules/gitea/index.ts"},
|
||||
Modules: []string{"gitea"}, Toolchain: "golang", Beside: map[string]Beside{
|
||||
"mesh-controller": {Repository: controller, Ref: controllerMain}, "mesh-lab": {Repository: lab, Ref: labMain}}}
|
||||
var logged []string
|
||||
v, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, func(step, message string) {
|
||||
logged = append(logged, message)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v.Gate == nil || v.Gate.Verdict != "error" || !strings.Contains(v.Gate.Summary, "could not be started") ||
|
||||
!strings.Contains(v.Gate.Summary, "invalid mount config") {
|
||||
t.Fatalf("replays that could not start were said %+v, not an error naming why", v.Gate)
|
||||
}
|
||||
if !strings.Contains(strings.Join(logged, "\n"), "the replays could not be started") {
|
||||
t.Fatalf("the build's log does not say what failed:\n%s", strings.Join(logged, "\n"))
|
||||
}
|
||||
|
||||
spec.ID = "Check 1/../../x"
|
||||
if _, err := Check(t.Context(), Command, spec, t.TempDir(), registry, GitCredential{}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "cannot name its place in the judge cache") {
|
||||
t.Fatalf("a check with an unsafe id was taken: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 713: after ADR 0350 the walk of novox/mesh-controller#331 built mesh-controller, build-agent and
|
||||
// route-proxy again, each "built: no check of novox/mesh-controller#331 built it". The check had built none: its
|
||||
// build log said "built for the merge: 0 of 3 module(s)", mesh-controller's go bundle failing with "go.mod file not
|
||||
// found". The build seat's holder runs in a container sharing only its workspace with the host, and a build's
|
||||
// toolchain container is run by the host's container engine, which mounts a path as the host names it. The check
|
||||
// built in a temporary directory of the holder's own (issue 703's copy), which the engine mounted as an empty one.
|
||||
|
||||
// aHostsEngine runs as the host's container engine does for a holder in a container: a mount of a path outside the
|
||||
// workspace the holder shares with the host is an empty directory, and a Go toolchain finds no go.mod in it.
|
||||
type aHostsEngine struct {
|
||||
onTrunk
|
||||
shared string
|
||||
}
|
||||
|
||||
func (h aHostsEngine) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
if name == "docker" && len(args) > 0 && args[0] == "run" {
|
||||
for i, a := range args {
|
||||
if a != "--volume" || i+1 >= len(args) {
|
||||
continue
|
||||
}
|
||||
from, _, _ := strings.Cut(args[i+1], ":")
|
||||
if !withinDir(h.shared, from) {
|
||||
return "", errors.New("go: go.mod file not found in current directory or any parent directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
return h.onTrunk.run(ctx, dir, name, args...)
|
||||
}
|
||||
|
||||
// aGoModule is a module of one Go bundle built from a repository's root.
|
||||
func aGoModule(module string) map[string]string {
|
||||
files := aSharedRepository("one", "1")
|
||||
files[ManifestName] = `{"module":"` + module + `","version":"1","build":{"artifacts":[
|
||||
{"name":"server","kind":"bundle","language":"go","system":"arch","from":"cmd/ctl","binary":"ctl"}]},
|
||||
"resources":[{"id":"server","type":"process","name":"` + module + `","artifact":"server","run":["./ctl"]}]}`
|
||||
return files
|
||||
}
|
||||
|
||||
// A controller change whose walk moves mesh-controller, build-agent and route-proxy (#331's) has its check build all
|
||||
// three, in the workspace the host's engine sees, and the walk of its merge takes each from the check by its key.
|
||||
func TestReplay713TheWalkOfAControllerChangeTakesAllThreeFromItsCheck(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
pull := PullRef{Owner: "novox", Repo: "mesh-controller", Number: 331}
|
||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
||||
repositories := map[string]string{"build-agent": "https://forge.invalid/mesh-catalog-build-agent.git",
|
||||
"route-proxy": "https://forge.invalid/mesh-catalog-route-proxy.git"}
|
||||
checked := func(shared string) (*recorded, aHostsEngine) {
|
||||
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
|
||||
secondary: map[string]map[string]string{}}
|
||||
for module, repository := range repositories {
|
||||
r.secondary[repository] = aGoModule(module)
|
||||
}
|
||||
return r, aHostsEngine{onTrunk: onTrunk{recorded: r}, shared: shared}
|
||||
}
|
||||
spec := CheckSpec{ID: "build-331", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
|
||||
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0},
|
||||
{Module: "build-agent", Repository: repositories["build-agent"], Ref: "main", Tier: 1},
|
||||
{Module: "route-proxy", Repository: repositories["route-proxy"], Ref: "main", Tier: 2}}}
|
||||
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
|
||||
passed := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
|
||||
|
||||
// As it ran: the builds made in the holder's own temporary directory, which the host's engine does not see.
|
||||
shared, outside := t.TempDir(), t.TempDir()
|
||||
merged := filepath.Join(outside, "merged")
|
||||
r, engine := checked(shared)
|
||||
asItRan := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: r, Scratch: newScratch(), Held: held},
|
||||
outside, merged, judged.Merge, judged, GitCredential{}, nil, logging(nil))
|
||||
if builtCount(asItRan) != 0 {
|
||||
t.Fatalf("the engine of this test sees the holder's temporary directory: %+v", asItRan)
|
||||
}
|
||||
|
||||
// The check as it builds now: every module its merge moves, kept under its key.
|
||||
scratch := newScratch()
|
||||
spec.BuildWith = &CheckBuilding{Publish: r, Scratch: scratch, Held: held}
|
||||
built := forTheMerge(t.Context(), engine.run, spec, passed, shared, merged, judged, GitCredential{}, nil,
|
||||
logging(nil))
|
||||
if len(built) != 3 || builtCount(built) != 3 {
|
||||
t.Fatalf("the check built %d of %d module(s) its merge moves: %+v", builtCount(built), len(built), built)
|
||||
}
|
||||
var names []NamedBuild
|
||||
for _, b := range built {
|
||||
n := NamedBuild{Pull: pull, Check: spec.ID, Module: b.Module, Key: b.Key, Digests: map[string]string{}}
|
||||
for _, m := range b.Made {
|
||||
d := m.Digest
|
||||
if d == "" {
|
||||
d = DigestOf(m.Reference)
|
||||
}
|
||||
n.Digests[m.Name] = d
|
||||
}
|
||||
names = append(names, n)
|
||||
}
|
||||
|
||||
// The walk of the merge commit, tier by tier: each module taken from the check, none built.
|
||||
walked := map[string]string{"mesh-controller": "https://forge.invalid/mesh-controller.git"}
|
||||
for module, repository := range repositories {
|
||||
walked[module] = repository
|
||||
}
|
||||
for _, module := range []string{"mesh-controller", "build-agent", "route-proxy"} {
|
||||
w, walk := checked(shared)
|
||||
w.secondary[walked["mesh-controller"]] = aGoModule("mesh-controller")
|
||||
got, err := BuildReusing(t.Context(), walk.run, w, Reuse{Scratch: scratch, Pulls: []PullRef{pull}, Named: names},
|
||||
walked[module], "", "main", t.TempDir(), held, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", module, err)
|
||||
}
|
||||
if got.Reused == nil || got.Why != "" {
|
||||
t.Fatalf("the walk built %s again: %s", module, got.Why)
|
||||
}
|
||||
for _, line := range w.ran {
|
||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "go build") {
|
||||
t.Fatalf("the walk compiled %s: %s", module, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// novox/hq issue 716: a check built the images its merge moves one after another, and only after its merge-check.sh
|
||||
// had passed — 3 to 6 minutes on a check of about 5. The modules of a tier stand on none of each other and are built
|
||||
// at the same time; with a judge cache, beside the tests; a check that does not pass lets go of them.
|
||||
|
||||
// atOnce is a fake engine whose compiles wait until every one of a tier's has started: builds one after another never
|
||||
// get there, and the first one's compile says so.
|
||||
type atOnce struct {
|
||||
mu sync.Mutex
|
||||
r *recorded
|
||||
want int
|
||||
started int
|
||||
all chan struct{}
|
||||
shared string
|
||||
}
|
||||
|
||||
func (a *atOnce) run(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||||
if name == "docker" && len(args) > 0 && args[0] == "run" && strings.Contains(strings.Join(args, " "), "go build") {
|
||||
a.mu.Lock()
|
||||
a.started++
|
||||
if a.started == a.want {
|
||||
close(a.all)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
select {
|
||||
case <-a.all:
|
||||
case <-time.After(5 * time.Second):
|
||||
return "", errors.New("built alone: no other module of its tier was being built")
|
||||
}
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return onTrunk{recorded: a.r}.run(ctx, dir, name, args...)
|
||||
}
|
||||
|
||||
func (a *atOnce) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.r.PublishImage(ctx, localTag, repository)
|
||||
}
|
||||
|
||||
func (a *atOnce) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.r.PublishArchive(ctx, repository, body, digest)
|
||||
}
|
||||
|
||||
// aLockedScratch is the scratch namespace in memory, said to by builds at once.
|
||||
type aLockedScratch struct {
|
||||
mu sync.Mutex
|
||||
*aScratch
|
||||
discarded []CheckBuild
|
||||
}
|
||||
|
||||
func (s *aLockedScratch) Keep(ctx context.Context, p PullRef, c string, at time.Time, b []CheckBuild) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.aScratch.Keep(ctx, p, c, at, b)
|
||||
}
|
||||
|
||||
func (s *aLockedScratch) Find(ctx context.Context, module, key string) (CheckBuild, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.aScratch.Find(ctx, module, key)
|
||||
}
|
||||
|
||||
func (s *aLockedScratch) Discard(_ context.Context, b []CheckBuild) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.discarded = append(s.discarded, b...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// threeAtOnce are three Go modules in one tier, each from its own repository.
|
||||
func threeAtOnce(t *testing.T) (*atOnce, CheckSpec, map[string]string) {
|
||||
t.Helper()
|
||||
r := &recorded{tree: "tree-of-the-merge", contents: aGoModule("mesh-controller"),
|
||||
secondary: map[string]map[string]string{}}
|
||||
spec := CheckSpec{ID: "build-716", Owner: "novox", Repo: "mesh-controller", Number: 716,
|
||||
Builds: []CheckBuildSpec{{Module: "mesh-controller", Merged: true, Tier: 0}}}
|
||||
for _, module := range []string{"build-agent", "route-proxy"} {
|
||||
repository := "https://forge.invalid/" + module + ".git"
|
||||
r.secondary[repository] = aGoModule(module)
|
||||
spec.Builds = append(spec.Builds, CheckBuildSpec{Module: module, Repository: repository, Ref: "main", Tier: 0})
|
||||
}
|
||||
held := map[string]string{"mesh-tools-go/build": "registry.invalid/mesh-tools-go/build@sha256:" + strings.Repeat("b", 64)}
|
||||
return &atOnce{r: r, want: 3, all: make(chan struct{})}, spec, held
|
||||
}
|
||||
|
||||
// The modules of one tier are built at the same time, each in a working copy of its own.
|
||||
func TestReplay716TheModulesOfATierAreBuiltAtTheSameTime(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
engine, spec, held := threeAtOnce(t)
|
||||
scratch := &aLockedScratch{aScratch: newScratch()}
|
||||
built := buildForTheMerge(t.Context(), engine.run, spec, CheckBuilding{Publish: engine, Scratch: scratch, Held: held},
|
||||
t.TempDir(), filepath.Join(t.TempDir(), "merged"), "2d22279b1d07",
|
||||
CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053"}, GitCredential{}, nil, logging(nil))
|
||||
if builtCount(built) != 3 {
|
||||
t.Fatalf("the check built %d of 3 module(s) of one tier: %+v", builtCount(built), built)
|
||||
}
|
||||
if len(scratch.builds) != 3 {
|
||||
t.Fatalf("the check kept %d build(s), not 3", len(scratch.builds))
|
||||
}
|
||||
}
|
||||
|
||||
// With a judge cache, the builds run while the tests run, where no container of the check reaches; a check that passed
|
||||
// keeps them, and one that failed lets go of every one and names none.
|
||||
func TestReplay716TheBuildsRunBesideTheTestsAndAFailedCheckLetsGoOfThem(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
workspace := t.TempDir()
|
||||
if _, err := buildsBesideTheTests(filepath.Join(workspace, "judge-cache"), workspace, "build-716"); err == nil {
|
||||
t.Fatal("a judge cache inside the workspace was built in, where a pull request's script reaches")
|
||||
}
|
||||
for _, passed := range []bool{true, false} {
|
||||
cache := t.TempDir()
|
||||
place, err := buildsBesideTheTests(cache, workspace, "build-716")
|
||||
if err != nil || !withinDir(cache, place) || withinDir(workspace, place) {
|
||||
t.Fatalf("the builds' place %q (%v) is not the judge cache's, outside the workspace", place, err)
|
||||
}
|
||||
engine, spec, held := threeAtOnce(t)
|
||||
scratch := &aLockedScratch{aScratch: newScratch()}
|
||||
spec.BuildWith = &CheckBuilding{Publish: engine, Scratch: scratch, Held: held}
|
||||
judged := CheckVerdict{Head: "98e856874913", Trunk: "c975dbf7b053", Merge: "2d22279b1d07"}
|
||||
beside := startBesideTheTests(t.Context(), engine.run, spec, place, filepath.Join(t.TempDir(), "merged"),
|
||||
judged, GitCredential{}, nil, logging(nil))
|
||||
// The tests: the builds are under way before the check has a verdict.
|
||||
select {
|
||||
case <-engine.all:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("nothing was built while the tests ran")
|
||||
}
|
||||
verdict := CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "pass"}}
|
||||
if !passed {
|
||||
verdict.Repo.Verdict = "fail"
|
||||
}
|
||||
built := beside.settle(t.Context(), verdict)
|
||||
beside.abandon()
|
||||
switch {
|
||||
case passed && (builtCount(built) != 3 || len(scratch.builds) != 3 || len(scratch.discarded) != 0):
|
||||
t.Fatalf("a check that passed: built %+v, kept %d, let go of %d", built, len(scratch.builds),
|
||||
len(scratch.discarded))
|
||||
case !passed && (built != nil || len(scratch.builds) != 0 || len(scratch.discarded) != 3):
|
||||
t.Fatalf("a check that failed: named %+v, kept %d, let go of %d", built, len(scratch.builds),
|
||||
len(scratch.discarded))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,11 +494,15 @@ func (r Registry) Discard(ctx context.Context, builds []CheckBuild) error {
|
||||
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
|
||||
store := r.store()
|
||||
stays := map[string]bool{}
|
||||
// recorded are the records under a key another check still names (novox/hq issue 703): a later check that kept
|
||||
// the build names it as the check's that built it, and the walk finds it by that record.
|
||||
recorded := map[string]bool{}
|
||||
for tag, c := range all {
|
||||
if _, going := checks[tag]; going {
|
||||
continue
|
||||
}
|
||||
for _, b := range c.Builds {
|
||||
recorded[b.Module+" "+b.Key+" "+b.Check] = true
|
||||
for _, m := range b.Made {
|
||||
stays[catalogue.Recorded(m.Reference)] = true
|
||||
}
|
||||
@@ -523,8 +527,10 @@ func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex)
|
||||
}
|
||||
}
|
||||
// The record under the key, only while it is still this check's: a later check of the same key
|
||||
// replaced it, and that one is its own check's to let go of.
|
||||
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found && kept.Check == c.Check {
|
||||
// replaced it, and that one is its own check's to let go of. A build a check kept is that build's
|
||||
// check's record, and goes with the last check naming it (novox/hq issue 703).
|
||||
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found &&
|
||||
(kept.Check == c.Check || kept.Check == b.Check) && !recorded[b.Module+" "+b.Key+" "+kept.Check] {
|
||||
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
|
||||
ok = false
|
||||
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
|
||||
@@ -610,6 +616,8 @@ func unnamed(named []NamedBuild, module, key string, found CheckBuild) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return "a build is kept under this key, and no check verdict of the walk's pull requests names it with its " +
|
||||
"digests: not taken"
|
||||
// A build of another pull request, or of a head the forge did not merge, is in no verdict the walk is handed
|
||||
// (novox/hq issue 703): said with the check that made it, whose head names which head it was.
|
||||
return "a build is kept under this key, by " + found.Said() + ", and no check verdict of the walk's pull requests " +
|
||||
"at the head the forge merged names it with its digests: not taken"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -227,7 +228,8 @@ func TestARecordNoVerdictNamesIsRefusedAndBuilt(t *testing.T) {
|
||||
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
|
||||
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
|
||||
}
|
||||
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests names it") {
|
||||
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests at the head the forge merged names it") ||
|
||||
!strings.Contains(got.Why, "at head abcdefabcdef") {
|
||||
t.Fatalf("the walk says %q", got.Why)
|
||||
}
|
||||
})
|
||||
@@ -637,3 +639,153 @@ func TestABuildTwoChecksShareGoesWithTheLastOfThem(t *testing.T) {
|
||||
t.Fatal("the build stayed after the last check naming it went")
|
||||
}
|
||||
}
|
||||
|
||||
// aCheckAgain is a later check (build-2) of aPull building the module from a tree, with the builds the build seat's
|
||||
// verdicts name for it (novox/hq issue 703).
|
||||
func aCheckAgain(t *testing.T, scratch *aScratch, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
|
||||
t.Helper()
|
||||
return aCheckOf(t, scratch, aPull, "abcdefabcdef", tree, leave)
|
||||
}
|
||||
|
||||
// aCheckOf is a later check (build-2) of a pull request at a head, the trunk moved since build-1.
|
||||
func aCheckOf(t *testing.T, scratch *aScratch, pull PullRef, head, tree string, leave []NamedBuild) (CheckBuilt, *recorded) {
|
||||
t.Helper()
|
||||
r, run, workspace := aModuleAt(t, tree)
|
||||
spec := CheckSpec{ID: "build-2", Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number,
|
||||
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
|
||||
Ref: "main"}}}
|
||||
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch, Named: leave},
|
||||
workspace, "", "", CheckVerdict{Head: head, Trunk: "654321654321"}, GitCredential{}, nil, logging(nil))
|
||||
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
|
||||
t.Fatalf("the second check did not answer the module for its merge: %+v", built)
|
||||
}
|
||||
return built[0], r
|
||||
}
|
||||
|
||||
// A check asked again when only the trunk moved, whose key is unchanged, builds nothing: it keeps the build an
|
||||
// earlier verdict names and names it by key and digest, so the walk can take it (novox/hq issue 703).
|
||||
func TestASecondCheckWhoseKeyIsUnchangedBuildsNothingAndNamesTheEarlierBuild(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
first := aCheckBuilt(t, scratch, "tree-one")
|
||||
|
||||
again, r := aCheckAgain(t, scratch, "tree-one", named(first))
|
||||
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
|
||||
t.Fatalf("the second check built again: ran %v, published %v %v", r.ran, r.images, r.archives)
|
||||
}
|
||||
if again.Key != first.Key || again.Kept != "build-1" || !reflect.DeepEqual(again.Made, first.Made) {
|
||||
t.Fatalf("the second check names %+v, not the first check's build %+v", again, first)
|
||||
}
|
||||
// The walk takes it by what the second verdict names.
|
||||
leave := named(again)
|
||||
leave[0].Check = again.Kept
|
||||
w, run, workspace := aModuleAt(t, "tree-one")
|
||||
got, err := BuildReusing(t.Context(), run, w, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: leave},
|
||||
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil || got.Reused == nil || dockerBuilt(w) {
|
||||
t.Fatalf("the walk did not take the kept build: %v, why %q", err, got.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// A check whose key changed builds, as before.
|
||||
func TestASecondCheckWhoseKeyChangedBuilds(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
first := aCheckBuilt(t, scratch, "tree-one")
|
||||
|
||||
again, r := aCheckAgain(t, scratch, "tree-two", named(first))
|
||||
if !dockerBuilt(r) || again.Kept != "" || again.Key == first.Key {
|
||||
t.Fatalf("a check whose key changed did not build: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
// A scratch record no verdict names is never kept: the store takes writes from anything on its network, so a
|
||||
// check builds rather than name a planted record (novox/hq issue 703, ADR 0350 §2).
|
||||
func TestASecondCheckKeepsNoRecordAVerdictDoesNotName(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
scratch := newScratch()
|
||||
first := aCheckBuilt(t, scratch, "tree-one")
|
||||
for name, leave := range map[string][]NamedBuild{
|
||||
"no verdict": nil,
|
||||
"another digest": func() []NamedBuild {
|
||||
n := named(first)
|
||||
n[0].Digests["server"] = "sha256:" + strings.Repeat("e", 64)
|
||||
return n
|
||||
}(),
|
||||
"another key": func() []NamedBuild { n := named(first); n[0].Key = "r1:" + strings.Repeat("f", 64); return n }(),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
again, r := aCheckAgain(t, scratch, "tree-one", leave)
|
||||
if !dockerBuilt(r) || again.Kept != "" {
|
||||
t.Fatalf("a record no verdict names was kept: %+v", again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A build a later check kept stays when the check that built it goes: its artifacts and its record, which the later
|
||||
// check's verdict names (novox/hq issue 703, ADR 0350 §5).
|
||||
func TestABuildALaterCheckKeptStaysWhenTheCheckThatBuiltItGoes(t *testing.T) {
|
||||
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
|
||||
server := httptest.NewServer(m.serve())
|
||||
defer server.Close()
|
||||
address := strings.TrimPrefix(server.URL, "http://")
|
||||
registry := Registry{Address: address}
|
||||
ctx := t.Context()
|
||||
now := time.Date(2026, 10, 14, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
body := `{"mediaType":"` + mediaManifestOCI + `","layers":[]}`
|
||||
digest := digestOf([]byte(body))
|
||||
m.manifests["scratch/meshboard/server@"+digest] = []byte(body)
|
||||
key := "r1:" + strings.Repeat("1", 64)
|
||||
b := CheckBuild{Key: key, Module: "meshboard", Pull: aPull, Check: "build-1", At: now,
|
||||
Made: []catalogue.Built{{Name: "server", Kind: "image", Reference: address + "/scratch/meshboard/server@" + digest}}}
|
||||
if err := registry.Keep(ctx, aPull, "build-1", now, []CheckBuild{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Another pull request's check keeps it: its own document names the build, which stays the first check's.
|
||||
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
||||
if err := registry.Keep(ctx, other, "build-2", now, []CheckBuild{b}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
held := func() bool { _, ok := m.manifests["scratch/meshboard/server@"+digest]; return ok }
|
||||
found := func() bool { _, ok, _ := registry.Find(ctx, "meshboard", key); return ok }
|
||||
|
||||
if n, err := registry.Forget(ctx, aPull); err != nil || n != 1 {
|
||||
t.Fatalf("forgot %d (%v)", n, err)
|
||||
}
|
||||
if !held() || !found() {
|
||||
t.Fatalf("the build a later check names went with the check that built it: held %v, record %v", held(), found())
|
||||
}
|
||||
if n, err := registry.Forget(ctx, other); err != nil || n != 1 {
|
||||
t.Fatalf("forgot %d (%v)", n, err)
|
||||
}
|
||||
if held() || found() {
|
||||
t.Fatalf("the build stayed after the last check naming it went: held %v, record %v", held(), found())
|
||||
}
|
||||
}
|
||||
|
||||
// Only an earlier check of the same pull request at the same head is kept from: another head of it, or another pull
|
||||
// request whose verdict names the same key and digests, builds (novox/hq issue 703).
|
||||
func TestACheckKeepsNoBuildOfAnotherHeadOrPullRequest(t *testing.T) {
|
||||
withVersion(t, "builder-1")
|
||||
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
|
||||
for name, c := range map[string]struct {
|
||||
pull PullRef
|
||||
head string
|
||||
}{
|
||||
"another head": {aPull, "999999999999"},
|
||||
"another pull request": {other, "abcdefabcdef"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
scratch := newScratch()
|
||||
first := aCheckBuilt(t, scratch, "tree-one")
|
||||
leave := named(first)
|
||||
leave[0].Pull = c.pull // as if its own verdict named it
|
||||
again, r := aCheckOf(t, scratch, c.pull, c.head, "tree-one", leave)
|
||||
if !dockerBuilt(r) || again.Kept != "" {
|
||||
t.Fatalf("a build of %s was kept: %+v", name, again)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A module moves only when its program changes (novox/hq issue 721). A merge check builds every module the delivery
|
||||
// plan moves; a module whose every artifact comes out the same as the build the mesh holds of it — the same files,
|
||||
// the same modes and owners, the same entrypoint and environment — is said unchanged, and the walk leaves it where it
|
||||
// is. **The whole image is compared, never its main binary alone**: a module whose image differs in any file moves.
|
||||
// What is ignored is only what a rebuild cannot help changing: each file's time, the image's labels and its creation
|
||||
// time. **A comparison that cannot be made says nothing**, and the module is built and moved as before.
|
||||
|
||||
// sameAsHeld says whether every artifact made is the same as the build the mesh holds of the module: the sentence
|
||||
// that says so and, per artifact, the held reference it was compared with. Empty when any artifact differs, is not
|
||||
// an image, has no held build, or could not be compared.
|
||||
func sameAsHeld(ctx context.Context, run Runner, dir, module string, made []catalogue.Built,
|
||||
held map[string]string) (string, map[string]string, error) {
|
||||
if len(made) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
against := map[string]string{}
|
||||
for _, m := range made {
|
||||
if m.Kind != "image" || m.Reference == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
was := held[module+"/"+m.Name]
|
||||
if was == "" {
|
||||
return "", nil, nil
|
||||
}
|
||||
if digestPart(was) != "" && digestPart(was) == digestPart(m.Reference) {
|
||||
against[m.Name] = was
|
||||
continue
|
||||
}
|
||||
now, err := imageContent(ctx, run, dir, m.Reference)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%s's new image: %w", m.Name, err)
|
||||
}
|
||||
before, err := imageContent(ctx, run, dir, was)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%s's held image: %w", m.Name, err)
|
||||
}
|
||||
if now != before {
|
||||
return "", nil, nil
|
||||
}
|
||||
against[m.Name] = was
|
||||
}
|
||||
var names []string
|
||||
for name, ref := range against {
|
||||
names = append(names, name+" "+shortRef(ref))
|
||||
}
|
||||
sort.Strings(names)
|
||||
return "unchanged: its program is the same as running build " + strings.Join(names, ", "), against, nil
|
||||
}
|
||||
|
||||
// digestPart is a reference's "sha256:…", empty when it is not pinned.
|
||||
func digestPart(ref string) string {
|
||||
if i := strings.LastIndex(ref, "@"); i >= 0 {
|
||||
return ref[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// shortRef names a reference by its digest's first twelve characters when it has one.
|
||||
func shortRef(ref string) string {
|
||||
if d := strings.TrimPrefix(digestPart(ref), "sha256:"); d != "" {
|
||||
return twelve(d)
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// imageContent is an image's content as one digest: its platform, its configuration without labels, and every file
|
||||
// of its filesystem by path, type, mode, owner, link, extended attributes and bytes — never by time.
|
||||
func imageContent(ctx context.Context, run Runner, dir, ref string) (string, error) {
|
||||
if _, err := run(ctx, dir, "docker", "pull", "--quiet", ref); err != nil {
|
||||
return "", fmt.Errorf("pulling %s: %w", ref, err)
|
||||
}
|
||||
config, err := run(ctx, dir, "docker", "image", "inspect", "--format",
|
||||
"{{.Os}}/{{.Architecture}}/{{.Variant}} {{json .Config}}", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
|
||||
}
|
||||
said, err := configContent(config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading %s's configuration: %w", ref, err)
|
||||
}
|
||||
id, err := run(ctx, dir, "docker", "create", "--entrypoint", "/", ref)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("opening %s's filesystem: %w", ref, err)
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", errors.New("the container engine named no container for " + ref)
|
||||
}
|
||||
defer func() { _, _ = run(context.WithoutCancel(ctx), dir, "docker", "rm", "--force", id) }()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
file := filepath.Join(dir, "content-"+twelve(id)+".tar")
|
||||
defer os.Remove(file)
|
||||
if _, err := run(ctx, dir, "docker", "export", "--output", file, id); err != nil {
|
||||
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
|
||||
}
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
files, err := treeContent(f)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading %s's filesystem: %w", ref, err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(said + "\n" + files))
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// configContent is an image's platform and configuration as compared: Labels, the parent's id and the hostname the
|
||||
// builder ran under are left out — the first holds the commit, the others no part of what runs.
|
||||
func configContent(inspected string) (string, error) {
|
||||
platform, raw, ok := strings.Cut(strings.TrimSpace(inspected), " ")
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unexpected inspection %q", inspected)
|
||||
}
|
||||
var config map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, k := range []string{"Labels", "Image", "Hostname", "Domainname"} {
|
||||
delete(config, k)
|
||||
}
|
||||
out, err := json.Marshal(config) // map keys are sorted
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return platform + " " + string(out), nil
|
||||
}
|
||||
|
||||
// treeContent is a filesystem tarball's entries, sorted by path, each with everything but its times.
|
||||
func treeContent(r io.Reader) (string, error) {
|
||||
tr := tar.NewReader(r)
|
||||
var lines []string
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.New()
|
||||
if _, err := io.Copy(sum, tr); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var xattrs []string
|
||||
for k, v := range h.PAXRecords {
|
||||
if strings.HasPrefix(k, "SCHILY.xattr.") {
|
||||
xattrs = append(xattrs, k+"="+hex.EncodeToString([]byte(v)))
|
||||
}
|
||||
}
|
||||
sort.Strings(xattrs)
|
||||
lines = append(lines, fmt.Sprintf("%q %c %o %d:%d %q %d,%d %d %x %s",
|
||||
strings.TrimPrefix(h.Name, "./"), h.Typeflag, h.Mode, h.Uid, h.Gid, h.Linkname, h.Devmajor, h.Devminor,
|
||||
h.Size, sum.Sum(nil), strings.Join(xattrs, ";")))
|
||||
}
|
||||
sort.Strings(lines)
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// aFilesystem is a filesystem tarball of files, each written at the time given.
|
||||
func aFilesystem(t *testing.T, at time.Time, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
tw := tar.NewWriter(&buf)
|
||||
for _, name := range []string{"usr/local/bin/mesh-builder", "etc/apk/world"} {
|
||||
body, ok := files[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o755, Size: int64(len(body)), ModTime: at,
|
||||
Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// anEngine answers the container engine's commands for images by reference: each image's filesystem and labels.
|
||||
func anEngine(images map[string][]byte, labels map[string]string, failing string) Runner {
|
||||
created := map[string]string{}
|
||||
return func(_ context.Context, _ string, name string, args ...string) (string, error) {
|
||||
joined := strings.Join(args, " ")
|
||||
ref := args[len(args)-1]
|
||||
if failing != "" && strings.Contains(joined, failing) {
|
||||
return "", fmt.Errorf("the engine refused %s", joined)
|
||||
}
|
||||
switch {
|
||||
case args[0] == "pull", args[0] == "rm":
|
||||
return "", nil
|
||||
case args[0] == "image":
|
||||
return fmt.Sprintf(`linux/amd64/ {"Entrypoint":["/usr/local/bin/mesh-builder"],"Labels":{"commit":%q}}`,
|
||||
labels[ref]), nil
|
||||
case args[0] == "create":
|
||||
id := fmt.Sprintf("container%04d", len(created))
|
||||
created[id] = ref
|
||||
return id + "\n", nil
|
||||
case args[0] == "export":
|
||||
return "", os.WriteFile(args[2], images[created[ref]], 0o600)
|
||||
}
|
||||
return "", fmt.Errorf("unexpected %s %s", name, joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged is novox/hq issue 721: mesh-controller #335 changed one sentence
|
||||
// in a package mesh-builder imports and never reaches, and build-agent moved. Built again, its image differs only in
|
||||
// the files' times and its commit label, and the check says it is the same as the build the mesh holds.
|
||||
func TestAModuleWhoseProgramDidNotChangeIsSaidUnchanged(t *testing.T) {
|
||||
held := map[string]string{"build-agent/agent": "store/build-agent@sha256:aaaa"}
|
||||
made := []catalogue.Built{{Name: "agent", Kind: "image", Reference: "scratch/build-agent@sha256:bbbb"}}
|
||||
files := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\n"}
|
||||
images := map[string][]byte{
|
||||
"store/build-agent@sha256:aaaa": aFilesystem(t, time.Unix(1000, 0), files),
|
||||
"scratch/build-agent@sha256:bbbb": aFilesystem(t, time.Unix(2000, 0), files),
|
||||
}
|
||||
labels := map[string]string{"store/build-agent@sha256:aaaa": "a5500f73", "scratch/build-agent@sha256:bbbb": "c2ec0a60"}
|
||||
same, against, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
|
||||
made, held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(same, "unchanged: its program is the same as running build agent") ||
|
||||
against["agent"] != held["build-agent/agent"] {
|
||||
t.Fatalf("an image the same but for its times and labels is not said unchanged: %q %v", same, against)
|
||||
}
|
||||
|
||||
// **Any file differing moves it**, not only its program.
|
||||
changed := map[string]string{"usr/local/bin/mesh-builder": "the same program", "etc/apk/world": "docker-cli\ngit\ncurl\n"}
|
||||
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), changed)
|
||||
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
|
||||
held); err != nil || same != "" {
|
||||
t.Fatalf("an image with another file is said unchanged: %q %v", same, err)
|
||||
}
|
||||
|
||||
// **A comparison that fails says nothing**, and the module moves.
|
||||
images["scratch/build-agent@sha256:bbbb"] = aFilesystem(t, time.Unix(2000, 0), files)
|
||||
if same, _, err := sameAsHeld(context.Background(), anEngine(images, labels, "export"), t.TempDir(), "build-agent",
|
||||
made, held); err == nil || same != "" {
|
||||
t.Fatalf("a comparison that failed said %q, %v", same, err)
|
||||
}
|
||||
|
||||
// No held build, or an artifact that is not an image: nothing compared.
|
||||
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent", made,
|
||||
nil); same != "" {
|
||||
t.Fatalf("a module the mesh holds no build of is said unchanged: %q", same)
|
||||
}
|
||||
archive := []catalogue.Built{{Name: "agent", Kind: "archive", Reference: "scratch/build-agent@sha256:bbbb"}}
|
||||
if same, _, _ := sameAsHeld(context.Background(), anEngine(images, labels, ""), t.TempDir(), "build-agent",
|
||||
archive, held); same != "" {
|
||||
t.Fatalf("an archive is said unchanged without being compared: %q", same)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnImageWithAnotherEntrypointIsNotTheSame: the configuration counts, but for its labels.
|
||||
func TestAnImageWithAnotherEntrypointIsNotTheSame(t *testing.T) {
|
||||
a, err := configContent(`linux/amd64/ {"Entrypoint":["/a"],"Labels":{"commit":"1"}}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := configContent(`linux/amd64/ {"Labels":{"commit":"2"},"Entrypoint":["/a"]}`)
|
||||
c, _ := configContent(`linux/amd64/ {"Entrypoint":["/b"],"Labels":{"commit":"1"}}`)
|
||||
if a != b || a == c {
|
||||
t.Fatalf("labels count, or the entrypoint does not: %q %q %q", a, b, c)
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,15 @@ import (
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// withTemplateTexts is what was built with each template's text, for a module whose files name templates
|
||||
// (novox/hq ADR 0349 §2): its templates archive gains the text of every file in it, read back from the packed
|
||||
// bytes, by its path inside the archive. Resolve reads them into the files' content.
|
||||
// withTemplateTexts is what was built with each template's text, for a module whose files name templates (novox/hq ADR
|
||||
// 0349 §2): its templates archive — the one built from its templates directory, files/ unless its manifest names
|
||||
// another (issue 731) — gains the text of every file in it, read back from the packed bytes, by its path inside the
|
||||
// archive. Resolve reads them into the files' content.
|
||||
//
|
||||
// **Read from the archive, proven by its digest.** The module's files/ is packed again, as the build packs it,
|
||||
// and the bytes are read only when they hash to the digest the build made or took: a fresh build's own archive,
|
||||
// or a check's build a walk copied by digest without building (novox/hq ADR 0350 §2). Packing is deterministic,
|
||||
// so one source packs to one digest; a tree that packs to another is not the archive, and its text is refused.
|
||||
// **Read from the archive, proven by its digest.** The module's templates directory is packed again, as the build packs
|
||||
// it, and the bytes are read only when they hash to the digest the build made or took: a fresh build's own archive, or
|
||||
// a check's build a walk copied by digest without building (novox/hq ADR 0350 §2). Packing is deterministic, so one
|
||||
// source packs to one digest; a tree that packs to another is not the archive, and its text is refused.
|
||||
func withTemplateTexts(manifest catalogue.Manifest, within string, built []catalogue.Built) ([]catalogue.Built, error) {
|
||||
if !manifest.Templated() {
|
||||
return built, nil
|
||||
@@ -35,18 +36,18 @@ func withTemplateTexts(manifest catalogue.Manifest, within string, built []catal
|
||||
}
|
||||
if name == "" || at < 0 {
|
||||
return nil, fmt.Errorf("%s names templates and built no archive of %s/ to read them from",
|
||||
manifest.Module, catalogue.TemplatesDir)
|
||||
manifest.Module, manifest.TemplatesDir())
|
||||
}
|
||||
body, err := pack(filepath.Join(within, catalogue.TemplatesDir))
|
||||
body, err := pack(filepath.Join(within, filepath.FromSlash(manifest.TemplatesDir())))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: packing %s/ to read its templates back failed: %w", manifest.Module,
|
||||
catalogue.TemplatesDir, err)
|
||||
manifest.TemplatesDir(), err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
digest := "sha256:" + hex.EncodeToString(sum[:])
|
||||
if built[at].Digest != digest {
|
||||
return nil, fmt.Errorf("%s: %s/ packs to the digest %s, and the archive %q built or taken is %s: the "+
|
||||
"templates are read only from the archive the mesh records", manifest.Module, catalogue.TemplatesDir,
|
||||
"templates are read only from the archive the mesh records", manifest.Module, manifest.TemplatesDir(),
|
||||
digest, name, orNone(built[at].Digest))
|
||||
}
|
||||
texts, err := unpackTexts(body)
|
||||
|
||||
@@ -2,6 +2,8 @@ package builder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -134,3 +136,46 @@ func writeTree(t *testing.T, dir string, files map[string]string) string {
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// A module whose files/ is an archive unpacked onto the machine keeps its templates in a directory it names (issue
|
||||
// 731): the build reads each template back from the archive packed from that directory, and files/ is packed as it
|
||||
// was, its templates nowhere in it.
|
||||
const unpackingModule = `{"module":"meshboard","version":"1",
|
||||
"templates":"templates",
|
||||
"build":{"artifacts":[
|
||||
{"name":"scripts","kind":"archive","from":"files"},
|
||||
{"name":"templates","kind":"archive","from":"templates"}]},
|
||||
"resources":[
|
||||
{"id":"scripts","type":"archive","path":"/usr/local/lib/meshboard","artifact":"scripts"},
|
||||
{"id":"conf","type":"file","path":"/etc/meshboard/meshboard.conf","template":"templates/meshboard.conf"}]}`
|
||||
|
||||
func TestABuildReadsEachTemplateBackFromTheTemplatesDirectoryItsManifestNames(t *testing.T) {
|
||||
files := map[string]string{"files/bin/meshboard-hook": "#!/bin/sh\n", "templates/meshboard.conf": "listen = 8080\n"}
|
||||
r, workspace := aRepository(t, unpackingModule, files)
|
||||
got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil,
|
||||
Npmrc{}, GitCredential{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if text := contentOf(t, got.Manifest, "conf"); text != files["templates/meshboard.conf"] {
|
||||
t.Fatalf("the template's text is %q, not the file's", text)
|
||||
}
|
||||
if got.Manifest.Templates != "" {
|
||||
t.Fatalf("the built manifest still names its templates directory: %q", got.Manifest.Templates)
|
||||
}
|
||||
// files/ packs exactly as it would with no templates beside it.
|
||||
alone, err := pack(writeTree(t, t.TempDir(), map[string]string{"bin/meshboard-hook": "#!/bin/sh\n"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(alone)
|
||||
packed := ""
|
||||
for _, b := range got.Built {
|
||||
if b.Name == "scripts" {
|
||||
packed = b.Digest
|
||||
}
|
||||
}
|
||||
if packed != "sha256:"+hex.EncodeToString(sum[:]) {
|
||||
t.Fatalf("files/ packed to %q, not to what it packs to alone", packed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A judge's build in a test starts from a warm Go cache** (novox/hq issue 717). The runtime these tests put on
|
||||
// PATH (aRuntimeThatBuildsTheJudge) runs the check's `go build` and `go test` on this machine with the Go cache the
|
||||
// check names — a fresh directory in every test — so each of them compiled the standard library from nothing: some
|
||||
// ten CPU-seconds a build, under twenty tests, half of the CPU the whole suite spent. The runtime now fills an empty
|
||||
// Go cache it is given from one this process warmed once, before it runs `go`; what the check names, mounts and
|
||||
// keeps is unchanged, and so is what each test reads of it. A cache entry is keyed by everything the compile
|
||||
// depends on, flags included, so a build with a planted -toolexec hook still misses it and still runs its hook.
|
||||
//
|
||||
// The warm cache lives beside the system's temporary directory as it was when the package started, never under a
|
||||
// TMPDIR a test set and then asserts left empty, and is removed when the package's tests end.
|
||||
var (
|
||||
warmCacheBase = os.TempDir()
|
||||
warmCacheOnce sync.Once
|
||||
warmCacheDir string
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
if warmCacheDir != "" {
|
||||
_ = os.RemoveAll(warmCacheDir)
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// warmGoCache is a Go cache holding what a judge's build and the replays' `go test` compile of the standard
|
||||
// library, or "" when it cannot be warmed — a test then builds cold, as before, and is only slower.
|
||||
func warmGoCache(t *testing.T) string {
|
||||
t.Helper()
|
||||
warmCacheOnce.Do(func() {
|
||||
dir, err := os.MkdirTemp(warmCacheBase, "judge-warm-go-cache-")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
src := filepath.Join(dir, "src")
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.org/warm\n\ngo 1.22\n",
|
||||
"main.go": "package main\n\nimport (\n\t\"fmt\"\n\t\"os\"\n)\n\nfunc main() { fmt.Fprintln(os.Stdout, len(os.Args)) }\n",
|
||||
"main_test.go": "package main\n\nimport \"testing\"\n\nfunc TestWarm(t *testing.T) {}\n",
|
||||
}
|
||||
for name, body := range files {
|
||||
if err := os.MkdirAll(src, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, name), []byte(body), 0o644); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
cache := filepath.Join(dir, "go-cache")
|
||||
env := append(os.Environ(), "GOCACHE="+cache, "GOENV=off", "GOTOOLCHAIN=local", "GOPROXY=off", "GOWORK=off",
|
||||
"GOFLAGS=-buildvcs=false", "TMPDIR="+warmCacheBase)
|
||||
// As the check builds a judge (CGO off) and runs the replays (CGO as the toolchain has it).
|
||||
for _, run := range [][]string{
|
||||
{"env", "CGO_ENABLED=0", "go", "build", "-o", filepath.Join(dir, "judge"), "."},
|
||||
{"go", "test", "-count=1", "."},
|
||||
} {
|
||||
cmd := exec.Command(run[0], run[1:]...)
|
||||
cmd.Dir, cmd.Env = src, env
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Logf("the warm Go cache could not be built, so judges build cold: %v\n%s", err, out)
|
||||
_ = os.RemoveAll(dir)
|
||||
return
|
||||
}
|
||||
}
|
||||
warmCacheDir = dir
|
||||
})
|
||||
if warmCacheDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(warmCacheDir, "go-cache")
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func anchorRendering(adopted bool) Rendering {
|
||||
Generators: map[string]Generator{"overlay": hub{}},
|
||||
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
||||
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
||||
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
||||
Mesh: []string{"10.42.0.1"},
|
||||
Foundation: []int{5671},
|
||||
// What the machine reported faces outside, which every rule in the filter is written
|
||||
@@ -310,14 +310,14 @@ func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
||||
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
||||
store := anAdoptedAnchor().Modules[1]
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "anchor", Values: map[string]any{PortsSetting: v}}}
|
||||
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
||||
got[5432] != 5433 {
|
||||
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
||||
}
|
||||
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
||||
Values: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
t.Fatal("a port given for the whole mesh was accepted")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
||||
@@ -409,7 +409,7 @@ func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
||||
onTheMachine := Manifest{Module: "daemon",
|
||||
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
|
||||
layers := []Layer{{From: "node anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
|
||||
_, err := GivenPorts(onTheMachine, layers)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not publish") {
|
||||
t.Fatalf("a port no container publishes was given: %v", err)
|
||||
@@ -421,7 +421,7 @@ func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
|
||||
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
|
||||
with := anchorRendering(true)
|
||||
with.Settings["postgres"] = []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
||||
composed, err := anAdoptedAnchor().Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -477,7 +477,7 @@ func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int {
|
||||
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
||||
forge := aForge()
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "node anchor", Values: map[string]any{PortsSetting: v}}}
|
||||
return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
|
||||
// The machine side — the number this module says it listens on, and the one the predecessor
|
||||
@@ -573,7 +573,7 @@ func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
|
||||
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
|
||||
forge := aForge()
|
||||
given, err := GivenPorts(forge, []Layer{{From: "node anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
|
||||
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
|
||||
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
|
||||
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
|
||||
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
|
||||
|
||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||
const BackupNamespace = "backup"
|
||||
|
||||
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
|
||||
// refused rather than written out as text.
|
||||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
||||
|
||||
// backupKinds is the contribution kind each key of the backup placement renders.
|
||||
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
|
||||
|
||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
||||
type backupPlacer struct {
|
||||
modules []Manifest
|
||||
with Rendering
|
||||
facts map[string]string
|
||||
caps map[string]bool
|
||||
unplaced *[]string
|
||||
}
|
||||
|
||||
// placed is the text of one key for a holder, or what is wrong with naming it.
|
||||
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
|
||||
kind, known := backupKinds[key]
|
||||
switch {
|
||||
case item:
|
||||
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
|
||||
case !known:
|
||||
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
|
||||
case !holder.ClaimsSeat(BackupSeat):
|
||||
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
|
||||
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
|
||||
}
|
||||
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
|
||||
if b.unplaced != nil {
|
||||
*b.unplaced = append(*b.unplaced, left...)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
||||
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
||||
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
||||
|
||||
const (
|
||||
oldBackupText = "# lines\n${contribution:node-backup:backup}"
|
||||
oldDataText = "# data\n${contribution:node-backup:data}"
|
||||
placedBackupTxt = "# lines\n${backup:lines}"
|
||||
placedDataTxt = "# data\n${backup:data}"
|
||||
backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
|
||||
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
|
||||
)
|
||||
|
||||
func dataModule(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
|
||||
}
|
||||
|
||||
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
|
||||
func backupsOn(t *testing.T, lines, data string, modules ...string) Resolution {
|
||||
t.Helper()
|
||||
all := []Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
|
||||
names := []string{"restic"}
|
||||
for _, name := range modules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
names = append(names, name)
|
||||
}
|
||||
r, err := Resolve(shelf(all...), names, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
var backupModules = []string{"alpha", "beta", "gamma"}
|
||||
|
||||
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
|
||||
func gammaLeftOut(with Rendering) Rendering {
|
||||
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
|
||||
// The old form is an unmoved holder: its placeholders are the controller's alone.
|
||||
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
|
||||
all := []Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
|
||||
for _, name := range backupModules {
|
||||
all = append(all, mustParse(t, dataModule(name)))
|
||||
}
|
||||
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.Account = "op"
|
||||
wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(Rendering{}), "restic.data-declared")
|
||||
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
|
||||
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
|
||||
}
|
||||
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
|
||||
// Not handed: the controller fills the new placeholders with the old text.
|
||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.declared"); got != wantLines {
|
||||
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := fileOf(t, moved, gammaLeftOut(Rendering{}), "restic.data-declared"); got != wantData {
|
||||
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
// Handed: the plan carries the same text as placement, and the files are the client's.
|
||||
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
|
||||
p := c.Plans["restic"].Placement
|
||||
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
|
||||
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
|
||||
}
|
||||
if got := "# data\n" + p["backup:data"]; got != wantData {
|
||||
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
|
||||
}
|
||||
if len(p) != 2 {
|
||||
t.Errorf("the plan's placement is %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) {
|
||||
plan := func(modules ...string) map[string]string {
|
||||
c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000"))
|
||||
return c.Plans["restic"].Placement
|
||||
}
|
||||
two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma")
|
||||
if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") {
|
||||
t.Errorf("two modules give %v, three give %v", two, three)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
|
||||
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("handed, an unknown key is %v", err)
|
||||
}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("not handed, an unknown key is %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fileOf(t *testing.T, r Resolution, with Rendering, id string) string {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["id"] == id {
|
||||
return res["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s was not composed", id)
|
||||
return ""
|
||||
}
|
||||
@@ -111,6 +111,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
}
|
||||
held.From, held.At, held.Module = node.Name, at, m.Module
|
||||
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
|
||||
held.CredentialForm = m.CredentialFormOf(n.Name)
|
||||
return held, true, ""
|
||||
}
|
||||
matching := bound.among(world.Offered[n.Name])
|
||||
@@ -124,6 +125,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
|
||||
identity := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
|
||||
held.CredentialForm = pm.CredentialFormOf(n.Name)
|
||||
identity = pm.IdentityBoundOf(n.Name)
|
||||
}
|
||||
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
|
||||
|
||||
@@ -77,26 +77,42 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) {
|
||||
|
||||
// **Each template's text, as the build read it back from its templates archive** (novox/hq ADR 0349 §2): into
|
||||
// the file's content, so what the mesh holds and composes is what the same text inline would be.
|
||||
var handed *Handed
|
||||
if m.Templated() {
|
||||
name := TemplatesArtifact(m)
|
||||
texts := by[name].Texts
|
||||
if name == "" || texts == nil {
|
||||
return Manifest{}, fmt.Errorf("%s names templates, and the build carried no text read back from the "+
|
||||
"archive it packs %s/ into (%q): the builder that built it does not read templates",
|
||||
m.Module, TemplatesDir, name)
|
||||
m.Module, m.TemplatesDir(), name)
|
||||
}
|
||||
filled, err := fillTemplates(m.Module, m.Resources, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, TemplatesDir+"/")]
|
||||
dir := m.TemplatesDir()
|
||||
filled, err := fillTemplates(m.Module, dir, m.Resources, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
items, err := fillItemTemplates(m, m.Items, func(template string) (string, bool) {
|
||||
t, ok := texts[strings.TrimPrefix(template, dir+"/")]
|
||||
return t, ok
|
||||
}, fmt.Sprintf("the archive %q", name))
|
||||
if err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
// And, for a moved module, where its templates are and which one each file and item is filled from
|
||||
// (handed.go): read while its resources and items still name their templates.
|
||||
handed = handedBy(m, by[name])
|
||||
m.Resources = filled
|
||||
m.Items = items
|
||||
}
|
||||
|
||||
out := m
|
||||
out.Handed = handed
|
||||
out.Build = nil
|
||||
// Read with the build, like Build: the manifest the mesh holds carries each file's text (issue 731).
|
||||
out.Templates = ""
|
||||
out.Resources = nil
|
||||
// What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is
|
||||
// named by no resource of the module's own — the node's runtime loads it — so this is the only
|
||||
|
||||
@@ -21,6 +21,16 @@ func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
||||
// granted the module's events, so a bus module that ships one may declare them.
|
||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
||||
if _, err := ParseManifest(bundled); err != nil {
|
||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
||||
}
|
||||
|
||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"tools":["bus_streams"]}`)
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Collections (novox/hq ADR 0349 clause 13, design 57 L1, issue 684): a provision with many providers.
|
||||
//
|
||||
// **A collector requires a collection; a contributor provides an item from a template.** A module taking additions
|
||||
// from others names a collection in a file of its own, `${collected:<collection>}`, and declares it under
|
||||
// `collections`, with the comment its tool writes a line in. Each contributor ships its item as a template of its
|
||||
// own repository and declares it under `items`: `{"collection": …, "template": "files/<name>"}`. Its build packs the
|
||||
// template with the module's others, and the mesh holds its text as it holds a file's (template.go).
|
||||
//
|
||||
// **The node's provisioner client fills both.** An item is filled on the node by the client of the node its
|
||||
// contributor is placed on, from the same owners as a file, and recorded in mesh-provisioner_collect; a collector's
|
||||
// file is filled by the same client from every item of its collection recorded for that node, so an item goes when
|
||||
// its contributor leaves. So a module contributing an item is a moved module (handed.go), and the controller renders
|
||||
// no item: the plan of a contributor names its items and their templates, and the plan of a collector names each
|
||||
// collection it reads and its comment. Neither carries a contributor's text.
|
||||
//
|
||||
// **The three old namespaces read as collections.** `${shell:<shell>:<slot>}`, `${environment:<format>}` and
|
||||
// `${contribution:<seat>:<kind>}` name, in a handed collector's file, the collection of the same name —
|
||||
// `shell:zsh:normal` — with the comment the controller wrote before each module's piece. An item is contributed to
|
||||
// one by that name, by a module the old form would have let contribute it. Each file is filled one way, never
|
||||
// both (ADR 0349 clause 17): a handed collector is filled from items alone and an unhanded one from the old forms
|
||||
// alone, and a piece of the other form on the same machine is said as not placed, never dropped quietly.
|
||||
|
||||
// Item is one item a module contributes to a collection: its template in the module's repository, or, in the
|
||||
// manifest the mesh holds, the template's text, read back from the build's templates archive like a file's.
|
||||
//
|
||||
// **Attributes, a capability and a name** (design 57 L3, issue 777): an item may carry `attributes` (string keys and
|
||||
// values; `order`, a whole number 0–99 defaulting to 50, orders the items of one placeholder) and `if-capability`,
|
||||
// the capability a machine must report for the item to be placed on it. A module may give several items to one
|
||||
// collection, each then with a `name` unique within the module and the collection. A collector takes only the items
|
||||
// whose attributes match, `${collected:<collection>:<k>=<v>,…}`, and all of them without the selector.
|
||||
type Item struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Template string `json:"template,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
IfCapability string `json:"if-capability,omitempty"`
|
||||
}
|
||||
|
||||
// ItemKey is an item's key among its module's: the collection, and `/<name>` for a named item.
|
||||
func ItemKey(collection, name string) string {
|
||||
if name == "" {
|
||||
return collection
|
||||
}
|
||||
return collection + "/" + name
|
||||
}
|
||||
|
||||
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
|
||||
|
||||
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
|
||||
func (it Item) order() int {
|
||||
if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
|
||||
return n
|
||||
}
|
||||
return OrderDefault
|
||||
}
|
||||
|
||||
// OrderAttribute is the attribute that orders items.
|
||||
const OrderAttribute = "order"
|
||||
|
||||
// matches is whether the item's attributes carry every pair of the selector.
|
||||
func (it Item) matches(where map[string]string) bool {
|
||||
for k, v := range where {
|
||||
if got, has := it.Attributes[k]; !has || got != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
|
||||
func itemsInOrder(items []Item) []Item {
|
||||
out := append([]Item(nil), items...)
|
||||
sort.SliceStable(out, func(a, b int) bool {
|
||||
if oa, ob := out[a].order(), out[b].order(); oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return out[a].Name < out[b].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
|
||||
// is introduced by `<comment> <contributor>`, and by nothing without one.
|
||||
type Collection struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// CollectedNamespace is a collection's placeholder namespace, `${collected:<collection>}`.
|
||||
const CollectedNamespace = "collected"
|
||||
|
||||
// EnvironmentCollection is the one collection of the session environment (design 57 L3, issue 795): its items are
|
||||
// typed by attributes, a variable (`kind=variable`, `name`) or a PATH entry (`kind=path`, `at=start|end`), and the
|
||||
// template's text is the value or the entry. A collector names the rendering, `${collected:environment:posix}` or
|
||||
// `${collected:environment:systemd}`, which is not a selector.
|
||||
const EnvironmentCollection = "environment"
|
||||
|
||||
// oldCollectionNamespaces are the namespaces read as collections of their own name.
|
||||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||||
|
||||
var (
|
||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
|
||||
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
|
||||
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
|
||||
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
|
||||
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||||
)
|
||||
|
||||
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
|
||||
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
|
||||
// before the first one.
|
||||
func splitSelector(name string) (collection, selector string) {
|
||||
eq := strings.Index(name, "=")
|
||||
if eq < 0 {
|
||||
return name, ""
|
||||
}
|
||||
colon := strings.LastIndex(name[:eq], ":")
|
||||
if colon < 0 {
|
||||
return name, ""
|
||||
}
|
||||
return name[:colon], name[colon+1:]
|
||||
}
|
||||
|
||||
// selectorOf is a selector's pairs, or why it is none.
|
||||
func selectorOf(selector string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
if selector == "" {
|
||||
return out, nil
|
||||
}
|
||||
for _, pair := range strings.Split(selector, ",") {
|
||||
k, v, ok := strings.Cut(pair, "=")
|
||||
switch {
|
||||
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
|
||||
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
|
||||
case out[k] != "":
|
||||
return nil, fmt.Errorf("%q names %s twice", selector, k)
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
|
||||
func collectionOfPlaceholder(namespace, key string) (string, bool) {
|
||||
key, _ = splitSelector(key)
|
||||
switch {
|
||||
case namespace == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"):
|
||||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
||||
return EnvironmentCollection, true
|
||||
case namespace == CollectedNamespace:
|
||||
return key, true
|
||||
case namespace == "environment":
|
||||
return EnvironmentCollection, true
|
||||
case slices.Contains(oldCollectionNamespaces, namespace):
|
||||
return namespace + ":" + key, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
|
||||
// ok is false for a name that is not an old namespace's.
|
||||
//
|
||||
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
|
||||
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
|
||||
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
if !slices.Contains(oldCollectionNamespaces, namespace) {
|
||||
return "", false, nil
|
||||
}
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, two := strings.Cut(key, ":")
|
||||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||||
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
|
||||
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
|
||||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
|
||||
strings.Join(knownSlots, ", "))
|
||||
}
|
||||
// shellCode's line before each module's code.
|
||||
return "#", true, nil
|
||||
case "environment":
|
||||
if key == "" {
|
||||
// The collection itself, which an item is contributed to; a placeholder names a rendering.
|
||||
return "#", true, nil
|
||||
}
|
||||
if key != EnvironmentPOSIX && key != EnvironmentSystemd {
|
||||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
||||
name, EnvironmentPOSIX, EnvironmentSystemd)
|
||||
}
|
||||
return "#", true, nil
|
||||
}
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
s, r, ok := receivable(seat, kind)
|
||||
switch {
|
||||
case !ok && s.Name == "":
|
||||
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
|
||||
case !ok:
|
||||
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
|
||||
case r.Shape != nil && (forItem || collectableAsText(r)):
|
||||
return r.Comment, true, nil
|
||||
case r.Shape != nil:
|
||||
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
|
||||
"(novox/hq ADR 0255)", name, s.Name)
|
||||
case r.Dirs:
|
||||
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
|
||||
"the controller fills; it is not yet a collection (design 57 L1)", name)
|
||||
case s.Name != canonicalSeat(seat):
|
||||
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
|
||||
}
|
||||
return r.Comment, true, nil
|
||||
}
|
||||
|
||||
// placerOfOld is the seat whose holder alone may collect an old namespace's collection (ADR 0203, 0204, 0208,
|
||||
// 0211, 0212): the same seat that places it the old way.
|
||||
func placerOfOld(name string) string {
|
||||
namespace, key, _ := strings.Cut(name, ":")
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, _, _ := strings.Cut(key, ":")
|
||||
return placerOf(target)
|
||||
case "environment":
|
||||
return EnvironmentSeat
|
||||
}
|
||||
seat, _, _ := strings.Cut(key, ":")
|
||||
return canonicalSeat(seat)
|
||||
}
|
||||
|
||||
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
|
||||
func collectionProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
declared := map[string]bool{}
|
||||
for i, c := range m.Collections {
|
||||
switch {
|
||||
case !collectionName.MatchString(c.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
|
||||
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
|
||||
case declared[c.Name]:
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
|
||||
}
|
||||
if _, old, _ := oldCollection(c.Name, false); old {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
|
||||
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
|
||||
c.Name))
|
||||
}
|
||||
if strings.ContainsAny(c.Comment, "\n\r") {
|
||||
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
|
||||
m.Module, c.Name))
|
||||
}
|
||||
declared[c.Name] = true
|
||||
}
|
||||
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
|
||||
if !m.Templated() {
|
||||
named := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if text, ok := r["content"].(string); ok {
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
|
||||
base, _ := splitSelector(f[1])
|
||||
named[base] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if !named[c.Name] && collectionName.MatchString(c.Name) {
|
||||
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
|
||||
"${collected:%s}", m.Module, c.Name, c.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
contributed := map[string]bool{}
|
||||
for i, it := range m.Items {
|
||||
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
|
||||
if !collectionName.MatchString(it.Collection) {
|
||||
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
|
||||
it.Collection))
|
||||
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
|
||||
}
|
||||
switch {
|
||||
case it.Name != "" && !itemName.MatchString(it.Name):
|
||||
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
|
||||
"hyphens", where, it.Name))
|
||||
case contributed[it.key()]:
|
||||
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
|
||||
"a collection, or several each with a name of its own", m.Module, it.key()))
|
||||
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
|
||||
return o.Collection == it.Collection && o.Name != ""
|
||||
}):
|
||||
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
|
||||
"of several items has a name of its own", m.Module, it.Collection, where))
|
||||
}
|
||||
contributed[it.key()] = true
|
||||
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
|
||||
if it.Collection == EnvironmentCollection {
|
||||
problems = append(problems, environmentItemProblems(where, it)...)
|
||||
}
|
||||
switch {
|
||||
case it.Template == "" && it.Content == "":
|
||||
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
|
||||
"own, \"template\": \"%s/<name>\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir()))
|
||||
case it.Template != "" && it.Content != "":
|
||||
problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where))
|
||||
case it.Template != "":
|
||||
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s", where, wrong))
|
||||
}
|
||||
// **Filled by the provisioner's client alone**: the controller renders no item.
|
||||
if !m.Moved {
|
||||
problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+
|
||||
"node its contributor is placed on, so a module contributing one is marked moved "+
|
||||
"(\"moved\": true; novox/hq ADR 0349 §13, §17)", where))
|
||||
}
|
||||
}
|
||||
if it.Content != "" {
|
||||
problems = append(problems, itemTextProblems(m.Module, it)...)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
|
||||
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
|
||||
func itemAttributeProblems(module, where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
v := it.Attributes[k]
|
||||
switch {
|
||||
case !attributeKey.MatchString(k):
|
||||
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
|
||||
"and hyphens", where, k))
|
||||
continue
|
||||
case !attributeFits(it.Collection, k, v):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
|
||||
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
|
||||
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
|
||||
}
|
||||
}
|
||||
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
var place *PlaceField
|
||||
for i := range r.Shape.Places {
|
||||
if r.Shape.Places[i].Field == k {
|
||||
place = &r.Shape.Places[i]
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case k == OrderAttribute:
|
||||
case place == nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
|
||||
it.Collection, placeFields(r), k))
|
||||
case !oneOf(place.Values, it.Attributes[k]):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
|
||||
strings.Join(place.Values, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case it.IfCapability == "":
|
||||
case !capabilityName.MatchString(it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
|
||||
case !oneOf(KnownCapabilities, it.IfCapability):
|
||||
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
|
||||
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
|
||||
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
|
||||
// provisioner client's, and the inventory's.
|
||||
func attributeFits(collection, k, v string) bool {
|
||||
if collection == EnvironmentCollection && k == "name" {
|
||||
return variableName.MatchString(v)
|
||||
}
|
||||
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
|
||||
}
|
||||
|
||||
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
|
||||
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
|
||||
func environmentItemProblems(where string, it Item) []string {
|
||||
var problems []string
|
||||
for _, k := range sortedKeys(it.Attributes) {
|
||||
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
|
||||
where, it.Collection, k))
|
||||
}
|
||||
}
|
||||
switch kind := it.Attributes["kind"]; kind {
|
||||
case "variable":
|
||||
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
|
||||
} else if it.Attributes["name"] == "PATH" {
|
||||
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
|
||||
}
|
||||
case "path":
|
||||
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
|
||||
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
|
||||
PathAtStart, PathAtEnd))
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
|
||||
it.Collection, kind))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||||
strings.Contains(name, "\\") {
|
||||
return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+
|
||||
"plain relative path, and the build packs only %s/", name, dir, dir+"/<name>", dir)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
|
||||
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
|
||||
// placeholder no pass fills, as in a file's content.
|
||||
func itemTextProblems(module string, it Item) []string {
|
||||
var problems []string
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
|
||||
namespace, key := f[1], f[2]
|
||||
switch _, collection := collectionOfPlaceholder(namespace, key); {
|
||||
case namespace == "secret" || namespace == "bound":
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
|
||||
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
|
||||
case collection:
|
||||
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
|
||||
module, it.Collection, f[0]))
|
||||
}
|
||||
}
|
||||
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
|
||||
"type": "file", "content": it.Content})...)
|
||||
}
|
||||
|
||||
// isOld is whether a collection's name is an old namespace's.
|
||||
func isOld(name string) bool {
|
||||
_, old, _ := oldCollection(name, true)
|
||||
return old
|
||||
}
|
||||
|
||||
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
|
||||
// and naming a collection the module declares it collects.
|
||||
func collectedPlaceholderProblems(m Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
|
||||
base, selector := splitSelector(f[1])
|
||||
if _, err := selectorOf(selector); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case field != "content":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
|
||||
"only in a file's content", m.Module, r["id"], f[0], field))
|
||||
case isOld(base):
|
||||
if _, _, wrong := oldCollection(base, false); wrong != nil {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
|
||||
}
|
||||
case !slices.ContainsFunc(m.Collections, func(c Collection) bool { return c.Name == base }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
|
||||
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
|
||||
func collectionComment(m Manifest, name string) (string, error) {
|
||||
if comment, old, wrong := oldCollection(name, false); old {
|
||||
return comment, wrong
|
||||
}
|
||||
for _, c := range m.Collections {
|
||||
if c.Name == name {
|
||||
return c.Comment, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
|
||||
}
|
||||
|
||||
// itemsLeftOut says each item of a collection a file is filled with the old way that the controller could not render
|
||||
// there (heldItemText): the collector is not handed to the provisioner on this machine (ADR 0349 clause 17). Said,
|
||||
// never dropped quietly.
|
||||
func itemsLeftOut(modules []Manifest, holder Manifest, collection string, facts map[string]string) []string {
|
||||
return itemsLeftOutFor(modules, holder, collection, nil, facts, nil)
|
||||
}
|
||||
|
||||
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
|
||||
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
|
||||
// wanted here.
|
||||
func itemsLeftOutFor(modules []Manifest, holder Manifest, collection string, where map[string]string,
|
||||
facts map[string]string, caps map[string]bool) []string {
|
||||
var out []string
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
|
||||
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
|
||||
m.Module, it.key(), holder.Module))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
|
||||
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
|
||||
func oldFormsLeftOut(modules []Manifest, holder Manifest, collections []PlanCollection) []string {
|
||||
var out []string
|
||||
for _, c := range collections {
|
||||
namespace, key, _ := strings.Cut(c.Name, ":")
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
old := false
|
||||
switch namespace {
|
||||
case "shell":
|
||||
target, slot, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(m.Shell, func(s ShellCode) bool { return s.For == target && s.Slot == slot })
|
||||
case "environment":
|
||||
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
|
||||
case "contribution":
|
||||
seat, kind, _ := strings.Cut(key, ":")
|
||||
old = slices.ContainsFunc(m.allContributions(), func(s SeatContribution) bool {
|
||||
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
|
||||
})
|
||||
}
|
||||
if old {
|
||||
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
|
||||
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
|
||||
holder.Module, m.Module))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldItemText is a moved contributor's items of an old namespace's collection as the controller renders them into a
|
||||
// collector not handed on this machine (design 57 L2, issue 681): the text the mesh holds, read back from the
|
||||
// contributor's templates archive, with the machine's facts filled, each ending its line. As a moved module's file
|
||||
// keeps its text for a machine that cannot take it handed (handed.go), an item keeps its place in a collector filled
|
||||
// the old way, so the file stays what its old form made it. Only `shell:` and `contribution:` collections, which the
|
||||
// controller renders as text; false for a module with no such item, or an item whose text the mesh does not hold or
|
||||
// which names anything but a machine fact this machine has, which is then said as not placed.
|
||||
func heldItemText(m Manifest, collection string, facts map[string]string) (string, bool) {
|
||||
items := heldItems(m, collection, nil, facts, nil)
|
||||
var b strings.Builder
|
||||
for _, h := range items {
|
||||
b.WriteString(h.text)
|
||||
}
|
||||
return b.String(), len(items) > 0
|
||||
}
|
||||
|
||||
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
|
||||
func renderedOldWay(collection string) bool {
|
||||
namespace, _, _ := strings.Cut(collection, ":")
|
||||
return namespace == "shell" || namespace == "contribution" || namespace == EnvironmentCollection
|
||||
}
|
||||
|
||||
// heldItem is one item of a module rendered the old way.
|
||||
type heldItem struct {
|
||||
item Item
|
||||
text string
|
||||
}
|
||||
|
||||
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
|
||||
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
|
||||
func heldItems(m Manifest, collection string, where map[string]string, facts map[string]string,
|
||||
caps map[string]bool) []heldItem {
|
||||
if !renderedOldWay(collection) {
|
||||
return nil
|
||||
}
|
||||
var out []heldItem
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != collection || !it.matches(where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
|
||||
continue
|
||||
}
|
||||
if text, ok := heldText(it, facts); ok {
|
||||
out = append(out, heldItem{item: it, text: text})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
|
||||
// does not hold or which names anything but a machine fact this machine has.
|
||||
func heldText(it Item, facts map[string]string) (string, bool) {
|
||||
if it.Content == "" {
|
||||
return "", false
|
||||
}
|
||||
text := it.Content
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
|
||||
value, has := facts[f[2]]
|
||||
if f[1] != "machine" || !has {
|
||||
return "", false
|
||||
}
|
||||
text = strings.ReplaceAll(text, f[0], value)
|
||||
}
|
||||
if !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as
|
||||
// `${collected:contribution:node-bar:block:bar=<b>,place=<p>}`, which a handed collector has its client fill from the
|
||||
// items and an unhanded one has the controller fill as it fills the old placeholder.
|
||||
|
||||
const (
|
||||
collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
|
||||
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
|
||||
collectedBars = `{"module":"bars","version":"1","moved":true,
|
||||
"claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
itemsOfPower = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
|
||||
func collectedBarsOn(t *testing.T, caps map[string]bool) Resolution {
|
||||
t.Helper()
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
return r
|
||||
}
|
||||
|
||||
func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
|
||||
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
|
||||
if _, err := collectedBarsOn(t, nil).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("the handed collector is refused: %v", err)
|
||||
}
|
||||
if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
|
||||
"content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
|
||||
t.Fatalf("the check refuses it: %v", got)
|
||||
}
|
||||
// A data kind whose shape does not say its items are text stays refused.
|
||||
_, plain, _ := receivable(BarSeat, BarKindBlock)
|
||||
plain.Shape = &Shape{}
|
||||
if collectableAsText(plain) {
|
||||
t.Fatal("a shape not marked as text is collectable")
|
||||
}
|
||||
if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) {
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
got := composedFile(t, collectedBarsOn(t, caps), "bars.conf")
|
||||
want := "top\nbottom\n# power\nD workstation\n"
|
||||
if caps["battery"] {
|
||||
want = "top\nbottom\n# power\nB\n# power\nD workstation\n"
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("caps %v: the file is %q, want %q", caps, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) {
|
||||
c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000"))
|
||||
if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections)
|
||||
}
|
||||
if n := len(c.Plans["power"].Items); n != 2 {
|
||||
t.Fatalf("the contributor's plan names %d items", n)
|
||||
}
|
||||
}
|
||||
|
||||
// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
|
||||
// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
|
||||
// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
|
||||
// collector is not handed the controller fills the files; where it is, it names the items for the client, which
|
||||
// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
|
||||
func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
|
||||
{"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
|
||||
power := `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
|
||||
{"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
|
||||
"power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
|
||||
bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
|
||||
blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
|
||||
r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
// Not handed: the controller fills them.
|
||||
for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
|
||||
if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
|
||||
t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
|
||||
}
|
||||
}
|
||||
// Handed: the plan names the items the machine's capabilities allow, for the client to fill.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
var names []string
|
||||
for _, it := range c.Plans["power"].Items {
|
||||
names = append(names, it.Name)
|
||||
}
|
||||
want := []string{}
|
||||
if caps["battery"] {
|
||||
want = append(want, "battery")
|
||||
}
|
||||
if caps["power-meter"] {
|
||||
want = append(want, "power-draw")
|
||||
}
|
||||
if !reflect.DeepEqual(append([]string{}, names...), want) {
|
||||
t.Errorf("%s: the plan names %v, want %v", name, names, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The collectors design 57 L2 moved (issue 681), composed with nothing handed: zsh's rc, triggerhappy's triggers and
|
||||
// power's after-wake moment, from the catalogue's real templates and its moved contributors' real items, are byte
|
||||
// for byte what the controller composed from the old forms before the move (testdata/l2-moved: templates/ and items/
|
||||
// copied from mesh-catalog at the move, want/ composed at the feature branch before it).
|
||||
|
||||
func l2Moved(t *testing.T, parts ...string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
|
||||
// Each moved contributor, its items read back from its templates archive as the mesh holds them.
|
||||
items := map[string][]string{
|
||||
"asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
|
||||
"claude-code": {"shell:zsh:normal"},
|
||||
"mesh-cli": {"shell:zsh:normal"},
|
||||
"powerlevel10k": {"shell:zsh:normal"},
|
||||
"zsh-autosuggestions": {"shell:zsh:normal"},
|
||||
"zsh-syntax-highlighting": {"shell:zsh:last"},
|
||||
}
|
||||
var modules []Manifest
|
||||
for _, module := range sortedKeys(items) {
|
||||
m := Manifest{Module: module, Moved: true}
|
||||
for _, collection := range items[module] {
|
||||
m.Items = append(m.Items, Item{Collection: collection,
|
||||
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
for _, c := range []struct{ module, seat string }{{"zsh", LoginShellSeat}, {"triggerhappy", HotkeysSeat},
|
||||
{"power", PowerSeat}} {
|
||||
holder := Manifest{Module: c.module, Moved: true, Claims: []Claim{{Name: c.seat}}}
|
||||
resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
|
||||
"content": l2Moved(t, "templates", c.module)}
|
||||
var unplaced []string
|
||||
if err := contributionsInto(resource, holder, append([]Manifest{holder}, modules...),
|
||||
map[string]string{"name": "g14"}, Rendering{}, map[string]bool{}, &unplaced); err != nil {
|
||||
t.Fatalf("%s: %v", c.module, err)
|
||||
}
|
||||
if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
|
||||
t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
|
||||
}
|
||||
if len(unplaced) != 0 {
|
||||
t.Errorf("%s: unplaced %v", c.module, unplaced)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An item may carry attributes and a capability, a module may give several items to one collection, and a
|
||||
// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose
|
||||
// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a
|
||||
// selected moved item into an old placeholder exactly as the contribution it replaces.
|
||||
|
||||
const (
|
||||
barCollector = `{"module":"bars","version":"1","moved":true,
|
||||
"collections":[{"name":"blocks","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
|
||||
barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}"
|
||||
barContribute = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"},
|
||||
{"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
|
||||
func blocksOn(t *testing.T, caps map[string]bool) Composed {
|
||||
t.Helper()
|
||||
bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText}))
|
||||
power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) {
|
||||
with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"]
|
||||
want := []PlanItem{
|
||||
{Collection: "blocks", Name: "battery", Template: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}},
|
||||
}
|
||||
if !reflect.DeepEqual(with.Items, want) {
|
||||
t.Fatalf("with the capability the plan carries %+v", with.Items)
|
||||
}
|
||||
without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"]
|
||||
if !reflect.DeepEqual(without.Items, want[1:]) {
|
||||
t.Fatalf("without it the plan carries %+v", without.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) {
|
||||
bars := blocksOn(t, nil).Plans["bars"]
|
||||
if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", bars.Collections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
|
||||
for placeholder, want := range map[string]string{
|
||||
"${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
|
||||
"${collected:blocks:bar=top,bar=bottom}": "names bar twice",
|
||||
"${collected:blocks:bar=}": "is not <attribute>=<value>",
|
||||
"${collected:other:bar=top}": `does not collect "other"`,
|
||||
} {
|
||||
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
|
||||
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
|
||||
raw = strings.Replace(raw, `"moved":true,`, "", 1)
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
_, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
|
||||
}
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", placeholder, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
|
||||
item := func(extra string) string {
|
||||
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[` + extra + `]}`
|
||||
}
|
||||
for items, want := range map[string]string{
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
|
||||
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
|
||||
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
|
||||
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
|
||||
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
|
||||
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
|
||||
} {
|
||||
_, err := ParseManifest([]byte(item(items)))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", items, err, want)
|
||||
}
|
||||
}
|
||||
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item
|
||||
// name) and with the same lines as the contributions they replace.
|
||||
func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) {
|
||||
piece := func(order, command string) string {
|
||||
return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n"
|
||||
}
|
||||
asOld := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`),
|
||||
block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`),
|
||||
block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`),
|
||||
}}
|
||||
asItems := Manifest{Module: "power", Moved: true, Items: []Item{
|
||||
{Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"),
|
||||
Attributes: map[string]string{"bar": "top", "place": "resources"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
}}
|
||||
other := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}}
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom")
|
||||
is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom")
|
||||
// The old form puts the contributions of a module under one line naming it and the item form puts one under
|
||||
// each item: the same pieces in the same order, otherwise the same bytes.
|
||||
want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") +
|
||||
"# power\n" + piece("", "one")
|
||||
if caps["battery"] {
|
||||
want += "# power\nx\n"
|
||||
}
|
||||
want += "own-last\n"
|
||||
if is != want {
|
||||
t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want)
|
||||
}
|
||||
if !strings.Contains(was, piece("", "two")) {
|
||||
t.Errorf("the contributions made\n%s", was)
|
||||
}
|
||||
}
|
||||
if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) {
|
||||
t.Errorf("the top bar renders\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A collection is declared and filled by the client (novox/hq ADR 0349 §13, design 57 L1, issue 684): a collector
|
||||
// names a collection it collects, a contributor an item from a template of its own, and a handed composition plans
|
||||
// both for the node's provisioner client and renders neither. The three old namespaces read as collections of their
|
||||
// own name. Each file is filled one way: a piece of the other form on the machine is said as not placed.
|
||||
|
||||
const (
|
||||
collectorZsh = `{"module":"zsh","version":"1","moved":true,
|
||||
"claims":[{"name":"node-login-shell","scope":"node"}],
|
||||
"collections":[{"name":"widgets","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}",
|
||||
"mode":"0644","template":"files/zshrc"}]}`
|
||||
zshrcText = "# zsh\n${shell:zsh:normal}${collected:widgets}"
|
||||
contributorText = "source ${machine:account-home}/.p10k.zsh\n"
|
||||
contributorP10k = `{"module":"prompt","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/zsh"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
oldFormAliases = `{"module":"aliases","version":"1",
|
||||
"shell":[{"for":"zsh","slot":"normal","code":"alias ll='ls -l'\n"}]}`
|
||||
)
|
||||
|
||||
func builtWith(module string, texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
|
||||
}
|
||||
|
||||
func collectionsOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
aliases, err := ParseManifest([]byte(oldFormAliases))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Resolve(shelf(zsh, prompt, aliases), []string{"zsh", "prompt", "aliases"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAContributorsBuildKeepsItsItemsTemplateAndText(t *testing.T) {
|
||||
m := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
if m.Handed == nil || !reflect.DeepEqual(m.Handed.Items, map[string]string{"shell:zsh:normal": "zsh"}) {
|
||||
t.Fatalf("the build kept %+v", m.Handed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Items, []Item{{Collection: "shell:zsh:normal", Content: contributorText}}) {
|
||||
t.Fatalf("the held items are %+v: the template's text, read back", m.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHandedCollectorAndContributorArePlannedAndNeitherIsRendered(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), handingTo("store:5000"))
|
||||
if strings.Contains(got, ".p10k.zsh") || strings.Contains(got, "alias ll") {
|
||||
t.Fatalf("the controller rendered a contribution into a handed collector: %s", got)
|
||||
}
|
||||
zsh, prompt := c.Plans["zsh"], c.Plans["prompt"]
|
||||
wantCollections := []PlanCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "widgets", Comment: "#"}}
|
||||
if !reflect.DeepEqual(zsh.Collections, wantCollections) || len(zsh.Items) != 0 {
|
||||
t.Fatalf("zsh's plan collects %+v and contributes %+v", zsh.Collections, zsh.Items)
|
||||
}
|
||||
if !reflect.DeepEqual(prompt.Items, []PlanItem{{Collection: "shell:zsh:normal", Template: "zsh"}}) ||
|
||||
len(prompt.Files) != 0 || prompt.Placement["machine:account-home"] != "/home/op" {
|
||||
t.Fatalf("prompt's plan is %+v: its item, by template, and the placement its template names", prompt)
|
||||
}
|
||||
// The old form on the same machine is not placed, and said so.
|
||||
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "aliases's shell:zsh:normal is not placed") {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// A collector filled the old way holds the old forms and each moved contributor's item, rendered from the text the
|
||||
// mesh holds as its old form was (design 57 L2, issue 681), so the file loses no line where nothing is handed; a
|
||||
// collection of the new form stays empty.
|
||||
func TestACollectorFilledTheOldWayHoldsTheOldFormsAndEachHeldItem(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), Rendering{ArtifactStore: "store:5000"})
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("handed on a machine that cannot take it: %v", c.Plans)
|
||||
}
|
||||
if !strings.Contains(got, `"content":"# zsh\n# aliases\nalias ll='ls -l'\n# prompt\nsource /home/op/.p10k.zsh\n"`) {
|
||||
t.Fatalf("the old way does not hold the old forms and the held item in module order: %s", got)
|
||||
}
|
||||
if len(c.Unplaced) != 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// An item the controller cannot render the old way (it names a value but a machine fact) is said as not placed.
|
||||
func TestAHeldItemNamingMoreThanAFactIsSaidNotPlacedTheOldWay(t *testing.T) {
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a config value was rendered by the controller")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "cd ${machine:account-home}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a fact this machine lacks was rendered")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "widgets", Content: "x\n"}}},
|
||||
"widgets", nil); ok {
|
||||
t.Fatal("an item of a collection of the new form was rendered the old way")
|
||||
}
|
||||
left := itemsLeftOut([]Manifest{zsh, {Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}}, zsh, "shell:zsh:normal", map[string]string{})
|
||||
if len(left) != 1 || !strings.Contains(left[0], "p's item of shell:zsh:normal is not placed") {
|
||||
t.Fatalf("left out: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// Each old namespace L2 moves renders a moved contributor's item exactly as its old form did, where the collector is
|
||||
// not handed: the same contributor written both ways composes the same bytes (design 57 L2, issue 681).
|
||||
func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
|
||||
code := "# two lines\nalias a=b"
|
||||
asOld := Manifest{Module: "laptop", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: code}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
asItem := Manifest{Module: "laptop", Moved: true, Items: []Item{{Collection: "shell:zsh:normal", Content: code},
|
||||
{Collection: "contribution:node-hotkeys:trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
other := Manifest{Module: "zz-other", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "x=1\n"}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute\n"}}}
|
||||
first := Manifest{Module: "aa-first", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "y=2\n"}}}
|
||||
for _, mods := range [][]Manifest{{other, first}, {}} {
|
||||
was := append(append([]Manifest(nil), mods...), asOld)
|
||||
is := append(append([]Manifest(nil), mods...), asItem)
|
||||
if a, b := shellCodeOn(was, "zsh", "normal", nil), shellCodeOn(is, "zsh", "normal", nil); a != b || a == "" {
|
||||
t.Errorf("shell: the old form made %q, the item %q", a, b)
|
||||
}
|
||||
a, _, errA := seatContributions(was, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
b, left, errB := seatContributions(is, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
if errA != nil || errB != nil || a != b || a == "" || len(left) != 0 {
|
||||
t.Errorf("hotkeys: the old form made %q, the item %q (%v %v %v)", a, b, errA, errB, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsAndItemsAreJudged(t *testing.T) {
|
||||
for name, c := range map[string]struct{ raw, says string }{
|
||||
"an item of a module not moved": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
|
||||
"two items to one collection": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
|
||||
"an item to no slot": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
|
||||
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
|
||||
"an item naming a secret": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
|
||||
"a collection not declared": {`{"module":"m","version":"1",
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
|
||||
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
|
||||
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
|
||||
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
|
||||
"only in a file's content"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
|
||||
t.Errorf("a collector: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,8 @@ func ConfigProblems(m Manifest) []string {
|
||||
}
|
||||
var problems []string
|
||||
placed := configPlaced(m)
|
||||
fallsBack := fileFallsBack(m)
|
||||
renamed := renamedTaken(m)
|
||||
for _, key := range sortedSettingKeys(m.Settings) {
|
||||
d := m.Settings[key]
|
||||
say := func(format string, args ...any) {
|
||||
@@ -239,12 +241,18 @@ func ConfigProblems(m Manifest) []string {
|
||||
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
|
||||
"the operator's own (novox/hq ADR 0348 §4)")
|
||||
}
|
||||
if as := renamed[key]; as != "" && d.Required {
|
||||
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
|
||||
"it is not: it is never required; give it the file's default, the empty value of its type "+
|
||||
"(novox/hq issue 751)", as)
|
||||
}
|
||||
for _, wrong := range validationProblems(d) {
|
||||
say("%s", wrong)
|
||||
}
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112)", word)
|
||||
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
|
||||
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
|
||||
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
|
||||
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
|
||||
}
|
||||
if d.Predefined != "" {
|
||||
p, listed := m.predefined[d.Predefined]
|
||||
@@ -294,13 +302,51 @@ func ConfigProblems(m Manifest) []string {
|
||||
func configPlaced(m Manifest) map[string]bool {
|
||||
placed := settingKeysUsedBy(m)
|
||||
for _, r := range m.Resources {
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
for _, k := range takenKeyList(r) {
|
||||
placed[k] = true
|
||||
}
|
||||
}
|
||||
return placed
|
||||
}
|
||||
|
||||
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
|
||||
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
|
||||
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
|
||||
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
|
||||
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
|
||||
// value belongs (ADR 0112).
|
||||
func fileFallsBack(m Manifest) map[string]bool {
|
||||
asked := settingKeysUsedBy(m)
|
||||
out := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, k := range takenKeyList(r) {
|
||||
if !asked[k] {
|
||||
out[k] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
||||
func renamedTaken(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
for _, e := range takenEntries(r[TakesSettingsField]) {
|
||||
if e.renamed {
|
||||
out[e.key] = e.as
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||
func validationProblems(d SettingDeclaration) []string {
|
||||
var problems []string
|
||||
@@ -388,7 +434,7 @@ func configTakesProblems(m Manifest) []string {
|
||||
continue
|
||||
}
|
||||
naming = true
|
||||
for _, k := range stringsOf(r[TakesSettingsField]) {
|
||||
for _, k := range takenKeyList(r) {
|
||||
_, declared := m.Settings[k]
|
||||
switch {
|
||||
case accountFacts[k]:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package catalogue
|
||||
|
||||
import "strings"
|
||||
|
||||
// What the move of config reads of a module's declaration (novox/hq ADR 0348 §9, design 57 K5, issue 664): where a
|
||||
// layer's key goes, how two layers' values become one, and whether a value is one its declaration takes.
|
||||
|
||||
// TakenAs is each name a mergeable file takes a config value under that is not the value's config key, with that
|
||||
// key (issue 751): a layer set before the module moved may still say the file's name, and the move sets it under
|
||||
// the config key.
|
||||
func TakenAs(m Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, as := range renamedTaken(m) {
|
||||
out[as] = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AskedKeys is every value a placeholder of the module asks for: a file, a unit, a directory's owner, a
|
||||
// contribution or a served fact. Such a value is taken whole from the last layer that sets it, where a mergeable
|
||||
// file lays one object over another key by key.
|
||||
func AskedKeys(m Manifest) map[string]bool { return settingKeysUsedBy(m) }
|
||||
|
||||
// LaidOver is a value laid over another as a later layer is laid over an earlier: two objects key by key, anything
|
||||
// else replaced whole.
|
||||
func LaidOver(under, over any) any { return mergeValue(under, over) }
|
||||
|
||||
// ValueProblems is what is wrong with a value for a declaration: its type, and its enum, bounds or pattern.
|
||||
func ValueProblems(d SettingDeclaration, v any) []string {
|
||||
if v == nil {
|
||||
return []string{"no value"}
|
||||
}
|
||||
d.Default = v
|
||||
d.Required = false
|
||||
var problems []string
|
||||
for _, p := range validationProblems(d) {
|
||||
problems = append(problems, strings.ReplaceAll(p, "its default", "the value"))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a
|
||||
// config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL.
|
||||
|
||||
// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file
|
||||
// falls back on when the file lacks it.
|
||||
func renaming() map[string]any {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme",
|
||||
map[string]any{"key": "url", "as": "NOTIFIER_URL"}}
|
||||
return m
|
||||
}
|
||||
|
||||
// rulesComposed is the rules file's content as the module's defaults and these layers make it.
|
||||
func rulesComposed(t *testing.T, m Manifest, layers ...Layer) string {
|
||||
t.Helper()
|
||||
all := layers
|
||||
if d, ok := Defaults(m); ok {
|
||||
all = append([]Layer{d}, layers...)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "rules" {
|
||||
continue
|
||||
}
|
||||
out, err := ApplySettings(r, all)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, left := out[TakesSettingsField]; left {
|
||||
t.Fatalf("the composed file still says %s: %v", TakesSettingsField, out)
|
||||
}
|
||||
return out["content"].(string)
|
||||
}
|
||||
t.Fatal("no rules file")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestATakenValueIsLaidUnderTheFilesOwnName(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatalf("a renamed takes-config entry was refused: %v", err)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://hooks.example.test/n"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://hooks.example.test/n" {
|
||||
t.Fatalf("url was not laid as NOTIFIER_URL: %s", got)
|
||||
}
|
||||
if _, said := doc["url"]; said {
|
||||
t.Fatalf("url was laid under the config key too: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) {
|
||||
plain := inConfigForm()
|
||||
base, err := parseRaw(t, plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := rulesComposed(t, base)
|
||||
if got := rulesComposed(t, m); got != want {
|
||||
t.Fatalf("with no layer the file is\n%s\nwanted, as without the entry,\n%s", got, want)
|
||||
}
|
||||
other := Layer{From: "ace", Values: map[string]any{"theme": "light"}}
|
||||
if got, want := rulesComposed(t, m, other), rulesComposed(t, base, other); got != want {
|
||||
t.Fatalf("with url unset the file is\n%s\nwanted\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine.
|
||||
// Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no
|
||||
// machine's file; the config key wins where one layer says both.
|
||||
func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{"url", "NOTIFIER_URL"} {
|
||||
if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 {
|
||||
t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil {
|
||||
t.Fatalf("a layer saying %s made the file %s", said, got)
|
||||
}
|
||||
}
|
||||
both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}})
|
||||
if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") {
|
||||
t.Fatalf("where one layer says both, the config key did not win: %s", both)
|
||||
}
|
||||
later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}},
|
||||
Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}})
|
||||
if !strings.Contains(later, "https://ace.test") {
|
||||
t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later)
|
||||
}
|
||||
// Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's.
|
||||
terminal := TerminalKeys(m)
|
||||
for _, k := range []string{"url", "NOTIFIER_URL"} {
|
||||
if !listHolds(terminal, k) {
|
||||
t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
|
||||
t.Fatalf("written back as %s", raw)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("read again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
|
||||
entry := func(m map[string]any, e any) map[string]any {
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
|
||||
return m
|
||||
}
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
|
||||
|
||||
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
|
||||
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
|
||||
refused(t, kept, `"NOTIFIER_URL"`, "protected")
|
||||
|
||||
// Laid only when set, so the code's own fallback holds: never required.
|
||||
required := renaming()
|
||||
declaration(required, "url")["required"] = true
|
||||
delete(declaration(required, "url"), "default")
|
||||
refused(t, required, `"url"`, "only when it is set", "never required")
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "notifier", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
|
||||
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
|
||||
},
|
||||
}
|
||||
refused(t, m, "notifier's rules", "config form")
|
||||
}
|
||||
@@ -452,3 +452,33 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
|
||||
t.Fatalf("the settings form's refusal changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
|
||||
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
|
||||
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
|
||||
// operator's decision on issue 664's second question (issue 735).
|
||||
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
|
||||
}
|
||||
|
||||
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
|
||||
// where the operator's belongs (ADR 0112).
|
||||
asked := inConfigForm()
|
||||
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
|
||||
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\npost = ${config:url}\n"
|
||||
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
|
||||
|
||||
// Read by the module's code alone, nothing in the definition says it falls back.
|
||||
runtime := inConfigForm()
|
||||
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
|
||||
"default": "", "runtime": true}
|
||||
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials, in a file of the provider's own (novox/hq ADR 0373, issue 679).
|
||||
//
|
||||
// A provider in the tool runner may not ask the vault (ADR 0349 clause 11, ADR 0370 clause 2), so its template names
|
||||
// `${consumers:<provision>}` and its machine's provisioner client fills it: one entry per consumer bound to the
|
||||
// provider for that provision, each carrying the credential the vault keeps for the pair. Only the client fills it,
|
||||
// so the placeholder is a provider's alone, for a provision it provides, in a file only the provider reads (mode 0600
|
||||
// or 0400, owned at runtime, so a change on disk is written back); and a module not handed to the client on a machine
|
||||
// is refused there, never written the placeholder's text, nor an empty list the provider would read as no consumers.
|
||||
|
||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
||||
const ConsumersNamespace = "consumers"
|
||||
|
||||
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
|
||||
// refused rather than passed over.
|
||||
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
|
||||
|
||||
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
|
||||
// for a provision the module provides, in a file only the provider reads and the client writes back.
|
||||
func consumersPlaceholderProblems(m Manifest, r map[string]any) []string {
|
||||
var problems []string
|
||||
for _, field := range sortedKeys(r) {
|
||||
v, ok := r[field].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
|
||||
switch {
|
||||
case field != "content" || fmt.Sprint(r["type"]) != "file":
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
|
||||
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
|
||||
continue
|
||||
case !slices.ContainsFunc(m.Provides, func(o Offer) bool { return o.Name == f[1] }):
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
|
||||
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
m.Module, f[1]))
|
||||
}
|
||||
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
|
||||
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||||
r["mode"]))
|
||||
}
|
||||
if owner := ownershipOf(r); owner != RuntimeOwned {
|
||||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
|
||||
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
|
||||
r["id"], f[0], owner))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
|
||||
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
|
||||
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
|
||||
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
|
||||
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`, the merge gate), on a machine
|
||||
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
|
||||
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
|
||||
func consumersHandedOnly(m Manifest, r map[string]any, with Rendering) (judged, leftOut bool) {
|
||||
content, _ := r["content"].(string)
|
||||
switch {
|
||||
case ofConsumers.FindString(content) == "":
|
||||
return false, false
|
||||
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
|
||||
return true, false
|
||||
}
|
||||
return false, true
|
||||
}
|
||||
|
||||
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
|
||||
func consumersLeftOutLine(m Manifest, r map[string]any) string {
|
||||
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
|
||||
"(novox/hq ADR 0373)", m.Module, r["id"])
|
||||
}
|
||||
|
||||
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
|
||||
func consumersNotHanded(m Manifest, r map[string]any, with Rendering) (judged bool, err error) {
|
||||
judged, left := consumersHandedOnly(m, r, with)
|
||||
if left {
|
||||
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
|
||||
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
|
||||
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
|
||||
}
|
||||
return judged, nil
|
||||
}
|
||||
|
||||
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
|
||||
// resource as if its text were inline.
|
||||
func consumersOfTemplate(m Manifest, id, text string) []string {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) != id {
|
||||
continue
|
||||
}
|
||||
withText := map[string]any{}
|
||||
for k, v := range r {
|
||||
withText[k] = v
|
||||
}
|
||||
withText["content"] = text
|
||||
return consumersPlaceholderProblems(m, withText)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
"an empty environment": {empty, nil},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := DependsOn(c.m)
|
||||
got := DependsOn(nil, c.m)
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
@@ -118,7 +118,7 @@ func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||
for _, moment := range powerMoments {
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||
if got := DependsOn(m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||
}
|
||||
if p := m.shellProblems(); len(p) != 0 {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// The core's own credentials (novox/hq ADR 0349 §16, issue 755). The bus, the node-engine, the controller and
|
||||
// mesh-provisioner need their files before any provisioner client runs, so their files stay placement and their
|
||||
// credentials travel in the declaration as values the vault sealed to the machine they run on, which the controller
|
||||
// carries and cannot open; the node-engine opens them there (mesh-host's VaultBoxContract).
|
||||
|
||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
||||
// its client. The node-engine and the controller hold no own secret of a module's. **Fixed here, in the controller's
|
||||
// code, as the clause says** — and the vault keeps the same list (mesh-vault's CoreModules), answering the controller
|
||||
// for these alone.
|
||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
||||
|
||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
||||
func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) }
|
||||
|
||||
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
|
||||
const VaultSealedField = "vault-sealed"
|
||||
|
||||
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
|
||||
// yet: the send asks the vault for it. Never sent.
|
||||
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
|
||||
|
||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
||||
func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) {
|
||||
box, ok := with.VaultSealed[module][name]
|
||||
return box, ok && len(box) > 0
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The core's own credentials travel sealed by the vault to their machine (novox/hq ADR 0349 §16, issue 755): the
|
||||
// controller carries the vault's box and cannot open it. The list is fixed here, and the composition carries the box
|
||||
// in place of the controller's own sealed value only where the rendering was given one.
|
||||
|
||||
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreCredentialModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", CoreCredentialModules, want)
|
||||
}
|
||||
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
|
||||
if !CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is not on the list", m)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
|
||||
if CarriesVaultSealed(m) {
|
||||
t.Errorf("%s is on the list", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
"own-secrets":{"provisioner-broker":"${dir:state}/broker"},
|
||||
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."}]}`
|
||||
|
||||
func clientOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(provisionerClient))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return notifierOn(t, m)
|
||||
}
|
||||
|
||||
func brokerFile(t *testing.T, c Composed) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range c.Resources {
|
||||
if r["id"] == "mesh-provisioner-client.needs-provisioner-broker" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`)
|
||||
|
||||
func TestACoreCredentialCarriesTheVaultsBoxAndNoSealOfTheControllers(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}},
|
||||
VaultSealed: map[string]map[string]json.RawMessage{"mesh-provisioner-client": {"provisioner-broker": aVaultBox}}}
|
||||
c, got := composeWith(t, r, with)
|
||||
f := brokerFile(t, c)
|
||||
if f == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
if f["sealed"] != nil || !reflect.DeepEqual(f["vault-sealed"], aVaultBox) || strings.Contains(got, `"nacl"`) {
|
||||
t.Fatalf("the file is %v: the vault's box alone, never the controller's seal", f)
|
||||
}
|
||||
// Without the controller's own value at all: the vault's box is enough.
|
||||
with.Needed = nil
|
||||
if c, _ := composeWith(t, r, with); brokerFile(t, c) == nil {
|
||||
t.Fatal("a core credential with the vault's box and no seal of the controller's was refused or dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACoreModuleTheVaultGaveNothingForIsLeftOutWithWhy(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, got := composeWith(t, r, Rendering{NotPlaced: map[string]string{
|
||||
"mesh-provisioner-client": "the vault did not answer for its credential"}})
|
||||
if brokerFile(t, c) != nil || strings.Contains(got, "mesh-provisioner-client.") {
|
||||
t.Fatalf("a core module the vault gave nothing for was placed: %s", got)
|
||||
}
|
||||
if !strings.Contains(c.LeftOut["mesh-provisioner-client"], "the vault did not answer") {
|
||||
t.Fatalf("left out %v", c.LeftOut)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine below VaultBoxContract is rendered with neither field: its composition is the controller's sealed value,
|
||||
// byte for byte what it was.
|
||||
func TestWithoutTheVaultsBoxTheCompositionIsAsBefore(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}}
|
||||
before, _ := composeWith(t, r, with)
|
||||
with.VaultSealed, with.NotPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
after, _ := composeWith(t, r, with)
|
||||
if !reflect.DeepEqual(before, after) || brokerFile(t, before)["sealed"] != "nacl" {
|
||||
t.Fatalf("the composition changed: %v", brokerFile(t, after))
|
||||
}
|
||||
}
|
||||
|
||||
// Asked ahead of a send that has no box kept yet: a stand-in, named as foreseen, never sent.
|
||||
func TestAForeseenVaultBoxIsNamed(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, _ := composeWith(t, r, Rendering{VaultSealed: map[string]map[string]json.RawMessage{
|
||||
"mesh-provisioner-client": {"provisioner-broker": ForeseenVaultBox}}})
|
||||
if !reflect.DeepEqual(c.Foreseen, []string{"mesh-provisioner-client/provisioner-broker"}) {
|
||||
t.Fatalf("foreseen %v", c.Foreseen)
|
||||
}
|
||||
}
|
||||
@@ -197,9 +197,9 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
}
|
||||
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
|
||||
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
|
||||
if !slices.Contains(DependsOn(media), BackupSeat) || !slices.Contains(DependsOn(pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(media), DependsOn(pg), DependsOn(agent))
|
||||
if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(nil, agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent))
|
||||
}
|
||||
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
|
||||
t.Fatalf("an operator's path reads %+v", it)
|
||||
|
||||
@@ -93,6 +93,15 @@ type Rendering struct {
|
||||
// makes. Nil on every composition that is sent, and on every other question, which then refuse
|
||||
// a secret not made with a *NotMadeError.
|
||||
Foreseen map[string]map[string]bool
|
||||
// VaultSealed is, for a module on the core's list (CoreCredentialModules), each own secret as the vault sealed it
|
||||
// to this machine (the mesh's sealed box, as the vault answered it), keyed like Needed (novox/hq ADR 0349 §16,
|
||||
// issue 755). Given only for a machine whose engine opens the box (VaultBoxContract): its file then carries the
|
||||
// box and never the controller's own sealed value. Nil everywhere else, and the composition is as it was.
|
||||
VaultSealed map[string]map[string]json.RawMessage
|
||||
// NotPlaced is each core module left out of this machine's declaration because the vault gave nothing for its
|
||||
// credential and no box was kept, with why (issue 755): the machine keeps what it holds of it, and a module not
|
||||
// yet there is not placed. Nil everywhere else.
|
||||
NotPlaced map[string]string
|
||||
|
||||
// Mesh is every node's address on the private network, which is what a rule saying "from the
|
||||
// mesh" resolves to. Passed in for the same reason grants are: who else is on the network is
|
||||
@@ -257,6 +266,13 @@ type Rendering struct {
|
||||
// contract is link.BusyContract or later). To an older, strict engine the field is not sent, and the
|
||||
// container is replaced at once, as before.
|
||||
ReadsBusy bool
|
||||
|
||||
// Hands says this machine's node-engine writes a filled file on the provisioner client's word alone (its
|
||||
// statement's contract is link.HandedContract or later, design 57 P7), and Provisioner is the account the
|
||||
// provisioner's client runs as here, empty where none runs (novox/hq ADR 0349 §4). With both, a moved module's
|
||||
// templated files are handed to the client (handed.go); without either, it composes as before.
|
||||
Hands bool
|
||||
Provisioner string
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -331,6 +347,15 @@ type Composed struct {
|
||||
// naming its module and why (novox/hq ADR 0255): that piece is left out and the machine is told
|
||||
// everything else. Push and plan say it; the merge gate refuses a change that adds one.
|
||||
Unplaced []string
|
||||
// HandedOnly is each file left out because only the provisioner's client fills it and the module is not handed on
|
||||
// this machine (a provider's consumers file, ADR 0373), said once in the plan. Not an unplaced contribution, which
|
||||
// the merge gate refuses: a module not handed composes as it did.
|
||||
HandedOnly []string
|
||||
// Filled is the path of every file handed to the provisioner, in the order composed: the declaration's
|
||||
// filled files (novox/hq ADR 0349 §4, §5). Plans is each handed module's plan on this machine, by module.
|
||||
// Both empty where nothing is handed, and the declaration is then what it was.
|
||||
Filled []string
|
||||
Plans map[string]Plan
|
||||
}
|
||||
|
||||
// ForeseenSealed is what stands for an own secret a send will make, in a composition asked ahead of
|
||||
@@ -390,8 +415,9 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
owner := map[string]string{}
|
||||
received := map[string]map[string][]Contribution{}
|
||||
leftOut := map[string]string{}
|
||||
var foreseen, unplaced []string
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced)
|
||||
var foreseen, unplaced, handedOnly []string
|
||||
handed := &handedFiles{plans: map[string]Plan{}}
|
||||
resources, err := r.compose(with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed)
|
||||
if err != nil {
|
||||
return Composed{}, err
|
||||
}
|
||||
@@ -405,8 +431,13 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||
})
|
||||
}
|
||||
sort.Strings(unplaced)
|
||||
return Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced}, nil
|
||||
sort.Strings(handedOnly)
|
||||
out := Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
|
||||
Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths}
|
||||
if plans := handed.finished(r.Needs); len(plans) > 0 {
|
||||
out.Plans = plans
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||
@@ -417,7 +448,7 @@ const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||
|
||||
func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
received map[string]map[string][]Contribution, leftOut map[string]string,
|
||||
foreseen, unplaced *[]string) ([]map[string]any, error) {
|
||||
foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) {
|
||||
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||
@@ -426,6 +457,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
kept := make([]Manifest, 0, len(r.Modules))
|
||||
var stillBackedUp []Manifest
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
for _, m := range r.Modules {
|
||||
if why, isLeft := left[m.Module]; isLeft {
|
||||
if leftOut != nil {
|
||||
@@ -648,7 +684,36 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
"id": NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name],
|
||||
}))
|
||||
}
|
||||
// **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is
|
||||
// declared placed and without a value, filled by the machine's provisioner client from the vault; the
|
||||
// controller makes and seals none of them here.
|
||||
handsSecrets := with.Handing(m)
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if handsSecrets {
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, HandedSecretField: name,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
// **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this
|
||||
// machine, which the controller cannot open; the controller's own sealed value is not sent with it.
|
||||
if box, ok := with.vaultSealedOf(m.Module, name); ok {
|
||||
if string(box) == string(ForeseenVaultBox) {
|
||||
*foreseen = append(*foreseen, m.Module+"/"+name)
|
||||
}
|
||||
owner := m.SecretsOwner
|
||||
if m.Module == RuntimeModule && r.Account != "" {
|
||||
owner = r.Account
|
||||
}
|
||||
first = append(first, ownedBy(owner, map[string]any{
|
||||
"id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, VaultSealedField: box,
|
||||
}))
|
||||
continue
|
||||
}
|
||||
sealed := with.Needed[m.Module][name]
|
||||
if sealed == "" && with.Foreseen[m.Module][name] {
|
||||
// Not made, and the next send makes it: composed with a stand-in so that whatever
|
||||
@@ -718,6 +783,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
found = &r.Needs[i]
|
||||
}
|
||||
}
|
||||
// **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its
|
||||
// file is declared placed and without a value, and the machine's client fills it from the vault.
|
||||
if found != nil && found.IssuedToHolder && with.Handing(m) {
|
||||
first = append(first, ownedBy(m.SecretsOwner, map[string]any{
|
||||
"id": SecretID(SecretLocal(to, file.Local)), "type": "file", "path": file.Path,
|
||||
HandedProvisionField: PlanProvision{Name: SecretLocal(to, file.Local), Provision: to,
|
||||
Local: file.Local, Provider: found.From, ProviderModule: found.Module},
|
||||
}))
|
||||
continue
|
||||
}
|
||||
if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager {
|
||||
// Answered by a record whose key has not been supplied since this consumer was
|
||||
// put on it. **Refused, not skipped.** The mesh discarded the plaintext when the
|
||||
@@ -837,7 +912,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
first = append(first, file)
|
||||
// Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723).
|
||||
first = append(first, ownedBy(m.SecretsOwner, file))
|
||||
}
|
||||
if m.Computed != "" {
|
||||
mine, part := generated[m.Module]
|
||||
@@ -973,7 +1049,100 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
// ADR 0163, rule 4); judged above, so an invalid one is not here.
|
||||
keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted)
|
||||
|
||||
// **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a
|
||||
// machine that can take them, each is declared placed and without its text, and no pass below fills it.
|
||||
handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs,
|
||||
backupPlacer{append(append([]Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities,
|
||||
unplaced})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// **What the mesh calls the module through each provision it requires** (issue 792): the names its binding
|
||||
// file carries, composed by the same call, handed to the provider through the plan's entry.
|
||||
if _, ok := handed.plans[m.Module]; ok && handing != nil {
|
||||
for _, to := range requirementsContributedTo(m) {
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handed.name(m.Module, to, own)
|
||||
}
|
||||
}
|
||||
// A handed collector is filled from items alone: each old form its collections would have held is said
|
||||
// (design 57 L1).
|
||||
if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 {
|
||||
*unplaced = append(*unplaced, oldFormsLeftOut(append(append([]Manifest(nil), r.Modules...),
|
||||
stillBackedUp...), m, plan.Collections)...)
|
||||
}
|
||||
for _, unsettled := range resources {
|
||||
if pr, ok := unsettled[HandedProvisionField].(PlanProvision); ok {
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, pr.Provision)
|
||||
}
|
||||
marked := map[string]any{}
|
||||
for k, v := range unsettled {
|
||||
marked[k] = v
|
||||
}
|
||||
delete(marked, HandedProvisionField)
|
||||
marked[HandedSecretField] = pr.Name
|
||||
file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
pr.Path = sec.Path
|
||||
if err := handed.addProvisionFile(m.Module, pr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if unsettled[HandedSecretField] != nil {
|
||||
if handing == nil {
|
||||
return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
|
||||
"not: nothing would fill it", m.Module, unsettled[HandedSecretField])
|
||||
}
|
||||
file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
if err := handed.addSecretFile(m.Module, sec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if handing != nil && fmt.Sprint(unsettled["type"]) == "file" {
|
||||
if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 &&
|
||||
!handing[fmt.Sprint(unsettled["id"])] {
|
||||
return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+
|
||||
"secrets reach only the files its provisioner client fills: keep it in a template "+
|
||||
"(design 57 V4)", m.Module, unsettled["id"])
|
||||
}
|
||||
}
|
||||
if handing[fmt.Sprint(unsettled["id"])] {
|
||||
file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
|
||||
owner[fmt.Sprint(file["id"])] = m.Module
|
||||
if err := handed.add(m.Module, line); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, file)
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(unsettled["type"]) == "file" {
|
||||
if _, left := consumersHandedOnly(m, unsettled, with); left {
|
||||
*handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled))
|
||||
continue
|
||||
}
|
||||
}
|
||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1202,6 +1371,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
// **And what a module of its own account is given, its own account reads** (issue 723): the files and
|
||||
// directories its words name that say no owner, given to the account it runs as, never the operator's.
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs == "" {
|
||||
continue
|
||||
}
|
||||
w, err := bundleWords(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs)
|
||||
}
|
||||
}
|
||||
if with.Adopted {
|
||||
// First, before anything a module declares: what the mesh needs reachable, then its guard.
|
||||
@@ -1449,6 +1630,10 @@ type Contribution struct {
|
||||
// Empty is root, which is what it was and what a module with no bundle and no declared owner
|
||||
// still wants.
|
||||
func (r Resolution) provisionsAs(m Manifest) string {
|
||||
// A module of its own account reads them as that account (ADR 0259 §8, issue 723).
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
if len(m.Bundles) > 0 && r.Account != "" {
|
||||
return r.Account
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func configured(block map[string]any) SettingsBy {
|
||||
return SettingsBy{"media": {{From: "node anchor",
|
||||
Values: map[string]any{EndpointsSetting: block}}}}
|
||||
Placement: map[string]any{EndpointsSetting: block}}}}
|
||||
}
|
||||
|
||||
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
|
||||
@@ -109,7 +109,7 @@ func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
|
||||
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
_, err := GivenPorts(m, []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := GivenPorts(m, []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
|
||||
PortsSetting: map[string]any{"80": 30000},
|
||||
}}})
|
||||
@@ -120,7 +120,7 @@ func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
|
||||
|
||||
// And the same for its reach, said once here and once through the older key.
|
||||
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
|
||||
ExposeSetting: map[string]any{"80": FromEverywhere},
|
||||
}}})
|
||||
|
||||
@@ -111,7 +111,7 @@ var (
|
||||
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution):([^}]*)\}`)
|
||||
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
|
||||
)
|
||||
|
||||
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
|
||||
@@ -226,6 +226,8 @@ func (m Manifest) contributionPlaceholderProblems() []string {
|
||||
for _, r := range m.Resources {
|
||||
problems = append(problems, placeholderProblems(m, r)...)
|
||||
problems = append(problems, seatPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, collectedPlaceholderProblems(m, r)...)
|
||||
problems = append(problems, consumersPlaceholderProblems(m, r)...)
|
||||
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
|
||||
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
|
||||
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
|
||||
@@ -353,10 +355,11 @@ func inModuleOrder(modules []Manifest) []Manifest {
|
||||
func variablesSetOnce(modules []Manifest) error {
|
||||
setter := map[string]string{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
if first, taken := setter[n]; taken {
|
||||
return fmt.Errorf(
|
||||
"%s and %s both set %s on this machine; the account has one environment, so one "+
|
||||
@@ -379,13 +382,14 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
}
|
||||
placed := map[string]bool{}
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
if m.Environment == nil {
|
||||
e := environmentOf(m)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
if len(m.Environment.Variables) > 0 {
|
||||
if len(e.Variables) > 0 {
|
||||
set := setBy{module: m.Module, values: map[string]string{}}
|
||||
for _, n := range sortedKeys(m.Environment.Variables) {
|
||||
v, err := factsIn(m.Environment.Variables[n], facts, m.Module, n)
|
||||
for _, n := range sortedKeys(e.Variables) {
|
||||
v, err := factsIn(e.Variables[n], facts, m.Module, n)
|
||||
if err != nil {
|
||||
return env, err
|
||||
}
|
||||
@@ -394,7 +398,7 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
}
|
||||
env.variables = append(env.variables, set)
|
||||
}
|
||||
for _, p := range m.Environment.Path {
|
||||
for _, p := range e.Path {
|
||||
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
|
||||
if err != nil {
|
||||
return env, err
|
||||
@@ -417,6 +421,37 @@ func environmentOn(modules []Manifest, facts map[string]string) (contributedEnvi
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
|
||||
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
|
||||
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
|
||||
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
|
||||
// said as not placed (itemsLeftOutFor).
|
||||
func environmentOf(m Manifest) *Environment {
|
||||
var out *Environment
|
||||
if m.Environment != nil {
|
||||
out = &Environment{Variables: map[string]string{}, Path: append([]PathEntry(nil), m.Environment.Path...)}
|
||||
for k, v := range m.Environment.Variables {
|
||||
out.Variables[k] = v
|
||||
}
|
||||
}
|
||||
for _, it := range itemsInOrder(m.Items) {
|
||||
if it.Collection != EnvironmentCollection || it.Content == "" {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &Environment{Variables: map[string]string{}}
|
||||
}
|
||||
value := strings.TrimSuffix(it.Content, "\n")
|
||||
switch it.Attributes["kind"] {
|
||||
case "variable":
|
||||
out.Variables[it.Attributes["name"]] = value
|
||||
case "path":
|
||||
out.Path = append(out.Path, PathEntry{Entry: value, At: it.Attributes["at"]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
|
||||
// either format is written, so both say the same thing (novox/hq ADR 0203).
|
||||
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
|
||||
@@ -521,22 +556,91 @@ func entriesOf(entries []placedOn) string {
|
||||
// order, each module's pieces in the order it declared them, each preceded by a line naming the
|
||||
// module, and empty when nothing is contributed.
|
||||
func shellCode(modules []Manifest, shell, slot string) string {
|
||||
var b strings.Builder
|
||||
return shellCodeOn(modules, shell, slot, nil)
|
||||
}
|
||||
|
||||
// shellCodeOn is shellCode on a machine with these facts: a moved contributor's item of the slot's collection is
|
||||
// rendered in its module's place as its old form was (heldItemText), so a collector not handed keeps every line.
|
||||
func shellCodeOn(modules []Manifest, shell, slot string, facts map[string]string) string {
|
||||
return shellCodeFor(modules, shell, slot, facts, nil)
|
||||
}
|
||||
|
||||
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
|
||||
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
|
||||
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
|
||||
func oldFormOfCollected(content string) string {
|
||||
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
name := ofCollected.FindStringSubmatch(placeholder)[1]
|
||||
base, _ := splitSelector(name)
|
||||
if format, is := strings.CutPrefix(base, EnvironmentCollection+":"); is {
|
||||
return "${environment:" + format + "}"
|
||||
}
|
||||
if !strings.HasPrefix(base, "contribution:") {
|
||||
return placeholder
|
||||
}
|
||||
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
|
||||
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
|
||||
return placeholder
|
||||
}
|
||||
return "${" + name + "}"
|
||||
})
|
||||
}
|
||||
|
||||
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
|
||||
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
|
||||
// provisioner's client places them.
|
||||
func shellCodeFor(modules []Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
|
||||
var blocks []textBlock
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
named := false
|
||||
var old strings.Builder
|
||||
for _, c := range m.Shell {
|
||||
if c.For != shell || c.Slot != slot {
|
||||
continue
|
||||
}
|
||||
if !named {
|
||||
fmt.Fprintf(&b, "# %s\n", m.Module)
|
||||
named = true
|
||||
if old.Len() == 0 {
|
||||
fmt.Fprintf(&old, "# %s\n", m.Module)
|
||||
}
|
||||
b.WriteString(c.Code)
|
||||
old.WriteString(c.Code)
|
||||
if !strings.HasSuffix(c.Code, "\n") {
|
||||
b.WriteString("\n")
|
||||
old.WriteString("\n")
|
||||
}
|
||||
}
|
||||
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
|
||||
}
|
||||
return joinBlocks(blocks)
|
||||
}
|
||||
|
||||
// textBlock is a stretch of a collector's text and the order it is placed by.
|
||||
type textBlock struct {
|
||||
order int
|
||||
text string
|
||||
}
|
||||
|
||||
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
|
||||
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
|
||||
// of the default order that follows the old form's text shares its line, as a module's item always did.
|
||||
func moduleBlocks(m Manifest, comment, old string, items []heldItem) []textBlock {
|
||||
var blocks []textBlock
|
||||
if old != "" {
|
||||
blocks = append(blocks, textBlock{OrderDefault, old})
|
||||
}
|
||||
for i, h := range items {
|
||||
text := h.text
|
||||
if i == 0 && old != "" && h.item.order() == OrderDefault {
|
||||
blocks[0].text += text
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textBlock{h.item.order(), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// joinBlocks is the blocks by order, those of one order in the order given (module order).
|
||||
func joinBlocks(blocks []textBlock) string {
|
||||
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
b.WriteString(blk.text)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -551,13 +655,34 @@ func shellCode(modules []Manifest, shell, slot string) string {
|
||||
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
|
||||
// wrote, so a contributed piece is never scanned again.
|
||||
func contributionsInto(resource map[string]any, m Manifest, modules []Manifest, facts map[string]string, with Rendering, caps map[string]bool, unplaced *[]string) error {
|
||||
if problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
|
||||
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", problems[0])
|
||||
}
|
||||
judged, err := consumersNotHanded(m, resource, with)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if judged {
|
||||
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
|
||||
}
|
||||
content, ok := resource["content"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
content = oldFormOfCollected(content)
|
||||
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
|
||||
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
|
||||
if unplaced != nil {
|
||||
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
|
||||
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
|
||||
_, selector := splitSelector(f[2])
|
||||
where, _ := selectorOf(selector)
|
||||
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
content = ofCollected.ReplaceAllString(content, "")
|
||||
if ofEnvironment.MatchString(content) {
|
||||
env, err := environmentOn(modules, facts)
|
||||
if err != nil {
|
||||
@@ -578,6 +703,13 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
|
||||
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
|
||||
found := ofContributed.FindStringSubmatch(placeholder)
|
||||
first, second, _ := strings.Cut(found[2], ":")
|
||||
if found[1] == BackupNamespace {
|
||||
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
|
||||
if problem != "" && failed == nil {
|
||||
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
|
||||
}
|
||||
return placed
|
||||
}
|
||||
if found[1] == "contribution" {
|
||||
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
|
||||
if err != nil && failed == nil {
|
||||
@@ -588,7 +720,7 @@ func contributionsInto(resource map[string]any, m Manifest, modules []Manifest,
|
||||
}
|
||||
return placed
|
||||
}
|
||||
return shellCode(modules, first, second)
|
||||
return shellCodeFor(modules, first, second, facts, caps)
|
||||
})
|
||||
if failed != nil {
|
||||
return failed
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with
|
||||
// kind=variable and name=<NAME>, a PATH entry one with kind=path and at=start|end, each a template whose text is the
|
||||
// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered;
|
||||
// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client
|
||||
// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five
|
||||
// contributors as the old form composed it, with all five and with zsh alone.
|
||||
|
||||
type envDecl struct{ module, item, name, value string }
|
||||
|
||||
func environmentOfTheCatalogue() []envDecl {
|
||||
return []envDecl{
|
||||
{"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"},
|
||||
{"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"},
|
||||
{"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"},
|
||||
{"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"},
|
||||
{"adwaita", "qt-select", "QT_SELECT", "6"},
|
||||
{"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"},
|
||||
{"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"},
|
||||
{"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"},
|
||||
{"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"},
|
||||
{"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"},
|
||||
{"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"},
|
||||
{"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"},
|
||||
{"xterm", "terminal", "TERMINAL", "xterm"},
|
||||
{"zsh", "editor", "EDITOR", "vim"},
|
||||
{"zsh", "visual", "VISUAL", "vim"},
|
||||
{"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"},
|
||||
{"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"},
|
||||
{"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"},
|
||||
{"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"},
|
||||
}
|
||||
}
|
||||
|
||||
const environmentCollector = `{"module":"node-env","version":"1","moved":true,
|
||||
"claims":[{"name":"node-environment","scope":"node"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"},
|
||||
{"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}`
|
||||
|
||||
// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items.
|
||||
func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution {
|
||||
t.Helper()
|
||||
collector := held(t, environmentCollector, builtWith("node-env", map[string]string{
|
||||
"posix": "# header\n" + plan, "systemd": "${environment:systemd}"}))
|
||||
mods := []Manifest{collector}
|
||||
order := []string{"node-env"}
|
||||
byModule := map[string][]envDecl{}
|
||||
var modules []string
|
||||
for _, d := range decls {
|
||||
if byModule[d.module] == nil {
|
||||
modules = append(modules, d.module)
|
||||
}
|
||||
byModule[d.module] = append(byModule[d.module], d)
|
||||
}
|
||||
for _, name := range modules {
|
||||
var items []string
|
||||
texts := map[string]string{}
|
||||
for _, d := range byModule[name] {
|
||||
attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name)
|
||||
if order, isPath := strings.CutPrefix(d.name, "path:"); isPath {
|
||||
attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order)
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`,
|
||||
d.item, attrs, d.item))
|
||||
texts["env-"+d.item] = d.value + "\n"
|
||||
}
|
||||
raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ","))
|
||||
mods = append(mods, held(t, raw, builtWith(name, texts)))
|
||||
order = append(order, name)
|
||||
}
|
||||
r, err := Resolve(shelf(mods...), order, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "environment", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for name, only := range map[string]string{"all": "", "zsh": "zsh"} {
|
||||
var decls []envDecl
|
||||
for _, d := range environmentOfTheCatalogue() {
|
||||
if only == "" || d.module == only {
|
||||
decls = append(decls, d)
|
||||
}
|
||||
}
|
||||
// Named as the collector names it, and as the old placeholder does.
|
||||
for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} {
|
||||
r := nodeEnvWith(t, decls, plan)
|
||||
// Not handed: the controller renders each moved contributor's items as the old form was rendered.
|
||||
if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
// Handed: one collection, named for the client; each contributor's plan names its items.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) {
|
||||
t.Errorf("%s: the plan collects %+v", name, got)
|
||||
}
|
||||
items, got := len(decls), 0
|
||||
for m := range c.Plans {
|
||||
got += len(c.Plans[m].Items)
|
||||
}
|
||||
if got != items {
|
||||
t.Errorf("%s: the plans name %d items, want %d", name, got, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
|
||||
item := func(attrs map[string]string) []string {
|
||||
return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs})
|
||||
}
|
||||
for _, ok := range []map[string]string{
|
||||
{"kind": "variable", "name": "GTK_THEME"},
|
||||
{"kind": "path", "at": "end", "order": "10"},
|
||||
} {
|
||||
if p := item(ok); len(p) != 0 {
|
||||
t.Errorf("%v is refused: %v", ok, p)
|
||||
}
|
||||
}
|
||||
for _, bad := range []map[string]string{
|
||||
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
|
||||
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
|
||||
{"kind": "variable", "name": "A", "color": "red"},
|
||||
} {
|
||||
if p := item(bad); len(p) == 0 {
|
||||
t.Errorf("%v is taken", bad)
|
||||
}
|
||||
}
|
||||
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
|
||||
attributeFits("environment", "kind", "Variable") {
|
||||
t.Error("an upper-case name is the environment's name alone")
|
||||
}
|
||||
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
|
||||
a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") {
|
||||
t.Errorf("two modules setting X: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
|
||||
out := map[int]string{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[ExposeSetting]
|
||||
raw, ok := layer.Placement[ExposeSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -651,7 +651,7 @@ func GivenPorts(m Manifest, layers []Layer) (map[int]int, error) {
|
||||
out := map[int]int{} // the port the module names → the machine port it is on
|
||||
by := map[int]int{} // and which of the setting's ports put it there, for the refusal
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[PortsSetting]
|
||||
raw, ok := layer.Placement[PortsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -951,7 +951,7 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
out[declared[name]] = ep.Reach
|
||||
}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[ReachSetting]
|
||||
raw, ok := layer.Placement[ReachSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ func Endpoints(m Manifest, layers []Layer) (map[string]Endpoint, error) {
|
||||
|
||||
out := map[string]Endpoint{}
|
||||
for _, layer := range layers {
|
||||
raw, ok := layer.Values[EndpointsSetting]
|
||||
raw, ok := layer.Placement[EndpointsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -752,7 +752,7 @@ func TestExposureSettingOverridesAListensSource(t *testing.T) {
|
||||
func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
|
||||
postgres := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}}
|
||||
layer := func(port, source string) []Layer {
|
||||
return []Layer{{From: "node", Values: map[string]any{ExposeSetting: map[string]any{port: source}}}}
|
||||
return []Layer{{From: "node", Placement: map[string]any{ExposeSetting: map[string]any{port: source}}}}
|
||||
}
|
||||
|
||||
if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil {
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
|
||||
|
||||
// The catalogue's number is a default and the node's setting moves it.
|
||||
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"3000": float64(3100)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's port cannot be given on a node: %v", err)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) {
|
||||
func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
|
||||
forge := catalogueManifest(t, "gitea")
|
||||
given, err := GivenPorts(forge, []Layer{{From: "anchor",
|
||||
Values: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"22": float64(9022)}}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err)
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ type barOption struct {
|
||||
// - shows: battery or command; options: what that kind takes.
|
||||
func barBlockShape() *Shape {
|
||||
return &Shape{
|
||||
Text: true,
|
||||
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
||||
{Field: "place", Values: []string{"resources", "status"}}},
|
||||
Order: "order",
|
||||
|
||||
@@ -0,0 +1,799 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A module handed to the provisioner (novox/hq ADR 0349 clauses 2, 4, 6 and 17, design 57 P6, issue 672).
|
||||
//
|
||||
// **Marked in its manifest, one module at a time.** A module whose manifest says `"moved": true` has moved: each
|
||||
// file of it that names a template is filled by the provisioner's client on the node, from the templates archive
|
||||
// its build packed, and the controller fills nothing in it. Every other resource of the module, and every file of
|
||||
// a module not marked, is composed exactly as before (clause 17: each file the old way or the new way, never both).
|
||||
//
|
||||
// **The build keeps what the client needs.** Resolving a moved module's build keeps, beside the files' text the
|
||||
// mesh holds, where its templates archive is and what it hashes to, and which template each file is filled from
|
||||
// (Handed). The text stays, so a machine whose node-engine cannot take a handed file is still sent the module the
|
||||
// old way, byte for byte.
|
||||
//
|
||||
// **Handed only where the machine can take it.** A node's declaration hands a moved module's files over only when
|
||||
// its node-engine writes a filled file on the client's word alone (Rendering.Hands) and the node runs a provisioner
|
||||
// client (Rendering.Provisioner): then each such file is declared with its path, mode, owner and who owns it at
|
||||
// runtime and without its text, the declaration names it among the filled files, and the module's plan says the
|
||||
// client where its templates are and the value of every placement placeholder they name. Anywhere else the module
|
||||
// composes as if it were not marked.
|
||||
|
||||
// MovedField is the manifest's key marking a module moved.
|
||||
const MovedField = "moved"
|
||||
|
||||
// Handed is what a moved module's build kept for the provisioner (novox/hq ADR 0349 §2): its templates archive, as
|
||||
// recorded (store-relative, routed at composition like any archive), and its digest; and, for each file resource
|
||||
// filled from a template, that template's path inside the archive. Written by Resolve, never by hand.
|
||||
type Handed struct {
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Templates map[string]string `json:"templates"`
|
||||
// Items is, for each item the module contributes, by ItemKey (its collection, and `/<name>` for a named one),
|
||||
// that item's template inside the archive (collections.go, design 57 L1).
|
||||
Items map[string]string `json:"items,omitempty"`
|
||||
}
|
||||
|
||||
// Plan is a moved module's plan on one node: what the node's provisioner client fills its files from. The shape
|
||||
// of the assignments mirror's `provisioning` (mesh-controller internal/inventory Provisioning).
|
||||
type Plan struct {
|
||||
Templates PlanTemplates `json:"templates"`
|
||||
Files []PlanFile `json:"files"`
|
||||
Placement map[string]string `json:"placement,omitempty"`
|
||||
// Secrets are the module's own secrets this declaration names (design 57 V4, issue 647): each its templates
|
||||
// name, and each it keeps in a file of its own, with that file's path. By name, never a value: the client asks
|
||||
// the vault for each, and the vault hands one only when the plan names it.
|
||||
Secrets []PlanSecret `json:"secrets,omitempty"`
|
||||
// Items are the module's items of collections (design 57 L1): each filled by the client like a file and recorded
|
||||
// in mesh-provisioner_collect for its node. Collections are the collections its files name, as their collector,
|
||||
// each with its comment. By template and name, never a contributor's text.
|
||||
Items []PlanItem `json:"items,omitempty"`
|
||||
Collections []PlanCollection `json:"collections,omitempty"`
|
||||
// Provisions are what it requires here, each with the provider resolution chose to serve it (issue 760), so the
|
||||
// vault sees a consumer bind to a provider and unbind from it (novox/hq ADR 0349 clause 8); and, for a
|
||||
// credential the vault keeps for it alone, the name its files call it and the file holding it (issue 757). By
|
||||
// name, never a value.
|
||||
Provisions []PlanProvision `json:"provisions,omitempty"`
|
||||
}
|
||||
|
||||
// PlanItem is one item the module contributes: to which collection, and its template inside the archive.
|
||||
//
|
||||
// A named item has its Name and Attributes (design 57 L3, issue 777): the client records them with the item and
|
||||
// places it under a placeholder selecting by them. An item whose `if-capability` the machine lacks is not in the plan.
|
||||
type PlanItem struct {
|
||||
Collection string `json:"collection"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
|
||||
// PlanCollection is one collection the module collects, and the comment introducing each contributor's item.
|
||||
type PlanCollection struct {
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// PlanProvision is one provision the module requires, under one local name, and the provider serving it: the module
|
||||
// (ProviderModule) on the machine (Provider) that resolution chose for this consumer. Where several providers offer
|
||||
// a mesh-scoped provision, it is the one this consumer is bound to (a pin, the seat's holder, or where its data is),
|
||||
// never the others (issue 760). Local is the name the consumer keeps the credential under where it keeps several
|
||||
// from one requirement (ADR 0094), empty for the ordinary one; a consumer has one provider per provision and local
|
||||
// name.
|
||||
//
|
||||
// Name and Path are set only for a credential the vault keeps for this holder alone, which its machine's client
|
||||
// fills (issue 757): the value the provider's software issued for this module on this machine. Name is what the
|
||||
// module's files call it (`${secret:<name>}`) and Path the file holding it alone.
|
||||
//
|
||||
// CredentialForm is the form the provider's offer declares for its credential (Manifest.CredentialFormOf, issue
|
||||
// 768): the vault makes a first value in that form for a provider it holds no row of. Empty where the provider's
|
||||
// manifest was not in hand, and the vault then makes nothing.
|
||||
type PlanProvision struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Provision string `json:"provision"`
|
||||
Local string `json:"local,omitempty"`
|
||||
Provider string `json:"provider"`
|
||||
ProviderModule string `json:"provider-module"`
|
||||
CredentialForm string `json:"credential-form,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
// Contributes is what the consumer's manifest tells its provider for this provision (Manifest.Contributes: a
|
||||
// database's name, its extensions), so the provider's helper learns it from the bus (issue 776). Nil where the
|
||||
// manifest contributes nothing for it, and the entry is then as it was.
|
||||
Contributes map[string]any `json:"contributes,omitempty"`
|
||||
// At, From and As are what today's binding file tells the consumer of this provision (issue 788): where the
|
||||
// provider is on the private network, the machine, and who the consumer is at its end (its slug first), so the
|
||||
// provider's helper writes them into the consumer's entry and the client fills `${bound:<provision>:at|from|as}`
|
||||
// from there. Empty where the resolution knew none.
|
||||
At string `json:"at,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
As string `json:"as,omitempty"`
|
||||
// Names are what the mesh calls the consumer through this provision (issue 792), keyed as its binding file names
|
||||
// them (`name`, `internal-name`, `name-<local>`, `internal-name-<local>`): composed by the controller, which
|
||||
// alone knows the node's domains, and carried unchanged by the provider's helper into the consumer's entry. Nil
|
||||
// where the consumer contributes nothing to the provision, and the entry is then as it was.
|
||||
Names map[string]string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
|
||||
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
|
||||
// out: the vault has no provider's value to make for it.
|
||||
func provisionsOf(needs []Needed, module, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []PlanProvision {
|
||||
var out []PlanProvision
|
||||
for _, n := range needs {
|
||||
if n.For != module || n.ByRecord || n.Module == "" || n.From == "" {
|
||||
continue
|
||||
}
|
||||
p := PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
|
||||
CredentialForm: n.CredentialForm, Contributes: maps.Clone(contributes[n.Name]),
|
||||
At: n.At, From: n.From, As: as}
|
||||
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
|
||||
// vault's row for this holder.
|
||||
if (named[n.Name] || named[SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
|
||||
p.Name = SecretLocal(n.Name, n.Local)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortProvisions orders a plan's provisions by provision, then local name.
|
||||
func sortProvisions(ps []PlanProvision) {
|
||||
sort.SliceStable(ps, func(i, j int) bool {
|
||||
if ps[i].Provision != ps[j].Provision {
|
||||
return ps[i].Provision < ps[j].Provision
|
||||
}
|
||||
return ps[i].Local < ps[j].Local
|
||||
})
|
||||
}
|
||||
|
||||
// PlanSecret is one of the module's own secrets, by its name in the manifest; Path is the file holding it alone,
|
||||
// when the manifest's own-secrets places one.
|
||||
type PlanSecret struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// PlanTemplates is where the templates archive is fetched from on this node, and what it hashes to.
|
||||
type PlanTemplates struct {
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// PlanFile is one file the client fills.
|
||||
type PlanFile struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Template string `json:"template"`
|
||||
OwnerAtRuntime string `json:"owner-at-runtime"`
|
||||
Takes string `json:"takes,omitempty"`
|
||||
}
|
||||
|
||||
// The words a placed file says who owns it at runtime with (novox/hq ADR 0349 §5).
|
||||
const (
|
||||
RuntimeOwned = "owned"
|
||||
RuntimeSeed = "seed"
|
||||
RuntimeManaged = "managed"
|
||||
)
|
||||
|
||||
// OwnerAtRuntimeField is the key a declared file says who owns it at runtime by (the node-engine's
|
||||
// `owner-at-runtime`).
|
||||
const OwnerAtRuntimeField = "owner-at-runtime"
|
||||
|
||||
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
|
||||
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
|
||||
func ownershipOf(r map[string]any) string {
|
||||
switch {
|
||||
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
|
||||
return RuntimeManaged
|
||||
case r["create-once"] == true:
|
||||
return RuntimeSeed
|
||||
}
|
||||
return RuntimeOwned
|
||||
}
|
||||
|
||||
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
|
||||
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
|
||||
func takesOf(m Manifest, id string) string {
|
||||
takes := ""
|
||||
for _, r := range m.Resources {
|
||||
if slices.Contains(stringsIn(r["restart-on"]), id) {
|
||||
return "restart-on"
|
||||
}
|
||||
if slices.Contains(stringsIn(r["reload-on"]), id) {
|
||||
takes = "reload-on"
|
||||
}
|
||||
}
|
||||
return takes
|
||||
}
|
||||
|
||||
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
|
||||
// what its build kept (as held), and Handed is the build's word, never a repository's.
|
||||
func movedProblems(m Manifest) []string {
|
||||
switch {
|
||||
case m.Handed != nil && !m.Moved:
|
||||
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
|
||||
m.Module)}
|
||||
case m.Handed != nil && m.Build != nil:
|
||||
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
|
||||
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
|
||||
case m.Moved && m.Handed == nil && !m.Templated():
|
||||
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
|
||||
"from template files of its own, `\"template\": \"%s/<name>\"` (novox/hq ADR 0349 §2)", m.Module,
|
||||
m.TemplatesDir())}
|
||||
}
|
||||
if !m.Moved {
|
||||
return nil
|
||||
}
|
||||
// **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the
|
||||
// mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be
|
||||
// absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their
|
||||
// own.
|
||||
var problems []string
|
||||
if fams := m.OwnSecrets.Families(); len(fams) > 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+
|
||||
"secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module,
|
||||
strings.Join(fams, ", ")))
|
||||
}
|
||||
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
|
||||
if m.OwnSecrets[name].IssuedBy == IssuedOutside {
|
||||
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
|
||||
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
|
||||
// moved.
|
||||
func handedBy(m Manifest, archive Built) *Handed {
|
||||
if !m.Moved {
|
||||
return nil
|
||||
}
|
||||
dir := m.TemplatesDir()
|
||||
h := &Handed{Source: Recorded(archive.Reference), Digest: archive.Digest, Templates: map[string]string{}}
|
||||
for _, r := range m.Resources {
|
||||
name, has, wrong := templateOf(m.Module, dir, r)
|
||||
if !has || wrong != "" {
|
||||
continue
|
||||
}
|
||||
h.Templates[fmt.Sprint(r["id"])] = strings.TrimPrefix(name, dir+"/")
|
||||
}
|
||||
for _, it := range m.Items {
|
||||
if it.Template == "" || itemTemplateProblem(m, it.Template) != "" {
|
||||
continue
|
||||
}
|
||||
if h.Items == nil {
|
||||
h.Items = map[string]string{}
|
||||
}
|
||||
h.Items[it.key()] = strings.TrimPrefix(it.Template, dir+"/")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// handing is whether this composition hands the module's templated files to the provisioner.
|
||||
func (with Rendering) handing(m Manifest) bool {
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
|
||||
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
|
||||
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
|
||||
// client asks the vault.
|
||||
func (with Rendering) Handing(m Manifest) bool { return with.handing(m) }
|
||||
|
||||
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
|
||||
// <module>`.
|
||||
const requiredByWords = "required by "
|
||||
|
||||
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
|
||||
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
|
||||
func (r Resolution) RequiredBy(module string) string {
|
||||
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
|
||||
if by == r.Because[module] {
|
||||
return ""
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// HandedProvisionField marks, between composing a handed module's credential file for something it requires and
|
||||
// placing it, one the vault keeps for this holder alone (issue 757): its value is the PlanProvision without its path.
|
||||
// Never on the wire.
|
||||
const HandedProvisionField = "handed-provision"
|
||||
|
||||
// HandedSecretField marks, between composing a module's own secret files and placing them, the one a handing
|
||||
// composition hands to the provisioner: its value is the secret's name. Never on the wire.
|
||||
const HandedSecretField = "handed-secret"
|
||||
|
||||
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
|
||||
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
|
||||
// vault.
|
||||
func handedSecretFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string,
|
||||
placed map[string]Placement) (map[string]any, PlanSecret, error) {
|
||||
name := fmt.Sprint(r[HandedSecretField])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
delete(out, HandedSecretField)
|
||||
delete(out, "sealed")
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if out["mode"] == nil {
|
||||
out["mode"] = "0600"
|
||||
}
|
||||
out[OwnerAtRuntimeField] = RuntimeOwned
|
||||
return out, PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
|
||||
}
|
||||
|
||||
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
|
||||
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
|
||||
|
||||
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
|
||||
// reading writes it as (toSettingsForm).
|
||||
var configNamespaces = []string{"config", SettingField}
|
||||
|
||||
// SettingField is the settings form's word for a config placeholder's namespace.
|
||||
const SettingField = "setting"
|
||||
|
||||
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
|
||||
func mergesFile(r map[string]any) bool {
|
||||
_, merges := r["merge"]
|
||||
_, takes := r[TakesSettingsField]
|
||||
return merges || takes
|
||||
}
|
||||
|
||||
// mergingIn is whether the module's resource id is a merging file (mergesFile).
|
||||
func mergingIn(m Manifest, id string) bool {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["id"]) == id {
|
||||
return mergesFile(r)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
|
||||
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
|
||||
func handedFile(r map[string]any, m Manifest, dirs, accessPaths, facts map[string]string, placed map[string]Placement,
|
||||
with Rendering) (map[string]any, PlanFile, error) {
|
||||
id := fmt.Sprint(r["id"])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
|
||||
// it goes and how it is written.
|
||||
delete(out, "content")
|
||||
delete(out, TrustedField)
|
||||
delete(out, NamesOnPurpose)
|
||||
delete(out, SecretsInEnvironment)
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
out[OwnerAtRuntimeField] = ownershipOf(out)
|
||||
path := fmt.Sprint(out["path"])
|
||||
return out, PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
|
||||
OwnerAtRuntime: fmt.Sprint(out[OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
|
||||
}
|
||||
|
||||
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
|
||||
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
|
||||
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
|
||||
// name: the client fills config and placement, and the rest move with their own packages (design 57).
|
||||
func placementOf(node string, m Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []PlanCollection, error) {
|
||||
out := map[string]string{}
|
||||
var secrets, required []string
|
||||
var problems []string
|
||||
var collections []PlanCollection
|
||||
all := map[string]string{}
|
||||
templateOf := map[string]string{}
|
||||
for id, text := range texts {
|
||||
all[id], templateOf[id] = text, m.Handed.Templates[id]
|
||||
}
|
||||
for key, text := range items {
|
||||
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
|
||||
}
|
||||
judgedConsumers := map[string]bool{}
|
||||
for _, id := range sortedKeys(all) {
|
||||
item := strings.HasPrefix(id, "item:")
|
||||
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
|
||||
kind, key := found[1], found[2]
|
||||
if collection, is := collectionOfPlaceholder(kind, key); is {
|
||||
// **A collection its file names, read by the client from the items there** (design 57 L1).
|
||||
comment, err := collectionComment(m, collection)
|
||||
_, selector := splitSelector(key)
|
||||
_, selErr := selectorOf(selector)
|
||||
switch {
|
||||
case selErr != nil && !item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], selErr))
|
||||
case item:
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
|
||||
"collector", m.Module, templateOf[id], found[0]))
|
||||
case err != nil:
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
|
||||
templateOf[id], found[0], err))
|
||||
case !slices.ContainsFunc(collections, func(c PlanCollection) bool { return c.Name == collection }):
|
||||
collections = append(collections, PlanCollection{Name: collection, Comment: comment})
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case slices.Contains(configNamespaces, kind):
|
||||
continue
|
||||
case kind == ConsumersNamespace:
|
||||
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
|
||||
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
|
||||
if item {
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
} else if !judgedConsumers[id] {
|
||||
judgedConsumers[id] = true
|
||||
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
|
||||
}
|
||||
continue
|
||||
case item && (kind == "secret" || kind == "bound"):
|
||||
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
|
||||
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
|
||||
continue
|
||||
case kind == "secret":
|
||||
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
|
||||
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
|
||||
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
|
||||
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
|
||||
// from the vault's row for this holder, which the plan names.
|
||||
if !slices.Contains(required, key) {
|
||||
required = append(required, key)
|
||||
}
|
||||
} else if !own {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
|
||||
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
|
||||
templateOf[id], found[0]))
|
||||
} else if !slices.Contains(secrets, key) {
|
||||
secrets = append(secrets, key)
|
||||
}
|
||||
continue
|
||||
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
|
||||
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
|
||||
continue
|
||||
case !slices.Contains(PlacementKinds, kind):
|
||||
if known(kind) {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
|
||||
"client fills only config, secrets, collections and placement until that domain moves "+
|
||||
"(design 57)", m.Module, templateOf[id], found[0]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := kind + ":" + key
|
||||
if _, done := out[name]; done {
|
||||
continue
|
||||
}
|
||||
if kind == BackupNamespace {
|
||||
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
|
||||
// placeholders render, from the same function, over the modules placed on this machine.
|
||||
value, problem := backups.placed(m, key, item)
|
||||
if problem != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
|
||||
templateOf[id], found[0], problem))
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
continue
|
||||
}
|
||||
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
|
||||
if err != nil {
|
||||
problems = append(problems, err.Error())
|
||||
continue
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
|
||||
}
|
||||
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
|
||||
return out, secrets, required, collections, nil
|
||||
}
|
||||
|
||||
// PlacementKinds are the placeholder namespaces placement answers (ADR 0349 clause 2), as a plan's keys begin.
|
||||
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", BackupNamespace}
|
||||
|
||||
// known is whether a namespace is one the mesh fills somewhere.
|
||||
func known(kind string) bool {
|
||||
for _, f := range fillers {
|
||||
if f.namespace == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
|
||||
func placed(node, placeholder string, m Manifest, dirs, accessPaths, facts map[string]string, with Rendering) (string, error) {
|
||||
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
|
||||
if err := dirInto(r, dirs, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := accessInto(r, accessPaths, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := portInto(r, m.Module, m.Listens, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := seatInto(r, m.Module, with); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := machineInto(r, facts, m.Module); err != nil {
|
||||
return "", err
|
||||
}
|
||||
value := fmt.Sprint(r["content"])
|
||||
if value == placeholder {
|
||||
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
|
||||
func routedTemplates(m Manifest, with Rendering) (PlanTemplates, error) {
|
||||
r := map[string]any{"id": "templates", "source": m.Handed.Source}
|
||||
if err := artifactsInto(r, m.Module, with); err != nil {
|
||||
return PlanTemplates{}, err
|
||||
}
|
||||
return PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
|
||||
}
|
||||
|
||||
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
|
||||
type handedFiles struct {
|
||||
paths []string
|
||||
plans map[string]Plan
|
||||
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
|
||||
// alone, or "" for one only its templates name.
|
||||
secrets map[string]map[string]string
|
||||
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
|
||||
provisions map[string][]PlanProvision
|
||||
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
|
||||
contributes map[string]map[string]map[string]any
|
||||
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
|
||||
// what it requires that its templates name.
|
||||
identities map[string]string
|
||||
required map[string]map[string]bool
|
||||
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
|
||||
names map[string]map[string]map[string]string
|
||||
}
|
||||
|
||||
// name records the names composed for a handed module through one requirement, as its binding file carries them.
|
||||
func (h *handedFiles) name(module, provision string, own map[string]any) {
|
||||
values := map[string]string{}
|
||||
withOwnNames(values, own)
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
if h.names == nil {
|
||||
h.names = map[string]map[string]map[string]string{}
|
||||
}
|
||||
if h.names[module] == nil {
|
||||
h.names[module] = map[string]map[string]string{}
|
||||
}
|
||||
h.names[module][provision] = values
|
||||
}
|
||||
|
||||
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
|
||||
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
|
||||
// as it is composed.
|
||||
//
|
||||
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
|
||||
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
|
||||
// its own, which says what pulled it in.
|
||||
func (h *handedFiles) begin(node string, m Manifest, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, caps map[string]bool, needs []Needed, backups backupPlacer) (map[string]bool, error) {
|
||||
if !with.handing(m) {
|
||||
return nil, nil
|
||||
}
|
||||
texts := map[string]string{}
|
||||
ids := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
if _, named := m.Handed.Templates[id]; !named {
|
||||
continue
|
||||
}
|
||||
if mergesFile(r) {
|
||||
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
|
||||
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
|
||||
continue
|
||||
}
|
||||
if fmt.Sprint(r["type"]) != "file" {
|
||||
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
|
||||
"from one", m.Module, id, r["type"])
|
||||
}
|
||||
text, _ := r["content"].(string)
|
||||
texts[id] = text
|
||||
ids[id] = true
|
||||
}
|
||||
for _, id := range sortedKeys(m.Handed.Templates) {
|
||||
if !ids[id] && !mergingIn(m, id) {
|
||||
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
|
||||
}
|
||||
}
|
||||
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
|
||||
items := map[string]string{}
|
||||
var planned []PlanItem
|
||||
for _, it := range m.Items {
|
||||
template, kept := m.Handed.Items[it.key()]
|
||||
if !kept {
|
||||
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, it.key())
|
||||
}
|
||||
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
|
||||
if it.IfCapability != "" && !caps[it.IfCapability] {
|
||||
continue
|
||||
}
|
||||
items[it.key()] = it.Content
|
||||
planned = append(planned, PlanItem{Collection: it.Collection, Name: it.Name,
|
||||
Attributes: it.Attributes, Template: template})
|
||||
}
|
||||
if len(ids) == 0 && len(planned) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
templates, err := routedTemplates(m, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
|
||||
moved := map[string]bool{}
|
||||
for _, n := range needs {
|
||||
if n.For == m.Module && n.ProviderMoved {
|
||||
moved[n.Name] = true
|
||||
// A credential kept per local name (issue 793) is named by its local in a template.
|
||||
moved[SecretLocal(n.Name, n.Local)] = true
|
||||
}
|
||||
}
|
||||
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
|
||||
with, moved, backups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.identities == nil {
|
||||
h.identities = map[string]string{}
|
||||
h.required = map[string]map[string]bool{}
|
||||
}
|
||||
h.identities[m.Module] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||
h.required[m.Module] = map[string]bool{}
|
||||
for _, name := range required {
|
||||
h.required[m.Module][name] = true
|
||||
}
|
||||
for _, name := range secrets {
|
||||
h.secret(m.Module, PlanSecret{Name: name})
|
||||
}
|
||||
plan := Plan{Templates: templates, Items: planned, Collections: collections}
|
||||
if len(placement) > 0 {
|
||||
plan.Placement = placement
|
||||
}
|
||||
h.plans[m.Module] = plan
|
||||
if len(m.Contributes) > 0 {
|
||||
if h.contributes == nil {
|
||||
h.contributes = map[string]map[string]map[string]any{}
|
||||
}
|
||||
h.contributes[m.Module] = m.Contributes
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
|
||||
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
|
||||
func (h *handedFiles) add(module string, f PlanFile) error {
|
||||
if slices.Contains(h.paths, f.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
|
||||
}
|
||||
plan := h.plans[module]
|
||||
plan.Files = append(plan.Files, f)
|
||||
h.plans[module] = plan
|
||||
h.paths = append(h.paths, f.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
|
||||
func (h *handedFiles) secret(module string, sec PlanSecret) {
|
||||
if h.secrets == nil {
|
||||
h.secrets = map[string]map[string]string{}
|
||||
}
|
||||
if h.secrets[module] == nil {
|
||||
h.secrets[module] = map[string]string{}
|
||||
}
|
||||
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
|
||||
h.secrets[module][sec.Name] = sec.Path
|
||||
}
|
||||
}
|
||||
|
||||
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
|
||||
func (h *handedFiles) addSecretFile(module string, sec PlanSecret) error {
|
||||
if slices.Contains(h.paths, sec.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
|
||||
}
|
||||
h.paths = append(h.paths, sec.Path)
|
||||
h.secret(module, sec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
|
||||
// alone (issue 757): a filled file, and the credential in the plan.
|
||||
func (h *handedFiles) addProvisionFile(module string, pr PlanProvision) error {
|
||||
if slices.Contains(h.paths, pr.Path) {
|
||||
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
|
||||
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
|
||||
}
|
||||
h.paths = append(h.paths, pr.Path)
|
||||
if h.provisions == nil {
|
||||
h.provisions = map[string][]PlanProvision{}
|
||||
}
|
||||
h.provisions[module] = append(h.provisions[module], pr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
|
||||
func (h *handedFiles) finished(needs []Needed) map[string]Plan {
|
||||
if len(h.plans) == 0 {
|
||||
return nil
|
||||
}
|
||||
for module, plan := range h.plans {
|
||||
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
|
||||
// is that provision's entry, with its name and file (issue 757).
|
||||
provisions := provisionsOf(needs, module, h.identities[module], h.contributes[module], h.required[module])
|
||||
for _, credential := range h.provisions[module] {
|
||||
i := slices.IndexFunc(provisions, func(p PlanProvision) bool {
|
||||
return p.Provision == credential.Provision && p.Local == credential.Local
|
||||
})
|
||||
if i < 0 {
|
||||
credential.As = h.identities[module]
|
||||
provisions = append(provisions, credential)
|
||||
continue
|
||||
}
|
||||
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
|
||||
}
|
||||
for i := range provisions {
|
||||
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
|
||||
}
|
||||
sortProvisions(provisions)
|
||||
plan.Provisions = provisions
|
||||
h.plans[module] = plan
|
||||
}
|
||||
for module, named := range h.secrets {
|
||||
plan, ok := h.plans[module]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
plan.Secrets = nil
|
||||
for _, name := range sortedKeys(named) {
|
||||
plan.Secrets = append(plan.Secrets, PlanSecret{Name: name, Path: named[name]})
|
||||
}
|
||||
h.plans[module] = plan
|
||||
}
|
||||
return h.plans
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gitea's server.env, as the catalogue keeps it: its database as the provider told it and the consumer's own login.
|
||||
const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n" +
|
||||
"GITEA__database__DB_TYPE=postgres\n" +
|
||||
"GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" +
|
||||
"GITEA__database__NAME=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__USER=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
|
||||
// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider
|
||||
// that has moved or not (issue 788); slug is the module's identity source when it declares one.
|
||||
func databaseConsumer(t *testing.T, slug string, moved bool) Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1)
|
||||
if slug != "" {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","slug":"`+slug+`",`, 1)
|
||||
}
|
||||
return withDatabase(t, raw, giteaServerEnv, moved)
|
||||
}
|
||||
|
||||
// withDatabase resolves the module in raw, whose template is text, against a provider of postgres-database that has
|
||||
// moved or not.
|
||||
func withDatabase(t *testing.T, raw, text string, moved bool) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot(text)),
|
||||
Manifest{Module: "postgres", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
// Answered on this machine, it states no need; one from the provider's machine is stated, as the plan does.
|
||||
r.Needs = []Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: moved, ProviderMoved: moved}}
|
||||
return r
|
||||
}
|
||||
|
||||
// The forge's server.env composes for a handed module when what it requires has moved, its plan entry carrying the
|
||||
// provider's address, machine and the consumer's identity as today's binding file gives them; and is refused with the
|
||||
// text it has always had when the provider has not.
|
||||
func TestAHandedModuleMayNameWhatItRequiresWhereItsProviderHasMoved(t *testing.T) {
|
||||
c, got := composeWith(t, databaseConsumer(t, "", true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value: %s", got)
|
||||
}
|
||||
want := []PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
|
||||
ProviderModule: "postgres", CredentialForm: CredentialFormPair, At: "10.7.0.1", From: "novox",
|
||||
As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
for _, unmoved := range []Resolution{databaseConsumer(t, "", false)} {
|
||||
_, err := unmoved.Compose(handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "which is no secret of its own; a credential it requires moves "+
|
||||
"with its provider (design 57 B1–B3)") {
|
||||
t.Fatalf("a provider not moved, the credential: %v", err)
|
||||
}
|
||||
bound := strings.Replace(giteaServerEnv, "${secret:postgres-database}", "x", 1)
|
||||
r := withDatabase(t, strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1),
|
||||
bound, false)
|
||||
_, err = r.Compose(handingTo("store:5000"))
|
||||
if err == nil || !strings.Contains(err.Error(), "the provisioner's client fills only config, secrets, "+
|
||||
"collections and placement until that domain moves (design 57)") {
|
||||
t.Fatalf("a provider not moved, the binding: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module with a slug is told who it is as its binding file says, the slug first (issue 788).
|
||||
func TestAHandedModulesPlanEntryCarriesItsIdentityAsTheBindingFileGivesIt(t *testing.T) {
|
||||
c, _ := composeWith(t, databaseConsumer(t, "agent", true), handingTo("store:5000"))
|
||||
if got := c.Plans["bot"].Provisions[0].As; got != ConsumerIdentity("workstation", "agent") {
|
||||
t.Fatalf("the plan entry says %q", got)
|
||||
}
|
||||
// Nothing known of where the provider is: the entry says none.
|
||||
c, _ = composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if p := c.Plans["bot"].Provisions[0]; p.At != "" || p.From != "novox" {
|
||||
t.Fatalf("the entry says at %q from %q", p.At, p.From)
|
||||
}
|
||||
}
|
||||
|
||||
// A handed module's credentials the vault keeps as one login per local name (issue 793): gitea's internal token and
|
||||
// admin password, which the `secret` provision hands as `${secret:<local>}`. Each is a plan entry naming its local,
|
||||
// its file declared placed and without a value; the template naming the local is allowed.
|
||||
func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
|
||||
`"requires":["secret"],"secrets":{"secret":{"internal-token":"${dir:state}/internal-token.secret",`+
|
||||
`"admin":"${dir:state}/admin.secret"}},"own-secrets":{"token":"${dir:state}/token"}`, 1)
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot("T=${secret:internal-token}\n")),
|
||||
Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
r.Needs = []Needed{
|
||||
{Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
{Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
}
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") {
|
||||
t.Fatalf("the declaration carries a sealed value: %s", got)
|
||||
}
|
||||
var names []string
|
||||
for _, p := range c.Plans["bot"].Provisions {
|
||||
if p.Provision != "secret" || p.Provider != "novox" || p.Path == "" || p.Name != p.Local {
|
||||
t.Errorf("the plan entry is %+v", p)
|
||||
}
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"admin", "internal-token"}) {
|
||||
t.Fatalf("the plan names %v, want admin and internal-token", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials reach it in a file its machine's provisioner client fills (novox/hq ADR 0373,
|
||||
// issue 679): `${consumers:<provision>}`, which only a provider's template may hold, for a provision it provides, in a
|
||||
// file of mode 0600 owned at runtime; filled by the client alone, so a module not handed to it on a machine is
|
||||
// refused there rather than written the placeholder's text, or nothing.
|
||||
|
||||
const (
|
||||
handedProvider = `{"module":"db","version":"1","moved":true,
|
||||
"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}`
|
||||
consumersText = "${consumers:postgres-database}"
|
||||
)
|
||||
|
||||
func builtProvider(text string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64),
|
||||
Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}}
|
||||
}
|
||||
|
||||
func TestAHandedProvidersConsumersFileIsHandedToTheClient(t *testing.T) {
|
||||
r := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "${consumers:") {
|
||||
t.Fatalf("the declaration carries the placeholder: %s", got)
|
||||
}
|
||||
plan := c.Plans["db"]
|
||||
if len(plan.Files) != 1 || plan.Files[0].Template != "consumers.json" || plan.Files[0].OwnerAtRuntime != RuntimeOwned {
|
||||
t.Fatalf("the plan fills %+v, want the consumers' file owned at runtime", plan.Files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
|
||||
for why, c := range map[string]struct{ raw, text, want string }{
|
||||
"a provision the module does not provide": {handedProvider, "${consumers:cache}", "does not provide"},
|
||||
"a module providing nothing": {strings.Replace(handedProvider, `"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],`,
|
||||
"", 1), consumersText, "does not provide"},
|
||||
"a file others may read": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0640","template"`, 1), consumersText, "0600"},
|
||||
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
|
||||
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
|
||||
} {
|
||||
m, err := ParseManifest([]byte(c.raw))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", why, err)
|
||||
}
|
||||
resolved, err := m.Resolve(builtProvider(c.text))
|
||||
if err == nil {
|
||||
r := notifierOn(t, resolved)
|
||||
_, err = r.Compose(handingTo("store:5000"))
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want a refusal saying %q", why, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where the provider is not handed, nothing fills the placeholder, and it is never written as its text or as an empty
|
||||
// list the provider would read as no consumers: the file is left out, said once, and the module composes exactly as
|
||||
// it would without the file, so a provider below the handing contract keeps working from its grant files (issue 780).
|
||||
func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) {
|
||||
withFile := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
without := strings.Replace(handedProvider, `,
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}`, "", 1)
|
||||
if without == handedProvider {
|
||||
t.Fatal("the manifest without the file was not made")
|
||||
}
|
||||
bare := notifierOn(t, held(t, strings.Replace(strings.Replace(without, `"moved":true,`, "", 1),
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1), nil))
|
||||
for name, with := range map[string]Rendering{
|
||||
"a node-engine that fills no file": {},
|
||||
"a node-engine that fills, no client": {Hands: true, ArtifactStore: "store:5000"},
|
||||
"a client, a node-engine that does not": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, withFile, with)
|
||||
_, want := composeWith(t, bare, with)
|
||||
if got != want {
|
||||
t.Errorf("%s: composes\n%s\nwithout its file, it composed\n%s", name, got, want)
|
||||
}
|
||||
if strings.Contains(got, "consumers") {
|
||||
t.Errorf("%s: the declaration names the consumers' file: %s", name, got)
|
||||
}
|
||||
if len(c.HandedOnly) != 1 || !strings.Contains(c.HandedOnly[0], "left out, handed only") {
|
||||
t.Errorf("%s: said %q, want the one line saying it is left out", name, c.HandedOnly)
|
||||
}
|
||||
if len(c.Unplaced) != 0 || len(c.Filled) != 0 || len(c.Plans) != 0 {
|
||||
t.Errorf("%s: unplaced %v, filled %v, plans %v", name, c.Unplaced, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
// Where it is handed, the file is there and nothing is said.
|
||||
c, _ := composeWith(t, withFile, handingTo("store:5000"))
|
||||
if len(c.HandedOnly) != 0 || len(c.Filled) != 1 {
|
||||
t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
|
||||
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
|
||||
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
|
||||
read := func(text string) (Manifest, error) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(handedProvider))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return WithTemplatesFrom(m, dir)
|
||||
}
|
||||
filled, err := read(consumersText)
|
||||
if err != nil {
|
||||
t.Fatalf("a provider read from its tree is refused: %v", err)
|
||||
}
|
||||
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
|
||||
}
|
||||
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// issue 831: a moved module's merging file is not handed (ADR 0349 clause 17, the lead's limit): the controller
|
||||
// fills and merges it as for a module not moved, and the module's other files stay handed.
|
||||
func TestAMovedModulesMergingFileIsFilledTheOldWayAndItsPlainFileIsHanded(t *testing.T) {
|
||||
raw := strings.Replace(handedNotifier, `{"id":"legacy"`,
|
||||
`{"id":"settings","type":"file","path":"${machine:account-home}/.config/notify/settings.json","owner":"${machine:account}","mode":"0644","template":"files/settings.json","merge":"json","takes-config":["width"]},
|
||||
{"id":"legacy"`, 1)
|
||||
moved := strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
built := []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64),
|
||||
Texts: map[string]string{"rc": notifierText, "settings.json": "{\"theme\": \"dark\"}\n"}}}
|
||||
cb, _ := composeWith(t, notifierOn(t, held(t, raw, built)), handingTo("store:5000"))
|
||||
c, got := composeWith(t, notifierOn(t, held(t, moved, built)), handingTo("store:5000"))
|
||||
var merged, plain map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.settings":
|
||||
merged = r
|
||||
case "notify.configuration":
|
||||
plain = r
|
||||
}
|
||||
}
|
||||
if merged == nil || plain == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
if text, _ := merged["content"].(string); !strings.Contains(text, `"theme"`) || merged["owner-at-runtime"] != nil {
|
||||
t.Fatalf("the merging file is not filled the old way: %v", merged)
|
||||
}
|
||||
if plain["content"] != nil || plain["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the plain file is not handed: %v", plain)
|
||||
}
|
||||
for _, p := range c.Filled {
|
||||
if strings.HasSuffix(p, "settings.json") {
|
||||
t.Fatalf("the merging file is named among the filled files: %v", c.Filled)
|
||||
}
|
||||
}
|
||||
if len(c.Plans["notify"].Files) != 1 || c.Plans["notify"].Files[0].ID != "configuration" {
|
||||
t.Fatalf("the plan is %+v", c.Plans)
|
||||
}
|
||||
if len(c.Unplaced) > 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
// byte-identical to the not-moved composition
|
||||
for _, r := range cb.Resources {
|
||||
if r["id"] == "notify.settings" && !reflect.DeepEqual(r, merged) {
|
||||
t.Fatalf("the merging file differs from the not-moved composition:\nbefore %v\nnow %v", r, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A handed module's plan names the provider serving each provision it requires (design 57 V5a, issue 760): the
|
||||
// module on the machine resolution chose for this consumer, and the local name it keeps the credential under where
|
||||
// it keeps several, so the vault, which reads the plan in the assignments mirror, sees a consumer bind and unbind
|
||||
// (novox/hq ADR 0349 clause 8). One entry per provision and local name, the same entry that names a credential
|
||||
// the vault keeps for it alone (issue 757).
|
||||
|
||||
// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential
|
||||
// kept under two local names (ADR 0094) when locals is set.
|
||||
func notifierConsumer(t *testing.T, moved, locals bool) Manifest {
|
||||
t.Helper()
|
||||
raw := handedNotifier
|
||||
if moved {
|
||||
raw = notifierMoved()
|
||||
}
|
||||
add := `"requires":["postgres-database"],`
|
||||
if locals {
|
||||
add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},`
|
||||
}
|
||||
raw = strings.Replace(raw, `"build":`, add+`"build":`, 1)
|
||||
return held(t, raw, builtNotifier())
|
||||
}
|
||||
|
||||
// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one.
|
||||
func twoStores(pin string) World {
|
||||
return World{Offered: map[string][]Provider{"postgres-database": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
||||
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
||||
}}, Pinned: map[string]Chosen{"postgres-database": {Node: pin}}}
|
||||
}
|
||||
|
||||
func consumerOn(t *testing.T, m Manifest, w World) Resolution {
|
||||
t.Helper()
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m,
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
|
||||
r, err := Resolve(stores, []string{m.Module}, node, w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAHandedModulesPlanNamesTheProviderServingEachProvision(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// **A changed binding changes the plan**: pinned to the other store, the plan names it and not the first.
|
||||
c, _ = composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("home")), handingTo("store:5000"))
|
||||
want = []PlanProvision{{Provision: "postgres-database", Provider: "home", ProviderModule: "store-two",
|
||||
CredentialForm: CredentialFormPair, At: "home.internal", From: "home", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("rebound, the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer keeping one provider's credential under several local names is bound once per local name.
|
||||
func TestAHandedModulesPlanNamesEachLocalName(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, true), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{
|
||||
{Provision: "postgres-database", Local: "cold", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
{Provision: "postgres-database", Local: "hot", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not moved, or moved where it is not handed, composes as before and has no plan.
|
||||
func TestAConsumerNotHandedComposesAsBefore(t *testing.T) {
|
||||
_, before := composeWith(t, consumerOn(t, notifierConsumer(t, false, false), twoStores("anchor")),
|
||||
handingTo("store:5000"))
|
||||
c, got := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")),
|
||||
Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"})
|
||||
if got != before {
|
||||
t.Fatalf("the moved consumer composes differently where it is not handed:\nbefore: %s\nnow: %s", before, got)
|
||||
}
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("a consumer not handed has plans %+v", c.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the vault keeps for the holder alone is its provision's one entry, with its provider's module too.
|
||||
func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) {
|
||||
r := forgeReader(t, true, true)
|
||||
r.Needs = append(r.Needs, Needed{Name: "s3-bucket", From: "ace", For: "bot", Module: "minio"},
|
||||
Needed{Name: "openai", From: "personal", For: "bot", ByRecord: true})
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["bot"].Provisions
|
||||
if len(got) != 2 || got[0].Provision != "forge-read" || got[0].Name != "forge-read" ||
|
||||
got[0].ProviderModule != "forge" || got[0].Path == "" ||
|
||||
!reflect.DeepEqual(got[1], PlanProvision{Provision: "s3-bucket", Provider: "ace", ProviderModule: "minio",
|
||||
From: "ace", As: ConsumerIdentity("workstation", "bot")}) {
|
||||
t.Fatalf("the plan names %+v: one entry per provision, the issued credential's with its name and file, "+
|
||||
"none for a licence", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
|
||||
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
|
||||
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
|
||||
// person (the offer's own secret issued outside).
|
||||
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
|
||||
storeOffering := func(credential string) Manifest {
|
||||
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
||||
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
||||
if strings.Contains(credential, `"own"`) {
|
||||
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` +
|
||||
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
|
||||
}
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued,
|
||||
} {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 || got[0].CredentialForm != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.Contributes = map[string]map[string]any{"postgres-database": {"name": "nextcloud"}}
|
||||
c, _ := composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"),
|
||||
// What the binding file says as `${bound:postgres-database:name}` (issue 792).
|
||||
Names: map[string]string{"name": "nextcloud"}}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
m = notifierConsumer(t, true, false)
|
||||
c, _ = composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
raw, err := json.Marshal(c.Plans["notify"].Provisions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const before = `[{"provision":"postgres-database","provider":"anchor","provider-module":"store",` +
|
||||
`"credential-form":"pair","at":"anchor.internal","from":"anchor","as":"mesh_workstation_notify"}]`
|
||||
if string(raw) != before {
|
||||
t.Fatalf("a consumer contributing nothing has the entry %s, want %s", raw, before)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries the names the mesh composed for it through that provision (issue 792): what its
|
||||
// binding file names as `${bound:<provision>:name|internal-name|name-<local>|internal-name-<local>}`, so a moved
|
||||
// provider's helper copies them into the consumer's entry and the client fills the same text. A consumer
|
||||
// contributing no label has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesTheNamesComposedForIt(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.ContributesMany = map[string]map[string]map[string]any{
|
||||
"postgres-database": {"web": {"label": "git"}, "api": {"label": "git-api"}}}
|
||||
r := consumerOn(t, m, twoStores("anchor"))
|
||||
r.PublicDomain = "example.org"
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["notify"].Provisions
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("the plan names %+v", got)
|
||||
}
|
||||
want := map[string]string{"name-web": "git.example.org", "name-api": "git-api.example.org"}
|
||||
own, err := r.ownNames(m, "postgres-database", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := map[string]string{}
|
||||
withOwnNames(file, own)
|
||||
for k, v := range file {
|
||||
if strings.HasPrefix(k, "internal-") {
|
||||
want[k] = v
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got[0].Names, want) {
|
||||
t.Fatalf("the entry carries %v, the binding file names %v", got[0].Names, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag a provides entry carries, `"answered-by": "helper"`, is read and written back, and refused with any other
|
||||
// word (issue 792). It is no part of moving the module: the flag alone hands none of the module's files, and the
|
||||
// module composes byte for byte as it did without it.
|
||||
func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
|
||||
with := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh","answered-by":"helper"}],`, 1)
|
||||
without := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh"}],`, 1)
|
||||
flagged := held(t, with, builtNotifier())
|
||||
if flagged.Moved || len(flagged.Provides) != 1 || flagged.Provides[0].AnsweredBy != AnsweredByHelper {
|
||||
t.Fatalf("the flag was read as %+v (moved %v)", flagged.Provides, flagged.Moved)
|
||||
}
|
||||
if flagged.Handed != nil {
|
||||
t.Fatalf("the flag alone kept what a moved module's build keeps: %+v", flagged.Handed)
|
||||
}
|
||||
raw, err := json.Marshal(flagged.Provides[0])
|
||||
if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) {
|
||||
t.Fatalf("written back as %s: %v", raw, err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
|
||||
t.Fatal("an unknown answerer was accepted")
|
||||
}
|
||||
c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000"))
|
||||
c2, got := composeWith(t, notifierOn(t, flagged), handingTo("store:5000"))
|
||||
if got != before || len(c2.Filled) > 0 || c2.Plans != nil || c1.Plans != nil {
|
||||
t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module only pulled in by another is handed exactly like an assigned one (issue 765): its templated files, its
|
||||
// own secrets and its items go to the machine's provisioner client wherever the machine can take them. A module not
|
||||
// moved composes as before, pulled in or not.
|
||||
|
||||
// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in.
|
||||
const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}`
|
||||
|
||||
// providing is raw's module providing what desk requires.
|
||||
func providing(raw string) string {
|
||||
return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1)
|
||||
}
|
||||
|
||||
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
|
||||
func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution {
|
||||
t.Helper()
|
||||
desk, err := ParseManifest([]byte(pulledInBy))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"desk"}
|
||||
if assigned {
|
||||
want = append(want, m.Module)
|
||||
}
|
||||
r, err := Resolve(shelf(m, desk), want, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why := map[bool]string{true: "assigned", false: "required by desk"}[assigned]
|
||||
if r.Because[m.Module] != why {
|
||||
t.Fatalf("%s is on the workstation because %q, want %q", m.Module, r.Because[m.Module], why)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
// resourcesOf is the module's resources in a composition, as JSON.
|
||||
func resourcesOf(t *testing.T, c Composed, module string) string {
|
||||
t.Helper()
|
||||
var out []map[string]any
|
||||
for _, r := range c.Resources {
|
||||
if strings.HasPrefix(r["id"].(string), module+".") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAModulePulledInIsHandedLikeAnAssignedOne(t *testing.T) {
|
||||
for name, m := range map[string]struct {
|
||||
module string
|
||||
held Manifest
|
||||
with Rendering
|
||||
}{
|
||||
"its templated file": {"notify", held(t, providing(notifierMoved()), builtNotifier()), handingTo("store:5000")},
|
||||
"its own secret": {"bot", held(t, providing(botMoved()), builtBot(botText)),
|
||||
sealedForBot(handingTo("store:5000"))},
|
||||
"its item": {"prompt", held(t, providing(contributorP10k),
|
||||
builtWith("prompt", map[string]string{"zsh": contributorText})), handingTo("store:5000")},
|
||||
} {
|
||||
assigned, _ := composeWith(t, onWorkstation(t, m.held, true), m.with)
|
||||
pulled, got := composeWith(t, onWorkstation(t, m.held, false), m.with)
|
||||
plan, handed := pulled.Plans[m.module]
|
||||
if !handed {
|
||||
t.Errorf("%s: a pulled-in moved module is not handed: plans %+v", name, pulled.Plans)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(plan, assigned.Plans[m.module]) || !reflect.DeepEqual(pulled.Filled, assigned.Filled) {
|
||||
t.Errorf("%s: pulled in, its plan is %+v and the filled files %v; assigned, %+v and %v", name, plan,
|
||||
pulled.Filled, assigned.Plans[m.module], assigned.Filled)
|
||||
}
|
||||
if a, p := resourcesOf(t, assigned, m.module), resourcesOf(t, pulled, m.module); a != p {
|
||||
t.Errorf("%s: pulled in, its resources are\n%s\nassigned,\n%s", name, p, a)
|
||||
}
|
||||
if strings.Contains(got, "sealed-to-the-node") || strings.Contains(got, "width = ") {
|
||||
t.Errorf("%s: the controller filled or sealed something of a handed module: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModulePulledInAndNotMovedComposesAsBefore(t *testing.T) {
|
||||
for name, m := range map[string]Manifest{
|
||||
"a file": held(t, providing(handedNotifier), builtNotifier()),
|
||||
"a secret": held(t, providing(handedBot), builtBot(botText)),
|
||||
} {
|
||||
_, before := composeWith(t, onWorkstation(t, m, false), sealedForBot(Rendering{ArtifactStore: "store:5000"}))
|
||||
c, got := composeWith(t, onWorkstation(t, m, false), sealedForBot(handingTo("store:5000")))
|
||||
if got != before || c.Plans != nil || len(c.Filled) > 0 {
|
||||
t.Errorf("%s: a pulled-in module not moved composes differently where the machine hands:\nbefore: %s\nnow: %s"+
|
||||
"\nplans %v, filled %v", name, before, got, c.Plans, c.Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What requires a module pulled in is said by name, for its value in the assignments mirror; nothing for one
|
||||
// assigned.
|
||||
func TestAPulledInModuleSaysWhatRequiresIt(t *testing.T) {
|
||||
m := held(t, providing(notifierMoved()), builtNotifier())
|
||||
if by := onWorkstation(t, m, false).RequiredBy("notify"); by != "desk" {
|
||||
t.Errorf("pulled in, notify is required by %q, want desk", by)
|
||||
}
|
||||
assigned := onWorkstation(t, m, true)
|
||||
if by := assigned.RequiredBy("notify"); by != "" {
|
||||
t.Errorf("assigned, notify is required by %q", by)
|
||||
}
|
||||
if by := assigned.RequiredBy("absent"); by != "" {
|
||||
t.Errorf("a module not on the machine is required by %q", by)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A moved module's own secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647):
|
||||
// where the module is handed, its declaration carries no sealed value — each file holding a secret alone is placed
|
||||
// without one and filled by the client, and the plan names every secret its templates and those files need, by
|
||||
// name. Where it is not handed, and for every module not moved, the composition is byte for byte what it was.
|
||||
|
||||
const (
|
||||
handedBot = `{"module":"bot","version":"1",
|
||||
"own-secrets":{"token":"${dir:state}/token"},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
|
||||
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
|
||||
botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
|
||||
)
|
||||
|
||||
func botMoved() string {
|
||||
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtBot(text string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
|
||||
}
|
||||
|
||||
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
|
||||
func sealedForBot(with Rendering) Rendering {
|
||||
with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}}
|
||||
return with
|
||||
}
|
||||
|
||||
func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
|
||||
r := notifierOn(t, held(t, botMoved(), builtBot(botText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got)
|
||||
}
|
||||
var token map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.needs-token" {
|
||||
token = res
|
||||
}
|
||||
}
|
||||
if token == nil {
|
||||
t.Fatalf("the token's file is not declared: %s", got)
|
||||
}
|
||||
if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" ||
|
||||
token["owner-at-runtime"] != "owned" || token[HandedSecretField] != nil {
|
||||
t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token)
|
||||
}
|
||||
path := token["path"].(string)
|
||||
if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) {
|
||||
t.Fatalf("the filled files are %v", c.Filled)
|
||||
}
|
||||
if want := []PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
|
||||
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
|
||||
}
|
||||
// A process naming the token's file still restarts when its client writes it.
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
|
||||
t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A secret only a template names is in the plan by name alone.
|
||||
func TestASecretOnlyATemplateNamesIsPlannedByName(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"},`,
|
||||
`"own-secrets":{"token":"${dir:state}/token","api-key":"${dir:state}/api-key"},`, 1)
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText+"key = ${secret:api-key}\n")))
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
names := []string{}
|
||||
for _, s := range c.Plans["bot"].Secrets {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, " ") != "api-key token" {
|
||||
t.Fatalf("the plan names %v", c.Plans["bot"].Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it is not handed: its secrets are
|
||||
// sealed into the declaration as before.
|
||||
func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedBot, builtBot(botText))),
|
||||
sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-to-the-node") {
|
||||
t.Fatalf("the unmoved module is not sealed the old way: %s", before)
|
||||
}
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
|
||||
// A template naming a credential the module requires: a binding's, which moves with its provider.
|
||||
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
|
||||
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
|
||||
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
|
||||
t.Errorf("a template naming a required credential: %v", err)
|
||||
}
|
||||
// A file with its text inline naming a secret, in a handed module.
|
||||
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
|
||||
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
|
||||
r = notifierOn(t, held(t, inline, builtBot(botText)))
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
|
||||
t.Errorf("an inline file naming a secret in a handed module: %v", err)
|
||||
}
|
||||
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
|
||||
for why, raw := range map[string]string{
|
||||
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
|
||||
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
|
||||
t.Errorf("a moved module with %s: %v", why, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
|
||||
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
|
||||
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
|
||||
// credential the mesh makes, the composition is what it was.
|
||||
func forgeReader(t *testing.T, moved bool, issued bool) Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(handedBot, `"own-secrets":`,
|
||||
`"requires":[{"name":"forge-read","optional":true}],"secrets":{"forge-read":"${dir:state}/forge.secret"},"own-secrets":`, 1)
|
||||
if moved {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText)))
|
||||
r.Needs = append(r.Needs, Needed{Name: "forge-read", From: "novox", For: "bot", Module: "forge",
|
||||
Sealed: "sealed-forge-read", IssuedToHolder: issued})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
c, got := composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or the marker: %s", got)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
file = res
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
path, _ := file["path"].(string)
|
||||
if path == "" || strings.Contains(path, "${") || file["mode"] != "0600" || file["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the credential's file is declared %v: placed, 0600, owned at runtime, no value", file)
|
||||
}
|
||||
if !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the credential's file is not among the filled files %v", c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, forgeReader(t, false, false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("the unmoved module's credential is not sealed the old way: %s", before)
|
||||
}
|
||||
for why, r := range map[string]Resolution{
|
||||
"a module not moved": forgeReader(t, false, true),
|
||||
"a moved module whose credential is made": forgeReader(t, true, false),
|
||||
} {
|
||||
c, got := composeWith(t, r, sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(got, "sealed-forge-read") {
|
||||
t.Errorf("%s: the credential is not sealed the old way: %s", why, got)
|
||||
}
|
||||
// Its provider is named (issue 760), and no credential for the client to fill.
|
||||
if p, ok := c.Plans["bot"]; ok && slices.ContainsFunc(p.Provisions, func(pr PlanProvision) bool {
|
||||
return pr.Name != "" || pr.Path != ""
|
||||
}) {
|
||||
t.Errorf("%s: the plan names a credential to fill: %+v", why, p.Provisions)
|
||||
}
|
||||
}
|
||||
c, got := composeWith(t, forgeReader(t, true, true), sealedForBot(Rendering{Provisioner: "mesh-provisioner-client",
|
||||
ArtifactStore: "store:5000"}))
|
||||
if !strings.Contains(got, "sealed-forge-read") || c.Plans != nil {
|
||||
t.Errorf("where the engine does not take a filled file alone, the credential is not sealed the old way: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A handed consumer's own login from a provider that gives each consumer one (issue 779): the same entry as for an
|
||||
// issued credential, with the provider's form on it, and nothing sealed; where the consumer is not handed, the
|
||||
// login is sealed as it was.
|
||||
func TestAHandedConsumersOwnLoginFromAPerLoginProviderIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
pair := func(handed bool) Resolution {
|
||||
r := forgeReader(t, true, handed)
|
||||
r.Needs[len(r.Needs)-1].CredentialForm = CredentialFormPair
|
||||
return r
|
||||
}
|
||||
c, got := composeWith(t, pair(true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed consumer's declaration carries its login sealed or the marker: %s", got)
|
||||
}
|
||||
var path string
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
path, _ = res["path"].(string)
|
||||
if res["mode"] != "0600" || res["owner-at-runtime"] != "owned" || res["content"] != nil || res["sealed"] != nil {
|
||||
t.Fatalf("the login's file is declared %v: placed, 0600, owned at runtime, no value", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" || !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the login's file %q is not declared and filled: %v", path, c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
CredentialForm: CredentialFormPair, Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Not marked as the vault's, the login is sealed as before.
|
||||
_, before := composeWith(t, pair(false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("a consumer whose login the vault does not keep for it has it sealed no more: %s", before)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module marked moved hands its templated files to the provisioner (novox/hq ADR 0349 §4, §6, §17, design 57 P6,
|
||||
// issue 672): on a machine whose node-engine takes a filled file on the client's word and that runs a client, each
|
||||
// is declared placed and without its text, named among the filled files, and planned for the client; anywhere
|
||||
// else, and for every module not marked, the composition is byte for byte what it was.
|
||||
|
||||
const (
|
||||
handedNotifier = `{"module":"notify","version":"1",
|
||||
"config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"},
|
||||
{"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"},
|
||||
{"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"},
|
||||
{"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}`
|
||||
notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n"
|
||||
)
|
||||
|
||||
func notifierMoved() string {
|
||||
return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
|
||||
func builtNotifier() []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}}
|
||||
}
|
||||
|
||||
func notifierOn(t *testing.T, m Manifest) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func composeWith(t *testing.T, r Resolution, with Rendering) (Composed, string) {
|
||||
t.Helper()
|
||||
c, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(c.Resources)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c, string(raw)
|
||||
}
|
||||
|
||||
func handingTo(store string) Rendering {
|
||||
return Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
|
||||
}
|
||||
|
||||
func TestAMovedModulesBuildKeepsWhatTheProvisionerNeeds(t *testing.T) {
|
||||
m := held(t, notifierMoved(), builtNotifier())
|
||||
want := &Handed{Source: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Templates: map[string]string{"configuration": "rc"}}
|
||||
if !reflect.DeepEqual(m.Handed, want) {
|
||||
t.Fatalf("the build kept %+v, want %+v", m.Handed, want)
|
||||
}
|
||||
if notMoved := held(t, handedNotifier, builtNotifier()); notMoved.Handed != nil {
|
||||
t.Fatalf("a module not marked moved kept %+v", notMoved.Handed)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it cannot be handed.
|
||||
func TestAMovedModuleComposesAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
for name, with := range map[string]Rendering{
|
||||
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), with)
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(before, `width = 250`) {
|
||||
t.Fatalf("the unmarked module is not filled the old way: %s", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), handingTo("store:5000"))
|
||||
var file, legacy, dir map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.configuration":
|
||||
file = r
|
||||
case "notify.legacy":
|
||||
legacy = r
|
||||
case "notify.configuration-dir":
|
||||
dir = r
|
||||
}
|
||||
}
|
||||
if file == nil || legacy == nil || dir == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
wantFile := map[string]any{"id": "notify.configuration", "type": "file", "path": "/home/op/.config/notify/rc",
|
||||
"owner": "op", "mode": "0644", "owner-at-runtime": "owned"}
|
||||
if !reflect.DeepEqual(file, wantFile) {
|
||||
t.Fatalf("the handed file is declared %v, want %v (placed, no text, owned at runtime)", file, wantFile)
|
||||
}
|
||||
if legacy["content"] != "plain = yes\n" || legacy["owner-at-runtime"] != nil {
|
||||
t.Fatalf("a file with its text inline is not composed the old way: %v", legacy)
|
||||
}
|
||||
if dir["path"] != "/home/op/.config/notify" {
|
||||
t.Fatalf("the module's other resources are not placed as before: %v", dir)
|
||||
}
|
||||
if strings.Contains(got, "width = ") {
|
||||
t.Fatalf("the controller filled the handed file: %s", got)
|
||||
}
|
||||
if !reflect.DeepEqual(c.Filled, []string{"/home/op/.config/notify/rc"}) {
|
||||
t.Fatalf("filled files %v", c.Filled)
|
||||
}
|
||||
want := map[string]Plan{"notify": {
|
||||
Templates: PlanTemplates{Source: "http://store:5000/v2/notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64)},
|
||||
Files: []PlanFile{{ID: "configuration", Path: "/home/op/.config/notify/rc", Template: "rc",
|
||||
OwnerAtRuntime: "owned", Takes: "reload-on"}},
|
||||
Placement: map[string]string{"machine:account-home": "/home/op"},
|
||||
}}
|
||||
if !reflect.DeepEqual(c.Plans, want) {
|
||||
t.Fatalf("the plans are %+v, want %+v", c.Plans, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesMarkIsJudged(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
|
||||
"handed and not moved": `{"module":"bare","version":"1",
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still
|
||||
// marked moved, and composes as before, since nothing of its build's archive is there to hand.
|
||||
func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(notifierMoved()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a moved module read from its tree is refused: %v", err)
|
||||
}
|
||||
if !filled.Moved || filled.Handed != nil {
|
||||
t.Fatalf("read from its tree: moved %v, handed %+v", filled.Moved, filled.Handed)
|
||||
}
|
||||
_, got := composeWith(t, notifierOn(t, filled), handingTo("store:5000"))
|
||||
_, want := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
if got != want {
|
||||
t.Fatalf("read from its tree it composes differently:\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
@@ -196,13 +196,31 @@ func healthProblems(m Manifest) []string {
|
||||
}
|
||||
var toolChecks []string
|
||||
otherKinds := 0
|
||||
for _, r := range m.Resources {
|
||||
resources := m.Resources
|
||||
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
|
||||
// and only a module that runs as its own account has one.
|
||||
own, hasOwn := OwnRuntimeHealth(m)
|
||||
if hasOwn {
|
||||
if m.RunsAs == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
|
||||
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
|
||||
"(novox/hq ADR 0366 §1)", m.Module))
|
||||
hasOwn = false
|
||||
} else {
|
||||
resources = append(append([]map[string]any(nil), m.Resources...), own)
|
||||
}
|
||||
}
|
||||
for i, r := range resources {
|
||||
h, has, read := ReadHealth(r)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
ownRuntime := hasOwn && i == len(resources)-1
|
||||
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
|
||||
where := m.Module + ": " + id
|
||||
if ownRuntime {
|
||||
where = m.Module + ": its runtime-health"
|
||||
}
|
||||
for _, p := range read {
|
||||
problems = append(problems, m.Module+": "+p)
|
||||
}
|
||||
@@ -279,7 +297,11 @@ func healthProblems(m Manifest) []string {
|
||||
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
|
||||
where, h.Tool, m.Module, orNoneWords(m.Tools)))
|
||||
}
|
||||
toolChecks = append(toolChecks, id)
|
||||
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
|
||||
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
|
||||
if !ownRuntime {
|
||||
toolChecks = append(toolChecks, id)
|
||||
}
|
||||
} else {
|
||||
if h.Tool != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
|
||||
@@ -372,7 +394,11 @@ func Undeclared(m Manifest) []string {
|
||||
// is granted to ask of its own node tools (to-be 48 §3).
|
||||
func HealthChecks(m Manifest) []string {
|
||||
var out []string
|
||||
for _, r := range m.Resources {
|
||||
resources := m.Resources
|
||||
if own, has := OwnRuntimeHealth(m); has && m.RunsAs != "" {
|
||||
resources = append(append([]map[string]any(nil), resources...), own)
|
||||
}
|
||||
for _, r := range resources {
|
||||
h, has, _ := ReadHealth(r)
|
||||
if has && h.Kind == HealthTool && h.Tool != "" && LongRunning(r) {
|
||||
out = append(out, m.Module+"."+h.Tool)
|
||||
@@ -382,6 +408,15 @@ func HealthChecks(m Manifest) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// OwnRuntimeHealth is a module's `runtime-health` as the resource it is put on (novox/hq ADR 0366 §1): the
|
||||
// process `<module>.own-runtime`, carrying it as its `health`. False when the module declares none.
|
||||
func OwnRuntimeHealth(m Manifest) (map[string]any, bool) {
|
||||
if m.RuntimeHealth == nil {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]any{"id": m.Module + "." + OwnRuntimeID(), "type": "process", HealthField: m.RuntimeHealth}, true
|
||||
}
|
||||
|
||||
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
|
||||
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
|
||||
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers
|
||||
// and moves none of its data, judged from the two builds' manifests.
|
||||
func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
raw := `{"module":"mailu","tools":["mailu_status"],
|
||||
"requires":["postgres-database"],
|
||||
"data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]},
|
||||
"bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}],
|
||||
"resources":[
|
||||
{"id":"state","type":"directory"},
|
||||
{"id":"mesh-state","type":"directory"},
|
||||
{"id":"data-mail","type":"directory"},
|
||||
{"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"},
|
||||
{"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"},
|
||||
{"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"},
|
||||
{"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"],
|
||||
"env-file":["${dir:state}/mailu.env"]},
|
||||
{"id":"watcher","type":"process","command":"watch"},
|
||||
{"id":"installation","type":"archive","path":"/opt/mailu-tools","artifact":"archive"},
|
||||
{"id":"guard","type":"package","package":"nftables"}]}`
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
resource := func(m *Manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(m *Manifest)
|
||||
holds string // empty: it rolls out
|
||||
}{
|
||||
{"nothing", func(*Manifest) {}, ""},
|
||||
{"a tools bundle", func(m *Manifest) {
|
||||
m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb"
|
||||
}, ""},
|
||||
{"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""},
|
||||
{"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""},
|
||||
{"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"},
|
||||
{"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"},
|
||||
{"a container added", func(m *Manifest) {
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"})
|
||||
}, "recreates"},
|
||||
{"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"},
|
||||
{"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"},
|
||||
{"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"},
|
||||
{"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"},
|
||||
{"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"},
|
||||
{"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"},
|
||||
{"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"},
|
||||
{"an archive resource", func(m *Manifest) { resource(m, "installation")["artifact"] = "archive2" }, "the archive installation"},
|
||||
{"a package resource", func(m *Manifest) { resource(m, "guard")["package"] = "nftables2" }, "the package guard"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
from, to := base(), base()
|
||||
c.change(&to)
|
||||
got := Holds(from, to)
|
||||
switch {
|
||||
case c.holds == "" && got != "":
|
||||
t.Fatalf("held: %s", got)
|
||||
case c.holds != "" && !strings.Contains(got, c.holds):
|
||||
t.Fatalf("holds %q; want it to say %q", got, c.holds)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A data directory nothing mounts still holds its data.
|
||||
from, to := base(), base()
|
||||
from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"})
|
||||
to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)}
|
||||
resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&to, "runtime-config")["content"] = "new"
|
||||
if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") {
|
||||
t.Fatalf("a file within data nothing mounts: %q", got)
|
||||
}
|
||||
// A process of a module with no data of its own may change.
|
||||
from, to = base(), base()
|
||||
from.Data, to.Data = nil, nil
|
||||
resource(&to, "watcher")["command"] = "watch2"
|
||||
if got := Holds(from, to); got != "" {
|
||||
t.Fatalf("a process of a module with no data held: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Scopes a claim can have.
|
||||
@@ -175,8 +176,16 @@ type Offer struct {
|
||||
// bound to its data, and moves only by a person (novox/hq ADR 0232). `false` for a provision any
|
||||
// provider answers alike, the mesh's resolver; unsaid, see KeepsConsumerData.
|
||||
KeepsConsumerData *bool `json:"keeps-consumer-data,omitempty"`
|
||||
// AnsweredBy says who answers this provision's consumers: AnsweredByHelper when the module runs the SDK's provider
|
||||
// helper for it, so a handed consumer's template may name what the provider tells it (`${bound:<provision>:<key>}`)
|
||||
// from the helper's entry on the bus (issue 792). It is separate from `moved`: it hands none of the module's own
|
||||
// files. Unsaid, the controller answers the consumer.
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}
|
||||
|
||||
// AnsweredByHelper is the only answerer an offer may name besides the controller.
|
||||
const AnsweredByHelper = "helper"
|
||||
|
||||
// OfferIdentity is what an offer says about the names its backend keeps for its consumers.
|
||||
type OfferIdentity struct {
|
||||
// None is set by `"identity": false`: the provision keeps no name derived from its consumer.
|
||||
@@ -333,6 +342,47 @@ func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// The credential forms an offer declares (issue 768), as a consumer's plan names them for the vault, which makes
|
||||
// a first value in that form for a provider it holds no row of.
|
||||
const (
|
||||
// CredentialFormShared is one of the provider's own secrets, shared by every consumer (novox/hq ADR 0158).
|
||||
CredentialFormShared = "shared"
|
||||
// CredentialFormPair is a login of its own for each consumer (novox/hq ADR 0048, ADR 0158 §11): the offer says
|
||||
// no credential.
|
||||
CredentialFormPair = "pair"
|
||||
// CredentialFormIssued is issued by the provider's software (novox/hq ADR 0305); the mesh never makes it.
|
||||
CredentialFormIssued = "issued"
|
||||
// CredentialFormGiven is given by a person (novox/hq ADR 0337): the offer's credential, or the own secret it
|
||||
// shares, is issued outside the mesh; the mesh never makes it.
|
||||
CredentialFormGiven = "given"
|
||||
)
|
||||
|
||||
// CredentialFormOf is the credential form this module's offer of a provision declares, read from the offer's
|
||||
// `credential` alone (issue 768): no new manifest field, since that field already says whether the provider shares
|
||||
// one of its own secrets, its software issues it, or a person gives it, and saying none is a login per consumer.
|
||||
// "" when the module does not offer the provision.
|
||||
func (m Manifest) CredentialFormOf(provision string) string {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name != provision {
|
||||
continue
|
||||
}
|
||||
switch c := o.Credential; {
|
||||
case c == nil:
|
||||
return CredentialFormPair
|
||||
case c.IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.IssuedBy == IssuedBySoftware:
|
||||
return CredentialFormIssued
|
||||
case c.Own != "" && m.OwnSecrets[c.Own].IssuedBy == IssuedOutside:
|
||||
return CredentialFormGiven
|
||||
case c.Own != "":
|
||||
return CredentialFormShared
|
||||
}
|
||||
return CredentialFormPair
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ProvisionsSharing is every provision of this module whose credential is the named own secret.
|
||||
func (m Manifest) ProvisionsSharing(own string) []string {
|
||||
var out []string
|
||||
@@ -367,13 +417,19 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
Reach string `json:"reach,omitempty"`
|
||||
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||
Keeps *bool `json:"keeps-consumer-data,omitempty"`
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity, "+
|
||||
"keeps-consumer-data}: %w", typedUnknown(err))
|
||||
"keeps-consumer-data, answered-by}: %w", typedUnknown(err))
|
||||
}
|
||||
if full.AnsweredBy != "" && full.AnsweredBy != AnsweredByHelper {
|
||||
return fmt.Errorf("%s says answered-by %q: the only answerer an offer names is %q", full.Name, full.AnsweredBy,
|
||||
AnsweredByHelper)
|
||||
}
|
||||
o.AnsweredBy = full.AnsweredBy
|
||||
o.Name, o.Scope, o.Credential, o.Reach, o.Identity = full.Name, full.Scope, full.Credential, full.Reach, full.Identity
|
||||
o.KeepsConsumerData = full.Keeps
|
||||
return nil
|
||||
@@ -382,7 +438,8 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||
// went through the mesh comes out looking like the one that went in.
|
||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil && o.KeepsConsumerData == nil {
|
||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil && o.KeepsConsumerData == nil &&
|
||||
o.AnsweredBy == "" {
|
||||
return json.Marshal(o.Name)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
@@ -392,7 +449,19 @@ func (o Offer) MarshalJSON() ([]byte, error) {
|
||||
Reach string `json:"reach,omitempty"`
|
||||
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||
Keeps *bool `json:"keeps-consumer-data,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential, o.Reach, o.Identity, o.KeepsConsumerData})
|
||||
AnsweredBy string `json:"answered-by,omitempty"`
|
||||
}{o.Name, o.Scope, o.Credential, o.Reach, o.Identity, o.KeepsConsumerData, o.AnsweredBy})
|
||||
}
|
||||
|
||||
// AnsweredByHelperFor is whether this module's offer of a provision says its consumers are answered by the provider
|
||||
// helper (issue 792).
|
||||
func (m Manifest) AnsweredByHelperFor(provision string) bool {
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == provision && o.AnsweredBy == AnsweredByHelper {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Manifest is everything a module says about itself.
|
||||
@@ -513,6 +582,13 @@ type Manifest struct {
|
||||
// person's account (design 25 §7) already had the same shape.
|
||||
Invokes []string `json:"invokes,omitempty"`
|
||||
|
||||
// Waits are the event families a session may wait on through the mesh MCP server's `mesh_wait`, each
|
||||
// `<emitter>.<event>` as `consumes` names one, a seat's or a module's (novox/hq ADR 0367 §2). Only the tool
|
||||
// runner's module declares them, one by one and never by a wildcard. **A grant apart from `consumes`**: the
|
||||
// controller grants the tool runner's account a plain subscription on those subjects alone, with no
|
||||
// durable consumer, and issues the same list in its membership; no call or setting adds one (§6).
|
||||
Waits []string `json:"waits,omitempty"`
|
||||
|
||||
// State is the current state this module keeps on the bus, by local name: each a key-value
|
||||
// bucket the controller creates, which every instance of the module writes and reads
|
||||
// (novox/hq ADR 0201). Not history — that is an event — and never a secret, sealed or not.
|
||||
@@ -523,6 +599,12 @@ type Manifest struct {
|
||||
// (novox/hq ADR 0297). A log is its owner's alone: no other module reads it.
|
||||
Logs []LogDeclaration `json:"logs,omitempty"`
|
||||
|
||||
// Streams are the streams this module owns on the bus (novox/hq ADR 0363): each named locally, the
|
||||
// events it carries named as `emits` names them but with `*` and `**`, and bounded in age and size; the
|
||||
// controller creates each. One may be restricted by a trusted setting of this module, so a consumer
|
||||
// hears a part of it only where that setting's value names it.
|
||||
Streams []StreamDeclaration `json:"streams,omitempty"`
|
||||
|
||||
// Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file,
|
||||
// a contribution or a served fact asks for as `${setting:<key>}`, its default, and why that
|
||||
// default. Only a preference has one — a font size, a width, a number of workers — and a value
|
||||
@@ -561,6 +643,10 @@ type Manifest struct {
|
||||
// why. A person's choice through the `upgrade` verb stands over it; the bus records whatever it says.
|
||||
Upgrade *UpgradePolicy `json:"upgrade,omitempty"`
|
||||
|
||||
// Unhealthy is how loud this module's unhealthy condition is (novox/hq ADR 0355): a warning when unsaid;
|
||||
// `urgent`, with why, for a module whose failure costs people something they rely on.
|
||||
Unhealthy *UnhealthyPolicy `json:"unhealthy,omitempty"`
|
||||
|
||||
// Reads are other modules' state this module reads and watches, each `<module>.<name>`
|
||||
// (novox/hq ADR 0201). Read-only: only the owner's instances write.
|
||||
Reads []string `json:"reads,omitempty"`
|
||||
@@ -643,6 +729,19 @@ type Manifest struct {
|
||||
// wrong the moment anybody edits anything.
|
||||
Build *Build `json:"build,omitempty"`
|
||||
|
||||
// Templates is the module's directory its template files are kept in, and its build packs into its templates
|
||||
// archive: files/ when it says none (DefaultTemplatesDir; novox/hq ADR 0349 §2, issue 731). A module whose files/
|
||||
// is already an archive unpacked onto the machine names another, so its templates neither land beside what it
|
||||
// unpacks nor change that archive's digest. A word of the repository's manifest only, like Build: the manifest
|
||||
// the mesh holds carries each file's text and names no templates directory.
|
||||
Templates string `json:"templates,omitempty"`
|
||||
|
||||
// Moved marks a module handed to the provisioner (novox/hq ADR 0349 §17, design 57 P6): each of its files that
|
||||
// names a template is filled on the node by the provisioner's client, and the controller fills nothing in it.
|
||||
// Handed is what its build kept for that (handed.go): written by Resolve, never in a repository.
|
||||
Moved bool `json:"moved,omitempty"`
|
||||
Handed *Handed `json:"handed,omitempty"`
|
||||
|
||||
// Binds is where this module wants to be told about something it requires, per requirement.
|
||||
//
|
||||
// Because "this machine needs a database from the anchor" is useless to the program that
|
||||
@@ -721,6 +820,11 @@ type Manifest struct {
|
||||
// that says a warrant, or speaks for a channel kind that proves its sender.
|
||||
RunsAs string `json:"runs-as,omitempty"`
|
||||
|
||||
// RuntimeHealth is how the runtime of its own a module of its own account is served by is ready
|
||||
// (novox/hq ADR 0366): one `health` in the form a resource's takes (ADR 0240), put by the controller on
|
||||
// the process it writes for that runtime (`<module>.own-runtime`). Refused without runs-as.
|
||||
RuntimeHealth any `json:"runtime-health,omitempty"`
|
||||
|
||||
// Keeps is where this module wants every operator-sealed secret in the mesh written — the
|
||||
// vault's field, and so far nobody else's (novox/hq ADR 0085, amended).
|
||||
//
|
||||
@@ -774,6 +878,12 @@ type Manifest struct {
|
||||
// holder places them; the module depends on the seat (ADR 0210 §3).
|
||||
Contributions []SeatContribution `json:"contributions,omitempty"`
|
||||
|
||||
// Collections are the collections this module collects (novox/hq ADR 0349 §13, design 57 L1): each placed in a
|
||||
// file of its own as ${collected:<name>}, filled on the node by the provisioner's client from every item of it
|
||||
// there. Items are what it contributes to others' collections, each a template of its own (collections.go).
|
||||
Collections []Collection `json:"collections,omitempty"`
|
||||
Items []Item `json:"items,omitempty"`
|
||||
|
||||
// Guards are ports of this module's the mesh refuses on an adopted node except from the
|
||||
// private network and from the machine itself (novox/hq ADR 0100) — the store's port and the
|
||||
// broker's management port. The ports the software uses; the mesh guards where the machine
|
||||
@@ -1675,16 +1785,22 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
// **The bus's own account copies the bus and says nothing on it** (novox/hq ADR 0235). The module
|
||||
// holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say
|
||||
// or hear on the bus would be granted nothing — refused here rather than silently dropped.
|
||||
//
|
||||
// **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries
|
||||
// the bundle and is granted the events the module emits, under the module's name, as for any module it
|
||||
// carries — the bus's account is not who publishes them. So emits are refused only for a bus module
|
||||
// with no bundle to say them.
|
||||
if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(BrokerSeat) {
|
||||
var said []string
|
||||
if len(m.EmitsAll()) > 0 {
|
||||
if len(m.EmitsAll()) > 0 && !m.shipsABundle() {
|
||||
said = append(said, "emits")
|
||||
}
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
n int
|
||||
}{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)},
|
||||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}} {
|
||||
{"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)},
|
||||
{"streams", len(m.Streams)}} {
|
||||
if f.n > 0 {
|
||||
said = append(said, f.name)
|
||||
}
|
||||
@@ -1828,6 +1944,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, StateProblems(m)...)
|
||||
// And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297).
|
||||
problems = append(problems, LogProblems(m)...)
|
||||
// And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363).
|
||||
problems = append(problems, StreamProblems(m)...)
|
||||
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the
|
||||
// config form, the whole declaration of each value (config.go, novox/hq ADR 0348).
|
||||
if m.DeclaresConfig() {
|
||||
@@ -1907,6 +2025,8 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
// A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside
|
||||
// content, only under files/, and packed by the module's build.
|
||||
problems = append(problems, templateProblems(m)...)
|
||||
problems = append(problems, movedProblems(m)...)
|
||||
problems = append(problems, collectionProblems(m)...)
|
||||
problems = append(problems, undeliveredBundles(m)...)
|
||||
// **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029).
|
||||
//
|
||||
@@ -2007,6 +2127,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
}
|
||||
}
|
||||
problems = append(problems, invokeProblems(m)...)
|
||||
problems = append(problems, waitProblems(m)...)
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
@@ -2218,6 +2339,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
"may make it (novox/hq ADR 0228)",
|
||||
m.Module, name, own.IssuedBy, IssuedOutside))
|
||||
}
|
||||
problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...)
|
||||
}
|
||||
localOf := map[string]string{}
|
||||
for _, to := range m.SecretRequirements() {
|
||||
@@ -2375,6 +2497,7 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, m.placementProblems()...)
|
||||
problems = append(problems, m.dataProblems()...)
|
||||
problems = append(problems, m.upgradeProblems()...)
|
||||
problems = append(problems, m.unhealthyProblems()...)
|
||||
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
@@ -2737,6 +2860,46 @@ func invokeProblems(m Manifest) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// waitToken is one name of an event family's subject: never a wildcard, never a dot, never empty.
|
||||
var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6):
|
||||
// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused
|
||||
// here, in the manifest's words, rather than at the composition of the bus's users.
|
||||
func waitProblems(m Manifest) []string {
|
||||
if len(m.Waits) == 0 {
|
||||
return nil
|
||||
}
|
||||
if m.Module != RuntimeModule {
|
||||
return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+
|
||||
"declares waits (novox/hq ADR 0367 §2)", m.Module, RuntimeModule)}
|
||||
}
|
||||
var problems []string
|
||||
seen := map[string]bool{}
|
||||
for _, w := range m.Waits {
|
||||
if strings.ContainsAny(w, "*>") {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+
|
||||
"wildcard (novox/hq ADR 0367 §2)", m.Module, w))
|
||||
continue
|
||||
}
|
||||
tokens := strings.Split(w, ".")
|
||||
whole := len(tokens) >= 2 && name.MatchString(tokens[0])
|
||||
for _, t := range tokens[1:] {
|
||||
whole = whole && waitToken.MatchString(t)
|
||||
}
|
||||
if !whole {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+
|
||||
"event: a family is <emitter>.<event>, as consumes names one", m.Module, w))
|
||||
continue
|
||||
}
|
||||
if seen[w] {
|
||||
problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w))
|
||||
}
|
||||
seen[w] = true
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or
|
||||
// applied by its own code to a backend that takes it once.
|
||||
const (
|
||||
@@ -2754,6 +2917,11 @@ type OwnSecret struct {
|
||||
Path string
|
||||
Taken string
|
||||
IssuedBy string
|
||||
// Prompt is the one line the desk asks for this secret as, in place of its bare name (novox/hq
|
||||
// issue 789): at most PromptMax characters, one line, holding only the {member} placeholder — a
|
||||
// family's member suffix, filled where the secret is asked for. Optional; reviewed in the
|
||||
// module's pull request like the rest of the manifest.
|
||||
Prompt string
|
||||
}
|
||||
|
||||
// MeshMayMake says the mesh may put a value it makes in place of the one the secret holds: the
|
||||
@@ -2764,6 +2932,50 @@ func (s OwnSecret) MeshMayMake() bool {
|
||||
return s.Taken == TakenAtStart && s.IssuedBy != IssuedOutside
|
||||
}
|
||||
|
||||
// PromptMax is the longest an own secret's prompt may be (novox/hq issue 789): one line, short enough that the
|
||||
// desk's prompt reads at a glance.
|
||||
const PromptMax = 120
|
||||
|
||||
// promptPlaceholder is a `{word}` token in a prompt's line; the only one a prompt may hold is `{member}`, a
|
||||
// family's member suffix (novox/hq issue 789).
|
||||
var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`)
|
||||
|
||||
// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words:
|
||||
// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line,
|
||||
// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}`
|
||||
// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so
|
||||
// `{member}` there would stay in the prompt literally (novox/hq issue 789).
|
||||
func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string {
|
||||
if prompt == "" {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if strings.ContainsAny(prompt, "\n\r") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)",
|
||||
m.Module, name))
|
||||
}
|
||||
if n := utf8.RuneCountInString(prompt); n > PromptMax {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters",
|
||||
m.Module, name, n, PromptMax))
|
||||
}
|
||||
for _, ph := range promptPlaceholder.FindAllString(prompt, -1) {
|
||||
if ph != "{member}" {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+
|
||||
"filled with a family's member suffix (novox/hq issue 789)",
|
||||
m.Module, name, ph))
|
||||
} else if !family {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+
|
||||
"family's member (novox/hq issue 789)",
|
||||
m.Module, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming
|
||||
// the path and how it is taken; written back the way it was read, so a manifest the mesh holds
|
||||
// keeps its bytes.
|
||||
@@ -2785,13 +2997,14 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
|
||||
Path string `json:"path"`
|
||||
Taken string `json:"taken,omitempty"`
|
||||
IssuedBy string `json:"issued-by,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&long); err != nil {
|
||||
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\"}: %w", name, typedUnknown(err))
|
||||
return fmt.Errorf("own-secrets.%s: a path, or {\"path\", \"taken\", \"issued-by\", \"prompt\"}: %w", name, typedUnknown(err))
|
||||
}
|
||||
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy}
|
||||
out[name] = OwnSecret{Path: long.Path, Taken: long.Taken, IssuedBy: long.IssuedBy, Prompt: long.Prompt}
|
||||
}
|
||||
*o = out
|
||||
return nil
|
||||
@@ -2800,7 +3013,7 @@ func (o *OwnSecrets) UnmarshalJSON(raw []byte) error {
|
||||
func (o OwnSecrets) MarshalJSON() ([]byte, error) {
|
||||
entries := make(map[string]any, len(o))
|
||||
for name, s := range o {
|
||||
if s.Taken == "" && s.IssuedBy == "" {
|
||||
if s.Taken == "" && s.IssuedBy == "" && s.Prompt == "" {
|
||||
entries[name] = s.Path
|
||||
continue
|
||||
}
|
||||
@@ -2811,6 +3024,9 @@ func (o OwnSecrets) MarshalJSON() ([]byte, error) {
|
||||
if s.IssuedBy != "" {
|
||||
long["issued-by"] = s.IssuedBy
|
||||
}
|
||||
if s.Prompt != "" {
|
||||
long["prompt"] = s.Prompt
|
||||
}
|
||||
entries[name] = long
|
||||
}
|
||||
return json.Marshal(entries)
|
||||
@@ -2849,7 +3065,8 @@ func (o OwnSecrets) Lookup(name string) (s OwnSecret, family string, ok bool) {
|
||||
continue
|
||||
}
|
||||
if family == "" || len(declared) > len(family) {
|
||||
s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy}, declared, true
|
||||
s, family, ok = OwnSecret{Path: strings.Replace(f.Path, "*", rest, 1), Taken: f.Taken, IssuedBy: f.IssuedBy,
|
||||
Prompt: f.Prompt}, declared, true
|
||||
}
|
||||
}
|
||||
return s, family, ok
|
||||
@@ -2959,3 +3176,16 @@ func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
|
||||
func (m Manifest) shipsABundle() bool {
|
||||
if m.Build == nil {
|
||||
return false
|
||||
}
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a.Kind == ArtifactBundle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An own secret (or a family) may declare `prompt`, one line naming what it is for a desk's prompt in place of
|
||||
// its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in
|
||||
// the module check.
|
||||
func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside",
|
||||
"prompt":"the password of the mail account {member}"},
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":"the key for the outbound relay"}}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest with a well-formed prompt was refused: %v", err)
|
||||
}
|
||||
if got := m.OwnSecrets["mail-credential-*"].Prompt; got != "the password of the mail account {member}" {
|
||||
t.Fatalf("the family's prompt: %q", got)
|
||||
}
|
||||
if got := m.OwnSecrets["smtp-relay-key"].Prompt; got != "the key for the outbound relay" {
|
||||
t.Fatalf("the plain secret's prompt: %q", got)
|
||||
}
|
||||
// Written back the way it was read, so a registered manifest keeps its bytes.
|
||||
out, err := json.Marshal(m.OwnSecrets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again OwnSecrets
|
||||
if err := json.Unmarshal(out, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again["mail-credential-*"].Prompt != "the password of the mail account {member}" ||
|
||||
again["smtp-relay-key"].Prompt != "the key for the outbound relay" {
|
||||
t.Fatalf("the round trip lost the prompt: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// No prompt declared is fine: the desk asks by name alone, as it does today.
|
||||
func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{
|
||||
"telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.OwnSecrets["telegram-token"].Prompt != "" {
|
||||
t.Fatalf("an undeclared prompt: %q", m.OwnSecrets["telegram-token"].Prompt)
|
||||
}
|
||||
out, err := json.Marshal(m.OwnSecrets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "prompt") {
|
||||
t.Fatalf("an empty prompt was written: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even
|
||||
// though it holds well over 120 bytes (novox/hq issue 789).
|
||||
func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) {
|
||||
prompt := strings.Repeat("é", 100)
|
||||
if n := len(prompt); n <= 120 {
|
||||
t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n)
|
||||
}
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":` + jsonString(prompt) + `}}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a 100-character prompt was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789).
|
||||
func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start",
|
||||
"prompt":"For {member}"}}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") {
|
||||
t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789).
|
||||
func TestAMalformedPromptIsRefused(t *testing.T) {
|
||||
for name, c := range map[string]struct{ prompt, says string }{
|
||||
"a newline": {"the password of\nthe mail account {member}", "one line"},
|
||||
"over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " +
|
||||
"send and receive mail for the operator's account on this machine today", "120 characters"},
|
||||
"a placeholder other than {member}": {"the password of the mail account {name}", "{member}"},
|
||||
"two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{
|
||||
"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside",
|
||||
"prompt":` + jsonString(c.prompt) + `}}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON.
|
||||
func jsonString(s string) string {
|
||||
out, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
@@ -34,14 +34,28 @@ const PlacesSetting = "places"
|
||||
// AccessesSetting is the settings key that says where a module's accesses are on this node, by id.
|
||||
const AccessesSetting = "accesses"
|
||||
|
||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issue 651). `places`, `accesses` and
|
||||
// `networks` are facts of an assignment the controller decides, held beside the assignment and apart
|
||||
// from its settings: a Layer carries them in Placement, never in Values, and only Places, AccessPlaces
|
||||
// and KeptNetworks read them. A person still says them as keys of a layer through `settings`; LayerOf
|
||||
// splits such a layer, so what they type is routed to the placement and nothing they do changes.
|
||||
// **Placement is not config** (novox/hq ADR 0348 clause 11, issues 651 and 736). `places`, `accesses` and
|
||||
// `networks` — where an assignment's things are — and `endpoints`, `expose`, `reach` and `ports` — where it
|
||||
// is reached and from how far — are facts of an assignment the controller decides, held beside the
|
||||
// assignment and apart from its settings: a Layer carries them in Placement, never in Values, and only
|
||||
// their readers (Places, AccessPlaces, KeptNetworks, Endpoints, Exposure, Reaches, GivenPorts) read them.
|
||||
// A person still says them as keys of a layer through `settings`; LayerOf splits such a layer, so what
|
||||
// they type is routed to the placement and nothing they do changes.
|
||||
|
||||
// placementKeys are the keys of a layer that are placement, not config.
|
||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true}
|
||||
var placementKeys = map[string]bool{PlacesSetting: true, AccessesSetting: true, NetworksSetting: true,
|
||||
EndpointsSetting: true, ExposeSetting: true, ReachSetting: true, PortsSetting: true}
|
||||
|
||||
// PlacementKeys are the keys of a layer that are placement, not config, sorted: what a settings row must
|
||||
// no longer hold once the move of placement has run (issue 736).
|
||||
func PlacementKeys() []string {
|
||||
keys := make([]string, 0, len(placementKeys))
|
||||
for k := range placementKeys {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// SplitPlacement divides a layer as a person says it into its config values and its placement. Each is
|
||||
// nil when it holds nothing; the values themselves are not copied.
|
||||
|
||||
@@ -219,3 +219,64 @@ func TestPlacementIsReadFromThePlacementOnly(t *testing.T) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// **The four words of where an assignment is reached are placement too** (novox/hq ADR 0348 clause 11, issue 736).
|
||||
// `endpoints`, `expose`, `reach` and `ports` among a layer's config values say nothing to the filter, the ports or
|
||||
// the names; the same in Placement do, and LayerOf puts them there. A mergeable file of today's form still takes
|
||||
// every key of the layer as it is said, so it composes as it did while they were kept among the values.
|
||||
func TestTheFourWordsAreReadFromThePlacementOnly(t *testing.T) {
|
||||
m := Manifest{Module: "forge", Version: "1",
|
||||
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: FromMesh}, {Name: "ssh", Port: 22, Protocol: "tcp", From: FromMesh},
|
||||
{Name: "api", Port: 8086, Protocol: "tcp", From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "image": "x", "ports": []any{"3000", "222:22", "8086"}}}}
|
||||
said := map[string]any{
|
||||
ExposeSetting: map[string]any{"22": FromEverywhere},
|
||||
PortsSetting: map[string]any{"22": float64(2222)},
|
||||
ReachSetting: map[string]any{"3000": "internal"},
|
||||
EndpointsSetting: map[string]any{"api": map[string]any{"label": "influx", "port": float64(18086), "reach": "public"}},
|
||||
"log": "warn",
|
||||
}
|
||||
among := []Layer{{From: "anchor", Values: said}}
|
||||
if e, err := Exposure(m, among); err != nil || e != nil {
|
||||
t.Fatalf("an expose among the config values exposed %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, among); err != nil || g != nil {
|
||||
t.Fatalf("a ports among the config values gave %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, among); err != nil || r != nil {
|
||||
t.Fatalf("a reach among the config values reached %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, among); err != nil || len(b) != 0 {
|
||||
t.Fatalf("an endpoints among the config values configured %v (%v)", b, err)
|
||||
}
|
||||
|
||||
beside := []Layer{LayerOf("anchor", said)}
|
||||
if len(beside[0].Values) != 1 || beside[0].Values["log"] != "warn" || len(beside[0].Placement) != 4 {
|
||||
t.Fatalf("LayerOf split %+v", beside[0])
|
||||
}
|
||||
if e, err := Exposure(m, beside); err != nil || e[22] != FromEverywhere {
|
||||
t.Fatalf("the placement did not expose: %v (%v)", e, err)
|
||||
}
|
||||
if g, err := GivenPorts(m, beside); err != nil || g[22] != 2222 || g[8086] != 18086 {
|
||||
t.Fatalf("the placement did not give the ports: %v (%v)", g, err)
|
||||
}
|
||||
if r, err := Reaches(m, beside); err != nil || r[3000] != "internal" || r[8086] != "public" {
|
||||
t.Fatalf("the placement did not reach: %v (%v)", r, err)
|
||||
}
|
||||
if b, err := Endpoints(m, beside); err != nil || b["api"].Label != "influx" {
|
||||
t.Fatalf("the placement did not configure the endpoint: %v (%v)", b, err)
|
||||
}
|
||||
|
||||
file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"}
|
||||
whole, err := ApplySettings(file, among)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
split, err := ApplySettings(file, beside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"endpoints"`) {
|
||||
t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,17 +111,17 @@ func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
list, _ := ParsePredefined([]byte(theFirstKeys))
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, TemplatesDir), 0o755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Join(dir, DefaultTemplatesDir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, TemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, DefaultTemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-size"
|
||||
r := resourceOf(m, "configuration")
|
||||
delete(r, "content")
|
||||
r["template"] = TemplatesDir + "/rc"
|
||||
r["template"] = DefaultTemplatesDir + "/rc"
|
||||
m["build"] = map[string]any{"artifacts": []any{map[string]any{"name": "files", "kind": "archive", "from": "files"}}}
|
||||
declaration(m, "width")["runtime"] = true
|
||||
declaration(m, "sender")["runtime"] = true
|
||||
|
||||
@@ -18,7 +18,7 @@ func aRoutedWeb() Manifest {
|
||||
|
||||
func reachSet(reach string) SettingsBy {
|
||||
return SettingsBy{"web": {{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}}
|
||||
}
|
||||
|
||||
// namesFor renders the contribution a routed module makes and returns the two names it carries.
|
||||
@@ -145,7 +145,7 @@ func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) {
|
||||
// written rather than accepted and ignored.
|
||||
func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "reaches nothing") {
|
||||
t.Fatalf("a reach naming an undeclared port was accepted: %v", err)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) {
|
||||
func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"mesh", "anywhere", "private", "true"} {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor",
|
||||
Values: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||
Placement: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||
t.Fatalf("%q was accepted as a reach: %v", wrong, err)
|
||||
}
|
||||
@@ -168,7 +168,7 @@ func TestAValueThatIsNotAReachIsRefused(t *testing.T) {
|
||||
// and accepting both would have the filter follow one while the names followed the other — the
|
||||
// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word.
|
||||
func TestReachAndExposeForOnePortAreRefused(t *testing.T) {
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Values: map[string]any{
|
||||
_, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
ReachSetting: map[string]any{"3000": ReachInternal},
|
||||
ExposeSetting: map[string]any{"3000": FromEverywhere},
|
||||
}}})
|
||||
|
||||
@@ -109,3 +109,224 @@ func sameDeclaration(a, b any) bool {
|
||||
_ = json.Unmarshal(rb, &nb)
|
||||
return reflect.DeepEqual(na, nb)
|
||||
}
|
||||
|
||||
// What a move under a module's own `record` may do without a person (novox/hq ADR 0377).
|
||||
//
|
||||
// A module records because its manifest says so or because it keeps irreplaceable data: mail, whose containers
|
||||
// recreated together interrupt everyone's mail, and the database server, whose restart drops every consumer. That
|
||||
// hold also held the moves that interrupt nothing — on 2026-10-15 the database server moved with only its tools
|
||||
// bundle changed, its walk said it "recreates none of its containers", and the machine stayed behind until a
|
||||
// person pushed. So such a move rolls out like any other, and only a move that recreates a container or touches
|
||||
// data still waits.
|
||||
|
||||
// Holds is why a move from one build's manifest to another's still waits for a person under a module's own
|
||||
// `record` (ADR 0377 §3), empty when it recreates none of its containers and moves none of its data: no container
|
||||
// changed, added or gone; no file or directory changed, added or gone that a container mounts, or that holds or
|
||||
// lies within a path of the module's declared data; no change to its `data` declaration, nor to what it provides,
|
||||
// requires or contributes; and, for a module that declares data of its own, no change to a long-running process.
|
||||
// Its tools bundles, a file nothing mounts that holds none of its data, and manifest fields that recreate
|
||||
// nothing — its tool list — may differ.
|
||||
func Holds(from, to Manifest) string {
|
||||
module := to.Module
|
||||
if module == "" {
|
||||
module = from.Module
|
||||
}
|
||||
if said := Recreates(from, to).Say(module); said != "" {
|
||||
return said
|
||||
}
|
||||
if !sameDeclaration(from.Data, to.Data) {
|
||||
return "it changes " + module + "'s data declaration"
|
||||
}
|
||||
for _, f := range []struct {
|
||||
what string
|
||||
was, s any
|
||||
}{
|
||||
{"what it provides", from.Provides, to.Provides},
|
||||
{"what it requires", from.Requires, to.Requires},
|
||||
{"which of its requirements are optional", from.OptionalRequires, to.OptionalRequires},
|
||||
{"what it contributes to a provider", from.Contributes, to.Contributes},
|
||||
{"what it contributes to a provider", from.ContributesMany, to.ContributesMany},
|
||||
// A setting is filled into the files the module writes: a changed default changes what a container may
|
||||
// mount without the file's own declaration changing.
|
||||
{"its settings, which the files it writes are filled from", from.Settings, to.Settings},
|
||||
} {
|
||||
if !sameDeclaration(emptyAsNil(f.was), emptyAsNil(f.s)) {
|
||||
return "it changes " + f.what
|
||||
}
|
||||
}
|
||||
mounted := append(mountSources(from), mountSources(to)...)
|
||||
data := append(dataPaths(from), dataPaths(to)...)
|
||||
keepsData := (from.Data != nil && len(from.Data.Own) > 0) || (to.Data != nil && len(to.Data.Own) > 0)
|
||||
before, after := resourcesByID(from), resourcesByID(to)
|
||||
var changed []string
|
||||
for id, r := range after {
|
||||
if was, held := before[id]; !held || !sameDeclaration(was, r) {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
for id := range before {
|
||||
if _, kept := after[id]; !kept {
|
||||
changed = append(changed, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(changed)
|
||||
dirs := absoluteDirectories(from, to)
|
||||
for _, key := range changed {
|
||||
for _, r := range []map[string]any{before[key], after[key]} {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
kind, id := fmt.Sprint(r["type"]), fmt.Sprint(r["id"])
|
||||
switch kind {
|
||||
case "file", "directory":
|
||||
for _, p := range pathsOf(r, dirs) {
|
||||
for _, m := range mounted {
|
||||
if pathsMeet(p, expandDir(m.source, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which its container %s mounts", kind, id, m.container)
|
||||
}
|
||||
}
|
||||
for _, d := range data {
|
||||
if pathsMeet(p, expandDir(d.path, dirs)) {
|
||||
return fmt.Sprintf("it changes the %s %s, which holds or lies within its data %s", kind, id, d.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "process", "service":
|
||||
if keepsData {
|
||||
return fmt.Sprintf("it changes its long-running %s %s, and it keeps data of its own", kind, id)
|
||||
}
|
||||
default:
|
||||
// Anything that is not a file or directory (judged above by whether a container mounts it
|
||||
// or it holds data) always holds: an archive, a package, a user, a network, or any type
|
||||
// added later (novox/hq ADR 0377 §3's allowlist names only tools bundles and manifest
|
||||
// fields that recreate nothing).
|
||||
return fmt.Sprintf("it changes the %s %s", kind, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type mountSource struct{ container, source string }
|
||||
|
||||
// mountSources is every path a container of the module reads from its machine: its volumes' sources and its
|
||||
// environment files.
|
||||
func mountSources(m Manifest) []mountSource {
|
||||
var out []mountSource
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
id := fmt.Sprint(r["id"])
|
||||
vols, _ := r["volumes"].([]any)
|
||||
for _, v := range vols {
|
||||
s, _ := v.(string)
|
||||
if mount := volumeMount.FindStringSubmatch(s); mount != nil {
|
||||
out = append(out, mountSource{id, mount[1]})
|
||||
}
|
||||
}
|
||||
switch files := r["env-file"].(type) {
|
||||
case string:
|
||||
out = append(out, mountSource{id, files})
|
||||
case []any:
|
||||
for _, f := range files {
|
||||
if s, ok := f.(string); ok {
|
||||
out = append(out, mountSource{id, s})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type dataPath struct{ id, path string }
|
||||
|
||||
func dataPaths(m Manifest) []dataPath {
|
||||
if m.Data == nil {
|
||||
return nil
|
||||
}
|
||||
var out []dataPath
|
||||
for _, it := range m.Data.Own {
|
||||
out = append(out, dataPath{it.ID, it.Path})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resourcesByID(m Manifest) map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
out[fmt.Sprint(r["type"])+":"+fmt.Sprint(r["id"])] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// absoluteDirectories is, per directory id, the absolute path a directory of either build states.
|
||||
func absoluteDirectories(ms ...Manifest) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range ms {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "directory" {
|
||||
continue
|
||||
}
|
||||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||||
out[fmt.Sprint(r["id"])] = strings.TrimRight(p, "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pathsOf is every way a file or directory resource is named: its path, and a directory's `${dir:<id>}`.
|
||||
func pathsOf(r map[string]any, dirs map[string]string) []string {
|
||||
var out []string
|
||||
if p, ok := r["path"].(string); ok && p != "" {
|
||||
out = append(out, expandDir(p, dirs))
|
||||
}
|
||||
if fmt.Sprint(r["type"]) == "directory" {
|
||||
out = append(out, expandDir("${dir:"+fmt.Sprint(r["id"])+"}", dirs))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expandDir writes a leading `${dir:<id>}` as the directory's absolute path when one is stated, so a path named
|
||||
// either way is one path.
|
||||
func expandDir(p string, dirs map[string]string) string {
|
||||
p = strings.TrimRight(p, "/")
|
||||
if ref := dirPlain.FindStringSubmatch(p); ref != nil {
|
||||
if abs, ok := dirs[ref[1]]; ok {
|
||||
return abs
|
||||
}
|
||||
return p
|
||||
}
|
||||
if strings.HasPrefix(p, "${dir:") {
|
||||
end := strings.Index(p, "}")
|
||||
if abs, ok := dirs[p[len("${dir:"):end]]; ok {
|
||||
return abs + p[end+1:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// pathsMeet is whether one path is the other or lies within it.
|
||||
func pathsMeet(a, b string) bool {
|
||||
if a == "" || b == "" {
|
||||
return false
|
||||
}
|
||||
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
|
||||
}
|
||||
|
||||
// emptyAsNil reads an empty list or map as nothing, so a field written empty is no change from one left out.
|
||||
func emptyAsNil(v any) any {
|
||||
rv := reflect.ValueOf(v)
|
||||
switch rv.Kind() {
|
||||
case reflect.Slice, reflect.Map:
|
||||
if rv.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
case reflect.Ptr:
|
||||
if rv.IsNil() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -227,6 +227,9 @@ type Needed struct {
|
||||
// by every consumer (novox/hq ADR 0158): the name of that secret in the provider's definition.
|
||||
// The plan mints the pair's copy from the provider's value rather than a value of its own.
|
||||
SharedOwn string
|
||||
// CredentialForm is the credential form the provider's offer declares (Manifest.CredentialFormOf, issue 768),
|
||||
// named in a handed consumer's plan for the vault; empty where the provider's manifest is not in hand.
|
||||
CredentialForm string
|
||||
// Manager is set when this holder is a refreshable-grant licence's MANAGER, delivered the refresh
|
||||
// token rather than an access token (novox/hq ADR 0050). It changes one thing downstream: an empty
|
||||
// Sealed is tolerated — the manager has not adopted a refresh token yet, which is a real waiting
|
||||
@@ -248,6 +251,16 @@ type Needed struct {
|
||||
// consumer's side for `status` and on the provider's before it grants. No bound for a provision
|
||||
// answered by a record, which keeps no name of anybody's.
|
||||
Identity IdentityBound
|
||||
// IssuedToHolder is set when the vault keeps this need's credential for this holder alone (issue 757): the
|
||||
// providing module is marked moved and its software issues the provision's credential for each holder (issue
|
||||
// 749), kept as provision/<From>/<Name>/<node>/<For>. Where the consumer is handed to the provisioner, its
|
||||
// machine's client asks the vault for it and the declaration carries no value. Set by the plan, which reads the
|
||||
// provider's manifest.
|
||||
IssuedToHolder bool
|
||||
// ProviderMoved is set when the providing module is marked moved (issue 788): a handed consumer's template may
|
||||
// then name `${bound:<provision>:<key>}` and `${secret:<provision>}`, which its machine's client fills from the
|
||||
// provider's entry and the vault. Set by the plan, which reads the provider's manifest.
|
||||
ProviderMoved bool
|
||||
}
|
||||
|
||||
// Refusal is why a set of assignments cannot become a declaration.
|
||||
@@ -530,7 +543,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Module: by.Module,
|
||||
Serves: servedByOne(by, want), For: because[want],
|
||||
SharedOwn: sharedByOne(by, want), Identity: by.IdentityBoundOf(want)})
|
||||
SharedOwn: sharedByOne(by, want), CredentialForm: by.CredentialFormOf(want),
|
||||
Identity: by.IdentityBoundOf(want)})
|
||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
||||
// Answered here with no credential to mint, but the provider serves facts the
|
||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||
@@ -550,7 +564,8 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
at = "127.0.0.1"
|
||||
}
|
||||
needs = append(needs, Needed{
|
||||
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module})
|
||||
Name: want, From: node.Name, At: at, Serves: served, For: because[want], Module: by.Module,
|
||||
CredentialForm: by.CredentialFormOf(want)})
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -579,16 +594,18 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
node.Name, want, p.Node, meshNetwork))
|
||||
return
|
||||
}
|
||||
shared := ""
|
||||
shared, form := "", ""
|
||||
// A provider whose definition is not in hand is held to the tightest bound the
|
||||
// mesh knows, not to none: what it keeps is not known here (ADR 0225).
|
||||
bound := DefaultIdentityBound
|
||||
if pm, known := catalogue[p.Module]; known {
|
||||
shared, _ = pm.SharedCredentialOf(want)
|
||||
form = pm.CredentialFormOf(want)
|
||||
bound = pm.IdentityBoundOf(want)
|
||||
}
|
||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module,
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound, LeftOut: p.LeftOut})
|
||||
Serves: p.Serves, For: because[want], SharedOwn: shared, CredentialForm: form, Identity: bound,
|
||||
LeftOut: p.LeftOut})
|
||||
}
|
||||
switch {
|
||||
case world.Unchecked:
|
||||
@@ -735,7 +752,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
||||
satisfied[o] = true
|
||||
}
|
||||
if _, ok := because[m.Module]; !ok {
|
||||
because[m.Module] = fmt.Sprintf("required by %s", because[want])
|
||||
because[m.Module] = requiredByWords + because[want]
|
||||
}
|
||||
|
||||
for _, r := range m.Wants() {
|
||||
|
||||
@@ -14,7 +14,7 @@ func TestTheResolverConfigSeatIsGone(t *testing.T) {
|
||||
t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file")
|
||||
}
|
||||
// An uplink's holder needs no seat beside it for the file: it is its own.
|
||||
if got := DependsOn(mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
|
||||
if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 {
|
||||
t.Errorf("an uplink holder with nothing declared depends on %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,6 +320,11 @@ func (r Resolution) ownRuntimes(with Rendering) ([]map[string]any, error) {
|
||||
"run": []any{"./" + program.Binary}, "env": env, "restart-on": toAny(restartOn),
|
||||
"user": m.RunsAs,
|
||||
}
|
||||
// How it is ready, as the module declared it (novox/hq ADR 0366 §1), in the node-engine's words.
|
||||
if _, has := OwnRuntimeHealth(m); has {
|
||||
process[HealthField] = m.RuntimeHealth
|
||||
healthInto(process, m, with)
|
||||
}
|
||||
if err := artifactsInto(process, RuntimeModule, with); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest
|
||||
// key, `runtime-health`, in the form a resource's `health` takes; the controller puts it on the process it
|
||||
// writes for that runtime, and a tool check there stands beside the node-engine's judgement that the runtime
|
||||
// is alive. A module that runs as no account of its own is refused the key.
|
||||
|
||||
// ownAccountManifest is a module of its own account with nothing long-running but its own runtime, given
|
||||
// runtime-health (or none, when health is nil), and running as no account when runsAs is false.
|
||||
func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "keeper", "tools": []any{"keeper_status", "keeper_health"},
|
||||
"own-secrets": map[string]any{"broker": map[string]any{"path": "/var/lib/keeper/broker"}},
|
||||
"resources": []any{map[string]any{"id": "account", "type": "user", "name": "keeper"}}}
|
||||
if runsAs {
|
||||
m["runs-as"], m["secrets-owner"] = "keeper", "keeper"
|
||||
}
|
||||
if health != nil {
|
||||
m["runtime-health"] = health
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing.T) {
|
||||
m, err := ParseManifest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err != nil {
|
||||
t.Fatalf("a tool check on its own runtime was refused: %v", err)
|
||||
}
|
||||
if got := HealthChecks(m); len(got) != 1 || got[0] != "keeper.keeper_health" {
|
||||
t.Errorf("the engine is granted %v, want keeper.keeper_health", got)
|
||||
}
|
||||
if r, has := OwnRuntimeHealth(m); !has || r["id"] != "keeper."+OwnRuntimeID() {
|
||||
t.Errorf("its runtime's health reads as %v %v", r, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) {
|
||||
_, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") {
|
||||
t.Errorf("runtime-health without runs-as: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
health any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"},
|
||||
{map[string]any{"kind": "exec", "command": "true"}, "only a container"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"},
|
||||
{"tool", "is an object"},
|
||||
} {
|
||||
_, err := ParseManifest(ownAccountManifest(t, true, c.health))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime-health is composed onto <module>.own-runtime, in the words the node-engine takes; to an engine
|
||||
// that does not read health it is not sent, as for every resource.
|
||||
func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keeper := aToolsModule(t, "keeper", "tools/index.js")
|
||||
keeper.Tools = []string{"keeper_health"}
|
||||
keeper.RunsAs, keeper.SecretsOwner = "keeper", "keeper"
|
||||
keeper.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/keeper/broker"}}
|
||||
keeper.RuntimeHealth = map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "60s", "timeout": "10s",
|
||||
"looks": 2.0}
|
||||
for _, reads := range []bool{true, false} {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101", ReadsHealth: reads,
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "keeper": {"broker": "own"}}}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{keeper, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own := fileNamed(out, "keeper."+OwnRuntimeID())
|
||||
if own == nil {
|
||||
t.Fatalf("no own runtime: %v", ids(out))
|
||||
}
|
||||
h, has := own[HealthField].(map[string]any)
|
||||
if !reads {
|
||||
if has {
|
||||
t.Errorf("health sent to an engine that does not read it: %v", h)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !has || h["kind"] != "tool" || h["tool"] != "keeper_health" || h["interval"] != "1m0s" ||
|
||||
h["timeout"] != "10s" || h["looks"] != 2 || h["grace"] != "1m0s" {
|
||||
t.Errorf("its own runtime's health: %v", own[HealthField])
|
||||
}
|
||||
if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil {
|
||||
t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField])
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Errorf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
}
|
||||
// The catalogue's own manifest is not written into.
|
||||
if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok {
|
||||
t.Error("composing wrote the defaults into the catalogue's manifest")
|
||||
}
|
||||
}
|
||||
@@ -529,3 +529,59 @@ func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testi
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 723: the vault runs as its own account, so what the mesh writes for it — the export it keeps, the grant
|
||||
// files it holds for its consumers, and what its words name — belongs to that account, never the operator's.
|
||||
func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Kept: &KeptExport{Export: 1, OperatorKey: "k"},
|
||||
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram",
|
||||
Values: map[string]any{"name": "telegram"}}}}
|
||||
goRuntime := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/node-tools"}}}}
|
||||
goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vault := aToolsModule(t, "vault", "tools/index.js")
|
||||
vault.RunsAs, vault.SecretsOwner = "vault", "vault"
|
||||
vault.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/vault/broker"}}
|
||||
vault.Keeps = "/var/lib/vault/root"
|
||||
vault.Grants = map[string]string{"secret": "/var/lib/vault/grants"}
|
||||
vault.Receives = map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}
|
||||
vault.Bundles[0].Env = map[string]string{"MESH_RECEIVES": "/var/lib/vault/grants/mesh.json"}
|
||||
out, err2 := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{vault, goRuntime}}.Declaration(with)
|
||||
if err = err2; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
for _, r := range out {
|
||||
path := fmt.Sprint(r["path"])
|
||||
if !strings.HasPrefix(path, "/var/lib/vault/") || r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if r["owner"] != "vault" {
|
||||
t.Errorf("%s (%v) belongs to %v, not the vault's own account", path, r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
if seen < 4 {
|
||||
t.Errorf("only %d of the vault's files were composed: %v", seen, ids(out))
|
||||
}
|
||||
// Carried by the machine's runtime instead, nothing of it changes: the operator's account reads them.
|
||||
carried := vault
|
||||
carried.RunsAs, carried.SecretsOwner = "", ""
|
||||
out, err = Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{carried, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if path := fmt.Sprint(r["path"]); strings.HasPrefix(path, "/var/lib/vault/") && r["owner"] == "vault" {
|
||||
t.Errorf("%s belongs to the vault's account though it runs as none", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,13 +209,13 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string,
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return shapedContributions(modules, holder, facts["name"], s, r, where, caps)
|
||||
return shapedContributions(modules, holder, facts["name"], facts, s, r, where, caps)
|
||||
}
|
||||
var failed error
|
||||
var unplacedLines []string
|
||||
var b strings.Builder
|
||||
var blocks []textBlock
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
named := false
|
||||
var old strings.Builder
|
||||
// A left-out module's lines are best effort (novox/hq ADR 0262): what cannot be placed is said,
|
||||
// and the machine is declared without it. A placement setting that does not read is not
|
||||
// replaced by the definition's own path, which may not be where the data is.
|
||||
@@ -273,15 +273,19 @@ func seatContributions(modules []Manifest, holder Manifest, placeholder string,
|
||||
failed = lineFailed
|
||||
}
|
||||
}
|
||||
if !named {
|
||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, m.Module)
|
||||
named = true
|
||||
if old.Len() == 0 {
|
||||
fmt.Fprintf(&old, "%s %s\n", r.Comment, m.Module)
|
||||
}
|
||||
b.WriteString(content)
|
||||
old.WriteString(content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
b.WriteString("\n")
|
||||
old.WriteString("\n")
|
||||
}
|
||||
}
|
||||
var held []heldItem
|
||||
if !r.Dirs {
|
||||
held = heldItems(m, "contribution:"+s.Name+":"+kind, nil, facts, caps)
|
||||
}
|
||||
blocks = append(blocks, moduleBlocks(m, r.Comment, old.String(), held)...)
|
||||
}
|
||||
return b.String(), unplacedLines, failed
|
||||
return joinBlocks(blocks), unplacedLines, failed
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestAContributionDependsOnItsSeat(t *testing.T) {
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
|
||||
}}
|
||||
if got, want := DependsOn(m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("depends on %v, want %v", got, want)
|
||||
}
|
||||
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
|
||||
|
||||
@@ -42,8 +42,14 @@ type Shape struct {
|
||||
// (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same
|
||||
// machine is granted to read that key, so the holder's manifest names no contributor.
|
||||
Reads func(data map[string]any) []StateRead `json:"-"`
|
||||
// Text says every piece of this kind is, rendered, text that a collector may place as a collection of items
|
||||
// (design 57 L3): a collector then names the kind as `${collected:contribution:<seat>:<kind>:<field>=<value>,…}`.
|
||||
Text bool
|
||||
}
|
||||
|
||||
// collectableAsText is whether a collector may name a kind received as data as a collection.
|
||||
func collectableAsText(r Receivable) bool { return r.Shape != nil && r.Shape.Text }
|
||||
|
||||
// PlaceField is one field a holder places by, and the values it takes.
|
||||
type PlaceField struct {
|
||||
Field string
|
||||
@@ -289,6 +295,10 @@ type placedPiece struct {
|
||||
module string
|
||||
data map[string]any
|
||||
order int
|
||||
// text is a moved contributor's item, rendered (collections.go heldItems): placed as it is, not through the
|
||||
// holder's template.
|
||||
text string
|
||||
item bool
|
||||
}
|
||||
|
||||
// shapedContributions is every module's data of one kind to one seat, narrowed by where and by the
|
||||
@@ -298,7 +308,7 @@ type placedPiece struct {
|
||||
// A piece the template renders to nothing — a `shows` the holder does not know yet — or fails on is
|
||||
// left out and answered as unplaced, naming its module: the other pieces and the rest of the machine's
|
||||
// declaration go on (novox/hq ADR 0255).
|
||||
func shapedContributions(modules []Manifest, holder Manifest, machine string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
|
||||
func shapedContributions(modules []Manifest, holder Manifest, machine string, facts map[string]string, s Seat, r Receivable, where map[string]string, caps map[string]bool) (string, []string, error) {
|
||||
var pieces []placedPiece
|
||||
for _, m := range inModuleOrder(modules) {
|
||||
for _, c := range m.allContributions() {
|
||||
@@ -323,18 +333,37 @@ func shapedContributions(modules []Manifest, holder Manifest, machine string, s
|
||||
}
|
||||
pieces = append(pieces, placedPiece{module: m.Module, data: c.Data, order: order})
|
||||
}
|
||||
// A moved contributor's items of the kind, by their attributes (design 57 L3, issue 777): each in its
|
||||
// module's place, by its order, then its name.
|
||||
if !r.Dirs {
|
||||
for _, h := range heldItems(m, "contribution:"+s.Name+":"+r.Kind, where, facts, caps) {
|
||||
pieces = append(pieces, placedPiece{module: m.Module, order: h.item.order(), text: h.text, item: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pieces) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
t, err := holderTemplate(holder, s, r.Kind)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
var t *template.Template
|
||||
for _, p := range pieces {
|
||||
if p.item {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
if t, err = holderTemplate(holder, s, r.Kind); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
sort.SliceStable(pieces, func(a, b int) bool { return pieces[a].order < pieces[b].order })
|
||||
var b strings.Builder
|
||||
var unplaced []string
|
||||
for _, p := range pieces {
|
||||
if p.item {
|
||||
fmt.Fprintf(&b, "%s %s\n", r.Comment, p.module)
|
||||
b.WriteString(p.text)
|
||||
continue
|
||||
}
|
||||
out, err := renderPiece(t, p.module, machine, p.data)
|
||||
if err == nil && strings.TrimSpace(out) == "" {
|
||||
err = fmt.Errorf("%s's template renders nothing for it", holder.Module)
|
||||
|
||||
@@ -333,9 +333,9 @@ func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) {
|
||||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
}}
|
||||
for _, seat := range DependsOn(power) {
|
||||
for _, seat := range DependsOn(nil, power) {
|
||||
if seat == BarSeat {
|
||||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(power))
|
||||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power))
|
||||
}
|
||||
}
|
||||
// And on a machine with no bar it is placed nowhere, and nothing fails.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user