Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
146c48fd96 | ||
|
|
1f3abd3e0e | ||
|
|
6d620f77c3 | ||
|
|
f8286c063d |
@@ -679,6 +679,10 @@ type answers struct {
|
|||||||
// failing is every consumer a provider says it keeps failing (novox/hq ADR 0224): a provider's
|
// failing is every consumer a provider says it keeps failing (novox/hq ADR 0224): a provider's
|
||||||
// journal was the only place that said so for a day (04-ISSUES/179).
|
// journal was the only place that said so for a day (04-ISSUES/179).
|
||||||
failing []inventory.ProviderStanding
|
failing []inventory.ProviderStanding
|
||||||
|
// overflowing is every module whose identity overflows the bound of a provision it requires
|
||||||
|
// (novox/hq ADR 0225): its provider leaves it out of the grants and composes everything else, so
|
||||||
|
// this is the one place it is said across the mesh. Not well while there is any.
|
||||||
|
overflowing []catalogue.Overflow
|
||||||
}
|
}
|
||||||
|
|
||||||
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
// heldBy is every artifact this mesh has built, for a build that may need one as its base.
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ import (
|
|||||||
// mesh seat is judged fully only at registration. A seat another module declares is unknown unless
|
// mesh seat is judged fully only at registration. A seat another module declares is unknown unless
|
||||||
// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that
|
// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that
|
||||||
// refused what it could not see would teach people to ignore it.
|
// refused what it could not see would teach people to ignore it.
|
||||||
|
//
|
||||||
|
// **And every identity against every bound it meets** (novox/hq ADR 0225, issue 263): each module's
|
||||||
|
// identity, on a machine whose name is `longestMachine` characters, against the bound of every
|
||||||
|
// provision it wants that a manifest given here offers. An overflow is refused in the pull request
|
||||||
|
// that introduces it — a new requirement, a lowered bound, a longer slug — instead of on the
|
||||||
|
// provider's machine when a real machine's name first meets the module's.
|
||||||
func moduleCheck(paths []string, out io.Writer) error {
|
func moduleCheck(paths []string, out io.Writer) error {
|
||||||
|
return moduleCheckFor(paths, catalogue.DefaultLongestMachine, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleCheckFor(paths []string, longestMachine int, out io.Writer) error {
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return errors.New("module check <manifest.json>... — one file per module; pass every " +
|
return errors.New("module check <manifest.json>... — one file per module; pass every " +
|
||||||
"manifest of a repository together so the rules between them are checked too")
|
"manifest of a repository together so the rules between them are checked too")
|
||||||
@@ -74,6 +84,15 @@ func moduleCheck(paths []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
failed += len(problems)
|
failed += len(problems)
|
||||||
|
|
||||||
|
// Between the manifests too: an identity against the bounds of the provisions it wants, which
|
||||||
|
// only the provider's manifest states.
|
||||||
|
identities := catalogue.IdentityProblems(shelf, longestMachine)
|
||||||
|
sort.Strings(identities)
|
||||||
|
for _, p := range identities {
|
||||||
|
fmt.Fprintln(out, p)
|
||||||
|
}
|
||||||
|
failed += len(identities)
|
||||||
|
|
||||||
var names []string
|
var names []string
|
||||||
for name := range shelf {
|
for name := range shelf {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
@@ -109,7 +128,8 @@ func moduleCheck(paths []string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+
|
fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+
|
||||||
"one of the mesh's own seats is judged fully at registration, and a seat declared by a "+
|
"one of the mesh's own seats is judged fully at registration, and a seat declared by a "+
|
||||||
"module not given here reads as unknown\n", len(paths))
|
"module not given here reads as unknown. Identities judged on a %d-character machine name, "+
|
||||||
|
"against the bounds of the providers given here\n", len(paths), longestMachine)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0225, issue 263: a consumer's identity is bounded by the provision it requires, an
|
||||||
|
// overflow is refused before merge by `module check`, and a provider's machine is never refused for
|
||||||
|
// one consumer's identity.
|
||||||
|
|
||||||
|
// `module check` refuses the pull request that introduces an overflow, naming the module.
|
||||||
|
func TestModuleCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, body string) string {
|
||||||
|
p := filepath.Join(dir, name+".json")
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
objects := write("objects", `{"module":"objects","version":"1",
|
||||||
|
"provides":[{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}],
|
||||||
|
"receives":{"s3-bucket":"/var/lib/mesh/objects/mesh.json"}}`)
|
||||||
|
resolver := write("resolver", `{"module":"resolver","version":"1",
|
||||||
|
"provides":[{"name":"wildcard-resolution","scope":"mesh","identity":false}]}`)
|
||||||
|
album := write("photoalbum", `{"module":"photoalbum","version":"1","requires":["s3-bucket"]}`)
|
||||||
|
nm := write("networkmanager", `{"module":"networkmanager","version":"1","requires":["wildcard-resolution"]}`)
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := moduleCheckFor([]string{resolver, nm}, 6, &out); err != nil {
|
||||||
|
t.Fatalf("a long name requiring a keyless provision was refused (issue 263): %v\n%s", err, out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
err := moduleCheckFor([]string{objects, album, resolver, nm}, 6, &out)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("an identity overflowing an S3 access key passed:\n%s", out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "photoalbum wants s3-bucket") ||
|
||||||
|
!strings.Contains(out.String(), "`slug` of at most 8 characters") ||
|
||||||
|
strings.Contains(out.String(), "networkmanager wants") {
|
||||||
|
t.Fatalf("the refusal does not name the one overflowing module and its remedy:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tonight's case, through the commands: networkmanager on a six-character machine requires the
|
||||||
|
// resolver provision, and a second consumer there overflows an object store's access key. The
|
||||||
|
// provider's machine still composes; the overflowing consumer is left out of its grants and named,
|
||||||
|
// by push and by `status`, and the keyless consumer is granted with its long name.
|
||||||
|
func TestAnOverflowingConsumerNeverRefusesItsProvidersMachine(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
register(t, open, catalogue.Manifest{Module: "objects", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh,
|
||||||
|
Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||||
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "resolver", Version: "1",
|
||||||
|
Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "networkmanager", Version: "1",
|
||||||
|
Requires: []string{"wildcard-resolution"}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "photoalbum", Version: "1", Requires: []string{"s3-bucket"}})
|
||||||
|
register(t, open, catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}})
|
||||||
|
for _, a := range [][2]string{{"anchor", "objects"}, {"anchor", "resolver"},
|
||||||
|
{"laptop", "networkmanager"}, {"laptop", "photoalbum"}, {"laptop", "files"}} {
|
||||||
|
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
|
||||||
|
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consumer's machine resolves, and says which of its modules no provider will grant.
|
||||||
|
consumer, _, err := planFor(ctx, open, "laptop")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
over := consumer.Overflowing()
|
||||||
|
if len(over) != 1 || over[0].Module != "photoalbum" || over[0].Provision != "s3-bucket" {
|
||||||
|
t.Fatalf("the consumer's side does not name exactly photoalbum: %+v", over)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The provider's machine composes. Under ADR 0049's one bound this was a refusal naming
|
||||||
|
// networkmanager, and no push to the provider could go through.
|
||||||
|
plan, settings, err := planFor(ctx, open, "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
declared, err := declarationFor(ctx, open, "anchor", plan, settings)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("one consumer's identity refused its provider's whole machine: %v", err)
|
||||||
|
}
|
||||||
|
if len(declared.withheld) != 1 || declared.withheld[0].Identity != "mesh_laptop_photoalbum" {
|
||||||
|
t.Fatalf("the overflowing consumer is not the one withheld: %+v", declared.withheld)
|
||||||
|
}
|
||||||
|
grants, _, err := grantsFor(ctx, open, "anchor")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var keyless bool
|
||||||
|
for _, g := range grants {
|
||||||
|
keyless = keyless || g.Provision == "wildcard-resolution" && g.From == "networkmanager"
|
||||||
|
}
|
||||||
|
if !keyless {
|
||||||
|
t.Fatalf("networkmanager, 26 characters, is not granted the keyless resolver provision: %+v", grants)
|
||||||
|
}
|
||||||
|
var granted []string
|
||||||
|
for _, c := range declared.Received["objects"]["s3-bucket"] {
|
||||||
|
granted = append(granted, c.From)
|
||||||
|
}
|
||||||
|
if strings.Join(granted, ",") != "files" {
|
||||||
|
t.Fatalf("the object store grants %v; files and only files fit", granted)
|
||||||
|
}
|
||||||
|
said := printed(t, func() error { reportLeftOut("anchor", declared); return nil })
|
||||||
|
if !strings.Contains(said, `photoalbum on laptop requires s3-bucket from anchor`) ||
|
||||||
|
!strings.Contains(said, "left out of anchor's grants") {
|
||||||
|
t.Fatalf("the push does not say whom it leaves out:\n%s", said)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And `status` names it, and does not call the mesh well while it stands.
|
||||||
|
asked, err := theThreeQuestions(ctx, open)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(asked.overflowing) != 1 || asked.overflowing[0].Module != "photoalbum" {
|
||||||
|
t.Fatalf("status does not carry the overflow: %+v", asked.overflowing)
|
||||||
|
}
|
||||||
|
if asked.well() {
|
||||||
|
t.Fatal("a mesh with a consumer left out of its grants reads as well")
|
||||||
|
}
|
||||||
|
shown := printed(t, func() error { return printStatus(asked) })
|
||||||
|
if !strings.Contains(shown, "identified too long for a provision they require") ||
|
||||||
|
!strings.Contains(shown, "mesh_laptop_photoalbum") {
|
||||||
|
t.Fatalf("status does not say it:\n%s", shown)
|
||||||
|
}
|
||||||
|
body, err := statusAsJSON(asked)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
Overflowing []catalogue.Overflow `json:"overflowing"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &doc); err != nil || len(doc.Overflowing) != 1 ||
|
||||||
|
doc.Overflowing[0].Bound.Max != 20 {
|
||||||
|
t.Fatalf("the document does not carry it: %v\n%s", err, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ func usage() {
|
|||||||
licence add|list|use|key model access, under the name a person calls it
|
licence add|list|use|key model access, under the name a person calls it
|
||||||
licence manager <name> <node> the node that holds a refreshable licence's refresh token
|
licence manager <name> <node> the node that holds a refreshable licence's refresh token
|
||||||
licence refresh <name> mint a new access token and seal it to every holder
|
licence refresh <name> mint a new access token and seal it to every holder
|
||||||
rotate <provision> [--consumer <n>] a new credential for every holder, both ends at once
|
rotate <provision> [--consumer <n>] [--module <m>] a new credential for every holder, both ends at once
|
||||||
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
ask <module> <tool> [json] call one of a module's tools over the broker, and print its answer
|
||||||
pin <node> <provision> <from-node> <module>
|
pin <node> <provision> <from-node> <module>
|
||||||
which provider this one gets a provision from: the module, and its node
|
which provider this one gets a provision from: the module, and its node
|
||||||
|
|||||||
@@ -59,8 +59,19 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
// `check` needs no mesh, and must not: it is what somebody runs in their own repository before
|
||||||
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
// there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it.
|
||||||
if args[0] == "check" {
|
if args[0] == "check" {
|
||||||
|
set := flag.NewFlagSet("module check", flag.ContinueOnError)
|
||||||
|
// The longest machine name an identity must fit on (novox/hq ADR 0225): a mesh passes its own.
|
||||||
|
longest := set.Int("longest-machine-name", catalogue.DefaultLongestMachine,
|
||||||
|
"judge each module's identity on a machine name this many characters long")
|
||||||
|
given, err := parseAround(set, args[1:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *longest < 1 {
|
||||||
|
return errors.New("--longest-machine-name is a length, at least 1")
|
||||||
|
}
|
||||||
var paths []string
|
var paths []string
|
||||||
for _, a := range args[1:] {
|
for _, a := range given {
|
||||||
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
if info, err := os.Stat(a); err == nil && info.IsDir() {
|
||||||
under, err := manifestsUnder(a)
|
under, err := manifestsUnder(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,7 +82,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
}
|
}
|
||||||
paths = append(paths, a)
|
paths = append(paths, a)
|
||||||
}
|
}
|
||||||
return moduleCheck(paths, os.Stdout)
|
return moduleCheckFor(paths, *longest, os.Stdout)
|
||||||
}
|
}
|
||||||
open, err := openStores(ctx)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+50
-16
@@ -399,7 +399,7 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
}
|
}
|
||||||
out := sendable{Resources: composed.Resources, Adoption: adoption,
|
out := sendable{Resources: composed.Resources, Adoption: adoption,
|
||||||
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
||||||
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld}
|
||||||
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
|
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
|
||||||
// 0221). Read only on the send path: a question about what would be sent records nothing.
|
// 0221). Read only on the send path: a question about what would be sent records nothing.
|
||||||
if choosing == Allocating {
|
if choosing == Allocating {
|
||||||
@@ -464,6 +464,11 @@ func reportLeftOut(node string, declared sendable) {
|
|||||||
"what the machine holds for it is kept and its containers are untouched. %s\n",
|
"what the machine holds for it is kept and its containers are untouched. %s\n",
|
||||||
node, m, declared.leftOutWhy[m])
|
node, m, declared.leftOutWhy[m])
|
||||||
}
|
}
|
||||||
|
// And whom it serves nothing, because their identity overflows what the provision keeps (ADR
|
||||||
|
// 0225): the machine is sent everything else, and the consumer is named.
|
||||||
|
for _, o := range declared.withheld {
|
||||||
|
fmt.Printf("%s: %s\n", node, o)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||||
@@ -471,7 +476,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
plan catalogue.Resolution, settings catalogue.SettingsBy,
|
plan catalogue.Resolution, settings catalogue.SettingsBy,
|
||||||
gens map[string]catalogue.Generator, choosing Choosing) (catalogue.Rendering, inventory.Node, error) {
|
gens map[string]catalogue.Generator, choosing Choosing) (catalogue.Rendering, inventory.Node, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
grants, err := grantsFor(ctx, open, node)
|
grants, withheld, err := grantsFor(ctx, open, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
return catalogue.Rendering{}, inventory.Node{}, err
|
||||||
}
|
}
|
||||||
@@ -794,7 +799,7 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
|
||||||
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
|
||||||
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
|
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
|
||||||
BusUsers: busUsers,
|
BusUsers: busUsers, Withheld: withheld,
|
||||||
}, record, nil
|
}, record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,28 +935,34 @@ func certificateFor(ctx context.Context, open *stores, node string) (string, str
|
|||||||
// The mirror of what a consumer is given, and the half that makes the credential real: a password
|
// The mirror of what a consumer is given, and the half that makes the credential real: a password
|
||||||
// nothing was told to create is a password that authenticates nowhere. Sealed to this node, so
|
// nothing was told to create is a password that authenticates nowhere. Sealed to this node, so
|
||||||
// the mesh hands over something it cannot itself use.
|
// the mesh hands over something it cannot itself use.
|
||||||
func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Grant, error) {
|
//
|
||||||
|
// **One consumer's identity never refuses the provider's machine** (novox/hq ADR 0225, issue 263).
|
||||||
|
// A consumer whose identity overflows the provision's bound is left out of the grants and returned
|
||||||
|
// beside them, for push, plan and `status` to say; every other consumer is granted and the provider's
|
||||||
|
// declaration composes. Refusing here once made a whole machine unpushable for one module elsewhere.
|
||||||
|
func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Grant, []catalogue.Overflow, error) {
|
||||||
inv := open.inventory
|
inv := open.inventory
|
||||||
issued, err := inv.SecretsFrom(ctx, node)
|
issued, err := inv.SecretsFrom(ctx, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Where each consumer is, so a provider that must reach back to one does not have to know how
|
// Where each consumer is, so a provider that must reach back to one does not have to know how
|
||||||
// the mesh names machines.
|
// the mesh names machines.
|
||||||
shelf, err := inv.Catalogue(ctx)
|
shelf, err := inv.Catalogue(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
onNetwork, err := whereEveryoneIs(ctx, inv, shelf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// What each consumer actually asked for, taken from that machine's own resolution rather than
|
// What each consumer actually asked for, taken from that machine's own resolution rather than
|
||||||
// from a record beside it. A provider told to create a password and not what to create it for
|
// from a record beside it. A provider told to create a password and not what to create it for
|
||||||
// can do nothing with it, and the name a consumer wants is the consumer's to say.
|
// can do nothing with it, and the name a consumer wants is the consumer's to say.
|
||||||
out := make([]catalogue.Grant, 0, len(issued))
|
out := make([]catalogue.Grant, 0, len(issued))
|
||||||
|
var withheld []catalogue.Overflow
|
||||||
for _, s := range issued {
|
for _, s := range issued {
|
||||||
plan, settings, err := planFor(ctx, open, s.Consumer)
|
plan, settings, err := planFor(ctx, open, s.Consumer)
|
||||||
switch {
|
switch {
|
||||||
@@ -964,11 +975,11 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
// The mesh could not be asked what they wanted, which is not the same as their wanting
|
// The mesh could not be asked what they wanted, which is not the same as their wanting
|
||||||
// nothing — and withholding a grant on that reading takes a consumer's access away
|
// nothing — and withholding a grant on that reading takes a consumer's access away
|
||||||
// (novox/hq 04-ISSUES/152).
|
// (novox/hq 04-ISSUES/152).
|
||||||
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err)
|
return nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Name, err)
|
||||||
}
|
}
|
||||||
values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings)
|
values, asks, err := plan.ContributionsFrom(s.Name, s.ConsumerModule, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
// A port in there is the consumer's software port until this. The consumer is on another
|
// A port in there is the consumer's software port until this. The consumer is on another
|
||||||
// machine, so the assignment that moved it is that machine's — fetched here rather than
|
// machine, so the assignment that moved it is that machine's — fetched here rather than
|
||||||
@@ -976,7 +987,7 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
// this case (novox/hq 04-ISSUES/038, the cross-node half).
|
// this case (novox/hq 04-ISSUES/038, the cross-node half).
|
||||||
published, err := portsOn(ctx, inv, s.Consumer, s.ConsumerModule)
|
published, err := portsOn(ctx, inv, s.Consumer, s.ConsumerModule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
values = catalogue.AtPublishedPort(values, s.ConsumerModule, published)
|
values = catalogue.AtPublishedPort(values, s.ConsumerModule, published)
|
||||||
from := s.ConsumerModule
|
from := s.ConsumerModule
|
||||||
@@ -987,9 +998,10 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
from = ""
|
from = ""
|
||||||
}
|
}
|
||||||
// The consumer's identity slug, from its own manifest, carried on the grant so the provider
|
// The consumer's identity slug, from its own manifest, carried on the grant so the provider
|
||||||
// derives the same login the consumer does (novox/hq ADR 0049). Refused here if it still would
|
// derives the same login the consumer does (novox/hq ADR 0049). Judged against the bound of
|
||||||
// not fit the tightest backend — the mesh chose the name, so the mesh refuses it, with the
|
// this provision, as the consumer's resolution states it from the provider's offer (ADR
|
||||||
// remedy a short slug rather than a login a provider silently shortened.
|
// 0225): a consumer it would not fit is left out of the grants and said, rather than a login a
|
||||||
|
// provider silently shortened — and rather than this whole machine refused for it.
|
||||||
slug := ""
|
slug := ""
|
||||||
for _, mm := range plan.Modules {
|
for _, mm := range plan.Modules {
|
||||||
if mm.Module == s.ConsumerModule {
|
if mm.Module == s.ConsumerModule {
|
||||||
@@ -998,15 +1010,32 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if from != "" {
|
if from != "" {
|
||||||
if err := catalogue.CheckIdentity(s.Consumer, catalogue.IdentitySource(slug, s.ConsumerModule)); err != nil {
|
bound := boundOfGrant(plan, s, node)
|
||||||
return nil, err
|
source := catalogue.IdentitySource(slug, s.ConsumerModule)
|
||||||
|
if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil {
|
||||||
|
withheld = append(withheld, catalogue.Overflow{Provision: s.Name, Provider: node,
|
||||||
|
Consumer: s.Consumer, Module: s.ConsumerModule,
|
||||||
|
Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound})
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out = append(out, catalogue.Grant{
|
out = append(out, catalogue.Grant{
|
||||||
Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer],
|
Provision: s.Name, Consumer: s.Consumer, At: onNetwork[s.Consumer],
|
||||||
From: from, Values: values, Slug: slug, Sealed: s.ForProvider, Local: s.Local})
|
From: from, Values: values, Slug: slug, Sealed: s.ForProvider, Local: s.Local})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, withheld, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this
|
||||||
|
// grant answers. A requirement not found there is held to the tightest bound the mesh knows rather
|
||||||
|
// than to none: what the provider keeps of it is not known here.
|
||||||
|
func boundOfGrant(consumer catalogue.Resolution, s inventory.Secret, provider string) catalogue.IdentityBound {
|
||||||
|
for _, n := range consumer.Needs {
|
||||||
|
if n.Name == s.Name && n.For == s.ConsumerModule && n.From == provider {
|
||||||
|
return n.Identity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalogue.DefaultIdentityBound
|
||||||
}
|
}
|
||||||
|
|
||||||
// listensLines is what a person is told about what this module would open, and why — the same
|
// listensLines is what a person is told about what this module would open, and why — the same
|
||||||
@@ -1082,6 +1111,11 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
left := plan.LeftOut(settings, record.Adopted)
|
left := plan.LeftOut(settings, record.Adopted)
|
||||||
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
|
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
|
||||||
}
|
}
|
||||||
|
// And which of its modules no provider will grant, because the identity overflows the bound of
|
||||||
|
// what it requires (novox/hq ADR 0225) — said on the machine the remedy is for.
|
||||||
|
for _, o := range plan.Overflowing() {
|
||||||
|
fmt.Printf("%s: %s\n", args[0], o)
|
||||||
|
}
|
||||||
// And a setting that reaches nothing — refused where it is stored, and said here for one
|
// And a setting that reaches nothing — refused where it is stored, and said here for one
|
||||||
// stored before its definition moved from under it.
|
// stored before its definition moved from under it.
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ type meshStatus struct {
|
|||||||
// document without this called the mesh well while the identity provider refused every consumer
|
// document without this called the mesh well while the identity provider refused every consumer
|
||||||
// for a day (04-ISSUES/179).
|
// for a day (04-ISSUES/179).
|
||||||
Failing []inventory.ProviderStanding `json:"failing,omitempty"`
|
Failing []inventory.ProviderStanding `json:"failing,omitempty"`
|
||||||
|
// Overflowing is every module whose identity overflows the bound of a provision it requires, and
|
||||||
|
// so is left out of its provider's grants (novox/hq ADR 0225). Absent when every identity fits.
|
||||||
|
Overflowing []catalogue.Overflow `json:"overflowing,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
// machineFiltered is one rule set on a converged machine that the mesh did not write and that
|
||||||
@@ -216,6 +219,7 @@ func statusAsJSON(asked answers) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
out.Unheld = asked.unheld
|
out.Unheld = asked.unheld
|
||||||
out.Failing = asked.failing
|
out.Failing = asked.failing
|
||||||
|
out.Overflowing = asked.overflowing
|
||||||
for name := range asked.refused {
|
for name := range asked.refused {
|
||||||
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
out.Unresolved = append(out.Unresolved, machineUnresolved{
|
||||||
Node: name, Problem: asked.refused[name]})
|
Node: name, Problem: asked.refused[name]})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rotateCommand replaces a credential and moves both ends together.
|
// rotateCommand replaces a credential and moves both ends together.
|
||||||
@@ -33,12 +35,16 @@ func rotateCommand(ctx context.Context, args []string) error {
|
|||||||
// One consumer rather than all of them. Ordinary: a credential is suspected on one machine,
|
// One consumer rather than all of them. Ordinary: a credential is suspected on one machine,
|
||||||
// and rotating the other nine would be a great deal of disruption for one suspicion.
|
// and rotating the other nine would be a great deal of disruption for one suspicion.
|
||||||
only := set.String("consumer", "", "only this machine's credential, rather than every holder's")
|
only := set.String("consumer", "", "only this machine's credential, rather than every holder's")
|
||||||
|
// One consuming module rather than every module on the machine. A machine runs many consumers
|
||||||
|
// of one provision, each with its own credential; one module that leaked its credential (novox/hq
|
||||||
|
// issue 268) is no reason to restart every other one on the machine.
|
||||||
|
module := set.String("module", "", "only this consuming module's credential")
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(positionals) != 1 {
|
if len(positionals) != 1 {
|
||||||
return errors.New("rotate <provision> [--consumer <machine>]")
|
return errors.New("rotate <provision> [--consumer <machine>] [--module <module>]")
|
||||||
}
|
}
|
||||||
provision := positionals[0]
|
provision := positionals[0]
|
||||||
|
|
||||||
@@ -53,6 +59,12 @@ func rotateCommand(ctx context.Context, args []string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
holders = ofModule(holders, *module)
|
||||||
|
if len(holders) == 0 && *module != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"no module %s%s holds a credential for %q, so there is nothing to rotate. `plan <machine>` "+
|
||||||
|
"says what a machine holds", *module, onMachine(*only), provision)
|
||||||
|
}
|
||||||
if len(holders) == 0 {
|
if len(holders) == 0 {
|
||||||
// Said, not silent. "Nobody holds this" and "this did not run" must never look the same —
|
// Said, not silent. "Nobody holds this" and "this did not run" must never look the same —
|
||||||
// and a rotation somebody believes happened is worse than one they know did not.
|
// and a rotation somebody believes happened is worse than one they know did not.
|
||||||
@@ -116,6 +128,27 @@ func rotateCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ofModule is the holders whose consuming module is this one; all of them when none is named.
|
||||||
|
func ofModule(holders []inventory.Holder, module string) []inventory.Holder {
|
||||||
|
if module == "" {
|
||||||
|
return holders
|
||||||
|
}
|
||||||
|
var out []inventory.Holder
|
||||||
|
for _, h := range holders {
|
||||||
|
if h.ConsumerModule == module {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func onMachine(machine string) string {
|
||||||
|
if machine == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " on " + machine
|
||||||
|
}
|
||||||
|
|
||||||
// asLocal names the credential inside the consumer where it holds several (ADR 0094).
|
// asLocal names the credential inside the consumer where it holds several (ADR 0094).
|
||||||
func asLocal(local string) string {
|
func asLocal(local string) string {
|
||||||
if local == "" {
|
if local == "" {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One consuming module's credential, and not its neighbours' on the same machine (novox/hq issue
|
||||||
|
// 268): a module that leaked its database password is no reason to restart every other consumer.
|
||||||
|
func TestARotationNarrowedToAModuleTouchesOnlyThatModulesCredential(t *testing.T) {
|
||||||
|
holders := []inventory.Holder{
|
||||||
|
{Provision: "postgres-database", Consumer: "ace", ConsumerModule: "letta", Provider: "ace"},
|
||||||
|
{Provision: "postgres-database", Consumer: "ace", ConsumerModule: "n8n", Provider: "ace"},
|
||||||
|
{Provision: "postgres-database", Consumer: "ace", ConsumerModule: "letta", Local: "reader", Provider: "ace"},
|
||||||
|
}
|
||||||
|
got := ofModule(holders, "letta")
|
||||||
|
if len(got) != 2 || got[0].ConsumerModule != "letta" || got[1].Local != "reader" {
|
||||||
|
t.Fatalf("narrowed to letta: %+v", got)
|
||||||
|
}
|
||||||
|
if len(ofModule(holders, "")) != 3 {
|
||||||
|
t.Fatal("no module named narrowed anyway")
|
||||||
|
}
|
||||||
|
if len(ofModule(holders, "absent")) != 0 {
|
||||||
|
t.Fatal("a module holding nothing matched")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -371,6 +371,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
|
|||||||
if c := str("consumer"); c != "" {
|
if c := str("consumer"); c != "" {
|
||||||
argv = append(argv, "--consumer", c)
|
argv = append(argv, "--consumer", c)
|
||||||
}
|
}
|
||||||
|
// With a provision, module narrows to one consuming module (novox/hq issue 268); node
|
||||||
|
// and secret stay the other shape's, and are refused as passed over.
|
||||||
|
if m := str("module"); m != "" {
|
||||||
|
argv = append(argv, "--module", m)
|
||||||
|
}
|
||||||
return argv, nil
|
return argv, nil
|
||||||
}
|
}
|
||||||
_, node := a.given["node"]
|
_, node := a.given["node"]
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ func TestRotateTakesAProvisionOrAnOwnSecret(t *testing.T) {
|
|||||||
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
|
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace" {
|
||||||
t.Fatalf("a pair credential: %v", argv)
|
t.Fatalf("a pair credential: %v", argv)
|
||||||
}
|
}
|
||||||
|
argv, _ = argvFor("rotate", map[string]any{"provision": "postgres-database", "consumer": "ace", "module": "letta"})
|
||||||
|
if strings.Join(argv, " ") != "rotate postgres-database --consumer ace --module letta" {
|
||||||
|
t.Fatalf("one consuming module's pair credential: %v", argv)
|
||||||
|
}
|
||||||
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
|
argv, _ = argvFor("rotate", map[string]any{"node": "ace", "module": "nodered", "secret": "api-token"})
|
||||||
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
|
if strings.Join(argv, " ") != "secret rotate ace nodered api-token" {
|
||||||
t.Fatalf("an own secret: %v", argv)
|
t.Fatalf("an own secret: %v", argv)
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ type sendable struct {
|
|||||||
LeftOut []string
|
LeftOut []string
|
||||||
// leftOutWhy is why each was, for push and plan to say; never on the wire.
|
// leftOutWhy is why each was, for push and plan to say; never on the wire.
|
||||||
leftOutWhy map[string]string
|
leftOutWhy map[string]string
|
||||||
|
// withheld is every consumer this machine's grants leave out, because its identity overflows the
|
||||||
|
// provision's bound (novox/hq ADR 0225); for push and plan to say, never on the wire.
|
||||||
|
withheld []catalogue.Overflow
|
||||||
// Builds is the build of each module this declaration carries — module to the commit its build
|
// Builds is the build of each module this declaration carries — module to the commit its build
|
||||||
// was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221).
|
// was made from — recorded with the send and never on the wire (novox/hq issue 259, ADR 0221).
|
||||||
// Composed only on the send path; nil records that it is not known.
|
// Composed only on the send path; nil records that it is not known.
|
||||||
|
|||||||
@@ -302,6 +302,19 @@ func printStatus(asked answers) error {
|
|||||||
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\n\n")
|
fmt.Printf("\n `assign <node> <holder>` meets it; reported until every machine has its holders, then refused\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(asked.overflowing) > 0 {
|
||||||
|
// **Reported, and the provider still pushed** (novox/hq ADR 0225, issue 263). Each is left
|
||||||
|
// out of its provider's grants, so the module holds a login nothing created; the provider's
|
||||||
|
// machine is sent everything else rather than refused for one consumer elsewhere.
|
||||||
|
fmt.Printf("%d module(s) identified too long for a provision they require, and not granted it:\n",
|
||||||
|
len(asked.overflowing))
|
||||||
|
for _, o := range asked.overflowing {
|
||||||
|
fmt.Printf(" %-12s %-20s %-22s %q is %d, %s keeps %d\n", o.Consumer, o.Module, o.Provision,
|
||||||
|
o.Identity, len(o.Identity), o.Bound.In, o.Bound.Max)
|
||||||
|
}
|
||||||
|
fmt.Printf("\n a shorter `slug` in the module's definition fits it; `module check` refuses one before merge\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
if adopted := adoptedNodes(nodes); len(adopted) > 0 {
|
||||||
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
// Said, because nothing forces the flip: a node left adopted is visible here rather than
|
||||||
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
// read as converged (novox/hq ADR 0100). Not a fault, so it does not break "all well".
|
||||||
@@ -419,6 +432,10 @@ func theThreeQuestions(ctx context.Context, open *stores) (answers, error) {
|
|||||||
return answers{}, err
|
return answers{}, err
|
||||||
}
|
}
|
||||||
out.unheld = append(out.unheld, plan.Unheld...)
|
out.unheld = append(out.unheld, plan.Unheld...)
|
||||||
|
// And which of its modules a provider leaves out of its grants, for an identity too long
|
||||||
|
// for what the provision keeps (novox/hq ADR 0225) — judged from the consumer's own
|
||||||
|
// resolution, as the provider's composition judges it.
|
||||||
|
out.overflowing = append(out.overflowing, plan.Overflowing()...)
|
||||||
}
|
}
|
||||||
// And every consumer a provider says it keeps failing (novox/hq ADR 0224). Read from what the
|
// And every consumer a provider says it keeps failing (novox/hq ADR 0224). Read from what the
|
||||||
// providers announced: nothing else in the mesh knows whether a provision is being made.
|
// providers announced: nothing else in the mesh knows whether a provision is being made.
|
||||||
@@ -538,7 +555,7 @@ func untakenModules(ctx context.Context, inv *inventory.Inventory, nodes []inven
|
|||||||
func (a answers) well() bool {
|
func (a answers) well() bool {
|
||||||
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
return len(a.wrong) == 0 && len(a.quiet) == 0 && len(a.behind) == 0 &&
|
||||||
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
len(a.waiting) == 0 && len(a.refused) == 0 && a.network == "" && len(a.untaken) == 0 &&
|
||||||
len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.failing) == 0
|
len(a.filtered) == 0 && len(a.unheld) == 0 && len(a.failing) == 0 && len(a.overflowing) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostSplit is which machines report which host version, for every version more than one machine
|
// hostSplit is which machines report which host version, for every version more than one machine
|
||||||
|
|||||||
@@ -119,3 +119,41 @@ func TestEveryCatalogueStoreSaysHowItIsBackedUp(t *testing.T) {
|
|||||||
t.Fatal("no module in the catalogue provides a store, so this proved nothing")
|
t.Fatal("no module in the catalogue provides a store, so this proved nothing")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
|
||||||
|
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
|
||||||
|
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
|
||||||
|
// the first time a real machine's name meets the module's (issue 263).
|
||||||
|
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
|
||||||
|
root := catalogueRoot(t)
|
||||||
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||||
|
if err != nil || len(found) == 0 {
|
||||||
|
t.Fatalf("no manifests under %s: %v", root, err)
|
||||||
|
}
|
||||||
|
shelf := Shelf{}
|
||||||
|
for _, p := range found {
|
||||||
|
raw, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
m, err := ParseManifest(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: %v", p, err)
|
||||||
|
}
|
||||||
|
shelf[m.Module] = m
|
||||||
|
}
|
||||||
|
for _, p := range IdentityProblems(shelf, DefaultLongestMachine) {
|
||||||
|
t.Error(p)
|
||||||
|
}
|
||||||
|
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
|
||||||
|
if dns, ok := shelf["dnsmasq"]; ok {
|
||||||
|
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
||||||
|
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if store, ok := shelf["minio"]; ok {
|
||||||
|
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
|
||||||
|
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -114,7 +114,9 @@ func consumerInto(value, as string) (string, error) {
|
|||||||
// asDNSLabel writes a minted identity as a DNS label.
|
// asDNSLabel writes a minted identity as a DNS label.
|
||||||
//
|
//
|
||||||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
// 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
|
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
|
||||||
|
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
|
||||||
|
// saying is held to twenty (IdentityBoundOf). So
|
||||||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
// 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
|
// 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.
|
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||||||
@@ -141,11 +143,31 @@ func CheckServes(m Manifest) []string {
|
|||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
|
||||||
}
|
}
|
||||||
|
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
|
||||||
|
// bound has to keep the identity inside one (ADR 0225).
|
||||||
|
if strings.Contains(text, "${consumer:as:dns}") {
|
||||||
|
if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s serves %s's consumers their identity as a DNS label in %q, and bounds "+
|
||||||
|
"that identity at %s: a label keeps %d — state an `identity` of at most %d",
|
||||||
|
m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4).
|
||||||
|
const dnsLabelLimit = 63
|
||||||
|
|
||||||
|
func boundWords(b IdentityBound) string {
|
||||||
|
if !b.Bounded() {
|
||||||
|
return "nothing"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d", b.Max)
|
||||||
|
}
|
||||||
|
|
||||||
func sortedServes(serves map[string]map[string]any) []string {
|
func sortedServes(serves map[string]map[string]any) []string {
|
||||||
out := make([]string, 0, len(serves))
|
out := make([]string, 0, len(serves))
|
||||||
for k := range serves {
|
for k := range serves {
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ type Rendering struct {
|
|||||||
// rather than resolved, because who consumes a node is a fact about the rest of the mesh and
|
// rather than resolved, because who consumes a node is a fact about the rest of the mesh and
|
||||||
// resolution answers questions about one machine.
|
// resolution answers questions about one machine.
|
||||||
Grants []Grant
|
Grants []Grant
|
||||||
|
// Withheld is every consumer left out of Grants because its identity overflows the provision's
|
||||||
|
// bound (novox/hq ADR 0225). Composed into nothing; carried so the machine's declaration can say
|
||||||
|
// whom it does not serve, and why, beside what it does.
|
||||||
|
Withheld []Overflow
|
||||||
|
|
||||||
// Ports is where this machine puts what each module needs reachable, by module and by the
|
// Ports is where this machine puts what each module needs reachable, by module and by the
|
||||||
// port the software itself uses (novox/hq ADR 0038).
|
// port the software itself uses (novox/hq ADR 0038).
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,13 +57,36 @@ func ConsumerIdentity(node, module string) string {
|
|||||||
return IdentityPrefix + clean(node) + "_" + clean(module)
|
return IdentityPrefix + clean(node) + "_" + clean(module)
|
||||||
}
|
}
|
||||||
|
|
||||||
// identityLimit is the shortest identifier limit among the systems these names reach: an S3 access
|
// IdentityBound is the longest consumer identity one provision's backend keeps, and what keeps it
|
||||||
// key's 20 (novox/hq 04-ISSUES/010). PostgreSQL keeps 63 and MinIO 20, so 20 is the one that binds —
|
// (novox/hq ADR 0049, refined by ADR 0225). Max zero means no bound: the provision keeps no name
|
||||||
// the comment used to name PostgreSQL and was wrong. A name over it is refused, with the remedy a
|
// derived from its consumer, or keeps one in something with no limit the mesh need respect.
|
||||||
// short slug (ADR 0049), not silently cut to fit.
|
type IdentityBound struct {
|
||||||
const identityLimit = 20
|
// Max is the longest identity that backend keeps, in characters; zero for none.
|
||||||
|
Max int `json:"max,omitempty"`
|
||||||
|
// In is what keeps it, in words a refusal can quote: "an S3 access key", "a PostgreSQL role".
|
||||||
|
In string `json:"in,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// CheckIdentity refuses an identity that would not fit the tightest backend a consumer reaches.
|
// Bounded is whether this bound refuses anything.
|
||||||
|
func (b IdentityBound) Bounded() bool { return b.Max > 0 }
|
||||||
|
|
||||||
|
// DefaultIdentityLimit is the bound on a provision whose provider receives its consumers and does
|
||||||
|
// not say how long a name it keeps: an S3 access key's 20 (novox/hq 04-ISSUES/010, 034), the
|
||||||
|
// tightest backend the mesh has met. It was the bound on every provision until ADR 0225; it stays
|
||||||
|
// the bound on any that has not said otherwise, because a provider that is told each consumer's
|
||||||
|
// identity may create a name from it in a backend nobody has measured.
|
||||||
|
const DefaultIdentityLimit = 20
|
||||||
|
|
||||||
|
// DefaultIdentityBound is DefaultIdentityLimit, said as a bound.
|
||||||
|
var DefaultIdentityBound = IdentityBound{Max: DefaultIdentityLimit,
|
||||||
|
In: "a backend that has not said its limit (the tightest known, an S3 access key's)"}
|
||||||
|
|
||||||
|
// identityLimit is the bound CheckIdentity applies, for a caller that does not know which provision
|
||||||
|
// the identity is for.
|
||||||
|
const identityLimit = DefaultIdentityLimit
|
||||||
|
|
||||||
|
// CheckIdentity refuses an identity that would not fit the tightest backend the mesh knows. A caller
|
||||||
|
// that knows the provision uses CheckIdentityWithin and that provision's own bound (ADR 0225).
|
||||||
//
|
//
|
||||||
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
|
// **Truncation is not an error in most of these systems** — a name past the limit is cut to fit and
|
||||||
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
|
// the statement succeeds, so two consumers agreeing for the first N bytes would become one login
|
||||||
@@ -70,12 +94,127 @@ const identityLimit = 20
|
|||||||
// name and is the only thing that can choose another. The remedy is a first-class one: give the
|
// name and is the only thing that can choose another. The remedy is a first-class one: give the
|
||||||
// module a short `slug` (ADR 0049), or shorten the machine's name.
|
// module a short `slug` (ADR 0049), or shorten the machine's name.
|
||||||
func CheckIdentity(node, module string) error {
|
func CheckIdentity(node, module string) error {
|
||||||
|
return CheckIdentityWithin(node, module, IdentityBound{Max: identityLimit, In: "a backend (an S3 access key)"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIdentityWithin refuses an identity that would not fit one provision's bound, and accepts any
|
||||||
|
// identity for a provision with none (novox/hq ADR 0225).
|
||||||
|
func CheckIdentityWithin(node, module string, bound IdentityBound) error {
|
||||||
|
if !bound.Bounded() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
got := ConsumerIdentity(node, module)
|
got := ConsumerIdentity(node, module)
|
||||||
if len(got) <= identityLimit {
|
if len(got) <= bound.Max {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%s on %s is identified as %q, %d characters where a backend (an S3 access key) keeps %d — "+
|
"%s on %s is identified as %q, %d characters where %s keeps %d — "+
|
||||||
"give the module a shorter `slug` or shorten the machine's name",
|
"give the module a shorter `slug` or shorten the machine's name",
|
||||||
module, node, got, len(got), identityLimit)
|
module, node, got, len(got), bound.In, bound.Max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflow is one consumer whose identity does not fit the provision it requires: left out of its
|
||||||
|
// provider's grants and reported, never a reason to refuse the provider's machine (ADR 0225).
|
||||||
|
type Overflow struct {
|
||||||
|
// Provision is what was required, Provider the machine answering it.
|
||||||
|
Provision string `json:"provision"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
// Consumer is the machine, Module the module on it that required it.
|
||||||
|
Consumer string `json:"consumer"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
// Identity is the name the mesh derived, and Bound what it overflows.
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Bound IdentityBound `json:"bound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Overflow) String() string {
|
||||||
|
return fmt.Sprintf("%s on %s requires %s from %s and is identified as %q, %d characters where %s "+
|
||||||
|
"keeps %d — left out of %s's grants until the module's `slug` is shorter",
|
||||||
|
o.Module, o.Consumer, o.Provision, o.Provider, o.Identity, len(o.Identity), o.Bound.In,
|
||||||
|
o.Bound.Max, o.Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overflowing is every requirement of this machine's modules whose identity overflows the bound of
|
||||||
|
// the provision answering it. The same judgement the provider's composition makes before it grants
|
||||||
|
// (grantsFor), made from the consumer's side so `status` can say it about every machine.
|
||||||
|
func (r Resolution) Overflowing() []Overflow {
|
||||||
|
slugs := map[string]string{}
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
slugs[m.Module] = m.Slug
|
||||||
|
}
|
||||||
|
var out []Overflow
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, n := range r.Needs {
|
||||||
|
if n.ByRecord || !n.Identity.Bounded() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
source := IdentitySource(slugs[n.For], n.For)
|
||||||
|
if CheckIdentityWithin(r.Node, source, n.Identity) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := n.Name + "\x00" + n.From + "\x00" + n.For
|
||||||
|
if seen[key] {
|
||||||
|
continue // one line per requirement, however many local names it has
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
out = append(out, Overflow{Provision: n.Name, Provider: n.From, Consumer: r.Node, Module: n.For,
|
||||||
|
Identity: ConsumerIdentity(r.Node, source), Bound: n.Identity})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Module != out[j].Module {
|
||||||
|
return out[i].Module < out[j].Module
|
||||||
|
}
|
||||||
|
return out[i].Provision < out[j].Provision
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultLongestMachine is the machine name the catalogue check judges identities on when it is not
|
||||||
|
// told one: the longest name of the mesh this catalogue is written for, so a catalogue that passes
|
||||||
|
// passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's;
|
||||||
|
// a mesh that names a longer machine raises this in the same change (ADR 0225).
|
||||||
|
const DefaultLongestMachine = 6
|
||||||
|
|
||||||
|
// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine
|
||||||
|
// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the
|
||||||
|
// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A
|
||||||
|
// provision no module in the shelf offers is not judged: its bound is not known here.
|
||||||
|
func IdentityProblems(shelf Shelf, longestMachine int) []string {
|
||||||
|
offeredBy := map[string][]string{}
|
||||||
|
for _, name := range shelfOrder(shelf) {
|
||||||
|
for _, o := range shelf[name].Offers() {
|
||||||
|
offeredBy[o] = append(offeredBy[o], name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machine := strings.Repeat("n", longestMachine)
|
||||||
|
var problems []string
|
||||||
|
for _, name := range shelfOrder(shelf) {
|
||||||
|
m := shelf[name]
|
||||||
|
source := IdentitySource(m.Slug, m.Module)
|
||||||
|
for _, want := range m.Wants() {
|
||||||
|
// The tightest bound among the modules offering it: whichever one answers on a given
|
||||||
|
// machine, the identity has to fit it.
|
||||||
|
tightest, by := IdentityBound{}, ""
|
||||||
|
for _, provider := range offeredBy[want] {
|
||||||
|
if provider == name {
|
||||||
|
continue // a module answering its own requirement is not its own consumer
|
||||||
|
}
|
||||||
|
b := shelf[provider].IdentityBoundOf(want)
|
||||||
|
if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) {
|
||||||
|
tightest, by = b, provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if CheckIdentityWithin(machine, source, tightest) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got := ConsumerIdentity(machine, source)
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+
|
||||||
|
"on a machine with a %d-character name it is identified as %q, %d — give %s a "+
|
||||||
|
"`slug` of at most %d characters",
|
||||||
|
name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name,
|
||||||
|
tightest.Max-len(IdentityPrefix)-longestMachine-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Each test names the decision it defends: novox/hq ADR 0225, which refines ADR 0049 after issue 263.
|
||||||
|
|
||||||
|
// The provision a module requires sets the bound on its identity, not the tightest backend anywhere.
|
||||||
|
func TestABoundIsTheProvisionsOwn(t *testing.T) {
|
||||||
|
store := Manifest{Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
||||||
|
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||||
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}
|
||||||
|
database := Manifest{Module: "db", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||||
|
Identity: &OfferIdentity{Max: 63, In: "a PostgreSQL role"}}},
|
||||||
|
Receives: map[string]string{"postgres-database": "/var/lib/mesh/db/mesh.json"}}
|
||||||
|
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 || b.In != "an S3 access key" {
|
||||||
|
t.Errorf("an object store's stated bound was not taken: %+v", b)
|
||||||
|
}
|
||||||
|
if b := database.IdentityBoundOf("postgres-database"); b.Max != 63 {
|
||||||
|
t.Errorf("a database's stated bound was not taken: %+v", b)
|
||||||
|
}
|
||||||
|
// mesh_workstation_keycloak is 25: refused by the object store, accepted by the database.
|
||||||
|
if CheckIdentityWithin("workstation", "keycloak", store.IdentityBoundOf("s3-bucket")) == nil {
|
||||||
|
t.Error("a 25-character identity fit a 20-character access key")
|
||||||
|
}
|
||||||
|
if err := CheckIdentityWithin("workstation", "keycloak", database.IdentityBoundOf("postgres-database")); err != nil {
|
||||||
|
t.Errorf("a database consumer paid the object store's limit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What an offer leaves unsaid follows from whether its provider can keep a name at all.
|
||||||
|
func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) {
|
||||||
|
// Told nothing about its consumers — no receives, nothing served from their identity: no bound.
|
||||||
|
// The resolver provision is exactly this, and its consumers paid an object store's limit (263).
|
||||||
|
resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}
|
||||||
|
if b := resolver.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
||||||
|
t.Errorf("a provision that is told nothing of its consumers bounds them: %+v", b)
|
||||||
|
}
|
||||||
|
// Told each consumer and silent about its backend: the old global bound, not none.
|
||||||
|
told := Manifest{Module: "told", Provides: []Offer{{Name: "thing", Scope: ScopeMesh}},
|
||||||
|
Receives: map[string]string{"thing": "/var/lib/mesh/told/mesh.json"}}
|
||||||
|
if b := told.IdentityBoundOf("thing"); b.Max != DefaultIdentityLimit {
|
||||||
|
t.Errorf("a provider told its consumers and silent about its backend is not held to %d: %+v",
|
||||||
|
DefaultIdentityLimit, b)
|
||||||
|
}
|
||||||
|
// Serving a value built from the identity is being told it, too.
|
||||||
|
serving := Manifest{Module: "serving", Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}},
|
||||||
|
Serves: map[string]map[string]any{"bucket": {"name": "b-${consumer:as:dns}"}}}
|
||||||
|
if b := serving.IdentityBoundOf("bucket"); b.Max != DefaultIdentityLimit {
|
||||||
|
t.Errorf("a provider deriving a name from its consumers is not bounded: %+v", b)
|
||||||
|
}
|
||||||
|
// And `false` says it outright, even for a provider that receives.
|
||||||
|
routes := Manifest{Module: "routes", Provides: []Offer{{Name: "route", Scope: ScopeMesh,
|
||||||
|
Identity: &OfferIdentity{None: true}}},
|
||||||
|
Receives: map[string]string{"route": "/var/lib/mesh/routes/mesh.json"}}
|
||||||
|
if b := routes.IdentityBoundOf("route"); b.Bounded() {
|
||||||
|
t.Errorf("`identity: false` still bounds: %+v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The field reads as written and writes back the same, and refuses what says nothing.
|
||||||
|
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
|
||||||
|
for _, raw := range []string{
|
||||||
|
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
|
||||||
|
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
|
||||||
|
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
|
||||||
|
} {
|
||||||
|
var o Offer
|
||||||
|
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
||||||
|
t.Fatalf("%s: %v", raw, err)
|
||||||
|
}
|
||||||
|
back, err := json.Marshal(o)
|
||||||
|
if err != nil || string(back) != raw {
|
||||||
|
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, raw := range []string{
|
||||||
|
`{"name":"x","identity":true}`,
|
||||||
|
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
|
||||||
|
} {
|
||||||
|
var o Offer
|
||||||
|
if err := json.Unmarshal([]byte(raw), &o); err == nil {
|
||||||
|
t.Errorf("accepted %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{
|
||||||
|
{Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}},
|
||||||
|
{Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}},
|
||||||
|
}}
|
||||||
|
raw, err := json.Marshal(bad)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = ParseManifest(raw)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
|
||||||
|
!strings.Contains(err.Error(), "the shortest the mesh makes") {
|
||||||
|
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
|
||||||
|
// name, against the bound of every provision it wants — and names the module and the slug to set.
|
||||||
|
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
||||||
|
shelf := Shelf{
|
||||||
|
"objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
||||||
|
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||||
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
|
||||||
|
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
|
||||||
|
"files": {Module: "files", Requires: []string{"s3-bucket"}},
|
||||||
|
}
|
||||||
|
problems := IdentityProblems(shelf, 6)
|
||||||
|
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
|
||||||
|
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
|
||||||
|
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
|
||||||
|
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
|
||||||
|
}
|
||||||
|
// A longer machine name refuses more: the check is about the mesh's machines, not one.
|
||||||
|
if got := IdentityProblems(shelf, 10); len(got) != 2 {
|
||||||
|
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
|
||||||
|
}
|
||||||
|
// And a slug is the remedy it names.
|
||||||
|
album := shelf["photoalbum"]
|
||||||
|
album.Slug = "album"
|
||||||
|
shelf["photoalbum"] = album
|
||||||
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||||
|
t.Fatalf("a slug that fits is still refused: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
|
||||||
|
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
|
||||||
|
// whole machine with it; the resolver keeps no name, so it is not refused at all.
|
||||||
|
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
|
||||||
|
shelf := Shelf{
|
||||||
|
"resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}},
|
||||||
|
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
|
||||||
|
}
|
||||||
|
if CheckIdentity("laptop", "networkmanager") == nil {
|
||||||
|
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
|
||||||
|
}
|
||||||
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||||
|
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
|
||||||
|
}
|
||||||
|
r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]},
|
||||||
|
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
|
||||||
|
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
|
||||||
|
if got := r.Overflowing(); len(got) != 0 {
|
||||||
|
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The consumer's side of the same judgement the provider's composition makes: what `status` says.
|
||||||
|
func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
|
||||||
|
bound := IdentityBound{Max: 20, In: "an S3 access key"}
|
||||||
|
r := Resolution{Node: "laptop",
|
||||||
|
Modules: []Manifest{{Module: "photoalbum"}, {Module: "files"}, {Module: "gallery", Slug: "gal"}},
|
||||||
|
Needs: []Needed{
|
||||||
|
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Identity: bound},
|
||||||
|
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Local: "second", Identity: bound},
|
||||||
|
{Name: "s3-bucket", From: "anchor", For: "files", Identity: bound},
|
||||||
|
{Name: "s3-bucket", From: "anchor", For: "gallery", Identity: bound},
|
||||||
|
{Name: "licence", From: "records", For: "photoalbum", ByRecord: true, Identity: bound},
|
||||||
|
}}
|
||||||
|
got := r.Overflowing()
|
||||||
|
if len(got) != 1 || got[0].Module != "photoalbum" || got[0].Identity != "mesh_laptop_photoalbum" ||
|
||||||
|
got[0].Provider != "anchor" {
|
||||||
|
t.Fatalf("one overflow, once, was expected: %+v", got)
|
||||||
|
}
|
||||||
|
if said := got[0].String(); !strings.Contains(said, "an S3 access key keeps 20") ||
|
||||||
|
!strings.Contains(said, "slug") {
|
||||||
|
t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
|
||||||
|
func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
|
||||||
|
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
|
||||||
|
wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh,
|
||||||
|
Identity: &OfferIdentity{Max: 255, In: "a client id"}}}}
|
||||||
|
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
|
||||||
|
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
|
||||||
|
}
|
||||||
|
unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}}
|
||||||
|
if got := CheckServes(unsaid); len(got) != 0 {
|
||||||
|
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -153,6 +154,84 @@ type Offer struct {
|
|||||||
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
|
// a seat's holder gated by the machine's graphical session, and pulling one in for whatever asked
|
||||||
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
|
// is the misassignment research 026 found. Unmet, the requirement is refused naming who could.
|
||||||
Reach string `json:"reach,omitempty"`
|
Reach string `json:"reach,omitempty"`
|
||||||
|
// Identity is the longest consumer identity this provision's backend keeps (novox/hq ADR 0225):
|
||||||
|
// `{"max": 63, "in": "a PostgreSQL role"}`, or `false` for a provision that keeps no name derived
|
||||||
|
// from its consumer. Unsaid, the mesh assumes the tightest backend it knows when the provider is
|
||||||
|
// told its consumers, and no bound when it is not — see IdentityBoundOf.
|
||||||
|
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfferIdentity is what an offer says about the names its backend keeps for its consumers.
|
||||||
|
type OfferIdentity struct {
|
||||||
|
// None is set by `"identity": false`: the provision keeps no name derived from its consumer.
|
||||||
|
None bool
|
||||||
|
// Max is the longest identity kept, in characters; zero with In set means no limit worth stating.
|
||||||
|
Max int
|
||||||
|
// In is what keeps it, for a refusal to quote.
|
||||||
|
In string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON accepts `false` or `{"max": N, "in": "..."}`.
|
||||||
|
func (i *OfferIdentity) UnmarshalJSON(raw []byte) error {
|
||||||
|
var flag bool
|
||||||
|
if err := json.Unmarshal(raw, &flag); err == nil {
|
||||||
|
if flag {
|
||||||
|
return errors.New("an offer's identity is false (it keeps no name) or {max, in}; true says nothing")
|
||||||
|
}
|
||||||
|
*i = OfferIdentity{None: true}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var full struct {
|
||||||
|
Max int `json:"max,omitempty"`
|
||||||
|
In string `json:"in"`
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if err := dec.Decode(&full); err != nil {
|
||||||
|
return fmt.Errorf("an offer's identity is false or {max, in}: %w", err)
|
||||||
|
}
|
||||||
|
*i = OfferIdentity{Max: full.Max, In: full.In}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON writes it back in the form it was written.
|
||||||
|
func (i OfferIdentity) MarshalJSON() ([]byte, error) {
|
||||||
|
if i.None {
|
||||||
|
return []byte("false"), nil
|
||||||
|
}
|
||||||
|
return json.Marshal(struct {
|
||||||
|
Max int `json:"max,omitempty"`
|
||||||
|
In string `json:"in"`
|
||||||
|
}{i.Max, i.In})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IdentityBoundOf is the bound this module's offer of a provision puts on its consumers' identities
|
||||||
|
// (novox/hq ADR 0225).
|
||||||
|
//
|
||||||
|
// **What an offer says, it gets.** Where it says nothing, the bound follows from whether the
|
||||||
|
// provider can keep a name at all: a provider that receives the provision, or serves its consumers
|
||||||
|
// a value built from their identity, is told who each consumer is and may create a name from it in
|
||||||
|
// a backend nobody measured — so it keeps the old global bound, DefaultIdentityBound. One that does
|
||||||
|
// neither is told nothing about its consumers and keeps nothing of them: no bound. The resolver
|
||||||
|
// provision is that case, and its consumers paid an object store's limit until this (issue 263).
|
||||||
|
func (m Manifest) IdentityBoundOf(provision string) IdentityBound {
|
||||||
|
for _, o := range m.Provides {
|
||||||
|
if o.Name != provision || o.Identity == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if o.Identity.None {
|
||||||
|
return IdentityBound{}
|
||||||
|
}
|
||||||
|
return IdentityBound{Max: o.Identity.Max, In: o.Identity.In}
|
||||||
|
}
|
||||||
|
if _, receives := m.Receives[provision]; receives {
|
||||||
|
return DefaultIdentityBound
|
||||||
|
}
|
||||||
|
if served, err := json.Marshal(m.Serves[provision]); err == nil &&
|
||||||
|
bytes.Contains(served, []byte("${consumer:as")) {
|
||||||
|
return DefaultIdentityBound
|
||||||
|
}
|
||||||
|
return IdentityBound{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineReach is whether a provision is usable only on its provider's own machine.
|
// MachineReach is whether a provision is usable only on its provider's own machine.
|
||||||
@@ -206,20 +285,21 @@ func (o *Offer) UnmarshalJSON(raw []byte) error {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
Reach string `json:"reach,omitempty"`
|
Reach string `json:"reach,omitempty"`
|
||||||
|
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||||
}
|
}
|
||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&full); err != nil {
|
if err := dec.Decode(&full); err != nil {
|
||||||
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach}: %w", err)
|
return fmt.Errorf("a provided name is either a string or {name, scope, credential, reach, identity}: %w", err)
|
||||||
}
|
}
|
||||||
o.Name, o.Scope, o.Credential, o.Reach = full.Name, full.Scope, full.Credential, full.Reach
|
o.Name, o.Scope, o.Credential, o.Reach, o.Identity = full.Name, full.Scope, full.Credential, full.Reach, full.Identity
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
// MarshalJSON writes back the short form when there is nothing else to say, so a manifest that
|
||||||
// went through the mesh comes out looking like the one that went in.
|
// went through the mesh comes out looking like the one that went in.
|
||||||
func (o Offer) MarshalJSON() ([]byte, error) {
|
func (o Offer) MarshalJSON() ([]byte, error) {
|
||||||
if o.Scope == "" && o.Credential == nil && o.Reach == "" {
|
if o.Scope == "" && o.Credential == nil && o.Reach == "" && o.Identity == nil {
|
||||||
return json.Marshal(o.Name)
|
return json.Marshal(o.Name)
|
||||||
}
|
}
|
||||||
return json.Marshal(struct {
|
return json.Marshal(struct {
|
||||||
@@ -227,7 +307,8 @@ func (o Offer) MarshalJSON() ([]byte, error) {
|
|||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Credential *OfferCredential `json:"credential,omitempty"`
|
Credential *OfferCredential `json:"credential,omitempty"`
|
||||||
Reach string `json:"reach,omitempty"`
|
Reach string `json:"reach,omitempty"`
|
||||||
}{o.Name, o.Scope, o.Credential, o.Reach})
|
Identity *OfferIdentity `json:"identity,omitempty"`
|
||||||
|
}{o.Name, o.Scope, o.Credential, o.Reach, o.Identity})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest is everything a module says about itself.
|
// Manifest is everything a module says about itself.
|
||||||
@@ -237,9 +318,10 @@ type Manifest struct {
|
|||||||
|
|
||||||
// Slug is a short identifier the mesh uses in place of the module name when it derives a
|
// Slug is a short identifier the mesh uses in place of the module name when it derives a
|
||||||
// consumer's login (novox/hq ADR 0049). Optional: a module with a short name needs none. It
|
// consumer's login (novox/hq ADR 0049). Optional: a module with a short name needs none. It
|
||||||
// exists because `mesh_<node>_<module>` must fit the tightest backend a consumer reaches — an S3
|
// exists because `mesh_<node>_<module>` must fit the bound of every provision the module
|
||||||
// access key is 20 characters — and a long module name would overflow it. A person choosing
|
// requires — an S3 access key's 20 characters is the tightest — and a long module name would
|
||||||
// `kc` for keycloak keeps the identity legible where a hash would not.
|
// overflow it (ADR 0225: the bound is the provision's own, and a keyless one has none). A person
|
||||||
|
// choosing `kc` for keycloak keeps the identity legible where a hash would not.
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
|
||||||
// Provides are the names other modules may require. A module always provides its own name;
|
// Provides are the names other modules may require. A module always provides its own name;
|
||||||
@@ -1265,8 +1347,10 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
|
"%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module))
|
||||||
}
|
}
|
||||||
// A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same
|
// A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same
|
||||||
// charset as a name; its length is checked against a backend's limit at assignment, where the
|
// charset as a name; its length is judged against the bound of each provision it wants on the
|
||||||
// node it joins is known — a slug that is fine on one machine's short name can overflow another's.
|
// longest machine name by the catalogue check (IdentityProblems, ADR 0225), and against the
|
||||||
|
// machine it is on when its provider grants it — a slug that is fine on one machine's short name
|
||||||
|
// can overflow another's.
|
||||||
if m.Slug != "" && !name.MatchString(m.Slug) {
|
if m.Slug != "" && !name.MatchString(m.Slug) {
|
||||||
problems = append(problems, fmt.Sprintf(
|
problems = append(problems, fmt.Sprintf(
|
||||||
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
|
"%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug))
|
||||||
@@ -1303,6 +1387,20 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
if !name.MatchString(p) {
|
if !name.MatchString(p) {
|
||||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p))
|
||||||
}
|
}
|
||||||
|
// A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it
|
||||||
|
// leaves room for the shortest identity the mesh makes, or it would refuse every consumer.
|
||||||
|
if id := offer.Identity; id != nil && !id.None {
|
||||||
|
if strings.TrimSpace(id.In) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s bounds the identities of %s's consumers without saying what keeps them: `in`",
|
||||||
|
m.Module, p))
|
||||||
|
}
|
||||||
|
if shortest := len(IdentityPrefix) + 3; id.Max < 0 || id.Max > 0 && id.Max < shortest {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s bounds %s's consumers' identities at %d characters, and the shortest the mesh "+
|
||||||
|
"makes is %d", m.Module, p, id.Max, shortest))
|
||||||
|
}
|
||||||
|
}
|
||||||
if offer.Credential != nil {
|
if offer.Credential != nil {
|
||||||
own, declared := m.OwnSecrets[offer.Credential.Own]
|
own, declared := m.OwnSecrets[offer.Credential.Own]
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -194,6 +194,11 @@ type Needed struct {
|
|||||||
// state, not a consumer missing its key. Set by the plan, which is the only layer that knows a
|
// state, not a consumer missing its key. Set by the plan, which is the only layer that knows a
|
||||||
// licence's manager; empty for every consumer.
|
// licence's manager; empty for every consumer.
|
||||||
Manager bool
|
Manager bool
|
||||||
|
// Identity is the longest consumer identity the answering provision keeps (novox/hq ADR 0225),
|
||||||
|
// from the provider's own offer: what the mesh judges this consumer's identity against, on the
|
||||||
|
// consumer's side for `status` and on the provider's before it grants. No bound for a provision
|
||||||
|
// answered by a record, which keeps no name of anybody's.
|
||||||
|
Identity IdentityBound
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refusal is why a set of assignments cannot become a declaration.
|
// Refusal is why a set of assignments cannot become a declaration.
|
||||||
@@ -403,7 +408,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
needs = append(needs, Needed{
|
needs = append(needs, Needed{
|
||||||
Name: want, From: node.Name, At: at,
|
Name: want, From: node.Name, At: at,
|
||||||
Serves: servedByOne(by, want), For: because[want],
|
Serves: servedByOne(by, want), For: because[want],
|
||||||
SharedOwn: sharedByOne(by, want)})
|
SharedOwn: sharedByOne(by, want), Identity: by.IdentityBoundOf(want)})
|
||||||
} else if served := servedByOne(by, want); len(served) > 0 {
|
} else if served := servedByOne(by, want); len(served) > 0 {
|
||||||
// Answered here with no credential to mint, but the provider serves facts the
|
// Answered here with no credential to mint, but the provider serves facts the
|
||||||
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
// consumer cannot guess — a port, a model name — and so still needs a binding.
|
||||||
@@ -447,11 +452,15 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
shared := ""
|
shared := ""
|
||||||
|
// A provider whose definition is not in hand is held to the tightest bound the
|
||||||
|
// mesh knows, not to none: what it keeps is not known here (ADR 0225).
|
||||||
|
bound := DefaultIdentityBound
|
||||||
if pm, known := catalogue[p.Module]; known {
|
if pm, known := catalogue[p.Module]; known {
|
||||||
shared, _ = pm.SharedCredentialOf(want)
|
shared, _ = pm.SharedCredentialOf(want)
|
||||||
|
bound = pm.IdentityBoundOf(want)
|
||||||
}
|
}
|
||||||
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
|
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At,
|
||||||
Serves: p.Serves, For: because[want], SharedOwn: shared})
|
Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound})
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case world.Unchecked:
|
case world.Unchecked:
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ var ControllerVerbs = []Verb{
|
|||||||
"node": "the machine's name; without it, every machine that is behind",
|
"node": "the machine's name; without it, every machine that is behind",
|
||||||
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
|
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
|
||||||
}, nil, "behind")},
|
}, nil, "behind")},
|
||||||
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine, " +
|
{Name: "rotate", Description: "Replace a credential. A pair credential, by provision (and a consuming machine and module, " +
|
||||||
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
"else every holder): both ends are re-sent together. Or a module's own secret, by machine, module and " +
|
||||||
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
|
"name: made anew and the machine sent, so the module starts again on it — only for a secret its " +
|
||||||
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
|
"definition says it reads at start; a value given to the mesh, or one the module applies to a backend, is refused with the reason.",
|
||||||
@@ -150,7 +150,7 @@ var ControllerVerbs = []Verb{
|
|||||||
"provision": "a pair credential: the provision whose credential to replace",
|
"provision": "a pair credential: the provision whose credential to replace",
|
||||||
"consumer": "with provision: only the holder on this machine (optional)",
|
"consumer": "with provision: only the holder on this machine (optional)",
|
||||||
"node": "an own secret: the machine",
|
"node": "an own secret: the machine",
|
||||||
"module": "an own secret: the module",
|
"module": "an own secret: the module; with provision: only this consuming module's credential (optional)",
|
||||||
"secret": "an own secret: its name in the module's definition",
|
"secret": "an own secret: its name in the module's definition",
|
||||||
}, nil)},
|
}, nil)},
|
||||||
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
{Name: "issue", Description: "Give a module on a machine its account on the bus: minted, and sealed to the " +
|
||||||
|
|||||||
Reference in New Issue
Block a user