diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go new file mode 100644 index 0000000..c2e1339 --- /dev/null +++ b/internal/catalogue/declaration.go @@ -0,0 +1,341 @@ +package catalogue + +// Turning a resolution into the declaration a node is sent. +// +// Separate from resolving because they answer different questions. Resolving asks *what should +// this machine run*; this asks *what does that look like as resources*, and the second is where +// settings are applied, generators are called, contributions are collected and credentials are +// placed. Both lived in one file until it was doing four jobs at once — which is the shape the +// system this replaces failed in, one import at a time. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// SettingsBy is the layers that apply to each module, keyed by module name. +type SettingsBy map[string][]Layer + +// Generator works out a module's resources for one node, where they cannot be written in advance. +type Generator interface { + // Resources for this node. Absent means the node is not part of whatever this generates, + // which is an ordinary answer rather than a failure — a machine assigned the module before it + // has an address on the network is in exactly that state. + Resources(node string) ([]map[string]any, bool, error) +} + +// Grant is one consumer's credential, on the machine that must create it. +type Grant struct { + // Provision is what was required. + 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 +} + +// Rendering is everything needed to turn a resolution into the declaration a node is sent. +type Rendering struct { + Settings SettingsBy + Generators map[string]Generator + // Grants are the credentials this node must create, for the provisions it offers. Passed in + // rather than resolved, because who consumes a node is a fact about the rest of the mesh and + // resolution answers questions about one machine. + Grants []Grant +} + +// Declaration is everything the resolved modules put on the node, with settings applied. +// +// Resource identities are prefixed with the module they came from. Two modules may reasonably +// 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) { + // 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 + } + + var out []map[string]any + for _, m := range r.Modules { + resources := m.Resources + for _, to := range sortedKeys(m.Secrets) { + var found *Needed + for i, n := range r.Needs { + if n.Name == to { + found = &r.Needs[i] + } + } + if found == nil || found.Sealed == "" { + // Answered on this machine, or answered by a node the mesh could not seal to. + // Nothing to write either way, and writing an empty credential file would be + // worse than none: something would read it and fail authenticating. + continue + } + resources = append(append([]map[string]any{}, resources...), map[string]any{ + "id": SecretID(to), "type": "file", "path": m.Secrets[to], + "sealed": found.Sealed, + }) + } + for _, to := range sortedKeys(m.Grants) { + for _, g := range with.Grants { + if g.Provision != to { + continue + } + resources = append(append([]map[string]any{}, resources...), map[string]any{ + "id": GrantID(to, g.Consumer), + "type": "file", + "path": grantPath(m.Grants[to], g.Consumer), + "sealed": g.Sealed, + }) + } + } + for _, to := range sortedKeys(m.Binds) { + var found *Needed + for i, n := range r.Needs { + if n.Name == to { + found = &r.Needs[i] + } + } + if found == nil { + // Bound to something answered on this machine rather than from the mesh. Nothing + // to write: the answer is here, and a file saying "it is on this node" would be + // a fact nobody needs and one more thing to keep true. + continue + } + file, err := boundFile(*found, m.Binds[to]) + if err != nil { + return nil, err + } + resources = append(append([]map[string]any{}, resources...), file) + } + for _, to := range sortedKeys(m.Receives) { + file, err := receivedFile(to, m.Receives[to], given[to]) + if err != nil { + return nil, err + } + resources = append(append([]map[string]any{}, resources...), file) + } + if m.Computed != "" { + generator, known := with.Generators[m.Computed] + if !known { + return nil, fmt.Errorf( + "%s says its resources are computed by %q, and this control plane has no %q", + m.Module, m.Computed, m.Computed) + } + generated, part, err := generator.Resources(r.Node) + if err != nil { + return nil, err + } + if !part { + // Assigned, and not yet part of what this generates. Nothing to put on the + // machine, which is different from an error: a node given the network module + // before it has an address is in exactly that state, briefly. + continue + } + resources = generated + } + for _, unsettled := range resources { + resource, err := ApplySettings(unsettled, with.Settings[m.Module]) + if err != nil { + return nil, err + } + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) + // A service saying what it reflects names resources within its own module, so those + // are prefixed too or they would point at nothing. + if reflects, ok := resource["restart-on"].([]any); ok { + var renamed []any + for _, id := range reflects { + renamed = append(renamed, m.Module+"."+fmt.Sprint(id)) + } + copied["restart-on"] = renamed + } + out = append(out, copied) + } + } + return out, nil +} + +// Contribution is one module telling the answer to a requirement what it needs from it. +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, 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 + // exactly the kind of thing that differs between one mesh and the next, and a module + // that could not have it set would have to be edited to be reused. + values, err := settle(m.Contributes[to], settings[m.Module], nil, + m.Module+" contributing to "+to) + if err != nil { + return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) + } + out[to] = append(out[to], Contribution{From: m.Module, Values: values}) + } + } + return out, nil +} + +// receivedFile is the file a provider is given its consumers' contributions in. +func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) { + if given == nil { + // Nobody contributed. The file is still written, empty, rather than left absent: a + // provider that finds no file cannot tell "nothing asked for me" from "the mesh never + // wrote it", and the two want completely different responses. + given = []Contribution{} + } + // The note goes *inside* the document, not above it. The first version wrote a `//` header + // and produced a file that says "do not edit" to a person and fails to parse for the program + // meant to read it — which is the whole audience. + body, err := json.MarshalIndent(map[string]any{ + "contributions": 1, + "requirement": requirement, + "generated": "by the mesh — do not edit; replaced whenever a module contributing to " + + requirement + " arrives or leaves", + "given": given, + }, "", " ") + if err != nil { + return nil, err + } + return map[string]any{ + "id": ReceivedID(requirement), "type": "file", "path": path, "mode": "0644", + "content": string(body) + "\n", + }, nil +} + +// sortedKeys is map iteration made repeatable, which everything written to a machine needs. +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// boundFile is what a module is told about something it requires from another machine. +// +// Where it is and what the providing module said about using it. **No credential**, and the file +// says so rather than leaving a reader to wonder whether one was meant to be there — a missing +// field looks like a bug, and a stated absence looks like a boundary. +func boundFile(n Needed, path string) (map[string]any, error) { + body, err := json.MarshalIndent(map[string]any{ + "binding": 1, + "provision": n.Name, + "from": n.From, + "at": n.At, + "serves": n.Serves, + "generated": "by the mesh — do not edit; replaced whenever this changes. " + + "It carries no credential: the mesh has no way to issue one yet", + }, "", " ") + if err != nil { + return nil, err + } + return map[string]any{ + "id": BoundID(n.Name), "type": "file", "path": path, "mode": "0644", + "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/internal/catalogue/resolve.go b/internal/catalogue/resolve.go index fd1bf4c..5f87950 100644 --- a/internal/catalogue/resolve.go +++ b/internal/catalogue/resolve.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "errors" "fmt" "sort" @@ -445,277 +444,6 @@ func checkResources(modules []Manifest) []string { return problems } -// SettingsBy is the layers that apply to each module, keyed by module name. -type SettingsBy map[string][]Layer - -// Generator works out a module's resources for one node, where they cannot be written in advance. -type Generator interface { - // Resources for this node. Absent means the node is not part of whatever this generates, - // which is an ordinary answer rather than a failure — a machine assigned the module before it - // has an address on the network is in exactly that state. - Resources(node string) ([]map[string]any, bool, error) -} - -// Grant is one consumer's credential, on the machine that must create it. -type Grant struct { - // Provision is what was required. - 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 -} - -// Rendering is everything needed to turn a resolution into the declaration a node is sent. -type Rendering struct { - Settings SettingsBy - Generators map[string]Generator - // Grants are the credentials this node must create, for the provisions it offers. Passed in - // rather than resolved, because who consumes a node is a fact about the rest of the mesh and - // resolution answers questions about one machine. - Grants []Grant -} - -// Declaration is everything the resolved modules put on the node, with settings applied. -// -// Resource identities are prefixed with the module they came from. Two modules may reasonably -// 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) { - // 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 - } - - var out []map[string]any - for _, m := range r.Modules { - resources := m.Resources - for _, to := range sortedKeys(m.Secrets) { - var found *Needed - for i, n := range r.Needs { - if n.Name == to { - found = &r.Needs[i] - } - } - if found == nil || found.Sealed == "" { - // Answered on this machine, or answered by a node the mesh could not seal to. - // Nothing to write either way, and writing an empty credential file would be - // worse than none: something would read it and fail authenticating. - continue - } - resources = append(append([]map[string]any{}, resources...), map[string]any{ - "id": SecretID(to), "type": "file", "path": m.Secrets[to], - "sealed": found.Sealed, - }) - } - for _, to := range sortedKeys(m.Grants) { - for _, g := range with.Grants { - if g.Provision != to { - continue - } - resources = append(append([]map[string]any{}, resources...), map[string]any{ - "id": GrantID(to, g.Consumer), - "type": "file", - "path": grantPath(m.Grants[to], g.Consumer), - "sealed": g.Sealed, - }) - } - } - for _, to := range sortedKeys(m.Binds) { - var found *Needed - for i, n := range r.Needs { - if n.Name == to { - found = &r.Needs[i] - } - } - if found == nil { - // Bound to something answered on this machine rather than from the mesh. Nothing - // to write: the answer is here, and a file saying "it is on this node" would be - // a fact nobody needs and one more thing to keep true. - continue - } - file, err := boundFile(*found, m.Binds[to]) - if err != nil { - return nil, err - } - resources = append(append([]map[string]any{}, resources...), file) - } - for _, to := range sortedKeys(m.Receives) { - file, err := receivedFile(to, m.Receives[to], given[to]) - if err != nil { - return nil, err - } - resources = append(append([]map[string]any{}, resources...), file) - } - if m.Computed != "" { - generator, known := with.Generators[m.Computed] - if !known { - return nil, fmt.Errorf( - "%s says its resources are computed by %q, and this control plane has no %q", - m.Module, m.Computed, m.Computed) - } - generated, part, err := generator.Resources(r.Node) - if err != nil { - return nil, err - } - if !part { - // Assigned, and not yet part of what this generates. Nothing to put on the - // machine, which is different from an error: a node given the network module - // before it has an address is in exactly that state, briefly. - continue - } - resources = generated - } - for _, unsettled := range resources { - resource, err := ApplySettings(unsettled, with.Settings[m.Module]) - if err != nil { - return nil, err - } - copied := map[string]any{} - for k, v := range resource { - copied[k] = v - } - copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) - // A service saying what it reflects names resources within its own module, so those - // are prefixed too or they would point at nothing. - if reflects, ok := resource["restart-on"].([]any); ok { - var renamed []any - for _, id := range reflects { - renamed = append(renamed, m.Module+"."+fmt.Sprint(id)) - } - copied["restart-on"] = renamed - } - out = append(out, copied) - } - } - return out, nil -} - -// Contribution is one module telling the answer to a requirement what it needs from it. -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, 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 - // exactly the kind of thing that differs between one mesh and the next, and a module - // that could not have it set would have to be edited to be reused. - values, err := settle(m.Contributes[to], settings[m.Module], nil, - m.Module+" contributing to "+to) - if err != nil { - return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) - } - out[to] = append(out[to], Contribution{From: m.Module, Values: values}) - } - } - return out, nil -} - -// receivedFile is the file a provider is given its consumers' contributions in. -func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) { - if given == nil { - // Nobody contributed. The file is still written, empty, rather than left absent: a - // provider that finds no file cannot tell "nothing asked for me" from "the mesh never - // wrote it", and the two want completely different responses. - given = []Contribution{} - } - // The note goes *inside* the document, not above it. The first version wrote a `//` header - // and produced a file that says "do not edit" to a person and fails to parse for the program - // meant to read it — which is the whole audience. - body, err := json.MarshalIndent(map[string]any{ - "contributions": 1, - "requirement": requirement, - "generated": "by the mesh — do not edit; replaced whenever a module contributing to " + - requirement + " arrives or leaves", - "given": given, - }, "", " ") - if err != nil { - return nil, err - } - return map[string]any{ - "id": ReceivedID(requirement), "type": "file", "path": path, "mode": "0644", - "content": string(body) + "\n", - }, nil -} - -// sortedKeys is map iteration made repeatable, which everything written to a machine needs. -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - // Offers is a list of node-scoped provisions, which is what nearly everything is. func Offers(names ...string) []Offer { out := make([]Offer, 0, len(names)) @@ -740,57 +468,3 @@ func FromAnywhere(names ...string) []Offer { // ships and this package must not depend on the thing it resolves. The name being wrong would // show up as a refusal naming a module nobody can assign, which a test checks. const meshNetwork = "networking" - -// boundFile is what a module is told about something it requires from another machine. -// -// Where it is and what the providing module said about using it. **No credential**, and the file -// says so rather than leaving a reader to wonder whether one was meant to be there — a missing -// field looks like a bug, and a stated absence looks like a boundary. -func boundFile(n Needed, path string) (map[string]any, error) { - body, err := json.MarshalIndent(map[string]any{ - "binding": 1, - "provision": n.Name, - "from": n.From, - "at": n.At, - "serves": n.Serves, - "generated": "by the mesh — do not edit; replaced whenever this changes. " + - "It carries no credential: the mesh has no way to issue one yet", - }, "", " ") - if err != nil { - return nil, err - } - return map[string]any{ - "id": BoundID(n.Name), "type": "file", "path": path, "mode": "0644", - "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 -}