Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635363adbd |
+1
-6
@@ -1,11 +1,6 @@
|
|||||||
# The Go it builds with, pinned here because genesis builds this file with no arguments (novox/hq
|
ARG GO_BASE=golang:1.25-alpine
|
||||||
# issue 223) — the Makefile passes the same digest. A tag older than go.mod asks for is how
|
|
||||||
# `make image` broke once before (issue 146).
|
|
||||||
ARG GO_BASE=golang@sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c
|
|
||||||
# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go
|
# The control plane's image — for genesis and the lab only. The mesh runs the controller as a Go
|
||||||
# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it.
|
# bundle the host starts as a process (module.json; novox/hq issue 213), and builds no image of it.
|
||||||
# Genesis builds this file and raises it as the container the process replaces on the first push
|
|
||||||
# (mesh-host internal/bootstrap, novox/hq issue 223).
|
|
||||||
#
|
#
|
||||||
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
|
# novox/hq ADR 0006: this image is pinned by digest in the bundle the host carries, fetched on a
|
||||||
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
# machine where no mesh exists yet, and run before there is anything to check it against. So it
|
||||||
|
|||||||
@@ -558,10 +558,6 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
|
|||||||
}
|
}
|
||||||
return manifest, kept, err
|
return manifest, kept, err
|
||||||
}
|
}
|
||||||
// The keep set just moved, and new bytes just landed (novox/hq ADR 0189). Asked here rather
|
|
||||||
// than on a timer of its own: this is the only moment either is true. Never fatal — the build
|
|
||||||
// worked and the module is registered.
|
|
||||||
collect(ctx, inv)
|
|
||||||
return manifest, kept, nil
|
return manifest, kept, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
)
|
)
|
||||||
@@ -91,17 +90,6 @@ func moduleCheck(paths []string, out io.Writer) error {
|
|||||||
if len(m.Invokes) > 0 {
|
if len(m.Invokes) > 0 {
|
||||||
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes))
|
||||||
}
|
}
|
||||||
// The state it keeps and reads (novox/hq ADR 0201), so a reviewer sees what lands on the bus.
|
|
||||||
if len(m.State) > 0 {
|
|
||||||
kept := make([]string, 0, len(m.State))
|
|
||||||
for _, s := range m.State {
|
|
||||||
kept = append(kept, s.Name)
|
|
||||||
}
|
|
||||||
fmt.Fprintf(out, ", keeps state %s", strings.Join(kept, ", "))
|
|
||||||
}
|
|
||||||
if len(m.Reads) > 0 {
|
|
||||||
fmt.Fprintf(out, ", reads %s", strings.Join(m.Reads, ", "))
|
|
||||||
}
|
|
||||||
fmt.Fprintln(out)
|
fmt.Fprintln(out)
|
||||||
}
|
}
|
||||||
if failed > 0 {
|
if failed > 0 {
|
||||||
|
|||||||
@@ -1,108 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/artifacts"
|
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Letting the artifact store go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
|
||||||
//
|
|
||||||
// **Run where the records change.** A build is the moment new bytes landed in the store and the
|
|
||||||
// moment the keep set moved, so it is the moment to say what may go — and it needs no timer of
|
|
||||||
// its own. Reclaiming the bytes is the store's own nightly step; this only decides.
|
|
||||||
//
|
|
||||||
// Never fatal to a build. The build succeeded, the module is registered, and a store that could
|
|
||||||
// not be reached is a thing to say rather than a reason to undo any of that. The next build asks
|
|
||||||
// again, and the references it could not collect are still uncollected, so nothing is lost by
|
|
||||||
// having failed.
|
|
||||||
|
|
||||||
// collect asks the store to let go of everything the mesh made and no longer keeps, and records
|
|
||||||
// what it let go of. Says what it did and what it could not; returns nothing, because nothing
|
|
||||||
// upstream should branch on it.
|
|
||||||
func collect(ctx context.Context, inv *inventory.Inventory) {
|
|
||||||
references, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not work out what the artifact store may let go of: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(references) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shelf, err := inv.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not read the catalogue to find the artifact store: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// As the mesh reaches it from the network. Empty means the store is not on the network — on a
|
|
||||||
// mesh being raised it is not yet, and there the store holds one build of anything and has
|
|
||||||
// nothing to collect.
|
|
||||||
address, err := artifactStoreAddress(ctx, inv, shelf, "")
|
|
||||||
if err != nil || address == "" {
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "could not find the artifact store to collect from: %v\n", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Bounded, because this runs inside somebody's build.** The first sweep of a mesh that has
|
|
||||||
// never collected has the whole history to get through, and a person waiting on `build` should
|
|
||||||
// not pay for it. Two bounds, and what is left over is simply offered again next time —
|
|
||||||
// builds are frequent, and the point is that the store stops growing, not that it empties
|
|
||||||
// tonight.
|
|
||||||
within, stop := context.WithTimeout(ctx, sweepBudget)
|
|
||||||
defer stop()
|
|
||||||
store := artifacts.Store{Address: address}
|
|
||||||
|
|
||||||
var done []string
|
|
||||||
var left int
|
|
||||||
for i, reference := range references {
|
|
||||||
if i >= mostPerSweep || within.Err() != nil {
|
|
||||||
left = len(references) - i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
err := store.LetGo(within, reference)
|
|
||||||
if err == nil || errors.Is(err, artifacts.Gone) {
|
|
||||||
// Gone is the outcome wanted, already true. Recorded so the next sweep does not ask
|
|
||||||
// again for ever.
|
|
||||||
done = append(done, reference)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// **Stopped at the first refusal, not pushed through.** A store that refuses one refuses
|
|
||||||
// all of them — deletion disabled, the store down, the network gone — so going on would
|
|
||||||
// be a hundred identical failures and a hundred identical log lines in front of whoever
|
|
||||||
// was building something.
|
|
||||||
fmt.Fprintf(os.Stderr, "the artifact store kept %s, so nothing more was asked of it: %v\n",
|
|
||||||
reference, err)
|
|
||||||
left = len(references) - i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(done) > 0 {
|
|
||||||
// Recorded outside `within`: the deletions happened, and losing the record of them because
|
|
||||||
// the sweep ran out of budget would mean asking about them again for ever.
|
|
||||||
if err := inv.MarkCollected(ctx, done); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "the store let go of %d artifact(s) and the record of it did not keep: %v\n",
|
|
||||||
len(done), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stderr, "the artifact store let go of %d artifact(s) the mesh no longer keeps\n",
|
|
||||||
len(done))
|
|
||||||
}
|
|
||||||
if left > 0 {
|
|
||||||
fmt.Fprintf(os.Stderr, "%d more to collect; the next build asks again\n", left)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mostPerSweep is how many artifacts one sweep will ask about. Enough that a mesh building
|
|
||||||
// several times a day converges within days of this landing; small enough that no single build
|
|
||||||
// waits on the whole backlog.
|
|
||||||
const mostPerSweep = 200
|
|
||||||
|
|
||||||
// sweepBudget is the longest a sweep will keep a build waiting.
|
|
||||||
const sweepBudget = 60 * time.Second
|
|
||||||
@@ -898,21 +898,6 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
if err := broker.RaiseSeats(js, inventory.MeshSeats(), holders); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Every module's state (novox/hq ADR 0201), from the catalogue: a bucket exists from
|
|
||||||
// registration, so a module reading one may watch it before its owner runs anywhere. One that
|
|
||||||
// nothing declares any more is said and kept — what it holds is data.
|
|
||||||
buckets, err := inv.DeclaredBuckets(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
undeclared, err := broker.RaiseBuckets(js, buckets)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(undeclared) > 0 {
|
|
||||||
fmt.Printf("the bus holds state nothing declares any more, kept because it is data: %s — "+
|
|
||||||
"removing it is a person's act\n", strings.Join(undeclared, ", "))
|
|
||||||
}
|
|
||||||
// And how every module hears what it consumes. Derived from the same records the user list is
|
// And how every module hears what it consumes. Derived from the same records the user list is
|
||||||
// composed from, so a module the mesh grants a consumer's subjects has that consumer waiting.
|
// composed from, so a module the mesh grants a consumer's subjects has that consumer waiting.
|
||||||
// Done on every raise, not only when a credential is issued: every module moved onto this bus
|
// Done on every raise, not only when a credential is issued: every module moved onto this bus
|
||||||
@@ -936,8 +921,8 @@ func raiseTheBus(ctx context.Context, inv *inventory.Inventory, address string)
|
|||||||
}
|
}
|
||||||
hearing++
|
hearing++
|
||||||
}
|
}
|
||||||
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, %d module(s) "+
|
fmt.Printf("the bus at %s has its streams, %d machine(s) can hear a declaration, and %d module(s) "+
|
||||||
"can hear what they consume, and %d bucket(s) of state\n", broker.BareAddress(address), len(names), hearing, len(buckets))
|
"can hear what they consume\n", broker.BareAddress(address), len(names), hearing)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
// Package artifacts speaks to the mesh's artifact store over its own door.
|
|
||||||
//
|
|
||||||
// Only what the mesh needs that nothing else does: letting go of something it put there
|
|
||||||
// (novox/hq ADR 0189, issue 108). Pushing is the builder's, through the container runtime; reading
|
|
||||||
// is every machine's, through its runtime. This is the one operation that belongs to the thing
|
|
||||||
// holding the records, because it is the only one that is a decision rather than a transfer.
|
|
||||||
package artifacts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store is the artifact store at an address, as this machine reaches it.
|
|
||||||
type Store struct {
|
|
||||||
// Address is `host:port` — the store as the caller reaches it now, composed and never
|
|
||||||
// recorded (novox/hq 04-ISSUES/102).
|
|
||||||
Address string
|
|
||||||
// HTTP is the client used; nil is a client with a modest timeout.
|
|
||||||
HTTP *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gone is the answer when the store does not hold it: the outcome wanted, already true.
|
|
||||||
var Gone = fmt.Errorf("the store does not hold it")
|
|
||||||
|
|
||||||
// LetGo asks the store to drop one artifact the mesh recorded making.
|
|
||||||
//
|
|
||||||
// Takes a reference as the mesh records it — `artifact-store://<module>/<artifact>@sha256:…` for
|
|
||||||
// an image, `…/blobs/sha256:…` for an archive — because that is the identity every record uses,
|
|
||||||
// and composes the address here at the moment of use.
|
|
||||||
//
|
|
||||||
// Returns Gone when the store answers that it does not have it. That is not a failure: the sweep
|
|
||||||
// wants the artifact absent, and it is. It is distinguished from success only so a caller can say
|
|
||||||
// which of the two happened.
|
|
||||||
func (s Store) LetGo(ctx context.Context, reference string) error {
|
|
||||||
path, kept := catalogue.InArtifactStore(reference)
|
|
||||||
if !kept {
|
|
||||||
// Nothing the mesh put in its own store. Refused rather than attempted: composing a
|
|
||||||
// delete for a reference of unknown shape is how a sweep reaches something that is not
|
|
||||||
// the mesh's.
|
|
||||||
return fmt.Errorf("%s is not a reference into the mesh's artifact store", reference)
|
|
||||||
}
|
|
||||||
if s.Address == "" {
|
|
||||||
return fmt.Errorf("this mesh has no artifact store on its network to ask about %s", reference)
|
|
||||||
}
|
|
||||||
repository, kind, digest, err := split(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
url := "http://" + s.Address + "/v2/" + repository + "/" + kind + "/" + digest
|
|
||||||
|
|
||||||
request, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
client := s.HTTP
|
|
||||||
if client == nil {
|
|
||||||
client = &http.Client{Timeout: 30 * time.Second}
|
|
||||||
}
|
|
||||||
response, err := client.Do(request)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer response.Body.Close()
|
|
||||||
switch response.StatusCode {
|
|
||||||
case http.StatusAccepted, http.StatusOK, http.StatusNoContent:
|
|
||||||
return nil
|
|
||||||
case http.StatusNotFound:
|
|
||||||
return Gone
|
|
||||||
case http.StatusMethodNotAllowed:
|
|
||||||
// The registry was started without deletion enabled. Said plainly, because the remedy is
|
|
||||||
// a setting on the store's module and not anything about this artifact.
|
|
||||||
return fmt.Errorf(
|
|
||||||
"the artifact store refuses deletion: its server was started without it enabled "+
|
|
||||||
"(REGISTRY_STORAGE_DELETE_ENABLED), so nothing can be collected until the store "+
|
|
||||||
"module is applied again (novox/hq ADR 0189). Asking about %s", reference)
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("the artifact store answered %s for %s", response.Status, reference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// split reads a recorded path into the repository, which endpoint names the thing, and the digest.
|
|
||||||
//
|
|
||||||
// Two shapes, which are the two the mesh records: `<repository>@sha256:<hex>` is a manifest, and
|
|
||||||
// `<repository>/blobs/sha256:<hex>` is a blob.
|
|
||||||
func split(path string) (repository, kind, digest string, err error) {
|
|
||||||
if before, after, ok := strings.Cut(path, "@sha256:"); ok {
|
|
||||||
return before, "manifests", "sha256:" + after, nil
|
|
||||||
}
|
|
||||||
if before, after, ok := strings.Cut(path, "/blobs/sha256:"); ok {
|
|
||||||
return before, "blobs", "sha256:" + after, nil
|
|
||||||
}
|
|
||||||
return "", "", "", fmt.Errorf("%q names nothing the store holds by digest", path)
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package artifacts
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Asking the store to let go of what the mesh no longer keeps (novox/hq ADR 0189, issue 108).
|
|
||||||
//
|
|
||||||
// A fake store records what it was asked to delete, so what is asserted is the mesh's decision
|
|
||||||
// and the shape of the request — not the registry's behaviour, which is the registry's to test.
|
|
||||||
|
|
||||||
func fakeStore(t *testing.T, answer int) (Store, *[]string) {
|
|
||||||
t.Helper()
|
|
||||||
var asked []string
|
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodDelete {
|
|
||||||
t.Errorf("the store was asked %s %s; collecting is a delete", r.Method, r.URL.Path)
|
|
||||||
}
|
|
||||||
asked = append(asked, r.URL.Path)
|
|
||||||
w.WriteHeader(answer)
|
|
||||||
}))
|
|
||||||
t.Cleanup(server.Close)
|
|
||||||
return Store{Address: strings.TrimPrefix(server.URL, "http://")}, &asked
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnImageAndAnArchiveAreAskedForAtTheirOwnEndpoints(t *testing.T) {
|
|
||||||
// The two shapes the mesh records: a manifest by digest, and a blob by digest. They are
|
|
||||||
// different endpoints, and asking at the wrong one answers 404 — which this would then
|
|
||||||
// record as collected, leaving the bytes on disk for ever while the record says otherwise.
|
|
||||||
store, asked := fakeStore(t, http.StatusAccepted)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
image := catalogue.ArtifactStoreScheme + "web/app@sha256:abc123"
|
|
||||||
archive := catalogue.ArtifactStoreScheme + "web/config/blobs/sha256:def456"
|
|
||||||
if err := store.LetGo(ctx, image); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := store.LetGo(ctx, archive); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := []string{"/v2/web/app/manifests/sha256:abc123", "/v2/web/config/blobs/sha256:def456"}
|
|
||||||
if len(*asked) != 2 || (*asked)[0] != want[0] || (*asked)[1] != want[1] {
|
|
||||||
t.Fatalf("the store was asked %v; want %v", *asked, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAStoreThatDoesNotHaveItAnswersGone(t *testing.T) {
|
|
||||||
// The outcome wanted, already true. Told apart from success only so the sweep can say which
|
|
||||||
// happened; both are recorded, because retrying for ever is the thing to avoid.
|
|
||||||
store, _ := fakeStore(t, http.StatusNotFound)
|
|
||||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
|
||||||
if !errors.Is(err, Gone) {
|
|
||||||
t.Fatalf("a store that does not hold it answered %v, want Gone", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAStoreWithDeletionOffSaysSoAndNamesTheRemedy(t *testing.T) {
|
|
||||||
// The registry answers 405 when it was started without deletion enabled. The remedy is a
|
|
||||||
// setting on the store's module, and saying "405" would send somebody to the wrong place.
|
|
||||||
store, _ := fakeStore(t, http.StatusMethodNotAllowed)
|
|
||||||
err := store.LetGo(context.Background(), catalogue.ArtifactStoreScheme+"web/app@sha256:abc123")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a store that refuses deletion was read as success")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "REGISTRY_STORAGE_DELETE_ENABLED") {
|
|
||||||
t.Fatalf("the refusal does not name the remedy: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAReferenceThatIsNotTheMeshsOwnIsNeverAsked(t *testing.T) {
|
|
||||||
// The whole safety of the sweep is that it names only what the mesh recorded putting there.
|
|
||||||
// A reference of another shape — a vendor's image, a package version — is refused rather
|
|
||||||
// than composed into a delete somewhere that is not the mesh's store.
|
|
||||||
store, asked := fakeStore(t, http.StatusAccepted)
|
|
||||||
for _, reference := range []string{
|
|
||||||
"docker.io/library/registry@sha256:abc123",
|
|
||||||
"registry@sha256:abc123",
|
|
||||||
"1.4.2",
|
|
||||||
} {
|
|
||||||
if err := store.LetGo(context.Background(), reference); err == nil {
|
|
||||||
t.Errorf("%s was asked about; it is not a reference into the mesh's store", reference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(*asked) != 0 {
|
|
||||||
t.Fatalf("the store was asked about %v", *asked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package broker
|
package broker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The JetStream side of the controller: the one place the mesh's streams and consumers are
|
// The JetStream side of the controller: the one place the mesh's streams and consumers are
|
||||||
@@ -318,50 +316,3 @@ func retentionOf(r Retention) nats.RetentionPolicy {
|
|||||||
return nats.LimitsPolicy
|
return nats.LimitsPolicy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureBucket creates a module's bucket if it is absent and brings its options to match if it is
|
|
||||||
// present (novox/hq ADR 0201).
|
|
||||||
//
|
|
||||||
// **An update, never a delete and recreate**, for the reason a stream is updated: recreating
|
|
||||||
// discards what the bucket holds, and what a module's state holds is data. The mesh's caps are
|
|
||||||
// asserted with the owner's options, so a bucket made by hand converges to them.
|
|
||||||
func (j *JetStream) EnsureBucket(b Bucket) error {
|
|
||||||
history := b.History
|
|
||||||
if history == 0 {
|
|
||||||
history = 1
|
|
||||||
}
|
|
||||||
js, err := jetstream.New(j.conn)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
|
|
||||||
Bucket: b.Bucket(),
|
|
||||||
Description: b.Why(),
|
|
||||||
History: uint8(history),
|
|
||||||
TTL: time.Duration(b.TTLSeconds) * time.Second,
|
|
||||||
MaxValueSize: StateMaxValueBytes,
|
|
||||||
MaxBytes: StateMaxBytes,
|
|
||||||
Storage: jetstream.FileStorage,
|
|
||||||
}); err != nil {
|
|
||||||
return fmt.Errorf("asserting bucket %s: %w", b.Bucket(), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BucketNames is every key-value bucket on the server, the mesh's and anybody else's.
|
|
||||||
func (j *JetStream) BucketNames() ([]string, error) {
|
|
||||||
js, err := jetstream.New(j.conn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
lister := js.KeyValueStoreNames(ctx)
|
|
||||||
var out []string
|
|
||||||
for name := range lister.Name() {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
return out, lister.Error()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,11 +45,6 @@ type Membership struct {
|
|||||||
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
// module that must tell the mesh from the world, the route proxy serving an internal name, reads
|
||||||
// it here rather than keeping a definition of its own.
|
// it here rather than keeping a definition of its own.
|
||||||
Mesh []string `json:"mesh,omitempty"`
|
Mesh []string `json:"mesh,omitempty"`
|
||||||
// State is every bucket this module's code may reach, by the name it uses for each, and whether
|
|
||||||
// it may write it (novox/hq ADR 0201): the runtime answers a bundle's state verbs from this list
|
|
||||||
// and refuses, with the reason, what is not on it — the bus enforces only the union over every
|
|
||||||
// module on the machine.
|
|
||||||
State []StateIssued `json:"state,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Served is one address a tool is answered on.
|
// Served is one address a tool is answered on.
|
||||||
@@ -108,7 +103,6 @@ func MembershipFor(node string, d Declared, where Placements) Membership {
|
|||||||
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m.State = stateIssuedFor(d)
|
|
||||||
if len(d.Invokes) > 0 {
|
if len(d.Invokes) > 0 {
|
||||||
m.Reaches = map[string][]string{}
|
m.Reaches = map[string][]string{}
|
||||||
for _, t := range d.Invokes {
|
for _, t := range d.Invokes {
|
||||||
|
|||||||
@@ -103,12 +103,6 @@ type Principal struct {
|
|||||||
// permission and nothing beside it.
|
// permission and nothing beside it.
|
||||||
Invokes []string
|
Invokes []string
|
||||||
|
|
||||||
// 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.
|
|
||||||
State []string
|
|
||||||
Reads []string
|
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// 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
|
// and never appears here: this file is written to a node's disk and read by a server, and a
|
||||||
// secret that can be read from a configuration file is a secret with a wider blast radius
|
// secret that can be read from a configuration file is a secret with a wider blast radius
|
||||||
@@ -427,10 +421,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and
|
|
||||||
// watched, its own written too.
|
|
||||||
pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...)
|
|
||||||
|
|
||||||
case KindNodeTools:
|
case KindNodeTools:
|
||||||
// **One process serves what every module on the machine would have served for itself**
|
// **One process serves what every module on the machine would have served for itself**
|
||||||
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
// (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that
|
||||||
@@ -497,13 +487,6 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
|||||||
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
"$JS.API.CONSUMER.MSG.NEXT."+stream+"."+durable,
|
||||||
"$JS.ACK."+stream+"."+durable+".>")
|
"$JS.ACK."+stream+"."+durable+".>")
|
||||||
}
|
}
|
||||||
// **And it keeps and reads state for the modules it carries** (novox/hq ADR 0201): the union
|
|
||||||
// of what each may do with a bucket — an owner's write, a reader's read. That one module's code
|
|
||||||
// does not write another's bucket through it is the runtime's to keep, from the membership
|
|
||||||
// each assignment is issued, as it keeps each module's events under that module's own name.
|
|
||||||
for _, d := range p.Carries {
|
|
||||||
pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...)
|
|
||||||
}
|
|
||||||
sub = unique(sub)
|
sub = unique(sub)
|
||||||
pub = unique(pub)
|
pub = unique(pub)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module's state on the bus (novox/hq ADR 0201, design 32 §4, design 25 §3).
|
|
||||||
//
|
|
||||||
// A module names the state it keeps (`state`) and the state of others it reads (`reads`), and each
|
|
||||||
// is a key-value bucket: the server's own last-per-subject stream with direct reads, delete markers
|
|
||||||
// and watches, which is the state relationship the mesh already uses for declarations, opened to
|
|
||||||
// modules. The controller creates every bucket from the catalogue — from registration, like a
|
|
||||||
// seat's stream, so a reader may watch before the owner runs anywhere — and no module can.
|
|
||||||
//
|
|
||||||
// Pure, like everything else in this package that decides what the bus holds; jetstream.go is the
|
|
||||||
// part that asks a server.
|
|
||||||
|
|
||||||
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
|
|
||||||
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
|
|
||||||
const (
|
|
||||||
StateMaxValueBytes = 256 * 1024
|
|
||||||
StateMaxBytes = 64 * 1024 * 1024
|
|
||||||
)
|
|
||||||
|
|
||||||
// A Bucket is one module's declared state as the bus holds it.
|
|
||||||
type Bucket struct {
|
|
||||||
Module string
|
|
||||||
Name string
|
|
||||||
// History is how many values a key keeps; zero is one.
|
|
||||||
History int
|
|
||||||
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
|
|
||||||
TTLSeconds int
|
|
||||||
}
|
|
||||||
|
|
||||||
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
|
|
||||||
// underscore, which neither may contain, so two modules can never derive one bucket.
|
|
||||||
func BucketName(module, name string) string { return module + "_" + name }
|
|
||||||
|
|
||||||
// Bucket is this bucket's name on the bus.
|
|
||||||
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
|
|
||||||
|
|
||||||
// Why is carried into the server's description of the bucket, so somebody reading the server's
|
|
||||||
// own state finds whose it is and why it is kept.
|
|
||||||
func (b Bucket) Why() string {
|
|
||||||
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
|
|
||||||
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
|
|
||||||
b.Module, b.Name, b.Module, b.Module)
|
|
||||||
}
|
|
||||||
|
|
||||||
// bucketOfRead is the bucket a read names, `<module>.<name>`, or false when it names none.
|
|
||||||
func bucketOfRead(read string) (string, bool) {
|
|
||||||
at := strings.LastIndex(read, ".")
|
|
||||||
if at <= 0 || at == len(read)-1 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
module, name := read[:at], read[at+1:]
|
|
||||||
if !safeSubject.MatchString(module) || !safeSubject.MatchString(name) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return BucketName(module, name), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateGrants is what a principal publishes to reach the state its modules keep and read: for every
|
|
||||||
// bucket, binding to it, reading a key directly, and an ordered consumer for listing and watching,
|
|
||||||
// created and deleted on the bucket's own stream, with its flow control answered; for a bucket an
|
|
||||||
// owner keeps, writing under the bucket's own subjects too.
|
|
||||||
//
|
|
||||||
// **Measured against a running server, 2026-10-04** (novox/hq research 024), and each one is there
|
|
||||||
// because leaving it out failed: without STREAM.INFO nothing binds; without DIRECT.GET nothing is
|
|
||||||
// read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a
|
|
||||||
// watch cannot be stopped and lingers on the server. A write outside these is refused by the server
|
|
||||||
// — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first.
|
|
||||||
func stateGrants(module string, keeps []string, reads []string) []string {
|
|
||||||
var out []string
|
|
||||||
read := func(bucket string) {
|
|
||||||
stream := "KV_" + bucket
|
|
||||||
out = append(out,
|
|
||||||
"$JS.API.STREAM.INFO."+stream,
|
|
||||||
"$JS.API.DIRECT.GET."+stream+".>",
|
|
||||||
"$JS.API.CONSUMER.CREATE."+stream+".>",
|
|
||||||
"$JS.API.CONSUMER.DELETE."+stream+".>",
|
|
||||||
"$JS.FC."+stream+".>")
|
|
||||||
}
|
|
||||||
for _, name := range keeps {
|
|
||||||
if !safeSubject.MatchString(name) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
bucket := BucketName(module, name)
|
|
||||||
read(bucket)
|
|
||||||
out = append(out, "$KV."+bucket+".>")
|
|
||||||
}
|
|
||||||
for _, r := range reads {
|
|
||||||
if bucket, ok := bucketOfRead(r); ok {
|
|
||||||
read(bucket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateIssued is one bucket an assignment may reach, by the name its module uses for it: its own
|
|
||||||
// state by the local name, another's as `<module>.<name>` (novox/hq ADR 0201).
|
|
||||||
type StateIssued struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Bucket string `json:"bucket"`
|
|
||||||
Writes bool `json:"writes,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateIssuedFor is every bucket a module's code may reach, as its membership lists them.
|
|
||||||
func stateIssuedFor(d Declared) []StateIssued {
|
|
||||||
var out []StateIssued
|
|
||||||
for _, b := range d.State {
|
|
||||||
out = append(out, StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true})
|
|
||||||
}
|
|
||||||
for _, r := range d.Reads {
|
|
||||||
if bucket, ok := bucketOfRead(r); ok {
|
|
||||||
out = append(out, StateIssued{Name: r, Bucket: bucket})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// stateNames is the local names of a module's own buckets.
|
|
||||||
func stateNames(buckets []Bucket) []string {
|
|
||||||
out := make([]string, 0, len(buckets))
|
|
||||||
for _, b := range buckets {
|
|
||||||
out = append(out, b.Name)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// A BucketAsserter is the part of a JetStream connection bucket assertion needs.
|
|
||||||
type BucketAsserter interface {
|
|
||||||
// EnsureBucket creates the bucket if absent and brings its options to match if present, never
|
|
||||||
// discarding what it holds.
|
|
||||||
EnsureBucket(b Bucket) error
|
|
||||||
// BucketNames is every key-value bucket on the server.
|
|
||||||
BucketNames() ([]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RaiseBuckets asserts every declared bucket and answers the buckets on the server that nothing
|
|
||||||
// declares any more.
|
|
||||||
//
|
|
||||||
// **Those are reported, never removed** (novox/hq ADR 0201, ADR 0030): what a module stored is
|
|
||||||
// data, and a manifest edited, a module renamed or a catalogue entry dropped is an ordinary day's
|
|
||||||
// work that must not take data with it. Removing one is a person's act.
|
|
||||||
func RaiseBuckets(a BucketAsserter, buckets []Bucket) (undeclared []string, err error) {
|
|
||||||
sorted := append([]Bucket(nil), buckets...)
|
|
||||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Bucket() < sorted[j].Bucket() })
|
|
||||||
declared := map[string]bool{}
|
|
||||||
for _, b := range sorted {
|
|
||||||
if err := a.EnsureBucket(b); err != nil {
|
|
||||||
return nil, fmt.Errorf("asserting %s's state %q: %w", b.Module, b.Name, err)
|
|
||||||
}
|
|
||||||
declared[b.Bucket()] = true
|
|
||||||
}
|
|
||||||
names, err := a.BucketNames()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("listing the bus's state: %w", err)
|
|
||||||
}
|
|
||||||
for _, n := range names {
|
|
||||||
if !declared[n] {
|
|
||||||
undeclared = append(undeclared, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(undeclared)
|
|
||||||
return undeclared, nil
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
package broker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The grants measured against a running server (novox/hq research 024): an owner reads and writes
|
|
||||||
// its bucket, a reader only reads, and neither reaches any other bucket.
|
|
||||||
func TestAnOwnerWritesItsStateAndAReaderOnlyReads(t *testing.T) {
|
|
||||||
owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "claude-code",
|
|
||||||
State: []string{"servers"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, s := range []string{
|
|
||||||
"$KV.claude-code_servers.>",
|
|
||||||
"$JS.API.STREAM.INFO.KV_claude-code_servers",
|
|
||||||
"$JS.API.DIRECT.GET.KV_claude-code_servers.>",
|
|
||||||
"$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>",
|
|
||||||
"$JS.API.CONSUMER.DELETE.KV_claude-code_servers.>",
|
|
||||||
"$JS.FC.KV_claude-code_servers.>",
|
|
||||||
} {
|
|
||||||
has(t, owner.Publish, s)
|
|
||||||
}
|
|
||||||
hasNot(t, owner.Publish, "$KV.>")
|
|
||||||
hasNot(t, owner.Publish, "$JS.API.>")
|
|
||||||
|
|
||||||
reader, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "console",
|
|
||||||
Reads: []string{"claude-code.servers"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, reader.Publish, "$JS.API.DIRECT.GET.KV_claude-code_servers.>")
|
|
||||||
has(t, reader.Publish, "$JS.API.CONSUMER.CREATE.KV_claude-code_servers.>")
|
|
||||||
hasNot(t, reader.Publish, "$KV.claude-code_servers.>")
|
|
||||||
for _, s := range reader.Subscribe {
|
|
||||||
if s == "$KV.claude-code_servers.>" {
|
|
||||||
t.Fatalf("a reader subscribes the bucket's subjects directly: %v", reader.Subscribe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// One runtime carries every module on its machine, so its grant is the union: the owner's write
|
|
||||||
// where an owner is carried, a read where only a reader is.
|
|
||||||
func TestTheRuntimeKeepsAndReadsStateForItsModules(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "one", Module: RuntimeModule,
|
|
||||||
Carries: []Declared{
|
|
||||||
{Module: "claude-code", State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
|
||||||
Reads: []string{"licence-manager.bindings"}},
|
|
||||||
{Module: "audit"},
|
|
||||||
}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
has(t, perms.Publish, "$KV.claude-code_servers.>")
|
|
||||||
has(t, perms.Publish, "$JS.API.DIRECT.GET.KV_licence-manager_bindings.>")
|
|
||||||
hasNot(t, perms.Publish, "$KV.licence-manager_bindings.>")
|
|
||||||
}
|
|
||||||
|
|
||||||
// A module with no state is granted nothing of any bucket — the composition of every module that
|
|
||||||
// existed before this is unchanged.
|
|
||||||
func TestAModuleWithNoStateReachesNoBucket(t *testing.T) {
|
|
||||||
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "billing",
|
|
||||||
Emits: []string{"order.placed"}, PasswordHash: "x"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, s := range perms.Publish {
|
|
||||||
if strings.HasPrefix(s, "$KV.") || strings.HasPrefix(s, "$JS.FC.") || strings.Contains(s, ".KV_") {
|
|
||||||
t.Fatalf("granted %q without declaring state", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A read that names no bucket grants nothing rather than something that happens to parse.
|
|
||||||
func TestAReadThatNamesNoBucketGrantsNothing(t *testing.T) {
|
|
||||||
if got := stateGrants("a", nil, []string{"nodot", "x.", ".y", "a.b>"}); len(got) != 0 {
|
|
||||||
t.Fatalf("granted %v for reads that name no bucket", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The membership lists every bucket the module's code may reach, by the name the module uses for
|
|
||||||
// it, and whether it may write it — the list the runtime refuses from.
|
|
||||||
func TestAMembershipListsTheStateItsModuleMayReach(t *testing.T) {
|
|
||||||
m := MembershipFor("one", Declared{Module: "claude-code",
|
|
||||||
State: []Bucket{{Module: "claude-code", Name: "servers"}},
|
|
||||||
Reads: []string{"licence-manager.bindings"}}, Placements{})
|
|
||||||
want := []StateIssued{
|
|
||||||
{Name: "servers", Bucket: "claude-code_servers", Writes: true},
|
|
||||||
{Name: "licence-manager.bindings", Bucket: "licence-manager_bindings"},
|
|
||||||
}
|
|
||||||
if !slices.Equal(m.State, want) {
|
|
||||||
t.Fatalf("issued %+v, want %+v", m.State, want)
|
|
||||||
}
|
|
||||||
if none := MembershipFor("one", Declared{Module: "audit"}, Placements{}); none.State != nil {
|
|
||||||
t.Fatalf("a module with no state was issued %+v", none.State)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type buckets struct {
|
|
||||||
ensured []string
|
|
||||||
on []string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *buckets) EnsureBucket(x Bucket) error {
|
|
||||||
b.ensured = append(b.ensured, x.Bucket())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (b *buckets) BucketNames() ([]string, error) { return b.on, nil }
|
|
||||||
|
|
||||||
// Every declared bucket is asserted; one on the server that nothing declares is said, not removed.
|
|
||||||
func TestRaisingStateReportsWhatNothingDeclares(t *testing.T) {
|
|
||||||
b := &buckets{on: []string{"claude-code_servers", "gone_old", "ours_by_hand"}}
|
|
||||||
undeclared, err := RaiseBuckets(b, []Bucket{{Module: "claude-code", Name: "servers"}, {Module: "a", Name: "b"}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !slices.Equal(b.ensured, []string{"a_b", "claude-code_servers"}) {
|
|
||||||
t.Fatalf("asserted %v", b.ensured)
|
|
||||||
}
|
|
||||||
if !slices.Equal(undeclared, []string{"gone_old", "ours_by_hand"}) {
|
|
||||||
t.Fatalf("reported %v", undeclared)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Against a real server: a bucket is created with the owner's options and the mesh's caps,
|
|
||||||
// asserting it again changes nothing and keeps what it holds, and a changed option is brought to
|
|
||||||
// match in place.
|
|
||||||
func TestABucketIsAssertedInPlace(t *testing.T) {
|
|
||||||
js := aLiveBus(t)
|
|
||||||
b := Bucket{Module: "statetest", Name: "servers"}
|
|
||||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
|
||||||
t.Fatalf("a real server refused a module's bucket: %v", err)
|
|
||||||
}
|
|
||||||
kv, err := js.Context().KeyValue(b.Bucket())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := kv.Put("all.one", []byte(`{"kept":true}`)); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
b.History = 3
|
|
||||||
if _, err := RaiseBuckets(js, []Bucket{b}); err != nil {
|
|
||||||
t.Fatalf("asserting the bucket again failed, so a restart would: %v", err)
|
|
||||||
}
|
|
||||||
got, err := kv.Get("all.one")
|
|
||||||
if err != nil || string(got.Value()) != `{"kept":true}` {
|
|
||||||
t.Fatalf("asserting again lost what the bucket held: %v %v", got, err)
|
|
||||||
}
|
|
||||||
status, err := kv.Status()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if status.History() != 3 {
|
|
||||||
t.Fatalf("history is %d, the owner declared 3", status.History())
|
|
||||||
}
|
|
||||||
if s, ok := status.(*nats.KeyValueBucketStatus); ok {
|
|
||||||
if c := s.StreamInfo().Config; c.MaxMsgSize != StateMaxValueBytes || c.MaxBytes != StateMaxBytes {
|
|
||||||
t.Fatalf("the mesh's caps are not on the bucket: value %d, bucket %d", c.MaxMsgSize, c.MaxBytes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,10 +33,6 @@ type Declared struct {
|
|||||||
Watches []Seat
|
Watches []Seat
|
||||||
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||||
Invokes []string
|
Invokes []string
|
||||||
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
|
|
||||||
State []Bucket
|
|
||||||
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
|
|
||||||
Reads []string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
||||||
@@ -85,7 +81,6 @@ func Users(r Records) ([]Principal, error) {
|
|||||||
Kind: KindModule, Node: node, Module: d.Module,
|
Kind: KindModule, Node: node, Module: d.Module,
|
||||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
||||||
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes,
|
||||||
State: stateNames(d.State), Reads: d.Reads,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if runtimeHere {
|
if runtimeHere {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
|
|||||||
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
|
||||||
for _, a := range artifacts {
|
for _, a := range artifacts {
|
||||||
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
|
||||||
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, say)
|
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
say("artifact", "%s FAILED: %v", a.Name, err)
|
say("artifact", "%s FAILED: %v", a.Name, err)
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -443,7 +443,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
|
|||||||
|
|
||||||
func one(ctx context.Context, run Runner, publish Publisher,
|
func one(ctx context.Context, run Runner, publish Publisher,
|
||||||
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
|
||||||
held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
|
held map[string]string, npmrc string, seats map[string]string,
|
||||||
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
say func(step, format string, args ...any)) (catalogue.Built, error) {
|
||||||
|
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
@@ -582,11 +582,6 @@ func one(ctx context.Context, run Runner, publish Publisher,
|
|||||||
"holds no copy of it. Build %s first",
|
"holds no copy of it. Build %s first",
|
||||||
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
|
||||||
}
|
}
|
||||||
// The module's own packages first, where the compiler and the bundler resolve them from
|
|
||||||
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
|
|
||||||
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
|
|
||||||
return catalogue.Built{}, fmt.Errorf("%s: %s: %w", module, a.Name, err)
|
|
||||||
}
|
|
||||||
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
say("bundle", "compiling %s in %s's toolchain", a.Language, chain.Base)
|
||||||
compiled, err := compile(ctx, run, tree, chain, base, a)
|
compiled, err := compile(ctx, run, tree, chain, base, a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module's own packages, installed before its bundle is compiled, so the bundler inlines them.
|
|
||||||
//
|
|
||||||
// **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler
|
|
||||||
// resolve an import by walking up from the module's source: the module's own directory first, then
|
|
||||||
// the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a
|
|
||||||
// database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose
|
|
||||||
// recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle").
|
|
||||||
// Now the module's `package.json` says what it depends on, as any Node package does, and the build
|
|
||||||
// installs exactly that into the module's own directory before compiling.
|
|
||||||
//
|
|
||||||
// **The SDK the toolchain carries is the one a bundle is built with, whatever the module says**
|
|
||||||
// (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A
|
|
||||||
// module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a
|
|
||||||
// workstation — and installing that range would shadow the toolchain's copy for this module alone:
|
|
||||||
// one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads.
|
|
||||||
// So the SDK is taken out of what is installed (and never fetched), and any copy something else
|
|
||||||
// pulls in is removed afterwards; every import of it resolves past the module's node_modules to the
|
|
||||||
// toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin.
|
|
||||||
//
|
|
||||||
// **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile
|
|
||||||
// when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept
|
|
||||||
// between builds. What is shared is npm's own download cache, a named volume, which is
|
|
||||||
// content-addressed and verified by integrity on every read — it saves the network, never the
|
|
||||||
// install. Install scripts do not run: the build node runs nobody's postinstall, and what a script
|
|
||||||
// would build natively could not be inlined into one file anyway.
|
|
||||||
|
|
||||||
// sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a
|
|
||||||
// module's own dependencies never supply (above).
|
|
||||||
const sdkPackage = "@novox/mesh-sdk"
|
|
||||||
|
|
||||||
// npmCache is the named volume npm's download cache lives in across builds on one build node.
|
|
||||||
const npmCache = "mesh-builder-npm-cache"
|
|
||||||
|
|
||||||
// ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when
|
|
||||||
// the module has no package.json or depends on nothing else — which builds exactly as before.
|
|
||||||
func ownDependencies(tree string) ([]string, error) {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(tree, "package.json"))
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var p struct {
|
|
||||||
Dependencies map[string]string `json:"dependencies"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &p); err != nil {
|
|
||||||
return nil, fmt.Errorf("the module's package.json is not JSON: %w", err)
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for name := range p.Dependencies {
|
|
||||||
if name != sdkPackage {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Strings(names)
|
|
||||||
return names, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// installSteps is the script run inside the toolchain image, from the module's own directory ($0).
|
|
||||||
// It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes
|
|
||||||
// the SDK out of what is installed, installs production dependencies only, removes any copy of the
|
|
||||||
// SDK something pulled in, and puts the result at the module's node_modules.
|
|
||||||
const installSteps = `set -e
|
|
||||||
work="$(mktemp -d)"
|
|
||||||
cp "$0/package.json" "$work/"
|
|
||||||
if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi
|
|
||||||
cd "$work"
|
|
||||||
node -e '
|
|
||||||
const fs = require("fs"), sdk = process.argv[1];
|
|
||||||
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
|
||||||
for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk];
|
|
||||||
delete p.devDependencies; delete p.scripts;
|
|
||||||
fs.writeFileSync("package.json", JSON.stringify(p));
|
|
||||||
' "$1"
|
|
||||||
shift
|
|
||||||
if [ -f package-lock.json ]; then
|
|
||||||
npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@"
|
|
||||||
else
|
|
||||||
npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@"
|
|
||||||
fi
|
|
||||||
find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} +
|
|
||||||
rm -rf "$0/node_modules"
|
|
||||||
cp -a node_modules "$0/node_modules"
|
|
||||||
`
|
|
||||||
|
|
||||||
// installOwn installs a TypeScript module's own production dependencies into its directory, in the
|
|
||||||
// toolchain image, before the compile — or does nothing at all for a module that has none.
|
|
||||||
func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string,
|
|
||||||
registry Npmrc, say func(step, format string, args ...any)) error {
|
|
||||||
if chain.Language != "typescript" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
deps, err := ownDependencies(tree)
|
|
||||||
if err != nil || len(deps) == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
scoped := strings.TrimSpace(registry.Scope)
|
|
||||||
if !registry.Enabled() {
|
|
||||||
// **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on
|
|
||||||
// the public one, where anybody may have published it: a dependency that installs is not
|
|
||||||
// the dependency the module meant.
|
|
||||||
for _, d := range deps {
|
|
||||||
if strings.HasPrefix(d, "@novox/") {
|
|
||||||
return fmt.Errorf("the module depends on %s, and this build knows no package registry "+
|
|
||||||
"for its scope; it would resolve from the public registry, which is not where the "+
|
|
||||||
"mesh publishes it", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const within = "/app/modules/module"
|
|
||||||
invocation := []string{"run", "--rm",
|
|
||||||
"--volume", tree + ":" + within,
|
|
||||||
"--volume", npmCache + ":/root/.npm",
|
|
||||||
"--workdir", within}
|
|
||||||
var flags []string
|
|
||||||
if registry.Enabled() {
|
|
||||||
// The registry is reached where the binding says it is, which may be this machine's own
|
|
||||||
// loopback — the reason an image build that resolves packages runs on the host network too.
|
|
||||||
invocation = append(invocation, "--network", "host")
|
|
||||||
reg := strings.TrimSpace(registry.Registry)
|
|
||||||
if !strings.HasSuffix(reg, "/") {
|
|
||||||
reg += "/"
|
|
||||||
}
|
|
||||||
flags = append(flags, "--"+scoped+":registry="+reg)
|
|
||||||
}
|
|
||||||
invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage)
|
|
||||||
invocation = append(invocation, flags...)
|
|
||||||
say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", "))
|
|
||||||
if _, err := run(ctx, tree, "docker", invocation...); err != nil {
|
|
||||||
return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
package builder
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module's own packages (dependencies.go): installed into its own directory, in the toolchain,
|
|
||||||
// before the compile, so the bundler inlines them — the SDK always the toolchain's.
|
|
||||||
|
|
||||||
func buildWithPackageJSON(t *testing.T, pkg string, extra map[string]string, registry Npmrc) (*recorded, error) {
|
|
||||||
t.Helper()
|
|
||||||
files := map[string]string{"index.ts": "console.log(1)"}
|
|
||||||
if pkg != "" {
|
|
||||||
files["package.json"] = pkg
|
|
||||||
}
|
|
||||||
for k, v := range extra {
|
|
||||||
files[k] = v
|
|
||||||
}
|
|
||||||
r, workspace := aRepository(t, aBundle, files)
|
|
||||||
held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)}
|
|
||||||
_, err := Build(context.Background(), compiling{r}.run, r,
|
|
||||||
"https://forge.invalid/greeter.git", "", "", workspace, held, registry, GitCredential{}, nil)
|
|
||||||
return r, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func installs(r *recorded) []string {
|
|
||||||
var out []string
|
|
||||||
for _, line := range r.ran {
|
|
||||||
if strings.HasPrefix(line, "docker run") && strings.Contains(line, "npm ci") {
|
|
||||||
out = append(out, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileIndex(r *recorded) int {
|
|
||||||
for i, line := range r.ran {
|
|
||||||
if strings.Contains(line, "--outDir") {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAModulesOwnPackagesAreInstalledInTheToolchainBeforeTheCompile(t *testing.T) {
|
|
||||||
r, err := buildWithPackageJSON(t, `{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0","pg":"^8"},"devDependencies":{"typescript":"^5"}}`,
|
|
||||||
nil, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got := installs(r)
|
|
||||||
if len(got) != 1 {
|
|
||||||
t.Fatalf("want one install of the module's own packages:\n%s", strings.Join(r.ran, "\n"))
|
|
||||||
}
|
|
||||||
line := got[0]
|
|
||||||
for _, want := range []string{
|
|
||||||
"mesh-tools/build@sha256:", // in the toolchain image
|
|
||||||
":/app/modules/module", // into the module's own directory
|
|
||||||
"--workdir /app/modules/module", //
|
|
||||||
npmCache + ":/root/.npm", // npm's verified download cache, and only that
|
|
||||||
"--omit=dev", "--ignore-scripts", // production packages, no build-node scripts
|
|
||||||
"npm ci", "npm install", "--no-package-lock", // the lockfile when there is one, else the ranges
|
|
||||||
"--@novox:registry=https://forge.invalid/api/packages/novox/npm/", // the scope from the mesh's registry
|
|
||||||
"--network host",
|
|
||||||
"@novox/mesh-sdk", // named, to be taken out of what is installed
|
|
||||||
} {
|
|
||||||
if !strings.Contains(line, want) {
|
|
||||||
t.Errorf("the install lacks %q:\n%s", want, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The SDK is the toolchain's: never installed from the module's range, and any copy removed.
|
|
||||||
if !strings.Contains(line, `delete p[k][sdk]`) || !strings.Contains(line, `-path "*/node_modules/@novox/mesh-sdk" -exec rm -rf`) {
|
|
||||||
t.Errorf("the module's own SDK range could shadow the toolchain's SDK:\n%s", line)
|
|
||||||
}
|
|
||||||
if i, c := strings.Index(strings.Join(r.ran, "\n"), "npm ci"), compileIndex(r); c < 0 ||
|
|
||||||
i > strings.Index(strings.Join(r.ran, "\n"), "--outDir") {
|
|
||||||
t.Fatalf("the install did not run before the compile:\n%s", strings.Join(r.ran, "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **A module with nothing beyond the SDK builds exactly as before**: the same commands, no install.
|
|
||||||
func TestAModuleDependingOnlyOnTheSDKBuildsExactlyAsBefore(t *testing.T) {
|
|
||||||
without, err := buildWithPackageJSON(t, "", nil, Npmrc{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, pkg := range []string{
|
|
||||||
`{"type":"module","dependencies":{"@novox/mesh-sdk":"^0.1.0"},"devDependencies":{"typescript":"^5"}}`,
|
|
||||||
`{"type":"module"}`,
|
|
||||||
} {
|
|
||||||
with, err := buildWithPackageJSON(t, pkg, map[string]string{"package-lock.json": "{}"}, Npmrc{Scope: "@novox", Registry: "https://forge.invalid/npm/"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.Join(with.ran, "\n"), "npm ") {
|
|
||||||
t.Fatalf("a module depending on nothing but the SDK ran npm:\n%s", strings.Join(with.ran, "\n"))
|
|
||||||
}
|
|
||||||
if len(with.ran) != len(without.ran) {
|
|
||||||
t.Fatalf("a module depending only on the SDK built differently from one with no package.json:\n%s\n---\n%s",
|
|
||||||
strings.Join(with.ran, "\n"), strings.Join(without.ran, "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without the mesh's registry a scoped package would resolve on the public one: refused by name.
|
|
||||||
func TestAScopedPackageWithNoRegistryIsRefused(t *testing.T) {
|
|
||||||
r, err := buildWithPackageJSON(t, `{"dependencies":{"@novox/mesh-sdk":"^0.1.0","@novox/other":"^1"}}`, nil, Npmrc{})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "@novox/other") {
|
|
||||||
t.Fatalf("a scoped package was installed with no registry for its scope: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(strings.Join(r.ran, "\n"), "--outDir") {
|
|
||||||
t.Fatal("the compile ran after the refusal")
|
|
||||||
}
|
|
||||||
// A public package installs without one, from the public registry and nothing else.
|
|
||||||
r, err = buildWithPackageJSON(t, `{"dependencies":{"mssql":"^11"}}`, nil, Npmrc{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := installs(r); len(got) != 1 || strings.Contains(got[0], ":registry=") || strings.Contains(got[0], "--network host") {
|
|
||||||
t.Fatalf("a public package's install: %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnUnreadablePackageJSONIsRefusedByName(t *testing.T) {
|
|
||||||
_, err := buildWithPackageJSON(t, `{"dependencies":`, nil, Npmrc{})
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "package.json") {
|
|
||||||
t.Fatalf("a broken package.json was not refused by name: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -67,9 +67,8 @@ type Toolchain struct {
|
|||||||
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
// `package.json` saying `"type": "module"` — Node reads a bare `.js` as CommonJS otherwise, so a
|
||||||
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
// bundle with its dependencies and without that line still fails to start — and the pruned,
|
||||||
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
// production-only node_modules the runtime itself ships with: the SDK's and the runtime's
|
||||||
// dependencies, and nothing module-specific (a module's own npm dependencies are installed into
|
// dependencies, and nothing module-specific yet (novox/hq ADR 0188 §5: a skeleton; a module's
|
||||||
// its own directory before the compile and inlined by the bundler: dependencies.go). Empty for a
|
// own npm dependencies are a later step). Empty for a language whose bundle carries its own —
|
||||||
// language whose bundle carries its own —
|
|
||||||
// a Go binary is static, a Python bundle is installed with its dependencies.
|
// a Go binary is static, a Python bundle is installed with its dependencies.
|
||||||
//
|
//
|
||||||
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
// A toolchain image without the directory fails the build by name rather than packing a bundle
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func boundUsed(content string) [][2]string {
|
|||||||
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
||||||
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
||||||
// reason.
|
// reason.
|
||||||
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]string {
|
||||||
out := map[string]map[string]string{}
|
out := map[string]map[string]string{}
|
||||||
for _, want := range m.Wants() {
|
for _, want := range m.Wants() {
|
||||||
for i := range needs {
|
for i := range needs {
|
||||||
@@ -54,20 +54,12 @@ func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]st
|
|||||||
if n.Name != want || n.For != m.Module {
|
if n.Name != want || n.For != m.Module {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
|
||||||
values := map[string]string{
|
values := map[string]string{
|
||||||
"at": n.At,
|
"at": n.At,
|
||||||
"from": n.From,
|
"from": n.From,
|
||||||
"as": as,
|
"as": ConsumerIdentity(node, IdentitySource(m.Slug, m.Module)),
|
||||||
}
|
}
|
||||||
// What the provider derives for this consumer rather than for all of them
|
for key, value := range n.Serves {
|
||||||
// (novox/hq ADR 0201). Filled here, the one place a provision and the module
|
|
||||||
// requiring it are both in hand.
|
|
||||||
served, err := ServedTo(n.Serves, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
|
||||||
}
|
|
||||||
for key, value := range served {
|
|
||||||
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
||||||
// which is what a float would write and what a connection string would refuse.
|
// which is what a float would write and what a connection string would refuse.
|
||||||
values[key] = plainly(value)
|
values[key] = plainly(value)
|
||||||
@@ -75,7 +67,7 @@ func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]st
|
|||||||
out[want] = values
|
out[want] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||||
|
|||||||
@@ -1,311 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider derives for one consumer, said once in the provider's definition and delivered
|
|
||||||
// to both ends (novox/hq ADR 0201, issue 124).
|
|
||||||
//
|
|
||||||
// A `serves` block is otherwise literal: the same values for every consumer. Where the provider
|
|
||||||
// *names the resource* — a bucket, a database, a vhost — the name is derived from who is asking,
|
|
||||||
// and before this the mesh had no channel for it. The provider recomputed it in its own code and
|
|
||||||
// every consumer transcribed it into its own definition by hand, which is a copy of somebody
|
|
||||||
// else's rule kept in agreement by nobody. One of three transcriptions was wrong for months.
|
|
||||||
//
|
|
||||||
// **The mesh learns no protocol here; it spells its own name in an alphabet it already knows.**
|
|
||||||
// The only fact a served value may name is the identity the mesh itself minted for the consumer,
|
|
||||||
// in one of two alphabets: as it was minted, and as a DNS label. Everything a provider wants
|
|
||||||
// around it — a prefix, a suffix, a separator — it writes around the placeholder, because a
|
|
||||||
// served value is a string.
|
|
||||||
|
|
||||||
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
|
|
||||||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
|
||||||
|
|
||||||
// consumerFacts are what a served value may name about the consumer it is being derived for.
|
|
||||||
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
|
|
||||||
// so it is the one thing the mesh can hand to a provider without either end guessing.
|
|
||||||
var consumerFacts = []string{"as"}
|
|
||||||
|
|
||||||
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
|
|
||||||
// identifier with its separator written `-` instead of `_` — the whole of the difference between
|
|
||||||
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
|
|
||||||
var consumerAlphabets = []string{"dns"}
|
|
||||||
|
|
||||||
// ServedTo fills a provider's served values for one consumer.
|
|
||||||
//
|
|
||||||
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
|
|
||||||
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
|
|
||||||
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
|
|
||||||
// nothing.
|
|
||||||
//
|
|
||||||
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
|
|
||||||
// stated outright, and is left alone.
|
|
||||||
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
|
|
||||||
if len(serves) == 0 {
|
|
||||||
return serves, nil
|
|
||||||
}
|
|
||||||
var out map[string]any
|
|
||||||
for _, key := range sortedAnyKeys(serves) {
|
|
||||||
text, ok := serves[key].(string)
|
|
||||||
if !ok || !strings.Contains(text, "${consumer:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filled, err := consumerInto(text, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("the value served as %q: %w", key, err)
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
// Copied only once something actually changes: the caller's map is the manifest's,
|
|
||||||
// and a provider that derives nothing must not have it rewritten underneath it.
|
|
||||||
out = make(map[string]any, len(serves))
|
|
||||||
for k, v := range serves {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out[key] = filled
|
|
||||||
}
|
|
||||||
if out == nil {
|
|
||||||
return serves, nil
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumerInto replaces every `${consumer:…}` in one value.
|
|
||||||
//
|
|
||||||
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
|
||||||
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
|
||||||
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
|
||||||
// is refused by (boundInto).
|
|
||||||
func consumerInto(value, as string) (string, error) {
|
|
||||||
var failed error
|
|
||||||
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
|
||||||
parts := consumerFact.FindStringSubmatch(match)
|
|
||||||
fact, alphabet := parts[1], parts[2]
|
|
||||||
if fact != "as" {
|
|
||||||
if failed == nil {
|
|
||||||
failed = fmt.Errorf(
|
|
||||||
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
switch alphabet {
|
|
||||||
case "":
|
|
||||||
return as
|
|
||||||
case "dns":
|
|
||||||
return asDNSLabel(as)
|
|
||||||
default:
|
|
||||||
if failed == nil {
|
|
||||||
failed = fmt.Errorf(
|
|
||||||
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
|
||||||
}
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if failed != nil {
|
|
||||||
return "", failed
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// asDNSLabel writes a minted identity as a DNS label.
|
|
||||||
//
|
|
||||||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
|
||||||
// already short enough for the tightest backend they reach (CheckIdentity, twenty characters). So
|
|
||||||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
|
||||||
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
|
||||||
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
|
||||||
func asDNSLabel(as string) string {
|
|
||||||
return strings.ReplaceAll(as, "_", "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
|
|
||||||
// have, when the definition is parsed rather than when a consumer is resolved.
|
|
||||||
//
|
|
||||||
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
|
|
||||||
// first time somebody required it is a definition that is wrong now.
|
|
||||||
func CheckServes(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
for _, provision := range sortedServes(m.Serves) {
|
|
||||||
for _, key := range sortedAnyKeys(m.Serves[provision]) {
|
|
||||||
text, ok := m.Serves[provision][key].(string)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// A probe identity, because what is checked is the shape of the statement and not
|
|
||||||
// what any consumer is called.
|
|
||||||
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedServes(serves map[string]map[string]any) []string {
|
|
||||||
out := make([]string, 0, len(serves))
|
|
||||||
for k := range serves {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedAnyKeys(values map[string]any) []string {
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for k := range values {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// derivedFor is what the provider on this machine derives for one consumer of one provision
|
|
||||||
// (novox/hq ADR 0201).
|
|
||||||
//
|
|
||||||
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
|
|
||||||
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
|
|
||||||
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
|
|
||||||
// already in the provider's own definition, so repeating it here would be a second copy to go
|
|
||||||
// stale.
|
|
||||||
//
|
|
||||||
// The first module in the resolved order that says it serves the provision answers, which is the
|
|
||||||
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
|
|
||||||
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
|
|
||||||
// then there is nothing derived to tell.
|
|
||||||
func (r Resolution) derivedFor(provision, as, consumer, local string, settings SettingsBy) (map[string]any, error) {
|
|
||||||
for _, m := range r.Modules {
|
|
||||||
serves, said := m.Serves[provision]
|
|
||||||
if !said {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var names map[string]any
|
|
||||||
for key, value := range serves {
|
|
||||||
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
|
|
||||||
if names == nil {
|
|
||||||
names = map[string]any{}
|
|
||||||
}
|
|
||||||
names[key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if names == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
// **A consumer that keeps several holders of this provision is refused** — this is issue
|
|
||||||
// 124's own failure one case to the side, and it would be just as quiet.
|
|
||||||
//
|
|
||||||
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
|
|
||||||
// login, so it would make one resource per holder. The consumer's side has no such
|
|
||||||
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
|
|
||||||
// derived from the un-suffixed identity. So the provider would create the holder's
|
|
||||||
// resource and the consumer would be configured against a name nothing made — it would
|
|
||||||
// authenticate successfully and be refused on every object, which reads like a credential
|
|
||||||
// fault and is not one.
|
|
||||||
//
|
|
||||||
// Lifting this means giving the consumer's side a local dimension. That is a decision,
|
|
||||||
// not an omission, and until it is taken the mesh says so rather than guessing.
|
|
||||||
if local != "" {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
|
|
||||||
"consumer from the login the mesh minted. Each holder has its own login, and a "+
|
|
||||||
"consumer is told one value per requirement — so the two ends would name "+
|
|
||||||
"different things and nothing would compare them (novox/hq ADR 0201)",
|
|
||||||
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
|
|
||||||
}
|
|
||||||
settled, err := Settle(names, settings[m.Module])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
|
|
||||||
}
|
|
||||||
derived, err := ServedTo(settled, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
|
|
||||||
}
|
|
||||||
return derived, nil
|
|
||||||
}
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
|
|
||||||
// instead of asking for it (novox/hq ADR 0201, issue 124).
|
|
||||||
//
|
|
||||||
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
|
|
||||||
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
|
|
||||||
// nothing compared it to what the provider would actually create: the module would have
|
|
||||||
// authenticated successfully and been refused on every object, which reads like a credential fault
|
|
||||||
// and is not one. It looked authoritative for months.
|
|
||||||
//
|
|
||||||
// The test is exact and costs one string search: a definition whose file already contains the
|
|
||||||
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
|
|
||||||
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
|
|
||||||
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
|
|
||||||
// because after substitution every consumer's file contains it legitimately.
|
|
||||||
//
|
|
||||||
// Only values that actually name the consumer are judged. A provider that serves a constant under
|
|
||||||
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
|
|
||||||
// rather than wrong.
|
|
||||||
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
|
|
||||||
if fmt.Sprint(resource["type"]) != "file" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, ok := resource["content"].(string)
|
|
||||||
if !ok || content == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, provision := range sortedKnown(known) {
|
|
||||||
values := known[provision]
|
|
||||||
identity := values["as"]
|
|
||||||
if identity == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, key := range sortedStringKeys(values) {
|
|
||||||
if key == "as" {
|
|
||||||
// The login is not derived from itself, and a consumer that must present it in a
|
|
||||||
// connection string legitimately has it from `${bound:…}` — which is what it will
|
|
||||||
// be after substitution, so this would judge the substitution, not the module.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value := values[key]
|
|
||||||
if value == "" || !namesTheConsumer(value, identity) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(content, value) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
|
|
||||||
"keeping its own copy of somebody else's naming rule is one that can disagree "+
|
|
||||||
"with it, silently. Say ${bound:%s:%s} and be told",
|
|
||||||
module, value, resource["id"], provision, provision, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
|
|
||||||
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
|
|
||||||
// from it, whatever else it may be.
|
|
||||||
func namesTheConsumer(value, identity string) bool {
|
|
||||||
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedKnown(known map[string]map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(known))
|
|
||||||
for k := range known {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortedStringKeys(values map[string]string) []string {
|
|
||||||
out := make([]string, 0, len(values))
|
|
||||||
for k := range values {
|
|
||||||
out = append(out, k)
|
|
||||||
}
|
|
||||||
sort.Strings(out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -645,16 +645,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
as := ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module))
|
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||||
// What the provider derives for THIS consumer, filled here where the consumer is
|
|
||||||
// known (novox/hq ADR 0201). The same fill knownFor does below, so the binding file
|
|
||||||
// and the module's `${bound:…}` substitutions cannot say different things.
|
|
||||||
told := *found
|
|
||||||
told.Serves, err = ServedTo(told.Serves, as)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
|
|
||||||
}
|
|
||||||
file, err := boundFile(told, m.Binds[to], as, own)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -728,10 +719,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// And what its bindings say, for the half of a connection that is not secret.
|
// And what its bindings say, for the half of a connection that is not secret.
|
||||||
known, err := knownFor(m, r.Needs, r.Node)
|
known := knownFor(m, r.Needs, r.Node)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||||
@@ -748,11 +736,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
}
|
}
|
||||||
local := *answered
|
local := *answered
|
||||||
local.For = m.Module
|
local.For = m.Module
|
||||||
here, err := knownFor(m, []Needed{local}, r.Node)
|
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for provision, values := range here {
|
|
||||||
known[provision] = values
|
known[provision] = values
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -777,17 +761,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
// And the machine underneath, which no binding of its own can tell it.
|
// And the machine underneath, which no binding of its own can tell it.
|
||||||
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
thisMachine := machineFacts(r, with.Names, with.MeshRange)
|
||||||
|
|
||||||
// **A definition that already holds the answer transcribed it** (novox/hq ADR 0201).
|
|
||||||
// Judged over what the module itself declares, and before anything is substituted: the
|
|
||||||
// mesh's own generated files — the binding, the contributions — legitimately carry the
|
|
||||||
// derived value, and after substitution so does every consumer's file, so this is the one
|
|
||||||
// moment the two can be told apart.
|
|
||||||
for _, own := range m.Resources {
|
|
||||||
if err := notTranscribed(own, known, m.Module); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Which of this module's files carry a secret, for the rule that a container may not read
|
// Which of this module's files carry a secret, for the rule that a container may not read
|
||||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||||
secretFiles := secretFilesOf(resources)
|
secretFiles := secretFilesOf(resources)
|
||||||
@@ -907,15 +880,6 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
|||||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||||
copied["reload-on"] = renamed
|
copied["reload-on"] = renamed
|
||||||
}
|
}
|
||||||
// And which of its module's containers a scheduled step holds still (novox/hq ADR 0189).
|
|
||||||
// **The loudest of the three when it is missed.** An unprefixed `restart-on` matches
|
|
||||||
// nothing and a service quietly never restarts; an unprefixed `while-stopped` names a
|
|
||||||
// container the declaration does not contain, and the host refuses the whole
|
|
||||||
// declaration — so the machine takes nothing at all, for every push, until this is
|
|
||||||
// right. That is what it did on the control node (2026-10-04).
|
|
||||||
if renamed := reflectsRenamed(m.Module, resource[WhileStopped]); renamed != nil {
|
|
||||||
copied[WhileStopped] = renamed
|
|
||||||
}
|
|
||||||
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
|
// And what a process replaces (novox/hq issue 213): a resource of this module's that it
|
||||||
// no longer declares, named as the host recorded it, or the host hands nothing over and
|
// no longer declares, named as the host recorded it, or the host hands nothing over and
|
||||||
// removes it first.
|
// removes it first.
|
||||||
@@ -1211,19 +1175,6 @@ type Contribution struct {
|
|||||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||||
// is what makes swapping one for another cost nothing.
|
// is what makes swapping one for another cost nothing.
|
||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
// Derived is what this provider's own definition said it derives for this consumer, already
|
|
||||||
// derived (novox/hq ADR 0201).
|
|
||||||
//
|
|
||||||
// **The provider is told, rather than recomputing it.** A served value may name the consumer's
|
|
||||||
// identity — a bucket named for who is asking, a database prefixed with it — and before this
|
|
||||||
// the rule lived twice: once in the provisioner's code, once transcribed into every consumer's
|
|
||||||
// definition. The mesh fills the provider's own statement here and delivers the same filled
|
|
||||||
// value to the consumer, so the two cannot disagree: there is no second computation to
|
|
||||||
// disagree with.
|
|
||||||
//
|
|
||||||
// Only the keys that are per-consumer. The rest of what the provider serves is the same for
|
|
||||||
// everyone and is in its own definition, where it already is.
|
|
||||||
Derived map[string]any `json:"derived,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||||
@@ -1315,17 +1266,12 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
|||||||
// told about it and withdraws the login on its next pass.
|
// told about it and withdraws the login on its next pass.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
as := holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local)
|
|
||||||
derived, err := r.derivedFor(g.Provision, as, g.From, g.Local, settings)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||||
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
|
From: g.From, Node: g.Consumer, At: g.At, Values: g.Values,
|
||||||
// One holder per local name: the identity the consumer is known by, and the local name
|
// One holder per local name: the identity the consumer is known by, and the local name
|
||||||
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
// after it where the module keeps several (ADR 0094). Not a login any backend checks —
|
||||||
// a secret is not a login — so the identity limit does not apply to the suffix.
|
// a secret is not a login — so the identity limit does not apply to the suffix.
|
||||||
As: as,
|
As: holderAs(ConsumerIdentity(g.Consumer, IdentitySource(g.Slug, g.From)), g.Local),
|
||||||
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
|
||||||
})
|
})
|
||||||
if granted[g.Provision] == nil {
|
if granted[g.Provision] == nil {
|
||||||
|
|||||||
@@ -1,343 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a provider derives for each consumer, said once and delivered to both ends
|
|
||||||
// (novox/hq ADR 0201, issue 124).
|
|
||||||
//
|
|
||||||
// The failure these are written against: the object store's provisioner derived each consumer's
|
|
||||||
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
|
||||||
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
|
||||||
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
|
||||||
// authenticated successfully and been refused on every object. Each of them also named the
|
|
||||||
// machine the module happens to run on, which a definition may not do.
|
|
||||||
|
|
||||||
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
|
||||||
// a bucket named for whoever is asking.
|
|
||||||
func store() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "store", Version: "1",
|
|
||||||
Provides: FromAnywhere("s3-bucket"),
|
|
||||||
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: FromMesh}},
|
|
||||||
Serves: map[string]map[string]any{"s3-bucket": {
|
|
||||||
"region": "eu-west",
|
|
||||||
"bucket": "${consumer:as:dns}",
|
|
||||||
}},
|
|
||||||
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
|
||||||
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// files is a consumer that writes the bucket into its own configuration — which is the thing it
|
|
||||||
// could not do before, and had to transcribe.
|
|
||||||
func files() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "files", Version: "1", Slug: "files",
|
|
||||||
Requires: []string{"s3-bucket"},
|
|
||||||
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
|
|
||||||
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pics is a second consumer of the same provider on the same machine: two derivations, neither
|
|
||||||
// the other's.
|
|
||||||
func pics() Manifest {
|
|
||||||
return Manifest{
|
|
||||||
Module: "pics", Version: "1", Slug: "pics",
|
|
||||||
Requires: []string{"s3-bucket"},
|
|
||||||
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
|
|
||||||
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
|
|
||||||
Resources: []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The three places the derived value lands must agree, because agreeing is the whole point: the
|
|
||||||
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
|
|
||||||
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
|
|
||||||
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
|
|
||||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
|
||||||
want := strings.ReplaceAll(as, "_", "-")
|
|
||||||
if want == as || !strings.Contains(as, "_") {
|
|
||||||
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
|
|
||||||
}
|
|
||||||
|
|
||||||
env := fileNamed(out, "files.env")
|
|
||||||
if env == nil {
|
|
||||||
t.Fatalf("the consumer was given no file: %v", out)
|
|
||||||
}
|
|
||||||
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
|
||||||
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
|
|
||||||
}
|
|
||||||
|
|
||||||
binding := fileNamed(out, "files.bound-s3-bucket")
|
|
||||||
if binding == nil {
|
|
||||||
t.Fatalf("the consumer was given no binding: %v", out)
|
|
||||||
}
|
|
||||||
var said struct {
|
|
||||||
Serves map[string]any `json:"serves"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if said.Serves["bucket"] != want {
|
|
||||||
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
|
|
||||||
}
|
|
||||||
// And what is the same for everybody is still the same for everybody.
|
|
||||||
if said.Serves["region"] != "eu-west" {
|
|
||||||
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
|
|
||||||
}
|
|
||||||
|
|
||||||
given := storeGrants(t, out)
|
|
||||||
if len(given) != 1 {
|
|
||||||
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
|
|
||||||
}
|
|
||||||
if given[0].Derived["bucket"] != want {
|
|
||||||
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
|
|
||||||
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
|
|
||||||
}
|
|
||||||
// Only the per-consumer half. The region is the same for everyone and is already in the
|
|
||||||
// provider's own definition; repeating it here would be a copy to go stale.
|
|
||||||
if _, carried := given[0].Derived["region"]; carried {
|
|
||||||
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
|
|
||||||
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files(), pics()),
|
|
||||||
[]string{"store", "files", "pics"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{Grants: []Grant{
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
}})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
forFiles := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
|
||||||
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("pics", "pics")), "_", "-")
|
|
||||||
if forFiles == forPics {
|
|
||||||
t.Fatal("the two consumers were given the same identity; this test proves nothing")
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
|
|
||||||
t.Errorf("files was not given its own bucket:\n%s", got)
|
|
||||||
}
|
|
||||||
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
|
|
||||||
t.Errorf("pics was not given its own bucket:\n%s", got)
|
|
||||||
}
|
|
||||||
var buckets []any
|
|
||||||
for _, g := range storeGrants(t, out) {
|
|
||||||
buckets = append(buckets, g.Derived["bucket"])
|
|
||||||
}
|
|
||||||
if len(buckets) != 2 || buckets[0] == buckets[1] {
|
|
||||||
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An operator may still set what the provider serves, and the mesh still derives the rest: the
|
|
||||||
// setting is laid on first, then the consumer's half is filled.
|
|
||||||
func TestASettingComposesWithADerivedValue(t *testing.T) {
|
|
||||||
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{
|
|
||||||
Settings: SettingsBy{"store": {{From: "the operator",
|
|
||||||
Values: map[string]any{"bucket": "team-${consumer:as:dns}"}}}},
|
|
||||||
Grants: []Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := "team-" + strings.ReplaceAll(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
|
||||||
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
|
|
||||||
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
|
|
||||||
}
|
|
||||||
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
|
|
||||||
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
|
||||||
// consumer happens to be resolved — and the refusal says what may be said instead.
|
|
||||||
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
|
||||||
for _, c := range []struct{ value, says string }{
|
|
||||||
{"${consumer:node}", "as"},
|
|
||||||
{"${consumer:as:punycode}", "dns"},
|
|
||||||
} {
|
|
||||||
m := store()
|
|
||||||
m.Serves["s3-bucket"]["bucket"] = c.value
|
|
||||||
raw, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = ParseManifest(raw)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("%s was accepted", c.value)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.value) {
|
|
||||||
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `dns` is checked against an identity the mesh actually mints, not an invented string.
|
|
||||||
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
|
|
||||||
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
|
||||||
if err := CheckIdentity("anchor", IdentitySource("ncloud", "nextcloud")); err != nil {
|
|
||||||
t.Fatalf("the mesh would not mint this identity at all: %v", err)
|
|
||||||
}
|
|
||||||
label := asDNSLabel(as)
|
|
||||||
if strings.Contains(label, "_") {
|
|
||||||
t.Errorf("%q is not a DNS label", label)
|
|
||||||
}
|
|
||||||
if strings.ReplaceAll(label, "-", "_") != as {
|
|
||||||
t.Errorf("%q is not %q with its separator rewritten", label, as)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The check that would have caught the one wrong instance: a consumer that writes the derived
|
|
||||||
// value into its own definition instead of asking for it is refused, whether it transcribed the
|
|
||||||
// right answer or a predecessor's.
|
|
||||||
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
|
|
||||||
as := ConsumerIdentity("workstation", IdentitySource("files", "files"))
|
|
||||||
transcribed := strings.ReplaceAll(as, "_", "-")
|
|
||||||
|
|
||||||
m := files()
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
// Exactly what the provider will create — correct today, and a copy of a rule that is
|
|
||||||
// not this module's.
|
|
||||||
"content": "BUCKET=" + transcribed + "\n",
|
|
||||||
}}
|
|
||||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
|
|
||||||
t.Errorf("the refusal does not say what to write instead: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// And a constant the provider serves to everyone is not a transcription: repeating it is
|
|
||||||
// redundant, not wrong, and refusing it would be the mesh policing style.
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "REGION=eu-west\n",
|
|
||||||
}}
|
|
||||||
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if _, err := r.Declaration(Rendering{Grants: []Grant{{
|
|
||||||
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Values: map[string]any{}, Sealed: "c2VhbGVk",
|
|
||||||
}}}); err != nil {
|
|
||||||
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeGrants(t *testing.T, out []map[string]any) []Contribution {
|
|
||||||
t.Helper()
|
|
||||||
for _, r := range out {
|
|
||||||
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Given []Contribution `json:"given"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return parsed.Given
|
|
||||||
}
|
|
||||||
t.Fatalf("the provider was given no contributions file: %v", out)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
|
|
||||||
// while its provider is told another.
|
|
||||||
//
|
|
||||||
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
|
|
||||||
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
|
|
||||||
// would make one resource per holder. The consumer's side has no such dimension: there is one
|
|
||||||
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
|
|
||||||
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
|
|
||||||
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
|
|
||||||
// which is exactly the fault this whole record exists to end.
|
|
||||||
//
|
|
||||||
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
|
|
||||||
// consumer's side a local dimension, which is a decision and not an omission.
|
|
||||||
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
|
|
||||||
m := files()
|
|
||||||
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
|
|
||||||
m.Secrets = nil
|
|
||||||
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
|
|
||||||
"hot": "/var/lib/files/hot.secret",
|
|
||||||
"cold": "/var/lib/files/cold.secret",
|
|
||||||
}}
|
|
||||||
m.Resources = []map[string]any{{
|
|
||||||
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
|
|
||||||
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
|
|
||||||
}}
|
|
||||||
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = r.Declaration(Rendering{Grants: []Grant{
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
|
|
||||||
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
|
|
||||||
}})
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
|
|
||||||
"its two ends would have disagreed in silence")
|
|
||||||
}
|
|
||||||
for _, want := range []string{"files", "s3-bucket", "bucket"} {
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("the refusal does not name %q: %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// novox/hq issue 223: genesis raises the controller as a container built from this repository's own
|
|
||||||
// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a
|
|
||||||
// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by
|
|
||||||
// digest, and the replacement the manifest's process names must be the container genesis raises.
|
|
||||||
func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) {
|
|
||||||
raw, err := os.ReadFile("../../Dockerfile")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) {
|
|
||||||
t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments")
|
|
||||||
}
|
|
||||||
makefile, err := os.ReadFile("../../Makefile")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`)
|
|
||||||
if string(pin.Find(raw)) != string(pin.Find(makefile)) {
|
|
||||||
t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -325,15 +325,6 @@ type Manifest struct {
|
|||||||
// person's account (design 25 §7) already had the same shape.
|
// person's account (design 25 §7) already had the same shape.
|
||||||
Invokes []string `json:"invokes,omitempty"`
|
Invokes []string `json:"invokes,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.
|
|
||||||
State []StateDeclaration `json:"state,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"`
|
|
||||||
|
|
||||||
// Capabilities the machine must have. A different field from Requires because the remedy
|
// Capabilities the machine must have. A different field from Requires because the remedy
|
||||||
// differs: a missing module can be assigned, and a missing capability means the wrong
|
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||||
// machine.
|
// machine.
|
||||||
@@ -1325,8 +1316,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
// module whose event names are wrong installs, starts, connects and reacts to nothing, with
|
// module whose event names are wrong installs, starts, connects and reacts to nothing, with
|
||||||
// every log line saying it is fine (novox/hq 04-ISSUES/127).
|
// every log line saying it is fine (novox/hq 04-ISSUES/127).
|
||||||
problems = append(problems, EventProblems(m)...)
|
problems = append(problems, EventProblems(m)...)
|
||||||
// And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201).
|
|
||||||
problems = append(problems, StateProblems(m)...)
|
|
||||||
wellFormed := true
|
wellFormed := true
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
if !name.MatchString(c.Name) {
|
if !name.MatchString(c.Name) {
|
||||||
@@ -1439,10 +1428,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
"%s serves %q to whoever requires it, and does not provide it", m.Module, to))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A served value may be derived for the consumer it is served to (novox/hq ADR 0201). Read
|
|
||||||
// here, where the definition is, rather than when somebody first requires it: a rule that
|
|
||||||
// would be refused at the first consumer is wrong from the moment it is written.
|
|
||||||
problems = append(problems, CheckServes(m)...)
|
|
||||||
for to, where := range m.Binds {
|
for to, where := range m.Binds {
|
||||||
if !placedOrAbsolute(where) {
|
if !placedOrAbsolute(where) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
@@ -1637,13 +1622,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// **A scheduled step may hold this module's own containers still while it runs**
|
|
||||||
// (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each
|
|
||||||
// id is a container placed on that machine; what belongs here is what only the definition
|
|
||||||
// shows: that the ids are this module's, that they are containers, and that the step is
|
|
||||||
// scheduled. A module naming a neighbour's container would be a module that can stop the
|
|
||||||
// mesh, and the manifest is where that is visible.
|
|
||||||
problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...)
|
|
||||||
}
|
}
|
||||||
for name, own := range m.OwnSecrets {
|
for name, own := range m.OwnSecrets {
|
||||||
if !placedOrAbsolute(own.Path) {
|
if !placedOrAbsolute(own.Path) {
|
||||||
@@ -2175,71 +2153,3 @@ func (o OwnSecrets) Paths() map[string]string {
|
|||||||
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
// InstancesInterchangeable is the one value of a definition's `instances`: the module is the same
|
||||||
// on every machine, so any instance may answer for the module.
|
// on every machine, so any instance may answer for the module.
|
||||||
const InstancesInterchangeable = "interchangeable"
|
const InstancesInterchangeable = "interchangeable"
|
||||||
|
|
||||||
// WhileStopped is the resource key naming the containers a scheduled step holds still while it
|
|
||||||
// runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`.
|
|
||||||
const WhileStopped = "while-stopped"
|
|
||||||
|
|
||||||
// hasSchedule is whether a resource declares a cadence, as a string.
|
|
||||||
func hasSchedule(r map[string]any) bool {
|
|
||||||
s, _ := r["schedule"].(string)
|
|
||||||
return s != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// whileStoppedProblems judges one container's maintenance window against its own definition
|
|
||||||
// (novox/hq ADR 0189).
|
|
||||||
//
|
|
||||||
// Three things the manifest is the only place to see: that the step is scheduled (a one-time
|
|
||||||
// offline job says *before* rather than *instead of* — at apply the host already has a window,
|
|
||||||
// because the declaration is applied in order and a run-once step gates what follows); that every
|
|
||||||
// id it names is **this module's own** container; and that it does not name itself.
|
|
||||||
//
|
|
||||||
// The host checks the fourth — that the container is actually placed on that machine — because
|
|
||||||
// that is a fact about the declaration and not about the definition.
|
|
||||||
func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string {
|
|
||||||
raw, present := r[WhileStopped]
|
|
||||||
if !present {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
ids, ok := raw.([]any)
|
|
||||||
if !ok {
|
|
||||||
return []string{fmt.Sprintf(
|
|
||||||
"%s declares %s on %v as a %T; it is a list of this module's container ids",
|
|
||||||
m.Module, WhileStopped, r["id"], raw)}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
if len(ids) > 0 && !scheduled {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+
|
|
||||||
"step: at apply the mesh already has one, because a run-once step gates what is "+
|
|
||||||
"declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"]))
|
|
||||||
}
|
|
||||||
containers := map[string]bool{}
|
|
||||||
for _, own := range m.Resources {
|
|
||||||
if fmt.Sprint(own["type"]) == "container" {
|
|
||||||
containers[fmt.Sprint(own["id"])] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, each := range ids {
|
|
||||||
id, ok := each.(string)
|
|
||||||
if !ok {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s on %v naming a %T; each entry is a container's id",
|
|
||||||
m.Module, WhileStopped, r["id"], each))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if id == fmt.Sprint(r["id"]) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !containers[id] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s declares %s on %v naming %q, which is not a container this module declares. "+
|
|
||||||
"A step may hold still its own module's containers and nobody else's — one "+
|
|
||||||
"that could quiesce a neighbour could stop the mesh",
|
|
||||||
m.Module, WhileStopped, r["id"], id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -215,13 +215,6 @@ func CatalogueProblems(shelf Shelf) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A read of a module's state that module does not keep (novox/hq ADR 0201) — said only where the
|
|
||||||
// owner is on the shelf, as a consumer may be installed before its emitter.
|
|
||||||
var manifests []Manifest
|
|
||||||
for _, module := range shelfOrder(shelf) {
|
|
||||||
manifests = append(manifests, shelf[module])
|
|
||||||
}
|
|
||||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
|
||||||
sort.Strings(problems)
|
sort.Strings(problems)
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What a module may call its state, and whose state it may ask to read (novox/hq ADR 0201).
|
|
||||||
//
|
|
||||||
// A module names its state **locally** — `servers`, never a bucket or a subject — and another
|
|
||||||
// module's as `<module>.<name>`, the way a consumed event names its emitter (design 32 §1). The
|
|
||||||
// mesh derives the bucket from the two names, so the module and the local name must each be one
|
|
||||||
// token: the bucket joins them with an underscore, which neither may contain, so two modules can
|
|
||||||
// never derive one bucket.
|
|
||||||
|
|
||||||
// stateName is one local name of a module's state: lower-case, no dot, no underscore.
|
|
||||||
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
|
||||||
|
|
||||||
// The mesh's caps on what a module may ask of a bucket's history.
|
|
||||||
const (
|
|
||||||
// StateMostHistory is the most past values a key may keep. The server's own limit.
|
|
||||||
StateMostHistory = 64
|
|
||||||
)
|
|
||||||
|
|
||||||
// StateDeclaration is one bucket a module owns: its local name, and the options that are the
|
|
||||||
// owner's to choose, as a seat chooses how long its backlog survives (design 32 §3).
|
|
||||||
type StateDeclaration struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
// History is how many values a key keeps, the current one included; zero is one.
|
|
||||||
History int `json:"history,omitempty"`
|
|
||||||
// TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted.
|
|
||||||
TTLSeconds int `json:"ttl-seconds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
|
|
||||||
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
|
||||||
trimmed := bytes.TrimSpace(raw)
|
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
|
||||||
return json.Unmarshal(trimmed, &s.Name)
|
|
||||||
}
|
|
||||||
type plain StateDeclaration
|
|
||||||
var full plain
|
|
||||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
|
||||||
dec.DisallowUnknownFields()
|
|
||||||
if err := dec.Decode(&full); err != nil {
|
|
||||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err)
|
|
||||||
}
|
|
||||||
*s = StateDeclaration(full)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
|
||||||
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
|
||||||
if s.History == 0 && s.TTLSeconds == 0 {
|
|
||||||
return json.Marshal(s.Name)
|
|
||||||
}
|
|
||||||
type plain StateDeclaration
|
|
||||||
return json.Marshal(plain(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ReadState splits a read into the owning module and the local name, or says why it is not one.
|
|
||||||
func ReadState(read string) (module, local string, err error) {
|
|
||||||
at := strings.LastIndex(read, ".")
|
|
||||||
if at <= 0 || at == len(read)-1 {
|
|
||||||
return "", "", fmt.Errorf("%q does not name a module and its state: a read is <module>.<name>", read)
|
|
||||||
}
|
|
||||||
module, local = read[:at], read[at+1:]
|
|
||||||
if !stateName.MatchString(module) {
|
|
||||||
return "", "", fmt.Errorf("%q cannot own state: a module whose state is read is one plain name", module)
|
|
||||||
}
|
|
||||||
if !stateName.MatchString(local) {
|
|
||||||
return "", "", fmt.Errorf("%q is not a state name: lower-case letters, digits and hyphens", local)
|
|
||||||
}
|
|
||||||
return module, local, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateProblems is what is wrong with a manifest's state and reads.
|
|
||||||
//
|
|
||||||
// Refused at registration, because a bucket name the bus cannot hold is a module that installs,
|
|
||||||
// starts, and is refused on its first write with a reason about a bucket nobody named.
|
|
||||||
func StateProblems(m Manifest) []string {
|
|
||||||
var problems []string
|
|
||||||
if len(m.State) > 0 && !stateName.MatchString(m.Module) {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps state, and a module's name is part of its buckets' names, which take one plain "+
|
|
||||||
"name — no dot (novox/hq ADR 0201)", m.Module))
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
switch {
|
|
||||||
case !stateName.MatchString(s.Name):
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+
|
|
||||||
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name))
|
|
||||||
case seen[s.Name]:
|
|
||||||
problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
seen[s.Name] = true
|
|
||||||
if s.History < 0 || s.History > StateMostHistory {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory))
|
|
||||||
}
|
|
||||||
if s.TTLSeconds < 0 {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Reads {
|
|
||||||
module, _, err := ReadState(r)
|
|
||||||
if err != nil {
|
|
||||||
problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if module == m.Module {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateReadsNothingDeclares is every read across a catalogue whose owner is present and declares no
|
|
||||||
// such state. An absent owner says nothing — a module may be installed long before the one whose
|
|
||||||
// state it reads, as a consumer may before its emitter (design 32 §1).
|
|
||||||
func StateReadsNothingDeclares(manifests []Manifest) []string {
|
|
||||||
declared := map[string]map[string]bool{}
|
|
||||||
for _, m := range manifests {
|
|
||||||
own := map[string]bool{}
|
|
||||||
for _, s := range m.State {
|
|
||||||
own[s.Name] = true
|
|
||||||
}
|
|
||||||
declared[m.Module] = own
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
for _, m := range manifests {
|
|
||||||
for _, r := range m.Reads {
|
|
||||||
module, local, err := ReadState(r)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if own, present := declared[module]; present && !own[local] {
|
|
||||||
problems = append(problems, fmt.Sprintf(
|
|
||||||
"%s reads %q, and %s keeps no state called %q", m.Module, r, module, local))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
|
|
||||||
// bare name or with the owner's options.
|
|
||||||
func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
|
|
||||||
m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` +
|
|
||||||
`"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` +
|
|
||||||
`"reads":["licence-manager.bindings"]}`))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 {
|
|
||||||
t.Fatalf("state not read: %+v", m.State)
|
|
||||||
}
|
|
||||||
if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" {
|
|
||||||
t.Fatalf("reads not read: %v", m.Reads)
|
|
||||||
}
|
|
||||||
// Written back as it came in: the short form where nothing else is said.
|
|
||||||
out, _ := json.Marshal(m.State)
|
|
||||||
if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` {
|
|
||||||
t.Fatalf("written back as %s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A name the bus could not hold, or that would let two modules derive one bucket, is refused at
|
|
||||||
// registration in the manifest's words.
|
|
||||||
func TestAStateNameIsLocalAndOneToken(t *testing.T) {
|
|
||||||
for _, c := range []struct{ manifest, says string }{
|
|
||||||
{`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`},
|
|
||||||
{`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`},
|
|
||||||
{`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`},
|
|
||||||
{`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`},
|
|
||||||
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
|
|
||||||
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
|
|
||||||
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
|
|
||||||
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`},
|
|
||||||
} {
|
|
||||||
_, err := ParseManifest([]byte(c.manifest))
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("%s was accepted", c.manifest)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
|
|
||||||
// because a module may be installed before the one whose state it reads.
|
|
||||||
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
|
|
||||||
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
|
|
||||||
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
|
|
||||||
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
|
|
||||||
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
|
|
||||||
t.Fatalf("a read of declared state was refused: %v", p)
|
|
||||||
}
|
|
||||||
p := StateReadsNothingDeclares([]Manifest{owner, bad})
|
|
||||||
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
|
|
||||||
t.Fatalf("a read of state nobody keeps was not named: %v", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
|
|
||||||
// names state that owner keeps.
|
|
||||||
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
|
||||||
manifests := theCatalogue(t)
|
|
||||||
var problems []string
|
|
||||||
for _, m := range manifests {
|
|
||||||
problems = append(problems, StateProblems(m)...)
|
|
||||||
}
|
|
||||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
|
||||||
if len(problems) > 0 {
|
|
||||||
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
|
|
||||||
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
|
|
||||||
shelf := Shelf{
|
|
||||||
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
|
|
||||||
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
|
|
||||||
}
|
|
||||||
problems := CatalogueProblems(shelf)
|
|
||||||
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
|
|
||||||
t.Fatalf("the catalogue pass said %v", problems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
|
|
||||||
//
|
|
||||||
// The host judges what it receives — whether each id is a container on that machine. What the
|
|
||||||
// definition is the only place to see is judged here, near whoever wrote it.
|
|
||||||
|
|
||||||
func aStoreManifest(step map[string]any) []byte {
|
|
||||||
m := map[string]any{
|
|
||||||
"module": "distribution", "version": "1",
|
|
||||||
"resources": []any{
|
|
||||||
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64)},
|
|
||||||
step,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(m)
|
|
||||||
return raw
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
|
|
||||||
raw := aStoreManifest(map[string]any{
|
|
||||||
"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
|
|
||||||
})
|
|
||||||
if _, err := ParseManifest(raw); err != nil {
|
|
||||||
t.Fatalf("a step holding its own module's container still was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
|
|
||||||
for _, c := range []struct {
|
|
||||||
name string
|
|
||||||
step map[string]any
|
|
||||||
says string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
"on a step with no schedule",
|
|
||||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"while-stopped": []any{"store"}},
|
|
||||||
"gates what is declared after it",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"on a run-once step, which already has order",
|
|
||||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"run-once": true, "while-stopped": []any{"store"}},
|
|
||||||
"A maintenance window is for a recurring step",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"naming a container this module does not declare",
|
|
||||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
|
|
||||||
"could quiesce a neighbour could stop the mesh",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"naming itself",
|
|
||||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
|
|
||||||
"naming itself",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"written as something that is not a list",
|
|
||||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"schedule": "30 3 * * *", "while-stopped": "store"},
|
|
||||||
"a list of this module's container ids",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
_, err := ParseManifest(aStoreManifest(c.step))
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("%s was accepted", c.name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), c.says) {
|
|
||||||
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A composed declaration names the step's held containers the way the machine knows them.
|
|
||||||
//
|
|
||||||
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
|
|
||||||
// ["store"]`, because a module names its own resources locally; the declaration a machine
|
|
||||||
// receives calls that container `distribution.store`, because every resource is composed under
|
|
||||||
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
|
|
||||||
// `while-stopped` was not — so the host found no container by that id and refused the whole
|
|
||||||
// declaration, every push, until it was fixed.
|
|
||||||
//
|
|
||||||
// It passed every test on both sides: the controller's tests read manifests, the host's read
|
|
||||||
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
|
|
||||||
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
|
|
||||||
store := Manifest{
|
|
||||||
Module: "distribution", Version: "1",
|
|
||||||
Provides: FromAnywhere("artifact-store"),
|
|
||||||
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
|
|
||||||
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
|
|
||||||
Resources: []map[string]any{
|
|
||||||
{"id": "store", "type": "container", "name": "mesh-registry",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
|
|
||||||
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
|
||||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
|
||||||
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
out, err := r.Declaration(Rendering{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
collect := fileNamed(out, "distribution.collect")
|
|
||||||
if collect == nil {
|
|
||||||
for _, res := range out {
|
|
||||||
if res["id"] == "distribution.collect" {
|
|
||||||
collect = res
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if collect == nil {
|
|
||||||
t.Fatalf("the step was not composed at all: %v", out)
|
|
||||||
}
|
|
||||||
held, _ := collect[WhileStopped].([]any)
|
|
||||||
if len(held) != 1 {
|
|
||||||
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
|
|
||||||
}
|
|
||||||
if got := fmt.Sprint(held[0]); got != "distribution.store" {
|
|
||||||
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
|
|
||||||
"called %q — the host refuses a declaration naming a container it does not have, "+
|
|
||||||
"whole, so the machine would take nothing at all", got, "distribution.store")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -132,9 +132,6 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
Serves: m.Tools,
|
Serves: m.Tools,
|
||||||
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
// And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's.
|
||||||
Invokes: m.Invokes,
|
Invokes: m.Invokes,
|
||||||
// And the state it keeps and reads (novox/hq ADR 0201).
|
|
||||||
State: bucketsOf(m),
|
|
||||||
Reads: m.Reads,
|
|
||||||
}
|
}
|
||||||
for _, c := range m.Claims {
|
for _, c := range m.Claims {
|
||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
@@ -151,30 +148,6 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
|
|||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// bucketsOf is the state a module keeps, as the bus holds it.
|
|
||||||
func bucketsOf(m catalogue.Manifest) []broker.Bucket {
|
|
||||||
var out []broker.Bucket
|
|
||||||
for _, s := range m.State {
|
|
||||||
out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeclaredBuckets is every bucket the catalogue declares, registered modules assigned or not: a
|
|
||||||
// bucket exists from registration, like a seat's stream, so a module reading it may watch before its
|
|
||||||
// owner runs anywhere (novox/hq ADR 0201).
|
|
||||||
func (i *Inventory) DeclaredBuckets(ctx context.Context) ([]broker.Bucket, error) {
|
|
||||||
declared, err := i.Catalogue(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot read the catalogue: %w", err)
|
|
||||||
}
|
|
||||||
var out []broker.Bucket
|
|
||||||
for _, m := range declared {
|
|
||||||
out = append(out, bucketsOf(m)...)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
|
||||||
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
|
||||||
Serves: catalogue.VerbNames(s.Serves)}
|
Serves: catalogue.VerbNames(s.Serves)}
|
||||||
|
|||||||
@@ -78,12 +78,11 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
// (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the
|
||||||
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
// runtime's image. Refused at registration, by name, for a module that is new to the catalogue
|
||||||
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
// or that was registered in another shape — the mechanism that keeps the old pattern from
|
||||||
// returning by habit. Before the runtime exists the pattern is accepted as it always was.
|
// returning by habit. **Not refused for a module already registered in that shape**: the
|
||||||
//
|
// catalogue holds some thirty of them the day the runtime arrives, each moves to a bundle in
|
||||||
// *Since 2026-10-04 (to-be 38 WP4b's last step):* refused for **every** module. While some
|
// its own change (to-be 38 WP4 onward), and a gate that refused every rebuild of every unmoved
|
||||||
// thirty modules still stood in that shape, one already registered so was rebuilt without
|
// module in the meantime would stop the whole pipeline to make a point the record already makes.
|
||||||
// complaint, so the pipeline kept running while each moved; every module has moved since, and
|
// Before the runtime exists the pattern is accepted as it always was.
|
||||||
// the exception would only let one move back.
|
|
||||||
if m.Module != catalogue.RuntimeModule {
|
if m.Module != catalogue.RuntimeModule {
|
||||||
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" {
|
||||||
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
runtime, err := i.hasModule(ctx, catalogue.RuntimeModule)
|
||||||
@@ -91,7 +90,13 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if runtime {
|
if runtime {
|
||||||
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
already, err := i.registeredInThatShape(ctx, m.Module)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !already {
|
||||||
|
return fmt.Errorf("%s is not registered: %s", m.Module, why)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,6 +152,26 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// registeredInThatShape is whether the catalogue already holds this module as a tools container on
|
||||||
|
// the runtime's image — judged from the manifest it holds and what that module's newest build stood
|
||||||
|
// on, the same two things the gate judges a new registration by. False for a module the catalogue
|
||||||
|
// does not hold.
|
||||||
|
func (i *Inventory) registeredInThatShape(ctx context.Context, name string) (bool, error) {
|
||||||
|
held, err := i.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
stored, has := held[name]
|
||||||
|
if !has {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
against, err := i.BuiltAgainst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return catalogue.ToolContainerOnTheRuntime(stored, against[name]) != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// hasModule is whether the catalogue holds a module of that name.
|
// hasModule is whether the catalogue holds a module of that name.
|
||||||
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
|
func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) {
|
||||||
var one int
|
var one int
|
||||||
|
|||||||
@@ -688,9 +688,9 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) {
|
|||||||
|
|
||||||
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
// Once the node's tool runtime is in the catalogue, a module serving its tools from a container
|
||||||
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175,
|
||||||
// to-be 38 WP2.4) — for every module, since every module has moved (WP4b's last step; WP3's
|
// to-be 38 WP2.4) — for a module new to the catalogue or one that had moved away from it; a module
|
||||||
// amendment let one already standing in that shape be rebuilt while each moved). Before the
|
// already standing in that shape is rebuilt as before, so the catalogue's pipeline keeps running
|
||||||
// runtime, it is accepted as it always was — so a
|
// while each moves (WP3's amendment). Before the runtime, it is accepted as it always was — so a
|
||||||
// mesh converts in the order the design says and nothing is refused before there is anything to
|
// mesh converts in the order the design says and nothing is refused before there is anything to
|
||||||
// move to.
|
// move to.
|
||||||
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
||||||
@@ -715,11 +715,11 @@ func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// **A module already registered in that shape is refused too** (WP4b's last step): every module
|
// **A module already registered in that shape is rebuilt without complaint** (to-be 38 WP2.4 as
|
||||||
// has moved, and a rebuild in the old shape is one moving back.
|
// amended by WP3): some thirty of them stand the day the runtime arrives, and each moves in its
|
||||||
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err == nil ||
|
// own change. The gate is against the pattern spreading, not against the pipeline running.
|
||||||
!strings.Contains(err.Error(), "ADR 0175") {
|
if err := inv.RegisterModule(ctx, filter, Source{Repository: "/r", Against: stoodOn}); err != nil {
|
||||||
t.Fatalf("a rebuild of a module in the old pattern was registered beside the runtime: %v", err)
|
t.Fatalf("a rebuild of a module that already had the pattern was refused: %v", err)
|
||||||
}
|
}
|
||||||
// A module new to the catalogue in that shape is refused, naming the record.
|
// A module new to the catalogue in that shape is refused, naming the record.
|
||||||
newcomer := filter
|
newcomer := filter
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the artifact store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
|
||||||
//
|
|
||||||
// The store has never collected anything: every build pushes another layer set and nothing has
|
|
||||||
// ever removed one. The registry's own answer — collect what no tag names — is wrong here, because
|
|
||||||
// the mesh pushes each artifact under one moving tag and pins machines by digest, so every build
|
|
||||||
// but the newest is untagged and some machine may still be running it.
|
|
||||||
//
|
|
||||||
// **So the mesh decides, from its own records, and it never has to look in the store to do it.**
|
|
||||||
// It has never put anything there it did not record, which means every digest it could remove is
|
|
||||||
// already in a build row. A digest the mesh did not record making is therefore never named here —
|
|
||||||
// not as a safety margin but as the rule restated, and it is what keeps the sweep away from the
|
|
||||||
// images genesis pushed before any record existed (04-ISSUES/102, F4).
|
|
||||||
|
|
||||||
// KeptBuilds is how many successful builds of each module keep their artifacts, counting the
|
|
||||||
// newest. The newest is what the mesh hands a machine now; the four behind it are how far back a
|
|
||||||
// release that turns out wrong can be taken.
|
|
||||||
const KeptBuilds = 5
|
|
||||||
|
|
||||||
// ToCollect is every artifact the mesh made, no longer keeps, and has not already collected.
|
|
||||||
//
|
|
||||||
// Three reasons an artifact stays, and nothing else is a reason:
|
|
||||||
//
|
|
||||||
// - **a definition names it** — the reference appears in a module's recorded manifest, which is
|
|
||||||
// what the mesh would hand a machine now. No age limit: this is the floor;
|
|
||||||
// - **the mesh can still go back to it** — it is an artifact of one of the KeptBuilds most
|
|
||||||
// recent successful builds of its module;
|
|
||||||
// - it was already collected, in which case there is nothing left to do.
|
|
||||||
//
|
|
||||||
// Returned in a stated order so two runs over the same records ask for the same things in the
|
|
||||||
// same sequence, which is what makes a failed sweep safe to simply run again.
|
|
||||||
func (i *Inventory) ToCollect(ctx context.Context) ([]string, error) {
|
|
||||||
keep, err := i.keptReferences(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
|
||||||
// Every artifact of every successful build, oldest first, minus what has already been
|
|
||||||
// collected. A failed build published nothing, so it names nothing to remove.
|
|
||||||
`select b.made
|
|
||||||
from build b
|
|
||||||
where b.failed = '' and b.module is not null and b.module <> ''
|
|
||||||
order by b.at asc, b.id asc`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
collected, err := i.alreadyCollected(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for rows.Next() {
|
|
||||||
var raw []byte
|
|
||||||
if err := rows.Scan(&raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var made []Artifact
|
|
||||||
if err := json.Unmarshal(raw, &made); err != nil {
|
|
||||||
// One unreadable record must not stop the rest being collected — and an artifact this
|
|
||||||
// row named is simply not offered, which errs toward keeping.
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, a := range made {
|
|
||||||
if a.Reference == "" || keep[a.Reference] || collected[a.Reference] || seen[a.Reference] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[a.Reference] = true
|
|
||||||
out = append(out, a.Reference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// keptReferences is every artifact reference the mesh still keeps, for either of the two reasons.
|
|
||||||
func (i *Inventory) keptReferences(ctx context.Context) (map[string]bool, error) {
|
|
||||||
keep := map[string]bool{}
|
|
||||||
|
|
||||||
// **Whatever a definition the mesh holds names.** Read as text rather than by walking the
|
|
||||||
// resource shapes: a reference may be a container's image, a bundle's source, or a field some
|
|
||||||
// later kind of resource grows, and what matters is only whether the mesh could hand this
|
|
||||||
// string to a machine. A manifest that mentions it is a manifest that might.
|
|
||||||
manifests, err := i.store.Pool().Query(ctx, `select manifest::text from module where manifest is not null`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer manifests.Close()
|
|
||||||
var named []string
|
|
||||||
for manifests.Next() {
|
|
||||||
var text string
|
|
||||||
if err := manifests.Scan(&text); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
named = append(named, text)
|
|
||||||
}
|
|
||||||
if err := manifests.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// The KeptBuilds most recent successful builds of each module, whole.
|
|
||||||
recent, err := i.store.Pool().Query(ctx,
|
|
||||||
`select made from (
|
|
||||||
select made, row_number() over (partition by module order by at desc, id desc) as back
|
|
||||||
from build
|
|
||||||
where failed = '' and module is not null and module <> ''
|
|
||||||
) ranked where back <= $1`, KeptBuilds)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer recent.Close()
|
|
||||||
for recent.Next() {
|
|
||||||
var raw []byte
|
|
||||||
if err := recent.Scan(&raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var made []Artifact
|
|
||||||
if err := json.Unmarshal(raw, &made); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, a := range made {
|
|
||||||
if a.Reference != "" {
|
|
||||||
keep[a.Reference] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := recent.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// And anything a manifest mentions. Done after the recent set so the scan runs over the
|
|
||||||
// candidates rather than over every reference ever recorded: a manifest holds a reference
|
|
||||||
// composed with the store's address or kept bare, so the search is for the digest within it.
|
|
||||||
if len(named) > 0 {
|
|
||||||
all, err := i.everyReferenceMade(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for _, reference := range all {
|
|
||||||
if keep[reference] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
digest := digestIn(reference)
|
|
||||||
if digest == "" {
|
|
||||||
// Not something the store holds by digest; nothing here can speak for it, so it
|
|
||||||
// is kept rather than guessed about.
|
|
||||||
keep[reference] = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, text := range named {
|
|
||||||
if strings.Contains(text, digest) {
|
|
||||||
keep[reference] = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keep, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// everyReferenceMade is every artifact reference any successful build recorded.
|
|
||||||
func (i *Inventory) everyReferenceMade(ctx context.Context) ([]string, error) {
|
|
||||||
rows, err := i.store.Pool().Query(ctx,
|
|
||||||
`select made from build where failed = '' and module is not null and module <> ''`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
seen := map[string]bool{}
|
|
||||||
var out []string
|
|
||||||
for rows.Next() {
|
|
||||||
var raw []byte
|
|
||||||
if err := rows.Scan(&raw); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var made []Artifact
|
|
||||||
if err := json.Unmarshal(raw, &made); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, a := range made {
|
|
||||||
if a.Reference == "" || seen[a.Reference] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[a.Reference] = true
|
|
||||||
out = append(out, a.Reference)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
|
|
||||||
func digestIn(reference string) string {
|
|
||||||
for _, marker := range []string{"@sha256:", "/sha256:"} {
|
|
||||||
if _, after, ok := strings.Cut(reference, marker); ok {
|
|
||||||
return "sha256:" + after
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// alreadyCollected is what the store has already been asked to let go.
|
|
||||||
func (i *Inventory) alreadyCollected(ctx context.Context) (map[string]bool, error) {
|
|
||||||
rows, err := i.store.Pool().Query(ctx, `select reference from artifact_collected`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
out := map[string]bool{}
|
|
||||||
for rows.Next() {
|
|
||||||
var reference string
|
|
||||||
if err := rows.Scan(&reference); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out[reference] = true
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarkCollected records that the store no longer holds these.
|
|
||||||
//
|
|
||||||
// **A store that answered "not found" is recorded too.** The outcome wanted is that the artifact
|
|
||||||
// is gone, and it is; retrying it every sweep for ever is the failure this table exists to
|
|
||||||
// prevent. Only a store that could not be reached, or refused, leaves a reference unmarked — and
|
|
||||||
// then the next sweep asks again, which is what should happen.
|
|
||||||
func (i *Inventory) MarkCollected(ctx context.Context, references []string) error {
|
|
||||||
for _, reference := range references {
|
|
||||||
if _, err := i.store.Pool().Exec(ctx,
|
|
||||||
`insert into artifact_collected (reference) values ($1) on conflict (reference) do nothing`,
|
|
||||||
reference); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
package inventory
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// What the store keeps, and what it may let go (novox/hq ADR 0189, issue 108).
|
|
||||||
//
|
|
||||||
// The store has collected nothing since it was raised, and the registry's own answer — collect
|
|
||||||
// what no tag names — would delete images machines are running, because the mesh pushes under one
|
|
||||||
// moving tag and pins by digest. So the rule is the mesh's, read from its own records, and these
|
|
||||||
// are the three reasons an artifact stays and the one reason it goes.
|
|
||||||
|
|
||||||
// ref is an artifact reference as the mesh records one.
|
|
||||||
func ref(module, artifact string, n int) string {
|
|
||||||
return fmt.Sprintf("%s%s/%s@sha256:%064x", catalogue.ArtifactStoreScheme, module, artifact, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// built records one successful build of a module publishing one image.
|
|
||||||
func built(t *testing.T, inv *Inventory, id, module string, n int) string {
|
|
||||||
t.Helper()
|
|
||||||
reference := ref(module, "app", n)
|
|
||||||
b := aBuild(id, module, "")
|
|
||||||
b.Made = []Artifact{{Name: "app", Kind: "image", Reference: reference}}
|
|
||||||
if err := inv.RecordBuild(context.Background(), b); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return reference
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheStoreKeepsTheRecentBuildsAndLetsGoOfTheRest(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Eight builds of one module, oldest first. Five are kept — the newest, and the four a
|
|
||||||
// release that turns out wrong can be taken back to.
|
|
||||||
var made []string
|
|
||||||
for i := 1; i <= 8; i++ {
|
|
||||||
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
|
||||||
}
|
|
||||||
|
|
||||||
go_, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := made[:3] // the three oldest
|
|
||||||
if len(go_) != len(want) {
|
|
||||||
t.Fatalf("offered %v to collect; want the %d oldest of %d", go_, len(want), len(made))
|
|
||||||
}
|
|
||||||
for i := range want {
|
|
||||||
if go_[i] != want[i] {
|
|
||||||
t.Fatalf("offered %v; want %v — and in that order, so a failed sweep is safe to run again",
|
|
||||||
go_, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestADefinitionNamingAnArtifactKeepsItHoweverOldItIs(t *testing.T) {
|
|
||||||
// The floor: no age limit. A module recorded at an older commit still names what the mesh
|
|
||||||
// would hand a machine now, and that is what must not be collected out from under it.
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
var made []string
|
|
||||||
for i := 1; i <= 8; i++ {
|
|
||||||
made = append(made, built(t, inv, fmt.Sprintf("b%02d", i), "web", i))
|
|
||||||
}
|
|
||||||
oldest := made[0]
|
|
||||||
|
|
||||||
// A definition the mesh holds, whose container runs that oldest image.
|
|
||||||
m := catalogue.Manifest{Module: "web", Version: "1", Resources: []map[string]any{{
|
|
||||||
"id": "app", "type": "container", "name": "web", "image": oldest,
|
|
||||||
}}}
|
|
||||||
if err := inv.RegisterModule(ctx, m, Source{Repository: "https://forge.invalid/web.git"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
go_, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
for _, reference := range go_ {
|
|
||||||
if reference == oldest {
|
|
||||||
t.Fatalf("the mesh offered to collect %s, which a definition it holds names", oldest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(go_) != 2 {
|
|
||||||
t.Fatalf("offered %v; want the two oldest that nothing names", go_)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWhatHasBeenCollectedIsNotOfferedAgain(t *testing.T) {
|
|
||||||
// Without this the sweep reissues a delete for every artifact it has ever collected, every
|
|
||||||
// time it runs, for ever — a number of requests that grows with the mesh's whole history.
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
for i := 1; i <= 7; i++ {
|
|
||||||
built(t, inv, fmt.Sprintf("b%02d", i), "web", i)
|
|
||||||
}
|
|
||||||
first, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(first) != 2 {
|
|
||||||
t.Fatalf("offered %v, want two", first)
|
|
||||||
}
|
|
||||||
if err := inv.MarkCollected(ctx, first); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
again, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(again) != 0 {
|
|
||||||
t.Fatalf("offered %v again after collecting it", again)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAFailedBuildNamesNothingToCollectAndEachModuleIsCountedOnItsOwn(t *testing.T) {
|
|
||||||
inv := fresh(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// A failed build published nothing, so it is neither kept nor collected — and it must not
|
|
||||||
// count against the module's five.
|
|
||||||
for i := 1; i <= 6; i++ {
|
|
||||||
built(t, inv, fmt.Sprintf("w%02d", i), "web", i)
|
|
||||||
}
|
|
||||||
if err := inv.RecordBuild(ctx, aBuild("w99", "web", "the recipe would not build")); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// And a second module with three builds keeps all three: five each, not five between them.
|
|
||||||
for i := 1; i <= 3; i++ {
|
|
||||||
built(t, inv, fmt.Sprintf("d%02d", i), "db", 100+i)
|
|
||||||
}
|
|
||||||
|
|
||||||
go_, err := inv.ToCollect(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(go_) != 1 || go_[0] != ref("web", "app", 1) {
|
|
||||||
t.Fatalf("offered %v; want only web's oldest — db's three are all within its five", go_)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
-- What the artifact store no longer keeps (novox/hq ADR 0189, issue 108).
|
|
||||||
--
|
|
||||||
-- The mesh removes from its store only what it put there and can account for: every digest it
|
|
||||||
-- could remove is already in a build record, so the sweep reads its own records rather than
|
|
||||||
-- enumerating the store. What it does not get from those records is whether it has already
|
|
||||||
-- removed something -- `build.made` says what that build published, for ever, which is history
|
|
||||||
-- and not an index of what is on disk.
|
|
||||||
--
|
|
||||||
-- Without this the sweep would reissue a delete for every artifact it has ever collected, every
|
|
||||||
-- time it runs, and each one would answer 404 -- a number of requests that grows with the mesh's
|
|
||||||
-- whole history and never shrinks.
|
|
||||||
--
|
|
||||||
-- Keyed by the reference as the mesh records it (`artifact-store://<module>/<artifact>@sha256:…`),
|
|
||||||
-- because that is the identity the record uses everywhere else. Not a foreign key to build: two
|
|
||||||
-- builds can publish the same digest (the same source built twice produces the same bytes), and
|
|
||||||
-- what is collected is the artifact, not the attempt that made it.
|
|
||||||
create table artifact_collected (
|
|
||||||
reference text primary key,
|
|
||||||
|
|
||||||
-- When the store answered. Kept so a reader of an old build record can tell "this artifact is
|
|
||||||
-- gone" from "this artifact was never there", which are different kinds of surprise.
|
|
||||||
at timestamptz not null default now()
|
|
||||||
);
|
|
||||||
Reference in New Issue
Block a user