ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk carries no composition rule. The catalogue functions only the node-engine's composition reaches (Resolve, Resolution's Compose, Declaration, DataItems, LeftOut, Rules and the rest, the settings, placeholder and template refusals, the rendering helpers) are copied with their tests into internal/compose/resolution; a method of a mesh-sdk shape becomes a function taking it first. The composition calls its own copies, and the captured catalogue its tests read lives here now, shared by mesh-compose's tests. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
194 lines
6.7 KiB
Go
194 lines
6.7 KiB
Go
package resolution
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// ofSeat is where a module asks about a seat: ${seat:<seat>:<the port its holder's software uses>}.
|
|
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
|
|
|
// **Where this machine reaches a mesh seat's holder** (novox/hq ADR 0222, issue 190).
|
|
//
|
|
// `${seat:<mesh-seat>:reach}` is host:port — the address this machine dials to reach whatever holds a
|
|
// seat the mesh holds once. The same reasoning as the port above, one step further: nothing is
|
|
// required, nothing is granted, no credential is minted, and the answer is an address the mesh
|
|
// already holds in the clear and composes into every reference it built. What it is for is a module
|
|
// that must *state* where a mesh service is to software it owns — the container runtime trusting
|
|
// the mesh's registry is the case — without becoming that service's consumer.
|
|
//
|
|
// Answered for the seats in reachedSeats only. Another seat is refused by name rather than answered
|
|
// with nothing: a module asking where something is that the mesh does not say would otherwise be
|
|
// given an empty value and never know the question was not understood.
|
|
//
|
|
// The answer may be empty: no machine on the private network holds the seat yet (genesis raises
|
|
// the store before the network). In a file written into as JSON, an empty member is dropped from
|
|
// its list, and a list left with none is dropped, so the runtime is never told to trust "".
|
|
var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`)
|
|
|
|
// reachedSeats is every seat ${seat:…:reach} answers for.
|
|
var reachedSeats = map[string]bool{"mesh-artifact-store": true}
|
|
|
|
// seatInto replaces a resource's ${seat:…} placeholders with where this machine put each seat's
|
|
// holder — in a file's content, and in a value of a container's or a process's environment. The
|
|
// same places portInto fills, for the same reason: they are where a program reads a number from.
|
|
func seatInto(resource map[string]any, module string, with mcat.Rendering) error {
|
|
switch fmt.Sprint(resource["type"]) {
|
|
case "file":
|
|
content, ok := resource["content"].(string)
|
|
if !ok || (!ofSeat.MatchString(content) && !ofSeatReach.MatchString(content)) {
|
|
return nil
|
|
}
|
|
where := fmt.Sprintf("%s has a file that", module)
|
|
filled, err := seatsFilledInto(content, where, with)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ofSeatReach.MatchString(filled) {
|
|
if filled, err = reachFilledInto(filled, where, with); err != nil {
|
|
return err
|
|
}
|
|
if fmt.Sprint(resource["into"]) == "json" {
|
|
if filled, err = withoutEmptyMembers(filled, where); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
resource["content"] = filled
|
|
|
|
case "container", "process":
|
|
env, ok := resource["env"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
named := make([]string, 0, len(env))
|
|
for key := range env {
|
|
named = append(named, key)
|
|
}
|
|
sort.Strings(named)
|
|
|
|
// A fresh map, and only when something changes — this map is the catalogue's, shared by
|
|
// every node running the module (see portInto).
|
|
var filled map[string]any
|
|
for _, key := range named {
|
|
written, ok := env[key].(string)
|
|
if !ok || (!ofSeat.MatchString(written) && !ofSeatReach.MatchString(written)) {
|
|
continue
|
|
}
|
|
where := fmt.Sprintf("%s's %s %s sets %s to something that",
|
|
module, resource["type"], resource["name"], key)
|
|
value, err := seatsFilledInto(written, where, with)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if value, err = reachFilledInto(value, where, with); err != nil {
|
|
return err
|
|
}
|
|
if filled == nil {
|
|
filled = map[string]any{}
|
|
for k, v := range env {
|
|
filled[k] = v
|
|
}
|
|
}
|
|
filled[key] = value
|
|
}
|
|
if filled != nil {
|
|
resource["env"] = filled
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// seatsFilledInto answers every ${seat:…} in one written value.
|
|
//
|
|
// A port the seat's holder does not publish on this machine — or a seat nothing on it holds —
|
|
// answers with nothing, for the reason the package comment gives. A port that is not one is
|
|
// refused: it was written by a person and it is wrong.
|
|
func seatsFilledInto(written, where string, with mcat.Rendering) (string, error) {
|
|
for _, m := range ofSeat.FindAllStringSubmatch(written, -1) {
|
|
seat, port := m[1], m[2]
|
|
wanted, err := strconv.Atoi(port)
|
|
if err != nil || wanted < 1 || wanted > 65535 {
|
|
return "", fmt.Errorf("%s says ${seat:%s:%s}, and %s is not a port", where, seat, port, port)
|
|
}
|
|
answer := ""
|
|
if at, known := with.Seats[seat][wanted]; known {
|
|
answer = strconv.Itoa(at)
|
|
}
|
|
written = strings.ReplaceAll(written, m[0], answer)
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
// reachFilledInto answers every ${seat:…:reach} in one written value with where this machine
|
|
// reaches the seat's holder, or with nothing when no holder is reachable yet. A seat the mesh does
|
|
// not answer this for is refused by name.
|
|
func reachFilledInto(written, where string, with mcat.Rendering) (string, error) {
|
|
for _, m := range ofSeatReach.FindAllStringSubmatch(written, -1) {
|
|
seat := m[1]
|
|
if !reachedSeats[seat] {
|
|
known := make([]string, 0, len(reachedSeats))
|
|
for s := range reachedSeats {
|
|
known = append(known, s)
|
|
}
|
|
sort.Strings(known)
|
|
return "", fmt.Errorf("%s says ${seat:%s:reach}, and the mesh says where a seat's holder is "+
|
|
"reached only for %s (novox/hq ADR 0222)", where, seat, strings.Join(known, ", "))
|
|
}
|
|
written = strings.ReplaceAll(written, m[0], with.SeatReach[seat])
|
|
}
|
|
return written, nil
|
|
}
|
|
|
|
// withoutEmptyMembers drops every empty string from the lists at the top of a JSON object written
|
|
// into a machine's file, and a list left with no members, so an unanswered ${seat:…:reach} adds
|
|
// nothing to the machine's list rather than adding "".
|
|
func withoutEmptyMembers(content, where string) (string, error) {
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal([]byte(content), &object); err != nil {
|
|
return "", fmt.Errorf("%s is written into as JSON and is not a JSON object: %w", where, err)
|
|
}
|
|
changed := false
|
|
for key, raw := range object {
|
|
var members []json.RawMessage
|
|
if err := json.Unmarshal(raw, &members); err != nil {
|
|
continue // not a list
|
|
}
|
|
kept := make([]json.RawMessage, 0, len(members))
|
|
for _, member := range members {
|
|
var s string
|
|
if json.Unmarshal(member, &s) == nil && s == "" {
|
|
continue
|
|
}
|
|
kept = append(kept, member)
|
|
}
|
|
if len(kept) == len(members) {
|
|
continue
|
|
}
|
|
changed = true
|
|
if len(kept) == 0 {
|
|
delete(object, key)
|
|
continue
|
|
}
|
|
list, err := json.Marshal(kept)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
object[key] = list
|
|
}
|
|
if !changed {
|
|
return content, nil
|
|
}
|
|
out, err := json.Marshal(object)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out) + "\n", nil
|
|
}
|