Making every soft dependency a provision made it hard: a mesh without influxdb could not assign
grafana, and one with influxdb in its catalogue would install it for grafana. A requirement may now
be written {"name": ..., "optional": true}. It resolves as a hard one when a provider is assigned or
reachable, and is otherwise absent: never refused, nothing installed for it. Its binding, credential
and only-when-bound files are left out, its ${bound:...|default:...} placeholders take their
defaults, the module check refuses one that could render empty, and plan and node list it unbound.
193 lines
6.8 KiB
Go
193 lines
6.8 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// The half of a connection that is not secret, put where the program reading it can find it.
|
|
//
|
|
// **The asymmetry this removes was backwards** (novox/hq 04-ISSUES/023). A sealed credential can
|
|
// be placed inside any configuration file a module writes: the module leaves a hole, the mesh
|
|
// delivers the value sealed beside it, and the host — the only thing that sees both — fills it in.
|
|
// The host and the port and the name to present are ordinary facts the mesh holds in the clear,
|
|
// and they were the ones stuck: readable only inside a JSON binding, which a program reading
|
|
// `KEY=value` cannot use.
|
|
//
|
|
// So the same shape, and simpler. These values are not secret, so **the control plane substitutes
|
|
// them itself** before the declaration is sent. Nothing new reaches the host, which learns no
|
|
// formats and gains no fields.
|
|
//
|
|
// **It stays name-agnostic** ([ADR 0027]). The mesh does not learn what a `postgres-database` is:
|
|
// `at`, `as`, `from` and `address` (the providing machine's private address, novox/hq ADR 0194) are
|
|
// facts about any provision at all, and everything else comes from what the provider said it
|
|
// serves — whose keys are agreed by the requirement's name, not by this file.
|
|
|
|
// bound is where a module says a value from one of its bindings belongs:
|
|
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
|
|
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
|
|
// absent requirement is never rendered as an empty string.
|
|
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
|
|
|
|
// knownFor is everything a module may name from one of its bindings.
|
|
//
|
|
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
|
|
// module may not reach a binding it does not have — the same boundary as a secret, for the same
|
|
// reason.
|
|
func knownFor(m Manifest, needs []Needed, node string) (map[string]map[string]string, error) {
|
|
out := map[string]map[string]string{}
|
|
for _, want := range m.Wants() {
|
|
for i := range needs {
|
|
n := needs[i]
|
|
if n.Name != want || n.For != m.Module {
|
|
continue
|
|
}
|
|
as := ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
|
values := map[string]string{
|
|
"at": n.At,
|
|
"from": n.From,
|
|
"as": as,
|
|
}
|
|
// What the provider derives for this consumer rather than for all of them
|
|
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
|
|
// requiring it are both in hand.
|
|
served, err := ServedTo(n.Serves, as)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
|
|
}
|
|
for key, value := range served {
|
|
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
|
|
// which is what a float would write and what a connection string would refuse.
|
|
values[key] = plainly(value)
|
|
}
|
|
out[want] = values
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
|
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
|
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
|
// what the module is called is the mesh's statement, not the provider's.
|
|
func withOwnNames(values map[string]string, own map[string]any) {
|
|
for _, key := range []string{"name", "internal-name"} {
|
|
if v, ok := own[key].(string); ok {
|
|
values[key] = v
|
|
}
|
|
}
|
|
many, _ := own["names"].(map[string]any)
|
|
for local, raw := range many {
|
|
names, _ := raw.(map[string]any)
|
|
for _, key := range []string{"name", "internal-name"} {
|
|
if v, ok := names[key].(string); ok {
|
|
values[key+"-"+local] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// plainly renders a served value as a program would expect to read it.
|
|
func plainly(value any) string {
|
|
switch v := value.(type) {
|
|
case string:
|
|
return v
|
|
case float64:
|
|
if v == float64(int64(v)) {
|
|
return fmt.Sprintf("%d", int64(v))
|
|
}
|
|
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
|
|
case bool:
|
|
return fmt.Sprintf("%t", v)
|
|
case nil:
|
|
return ""
|
|
default:
|
|
return fmt.Sprint(v)
|
|
}
|
|
}
|
|
|
|
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
|
|
//
|
|
// A placeholder naming something the module does not require, or a key the provider does not
|
|
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
|
|
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
|
|
// somewhere that names neither the module nor the mesh.
|
|
//
|
|
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
|
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
|
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
|
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
|
if fmt.Sprint(resource["type"]) != "file" {
|
|
return nil
|
|
}
|
|
content, ok := resource["content"].(string)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var refused error
|
|
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
|
|
if refused != nil {
|
|
return token
|
|
}
|
|
m := bound.FindStringSubmatch(token)
|
|
provision, key, fallback := m[1], m[2], m[3]
|
|
values, has := known[provision]
|
|
if !has {
|
|
if absent[provision] {
|
|
if fallback != "" {
|
|
return fallback
|
|
}
|
|
refused = fmt.Errorf(
|
|
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
|
|
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
|
|
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
|
|
return token
|
|
}
|
|
refused = fmt.Errorf(
|
|
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
|
|
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
|
|
return token
|
|
}
|
|
value, said := values[key]
|
|
if !said {
|
|
refused = fmt.Errorf(
|
|
"%s asks its %s binding for %q, and what answers it says %s",
|
|
module, provision, key, orNothing(namesOfKeys(values)))
|
|
return token
|
|
}
|
|
return value
|
|
})
|
|
if refused != nil {
|
|
return refused
|
|
}
|
|
resource["content"] = filled
|
|
return nil
|
|
}
|
|
|
|
func namesOfBindings(known map[string]map[string]string) []string {
|
|
var names []string
|
|
for name := range known {
|
|
names = append(names, fmt.Sprintf("%q", name))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func namesOfKeys(values map[string]string) []string {
|
|
var names []string
|
|
for key := range values {
|
|
names = append(names, fmt.Sprintf("%q", key))
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func orNothing(names []string) string {
|
|
if len(names) == 0 {
|
|
return "nothing"
|
|
}
|
|
return join(names)
|
|
}
|