diff --git a/cmd/mesh-control/main.go b/cmd/mesh-control/main.go index dbfbbfd..3f9dfa5 100644 --- a/cmd/mesh-control/main.go +++ b/cmd/mesh-control/main.go @@ -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 } diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..4c06917 --- /dev/null +++ b/examples/README.md @@ -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 | diff --git a/examples/postgres-provisioner/main.go b/examples/postgres-provisioner/main.go new file mode 100644 index 0000000..4792ef9 --- /dev/null +++ b/examples/postgres-provisioner/main.go @@ -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/.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, `'`, `''`) + `'` } diff --git a/go.mod b/go.mod index 2ff6a81..5cff5ce 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index c927634..478b2c7 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/catalogue/contributes_test.go b/internal/catalogue/contributes_test.go index 3a742a5..44c6b59 100644 --- a/internal/catalogue/contributes_test.go +++ b/internal/catalogue/contributes_test.go @@ -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) + } +} diff --git a/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index bb0d8a7..fd1bf4c 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -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 +} diff --git a/postgres-provisioner b/postgres-provisioner new file mode 100755 index 0000000..a765480 Binary files /dev/null and b/postgres-provisioner differ