A provider is told who its consumers are, and a reference provisioner
Contributions were node-local, so a mesh-scoped provider — the one case that most needs them — never heard from its consumers. A database was given a password and no idea what to create it for. Cross-node consumers now reach the provider's `receives` file, merged in with the ones on its own machine: from the provider's side they are the same thing, and a provider that had to read two lists would read one of them. Each names the file its credential is in rather than carrying it, because the mesh discarded the value and could not put it there. The readable half therefore stays readable. And examples/postgres-provisioner, which is the last step: it reads what the host wrote and makes PostgreSQL accept it. Explicitly not part of the control plane — the control plane decides and never touches a machine. This runs on the machine and touches it, and a real one ships with the module that ships PostgreSQL. It lives here because this is where the contract is defined, written as something that runs so it can be read. It reconciles rather than applying a change, because it is never told what changed. Three things that follow, and each is a fault somebody has shipped: - the password is set every time, not only on creation, or a rotation reports success and changes nothing - what it made and nobody asks for any more is revoked, or a departed consumer keeps a working login for ever - what it did not make is left alone, or it cannot be run on a database that predates it Proven in the lab against a real PostgreSQL, each assertion confirmed to fail with the behaviour removed. The suite is in mesh-lab, which also records the two ways the test itself was wrong first.
This commit is contained in:
@@ -1232,10 +1232,26 @@ func grantsFor(ctx context.Context, inv *inventory.Inventory, node string) ([]ca
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
// can do nothing with it, and the name a consumer wants is the consumer's to say.
|
||||
out := make([]catalogue.Grant, 0, len(issued))
|
||||
for _, s := range issued {
|
||||
plan, settings, err := planFor(ctx, inv, s.Consumer)
|
||||
if err != nil {
|
||||
// Their set does not resolve. Skipped rather than fatal: this node is not the place
|
||||
// to report another machine's problem, and a grant for something that is not going to
|
||||
// run would have the provider create a user nothing uses.
|
||||
continue
|
||||
}
|
||||
from, values, err := plan.ContributionsTo(s.Name, settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, catalogue.Grant{
|
||||
Provision: s.Name, Consumer: s.Consumer, Sealed: s.ForProvider})
|
||||
Provision: s.Name, Consumer: s.Consumer,
|
||||
From: from, Values: values, Sealed: s.ForProvider})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# examples
|
||||
|
||||
Things that run, kept here because a contract is easier to read as working code than as prose.
|
||||
|
||||
**Nothing here is part of the control plane.** The control plane decides and never touches a
|
||||
machine ([README](../README.md)); everything in this directory runs *on* a machine and touches it.
|
||||
These are reference implementations of contracts the control plane defines, and a real one ships
|
||||
with the module that ships the software it configures.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `postgres-provisioner` | the last step of a credential: reads what the mesh delivered and makes PostgreSQL accept it |
|
||||
@@ -0,0 +1,216 @@
|
||||
// A provisioner, in the form the mesh expects one.
|
||||
//
|
||||
// The mesh generated a password, sealed it to the machine that must accept it, and discarded the
|
||||
// plaintext — so it cannot tell PostgreSQL to start accepting it. Something on that machine reads
|
||||
// what the host wrote and makes it true. This is that something.
|
||||
//
|
||||
// **It is an example, not part of the control plane.** The control plane decides and never
|
||||
// touches a machine; this runs on the machine and touches it. A real one ships with the module
|
||||
// that ships PostgreSQL (novox/hq ADR 0001 — third-party software runs *on* the mesh, not *of*
|
||||
// it). What lives here is the contract, written as something that runs so it can be read rather
|
||||
// than described.
|
||||
//
|
||||
// What it is given, both written by the host from an ordinary declaration:
|
||||
//
|
||||
// $GRANTS/mesh.json every consumer, what it asked for, and where its credential is
|
||||
// $GRANTS/<node>.secret one consumer's password, alone in the file
|
||||
//
|
||||
// Two files because the mesh discarded the value and could not compose a document containing it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// mark is what this provisioner names the roles it owns.
|
||||
//
|
||||
// So it never removes one a person made by hand — the mesh's own rule about origins, one level
|
||||
// down (novox/hq 04-ISSUES/010). A provisioner that dropped every role it did not recognise would
|
||||
// be a provisioner nobody could safely run on a database that predates it.
|
||||
const mark = "mesh_"
|
||||
|
||||
// contribution is one consumer, as the mesh described it.
|
||||
type contribution struct {
|
||||
From string `json:"from"`
|
||||
// Node is empty for a module on this machine, which is asking for something local and is not
|
||||
// this provisioner's business.
|
||||
Node string `json:"node"`
|
||||
Secret string `json:"secret"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
Requirement string `json:"requirement"`
|
||||
Given []contribution `json:"given"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "mesh-provision-postgres: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) error {
|
||||
grants := os.Getenv("GRANTS")
|
||||
if grants == "" {
|
||||
grants = "/var/lib/postgres/grants"
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(grants, "mesh.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Nothing has been granted here. Not a failure: a provider with no consumers is an
|
||||
// ordinary state, and one this must be able to reach from any other.
|
||||
fmt.Printf("nothing has been granted to this machine\n")
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return fmt.Errorf("the manifest at %s is not readable: %w", grants, err)
|
||||
}
|
||||
|
||||
db, err := pgx.Connect(ctx, os.Getenv("MESH_PROVISION_POSTGRES"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close(ctx)
|
||||
|
||||
// **Reconciling, not applying a change.** It runs after every declaration and is never told
|
||||
// what changed, so it must reach the same state from wherever it starts.
|
||||
wanted := map[string]bool{}
|
||||
for _, c := range sorted(m.Given) {
|
||||
if c.Node == "" {
|
||||
continue
|
||||
}
|
||||
name, _ := c.Values["name"].(string)
|
||||
if name == "" {
|
||||
return fmt.Errorf("%s asked for a database and did not name it", c.Node)
|
||||
}
|
||||
password, err := os.ReadFile(c.Secret)
|
||||
if err != nil {
|
||||
// The manifest says there is a credential and the host has not written it. Refused
|
||||
// rather than creating a role with no password — a login nothing can use, which
|
||||
// nothing would report until something tried to connect.
|
||||
return fmt.Errorf("%s's credential should be at %s and is not there", c.Node, c.Secret)
|
||||
}
|
||||
|
||||
role := mark + c.Node
|
||||
wanted[role] = true
|
||||
if err := ensureRole(ctx, db, role, strings.TrimSpace(string(password))); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDatabase(ctx, db, name, role); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// And everything this provisioner made that nobody asks for any more. **The half usually
|
||||
// missing**: a consumer that goes away otherwise keeps a working login for ever and nothing
|
||||
// says so.
|
||||
return revokeOrphans(ctx, db, wanted)
|
||||
}
|
||||
|
||||
func ensureRole(ctx context.Context, db *pgx.Conn, role, password string) error {
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx,
|
||||
`select true from pg_roles where rolname = $1`, role).Scan(&exists); err != nil && err != pgx.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
// Set every time rather than only on creation. The mesh replaces the file when it rotates,
|
||||
// and a provisioner that only ever created would leave the old password working — a rotation
|
||||
// that reports success and changes nothing.
|
||||
verb := "create"
|
||||
if exists {
|
||||
verb = "alter"
|
||||
}
|
||||
_, err := db.Exec(ctx, fmt.Sprintf("%s role %s with login password %s",
|
||||
verb, quoteName(role), quoteString(password)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
fmt.Printf("created %s\n", role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureDatabase(ctx context.Context, db *pgx.Conn, name, owner string) error {
|
||||
var exists bool
|
||||
if err := db.QueryRow(ctx,
|
||||
`select true from pg_database where datname = $1`, name).Scan(&exists); err != nil && err != pgx.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec(ctx, fmt.Sprintf("create database %s owner %s",
|
||||
quoteName(name), quoteName(owner))); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("created database %s owned by %s\n", name, owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func revokeOrphans(ctx context.Context, db *pgx.Conn, wanted map[string]bool) error {
|
||||
rows, err := db.Query(ctx,
|
||||
`select rolname from pg_roles where rolname like $1 and rolcanlogin order by rolname`,
|
||||
mark+"%")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var found []string
|
||||
for rows.Next() {
|
||||
var role string
|
||||
if err := rows.Scan(&role); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
found = append(found, role)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, role := range found {
|
||||
if wanted[role] {
|
||||
continue
|
||||
}
|
||||
// Login removed rather than the role dropped. Dropping fails while the role owns
|
||||
// anything, and a provisioner that failed there would stop reconciling everything else —
|
||||
// so the credential stops working immediately and what it owns is somebody's to decide
|
||||
// about.
|
||||
if _, err := db.Exec(ctx, fmt.Sprintf("alter role %s with nologin",
|
||||
quoteName(role))); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("revoked %s — nothing in the mesh asks for it\n", role)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sorted puts consumers in a stable order, so two runs do the same work in the same sequence and
|
||||
// the output of one can be compared with another.
|
||||
func sorted(given []contribution) []contribution {
|
||||
out := append([]contribution{}, given...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
|
||||
return out
|
||||
}
|
||||
|
||||
// quoteName and quoteString exist because PostgreSQL takes no parameters in DDL.
|
||||
//
|
||||
// Both double the quote character, which is the whole of the escaping rule. Worth doing properly
|
||||
// even here: a password is chosen by the mesh and a node name by a person, and "the value happens
|
||||
// to be safe today" is not a property anything should rest on.
|
||||
func quoteName(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
||||
func quoteString(s string) string { return `'` + strings.ReplaceAll(s, `'`, `''`) + `'` }
|
||||
@@ -2,13 +2,16 @@ module github.com/novox/mesh-control
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/rabbitmq/amqp091-go v1.14.0
|
||||
golang.org/x/crypto v0.55.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.10.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 // indirect
|
||||
golang.org/x/crypto v0.55.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -7,23 +9,26 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0 h1:RSaT7aOKt/OrkVUyswPDW29lnRz9psuGmfZFBmLqLek=
|
||||
github.com/rabbitmq/amqp091-go v1.14.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -220,3 +220,113 @@ func TestAnEmptyContributionIsRefused(t *testing.T) {
|
||||
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are. Contributions were node-local until this, which meant the
|
||||
// one case that most needed them was the one they did not reach.
|
||||
|
||||
func provider() Manifest {
|
||||
return Manifest{Module: "postgres", Version: "1",
|
||||
Provides: FromAnywhere("database"),
|
||||
Receives: map[string]string{"database": "/var/lib/postgres/grants/mesh.json"},
|
||||
Grants: map[string]string{"database": "/var/lib/postgres/grants"},
|
||||
}
|
||||
}
|
||||
|
||||
// oneGrant is a declaration with a single consumer on another machine.
|
||||
func oneGrant(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "database", Consumer: "workstation", From: "meshboard",
|
||||
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func grantedTo(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/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 manifest: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAProviderIsToldAboutConsumersOnOtherMachines(t *testing.T) {
|
||||
// A database told to create a password and not who for can do nothing with it.
|
||||
given := grantedTo(t, oneGrant(t))
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("got %v", given)
|
||||
}
|
||||
if given[0].Node != "workstation" || given[0].From != "meshboard" {
|
||||
t.Fatalf("it does not say who asked: %v", given[0])
|
||||
}
|
||||
if given[0].Values["name"] != "meshboard" {
|
||||
t.Fatalf("it does not say what was asked for: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManifestNamesTheFileRatherThanCarryingTheCredential(t *testing.T) {
|
||||
// The mesh discarded the value and could not put it here if it wanted to. What is here is
|
||||
// where to find it — and the readable half therefore stays readable.
|
||||
out := oneGrant(t)
|
||||
given := grantedTo(t, out)
|
||||
if given[0].Secret != "/var/lib/postgres/grants/workstation.secret" {
|
||||
t.Fatalf("the manifest does not name the credential's file: %q", given[0].Secret)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(r["content"].(string), "c2VhbGVk") {
|
||||
t.Fatal("the readable manifest carries the sealed credential")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCredentialItselfLandsSealedBesideIt(t *testing.T) {
|
||||
for _, r := range oneGrant(t) {
|
||||
if r["path"] != "/var/lib/postgres/grants/workstation.secret" {
|
||||
continue
|
||||
}
|
||||
if r["sealed"] != "c2VhbGVk" {
|
||||
t.Fatalf("got %v", r)
|
||||
}
|
||||
if r["content"] != nil {
|
||||
t.Fatal("a credential was written in the clear")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no credential file")
|
||||
}
|
||||
|
||||
func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
|
||||
// Cross-node grants are merged in with this machine's own, because from the provider's side
|
||||
// they are the same thing — somebody wanting something — and a provider that had to read two
|
||||
// lists would read one of them.
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 || given[0].Node != "" {
|
||||
t.Fatalf("a local contribution grew a node: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +462,12 @@ type Grant struct {
|
||||
Provision string
|
||||
// Consumer is the node that will use it, which is also what names the file.
|
||||
Consumer string
|
||||
// From is the module on that machine which asked, so the provider can name what it creates
|
||||
// after the thing using it rather than after the machine.
|
||||
From string
|
||||
// Values are what that module contributed — the name it wants, and anything else the
|
||||
// provision's own vocabulary defines.
|
||||
Values map[string]any
|
||||
// Sealed is the credential, closed to the providing node.
|
||||
Sealed string
|
||||
}
|
||||
@@ -482,7 +488,15 @@ type Rendering struct {
|
||||
// both call something "config", and without this the second would silently replace the first —
|
||||
// the node applying one of them and reporting success.
|
||||
func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
given, err := r.contributions(with.Settings)
|
||||
// Where each provision's credentials land, so a contribution can name the file rather than
|
||||
// carry a value the mesh does not have.
|
||||
directories := map[string]string{}
|
||||
for _, m := range r.Modules {
|
||||
for provision, where := range m.Grants {
|
||||
directories[provision] = where
|
||||
}
|
||||
}
|
||||
given, err := r.contributions(with.Settings, with.Grants, directories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -516,7 +530,7 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
resources = append(append([]map[string]any{}, resources...), map[string]any{
|
||||
"id": GrantID(to, g.Consumer),
|
||||
"type": "file",
|
||||
"path": strings.TrimRight(m.Grants[to], "/") + "/" + g.Consumer,
|
||||
"path": grantPath(m.Grants[to], g.Consumer),
|
||||
"sealed": g.Sealed,
|
||||
})
|
||||
}
|
||||
@@ -596,21 +610,59 @@ type Contribution struct {
|
||||
// From is the module that said it, so the provider and a person reading the file can tell
|
||||
// which route belongs to what.
|
||||
From string `json:"from"`
|
||||
// Node is the machine it said it from, empty when that is this one.
|
||||
//
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are — a database told to create a password and not who for
|
||||
// cannot do anything with it. Contributions were node-local until this, which meant the one
|
||||
// case that most needed them was the one they did not reach.
|
||||
Node string `json:"node,omitempty"`
|
||||
// Secret is the file on this machine holding that consumer's credential, sealed to it.
|
||||
//
|
||||
// Named rather than carried, for the same reason the private network's key is: the mesh
|
||||
// discarded the value and could not put it here if it wanted to. What is here is where to
|
||||
// find it.
|
||||
Secret string `json:"secret,omitempty"`
|
||||
// Values are the module's own, with settings applied. What the keys mean is agreed by the
|
||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||
// is what makes swapping one for another cost nothing.
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// grantPath is where one consumer's sealed credential lands on the providing machine.
|
||||
//
|
||||
// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
|
||||
// node named like something else in that directory cannot collide with it.
|
||||
func grantPath(directory, consumer string) string {
|
||||
return strings.TrimRight(directory, "/") + "/" + consumer + ".secret"
|
||||
}
|
||||
|
||||
// contributions collects what every module in this set contributes, by requirement.
|
||||
//
|
||||
// Ordered by contributing module, because the result becomes a file on a machine and a file whose
|
||||
// lines move about is a file that looks changed when nothing changed.
|
||||
func (r Resolution) contributions(settings SettingsBy) (map[string][]Contribution, error) {
|
||||
func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
directories map[string]string) (map[string][]Contribution, error) {
|
||||
out := map[string][]Contribution{}
|
||||
modules := append([]Manifest{}, r.Modules...)
|
||||
sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module })
|
||||
|
||||
// What consumers on other machines asked for. Merged in with this machine's own, because from
|
||||
// the provider's side they are the same thing — somebody wanting something — and a provider
|
||||
// that had to read two lists would be a provider that reads one of them.
|
||||
sorted := append([]Grant{}, grants...)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
if sorted[i].Provision != sorted[j].Provision {
|
||||
return sorted[i].Provision < sorted[j].Provision
|
||||
}
|
||||
return sorted[i].Consumer < sorted[j].Consumer
|
||||
})
|
||||
for _, g := range sorted {
|
||||
out[g.Provision] = append(out[g.Provision], Contribution{
|
||||
From: g.From, Node: g.Consumer, Values: g.Values,
|
||||
Secret: grantPath(directories[g.Provision], g.Consumer),
|
||||
})
|
||||
}
|
||||
for _, m := range modules {
|
||||
for _, to := range sortedKeys(m.Contributes) {
|
||||
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
||||
@@ -712,3 +764,33 @@ func boundFile(n Needed, path string) (map[string]any, error) {
|
||||
"content": string(body) + "\n",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ContributionsTo is what this node's set asked of one requirement, settled.
|
||||
//
|
||||
// Exported because a provider's grants are assembled from its consumers' resolutions, one machine
|
||||
// at a time, and the alternative was for the control plane to reimplement settling.
|
||||
func (r Resolution) ContributionsTo(requirement string, settings SettingsBy) (
|
||||
string, map[string]any, error) {
|
||||
all, err := r.contributions(settings, nil, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
given := all[requirement]
|
||||
if len(given) == 0 {
|
||||
return "", nil, nil
|
||||
}
|
||||
if len(given) > 1 {
|
||||
// Two modules on one machine wanting the same provision would share one credential, and
|
||||
// the provider would be told to create one thing under two names. Refused rather than
|
||||
// resolved by picking, which is the rule everywhere else here.
|
||||
var who []string
|
||||
for _, g := range given {
|
||||
who = append(who, g.From)
|
||||
}
|
||||
sort.Strings(who)
|
||||
return "", nil, fmt.Errorf(
|
||||
"%s has %d modules asking for %q and they would share one credential: %s",
|
||||
r.Node, len(given), requirement, strings.Join(who, ", "))
|
||||
}
|
||||
return given[0].From, given[0].Values, nil
|
||||
}
|
||||
|
||||
Executable
BIN
Binary file not shown.
Reference in New Issue
Block a user