Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d54d9d09 | ||
|
|
7953d2889d |
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// machineLines is what a machine said it is made of (novox/hq issue 160), as `node` shows it: every fact in
|
||||
// one block with its age, the architecture and kernel the profile always carried among them, and each fact
|
||||
// the machine could not determine said as unknown with its why — never as a zero, and never left out.
|
||||
func machineLines(a inventory.MachineAccount, had bool, now time.Time) []string {
|
||||
if !had {
|
||||
return []string{" what it is made of: never said — this machine has not reported a profile"}
|
||||
}
|
||||
m := a.Machine
|
||||
if m == nil {
|
||||
return []string{
|
||||
" what it is made of: not said — its node-engine is older than the one that says it",
|
||||
" " + factLine("architecture", orUnsaid(a.Architecture)),
|
||||
" " + factLine("kernel", orUnsaid(a.Kernel)),
|
||||
}
|
||||
}
|
||||
age := roughly(now.Sub(m.ObservedAt)) + " ago"
|
||||
if m.ObservedAt.IsZero() {
|
||||
// Said with no time: its age is not known, and is never read as two thousand years.
|
||||
age = "at a time it did not say (age unknown)"
|
||||
}
|
||||
out := []string{fmt.Sprintf(" what it is made of, as it said %s:", age)}
|
||||
add := func(name, value string) { out = append(out, " "+factLine(name, value)) }
|
||||
unknown := map[string]string{}
|
||||
for k, v := range m.Unknown {
|
||||
unknown[k] = v
|
||||
}
|
||||
// said is the value, or the fact's own reason it is unknown; a fact neither said nor unknown is a
|
||||
// node-engine that broke its own rule, and is said so rather than shown blank.
|
||||
said := func(fact, value string) string {
|
||||
if value != "" {
|
||||
delete(unknown, fact)
|
||||
return value
|
||||
}
|
||||
if why, ok := unknown[fact]; ok {
|
||||
delete(unknown, fact)
|
||||
return "unknown: " + why
|
||||
}
|
||||
return "unknown: the machine said neither it nor why"
|
||||
}
|
||||
|
||||
add("architecture", orUnsaid(a.Architecture))
|
||||
release := said("kernel release", m.KernelRelease)
|
||||
if m.KernelRelease == "" {
|
||||
release = "(release " + release + ")"
|
||||
}
|
||||
add("kernel", orUnsaid(a.Kernel)+" "+release)
|
||||
dist := said("distribution", m.Distribution)
|
||||
if m.Distribution != "" {
|
||||
dist += " " + said("version", m.Version)
|
||||
}
|
||||
add("distribution", dist)
|
||||
add("processor", said("processor", m.Processor))
|
||||
add("cores", said("cores", countWords(m.Cores)))
|
||||
add("threads", said("threads", countWords(m.Threads)))
|
||||
add("memory", said("memory", bytesWords(m.MemoryBytes)))
|
||||
add("virtual", said("virtual or physical", m.Virtual))
|
||||
up := ""
|
||||
if m.BootedAt != nil {
|
||||
up = fmt.Sprintf("%s, since %s", roughly(now.Sub(*m.BootedAt)), m.BootedAt.Local().Format("2006-01-02 15:04"))
|
||||
}
|
||||
add("up", said("uptime", up))
|
||||
add("timezone", said("timezone", m.Timezone))
|
||||
if len(m.Filesystems) == 0 {
|
||||
add("filesystems", said("filesystems", ""))
|
||||
} else {
|
||||
for i, f := range m.Filesystems {
|
||||
name := ""
|
||||
if i == 0 {
|
||||
name = "filesystems"
|
||||
}
|
||||
add(name, fmt.Sprintf("%s %s, %s free (%s %s)", f.Mount, bytesWords(f.SizeBytes),
|
||||
bytesWords(f.FreeBytes), f.Type, f.Device))
|
||||
}
|
||||
if why, ok := unknown["filesystems"]; ok {
|
||||
add("", "and some unknown: "+why)
|
||||
delete(unknown, "filesystems")
|
||||
}
|
||||
}
|
||||
add("public address", said("public address", ""))
|
||||
// Any other fact a newer node-engine named unknown, so nothing it said is dropped.
|
||||
rest := make([]string, 0, len(unknown))
|
||||
for k := range unknown {
|
||||
rest = append(rest, k)
|
||||
}
|
||||
sort.Strings(rest)
|
||||
for _, k := range rest {
|
||||
add(k, "unknown: "+unknown[k])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func factLine(name, value string) string { return fmt.Sprintf("%-15s %s", name, value) }
|
||||
|
||||
func orUnsaid(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "not said"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// countWords is a number a machine said, or nothing when it said none (its unknown says why).
|
||||
func countWords(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(n)
|
||||
}
|
||||
|
||||
// bytesWords is an amount of bytes a person reads, in binary units; nothing when none was said.
|
||||
func bytesWords(n int64) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"}
|
||||
v, u := float64(n), 0
|
||||
for v >= 1024 && u < len(units)-1 {
|
||||
v /= 1024
|
||||
u++
|
||||
}
|
||||
if u == 0 {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", v, units[u])
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// Defends novox/hq issue 160: `node` shows every fact a machine said it is made of, in one block with its
|
||||
// age, the architecture and kernel among them; and a fact it could not determine as unknown with why.
|
||||
func TestNodeShowsWhatAMachineIsMadeOfWithItsAge(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC)
|
||||
booted := now.Add(-75 * time.Hour)
|
||||
a := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{
|
||||
ObservedAt: now.Add(-3 * time.Hour), KernelRelease: "7.2.8-arch1-2", Distribution: "Arch Linux",
|
||||
Version: "rolling", Processor: "AMD Ryzen 9 8945HS", Cores: 8, Threads: 16, MemoryBytes: 32891494400,
|
||||
Virtual: "physical", BootedAt: &booted, Timezone: "Europe/Brussels",
|
||||
Filesystems: []inventory.MountedFilesystem{{Mount: "/", Device: "/dev/nvme0n1p3", Type: "ext4",
|
||||
SizeBytes: 987978522624, FreeBytes: 31830110208}},
|
||||
Unknown: map[string]string{"public address": "how it learns it is not decided yet"},
|
||||
}}
|
||||
got := strings.Join(machineLines(a, true, now), "\n")
|
||||
t.Log("\n" + got)
|
||||
for _, want := range []string{
|
||||
"what it is made of, as it said 3h ago:",
|
||||
"architecture amd64",
|
||||
"kernel linux 7.2.8-arch1-2",
|
||||
"distribution Arch Linux rolling",
|
||||
"processor AMD Ryzen 9 8945HS",
|
||||
"cores 8",
|
||||
"threads 16",
|
||||
"memory 30.6 GiB",
|
||||
"virtual physical",
|
||||
"up 3d, since",
|
||||
"timezone Europe/Brussels",
|
||||
"filesystems / 920.1 GiB, 29.6 GiB free (ext4 /dev/nvme0n1p3)",
|
||||
"public address unknown: how it learns it is not decided yet",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFactAMachineCouldNotReadIsShownUnknownNeverZero(t *testing.T) {
|
||||
now := time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC)
|
||||
a := inventory.MachineAccount{Architecture: "arm64", Kernel: "linux", Machine: &inventory.MadeOf{
|
||||
ObservedAt: now, Unknown: map[string]string{"memory": "MemTotal not found in /proc/meminfo",
|
||||
"cores": "/proc/cpuinfo says no core id", "kernel release": "permission denied"}}}
|
||||
got := strings.Join(machineLines(a, true, now), "\n")
|
||||
t.Log("\n" + got)
|
||||
for _, want := range []string{
|
||||
"memory unknown: MemTotal not found in /proc/meminfo",
|
||||
"cores unknown: /proc/cpuinfo says no core id",
|
||||
"kernel linux (release unknown: permission denied)",
|
||||
// Neither said nor named unknown: the node-engine broke its rule, and that is shown.
|
||||
"timezone unknown: the machine said neither it nor why",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, " 0 ") || strings.Contains(got, "0 B") {
|
||||
t.Error("a fact the machine could not read was shown as a zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOlderNodeEngineStillShowsArchitectureAndKernel(t *testing.T) {
|
||||
got := strings.Join(machineLines(inventory.MachineAccount{Architecture: "amd64", Kernel: "linux"}, true,
|
||||
time.Now()), "\n")
|
||||
if !strings.Contains(got, "older than the one that says it") || !strings.Contains(got, "architecture amd64") ||
|
||||
!strings.Contains(got, "kernel linux") {
|
||||
t.Errorf("%s", got)
|
||||
}
|
||||
if got := strings.Join(machineLines(inventory.MachineAccount{}, false, time.Now()), "\n"); !strings.Contains(got, "never said") {
|
||||
t.Errorf("%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAccountWithoutItsTimeSaysAgeUnknown(t *testing.T) {
|
||||
got := machineLines(inventory.MachineAccount{Architecture: "amd64", Kernel: "linux",
|
||||
Machine: &inventory.MadeOf{KernelRelease: "7.2"}}, true, time.Now())[0]
|
||||
if !strings.Contains(got, "age unknown") || strings.Contains(got, "d ago") {
|
||||
t.Errorf("%s", got)
|
||||
}
|
||||
}
|
||||
@@ -875,6 +875,17 @@ func showNode(ctx context.Context, stored *stores, name string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// What it is made of, in one block with its age (novox/hq issue 160): collected and shown in the same
|
||||
// breath, so no fact the machine says is kept and read by nothing.
|
||||
if account, had, err := inv.MachineOf(ctx, name); err != nil {
|
||||
fmt.Printf("\n what it is made of could NOT be read: %v\n", err)
|
||||
} else {
|
||||
fmt.Println()
|
||||
for _, line := range machineLines(account, had, time.Now()) {
|
||||
fmt.Println(line)
|
||||
}
|
||||
}
|
||||
|
||||
held, err := inv.Profile(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -947,9 +947,14 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
grantsGroups, err := engineGrantsGroups(ctx, inv, node)
|
||||
if err != nil {
|
||||
return catalogue.Rendering{}, inventory.Node{}, err
|
||||
}
|
||||
return catalogue.Rendering{
|
||||
ReadsHealth: readsHealth,
|
||||
JudgesRoot: judgesRoot,
|
||||
GrantsGroups: grantsGroups,
|
||||
BusMembership: memberships[node],
|
||||
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
|
||||
Certificate: certificate, Authority: authority, Mesh: private, Names: names,
|
||||
@@ -982,6 +987,16 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
|
||||
return had && stated.Contract >= link.RootContract, nil
|
||||
}
|
||||
|
||||
// engineGrantsGroups says whether a machine's node-engine reads an access's `grant` (novox/hq ADR 0312), by its own
|
||||
// newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
|
||||
func engineGrantsGroups(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
|
||||
stated, had, err := inv.HealthOf(ctx, node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return had && stated.Contract >= link.GrantContract, nil
|
||||
}
|
||||
|
||||
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
|
||||
// 0199): the zone settled from that node's settings, the node's private address, the port the
|
||||
// answering listen is published on there.
|
||||
|
||||
@@ -35,4 +35,4 @@ require (
|
||||
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
|
||||
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
|
||||
// `go mod vendor` fails loudly, at once, everywhere.
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3 h1:VpH8CesuBOo7k8GcXZq1eRUfbvUoktfmcUynI5N3IFQ=
|
||||
git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -246,6 +246,11 @@ type Rendering struct {
|
||||
// statement's contract is link.RootContract or later). To an older, strict engine the field is not
|
||||
// sent, and the account it names is not judged — which the self-check says, as not judged.
|
||||
JudgesRoot bool
|
||||
|
||||
// GrantsGroups says this machine's node-engine reads an access's `grant` (novox/hq ADR 0312: its statement's
|
||||
// contract is link.GrantContract or later). To an older, strict engine the field is not sent, nor the
|
||||
// runtime's restarts on it, and a read-write access is reached as before: by whoever owns the path.
|
||||
GrantsGroups bool
|
||||
}
|
||||
|
||||
// RootField is a user resource's field saying the account must never become root without a person
|
||||
@@ -543,6 +548,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
|
||||
var out []map[string]any
|
||||
// The read-write accesses each module grants its tools' account, by module (novox/hq ADR 0312): the runtime
|
||||
// that runs as that account is restarted on them.
|
||||
grantedToTools := map[string][]string{}
|
||||
for _, m := range r.Modules {
|
||||
if with.Adopted && m.Filtering != nil {
|
||||
// Nothing of a module that loads a filter, on an adopted node: its table would drop
|
||||
@@ -1115,6 +1123,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
out = append(out, copied)
|
||||
}
|
||||
|
||||
// **A read-write access grants its path's group to what runs this module's code** (novox/hq ADR 0312):
|
||||
// the containers composed above that mount it, and the account its tools run as. Only to an engine that
|
||||
// reads the field.
|
||||
if with.GrantsGroups {
|
||||
grantedToTools[m.Module] = grantAccesses(out, owner, m.Module, r.toolsAccount(m, with))
|
||||
}
|
||||
|
||||
// **What only the mesh knows, where this module asked for it.** The graph is the control
|
||||
// plane's; making a name resolve is the module's software. Emitted as ordinary files under
|
||||
// this module's name, so they are applied, reported and removed exactly as anything else
|
||||
@@ -1141,6 +1156,12 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
}
|
||||
owner[fmt.Sprint(process["id"])] = RuntimeModule
|
||||
out = append(out, process)
|
||||
// Restarted when an access moved its account's groups, so its process holds them (novox/hq ADR 0312 §6).
|
||||
for _, m := range r.Modules {
|
||||
if m.RunsAs == "" {
|
||||
restartOnGrants(process, grantedToTools[m.Module])
|
||||
}
|
||||
}
|
||||
// And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
|
||||
owns, err := r.ownRuntimes(with)
|
||||
if err != nil {
|
||||
@@ -1149,6 +1170,7 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
|
||||
for _, p := range owns {
|
||||
id := fmt.Sprint(p["id"])
|
||||
owner[id] = strings.TrimSuffix(id, "."+OwnRuntimeID())
|
||||
restartOnGrants(p, grantedToTools[owner[id]])
|
||||
out = append(out, p)
|
||||
}
|
||||
// What each module's bundles are given is read as the account the runtime runs as — not what a
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A read-write access grants the path's own group to what runs the module's code (novox/hq ADR 0312, issue 537).
|
||||
//
|
||||
// **The controller says who; the node-engine reads which group.** Which group owns the operator's path is a fact
|
||||
// of the machine the controller cannot know, and the path is never changed (§2): so each read-write access
|
||||
// reaches a machine naming the identities its module's code runs as, and the node-engine reads the path's group
|
||||
// at apply and grants it to them. Two kinds of identity run a module's code here:
|
||||
//
|
||||
// - each of its containers that mounts the path — the source of one of its volumes is the path, or below it —
|
||||
// which gains the group as a supplementary group of its process and is recreated with it;
|
||||
// - the account its tools run as, where it has a tools bundle the machine's runtime loads: the operator's
|
||||
// account the runtime runs as, or the module's own account for a module served by a runtime of its own
|
||||
// (ADR 0259 §8). That runtime names the access under its `restart-on`, so it is restarted when the access
|
||||
// moved the account's groups, and its new process holds the group with no new login (§6).
|
||||
//
|
||||
// A read access grants nothing (§3): it is sent as before. A read-write access whose module runs nothing here is
|
||||
// sent an empty grant, so the node-engine still states it unhealthy while its group cannot write (§2).
|
||||
//
|
||||
// **Only to an engine that reads it.** An older node-engine parses strictly and refuses a whole declaration for a
|
||||
// field it does not know; the field and the restarts it brings are composed only where the engine's statement
|
||||
// says link.GrantContract or later (Rendering.GrantsGroups).
|
||||
|
||||
// GrantField is an access's field naming who its path's group is granted to (novox/hq ADR 0312).
|
||||
const GrantField = "grant"
|
||||
|
||||
// toolsAccount is the account module m's tools run as on this machine, or empty where they run nowhere as one:
|
||||
// no runtime here, no bundle it loads, a module left out of an adopted machine's declaration, or a runtime
|
||||
// running as root on a machine with no account.
|
||||
func (r Resolution) toolsAccount(m Manifest, with Rendering) string {
|
||||
if !r.runtimeHere() || (with.Adopted && m.Filtering != nil) {
|
||||
return ""
|
||||
}
|
||||
loads := false
|
||||
for _, b := range m.Bundles {
|
||||
if len(b.Loads) > 0 {
|
||||
loads = true
|
||||
}
|
||||
}
|
||||
if !loads {
|
||||
return ""
|
||||
}
|
||||
if m.RunsAs != "" {
|
||||
return m.RunsAs
|
||||
}
|
||||
return r.Account
|
||||
}
|
||||
|
||||
// grantAccesses writes the grant of every read-write access of module, among the resources composed so far: the
|
||||
// module's containers that mount the access's path, and account when it is not empty. The ids of the accesses
|
||||
// that grant to account, for the runtime that runs as it to be restarted on.
|
||||
func grantAccesses(out []map[string]any, owner map[string]string, module, account string) []string {
|
||||
var containers []map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] == "container" && owner[fmt.Sprint(res["id"])] == module {
|
||||
containers = append(containers, res)
|
||||
}
|
||||
}
|
||||
var toAccount []string
|
||||
for _, res := range out {
|
||||
if res["type"] != "access" || owner[fmt.Sprint(res["id"])] != module || res["mode"] != AccessReadWrite {
|
||||
continue
|
||||
}
|
||||
path, _ := res["path"].(string)
|
||||
grant := map[string]any{}
|
||||
var mounting []any
|
||||
for _, c := range containers {
|
||||
if mounts(c, path) {
|
||||
mounting = append(mounting, fmt.Sprint(c["id"]))
|
||||
}
|
||||
}
|
||||
if len(mounting) > 0 {
|
||||
grant["containers"] = mounting
|
||||
}
|
||||
if account != "" {
|
||||
grant["account"] = account
|
||||
toAccount = append(toAccount, fmt.Sprint(res["id"]))
|
||||
}
|
||||
res[GrantField] = grant
|
||||
}
|
||||
sort.Strings(toAccount)
|
||||
return toAccount
|
||||
}
|
||||
|
||||
// mounts says whether a container mounts path, or something below it, from the machine. A path that merely
|
||||
// begins with the same letters — /storage/media2 beside /storage/media — is another path. A read-only mount of a
|
||||
// read-write access still counts: the module declared the access read-write, the engine grants the group to what
|
||||
// mounts it, and a read-only mount only means that container chose not to write through it.
|
||||
func mounts(container map[string]any, path string) bool {
|
||||
volumes, _ := container["volumes"].([]any)
|
||||
path = filepath.Clean(path)
|
||||
for _, v := range volumes {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source, _, _ := strings.Cut(s, ":")
|
||||
if !strings.HasPrefix(source, "/") {
|
||||
continue // a named volume: the runtime's, not the operator's path
|
||||
}
|
||||
source = filepath.Clean(source)
|
||||
if source == path || strings.HasPrefix(source, path+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// restartOnGrants adds to a runtime process's restart-on the accesses that grant to the account it runs as, so it
|
||||
// is restarted to hold a group the account was put in or taken out of (novox/hq ADR 0312 §6).
|
||||
func restartOnGrants(process map[string]any, ids []string) {
|
||||
if process == nil || len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
restarts, _ := process["restart-on"].([]any)
|
||||
seen := map[string]bool{}
|
||||
for _, id := range restarts {
|
||||
seen[fmt.Sprint(id)] = true
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !seen[id] {
|
||||
restarts = append(restarts, id)
|
||||
seen[id] = true
|
||||
}
|
||||
}
|
||||
process["restart-on"] = restarts
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0312 and issue 537 on the controller's side: each read-write access reaches the machine
|
||||
// naming who its path's group is granted to — the module's containers that mount it, and the account its tools
|
||||
// run as — and the tool runner is restarted on it; a read access grants nothing; and none of it is sent to a
|
||||
// node-engine older than the field, which would refuse the whole declaration.
|
||||
|
||||
const anImage = "x@sha256:1111111111111111111111111111111111111111111111111111111111111111"
|
||||
|
||||
// anImporter files into one library it writes and reads another: a container mounting both, a step mounting
|
||||
// neither, and tools served by the machine's runtime.
|
||||
func anImporter(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := aToolsModule(t, "importer", "tools/index.js")
|
||||
m.Accesses = []Access{
|
||||
{ID: "music", Path: "/storage/media/music", Mode: AccessReadWrite},
|
||||
{ID: "movies", Path: "/storage/media/movies", Mode: AccessRead},
|
||||
}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "app", "type": "container", "name": "importer", "image": anImage,
|
||||
"volumes": []any{"${access:music}/incoming:/music", "${access:movies}:/movies:ro"}},
|
||||
{"id": "sidecar", "type": "container", "name": "importer-sidecar", "image": anImage},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func composeImporter(t *testing.T, r Resolution, with Rendering) map[string]map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// What the node-engine this controller pins would make of it: nothing refused.
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
return byID(out)
|
||||
}
|
||||
|
||||
func TestAReadWriteAccessNamesTheContainersThatMountItAndTheToolsAccount(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{GrantsGroups: true})
|
||||
|
||||
music := got["importer."+AccessID("/storage/media/music")]
|
||||
if music == nil {
|
||||
t.Fatalf("no access resource for the library: %v", keysOf(got))
|
||||
}
|
||||
grant, _ := music[GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[importer.app]" || grant["account"] != "jochen" {
|
||||
t.Errorf("the read-write access grants %v; want the container that mounts it and the tools' account", grant)
|
||||
}
|
||||
// A read access grants nothing (§3).
|
||||
if movies := got["importer."+AccessID("/storage/media/movies")]; movies[GrantField] != nil {
|
||||
t.Errorf("a read access names a grant: %v", movies[GrantField])
|
||||
}
|
||||
// The tool runner is restarted when the access moved its account's groups (§6).
|
||||
runtime := got[RuntimeModule+"."+RuntimeProcessID()]
|
||||
found := false
|
||||
for _, id := range runtime["restart-on"].([]any) {
|
||||
if id == "importer."+AccessID("/storage/media/music") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the tool runner is not restarted on the access that grants its account: %v", runtime["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountIsGrantedAsThatAccount(t *testing.T) {
|
||||
m := anImporter(t)
|
||||
m.RunsAs = "importer"
|
||||
m.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh/importer/broker"}}
|
||||
runtime := theRuntime(t)
|
||||
runtime.Bundles[0].Binary = "node-tools"
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m, runtime}}
|
||||
with := Rendering{GrantsGroups: true}
|
||||
with.Needed = map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "importer": {"broker": "sealed"}}
|
||||
with.ArtifactStore = "anchor.internal:5101"
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(out)
|
||||
grant, _ := got["importer."+AccessID("/storage/media/music")][GrantField].(map[string]any)
|
||||
if grant["account"] != "importer" {
|
||||
t.Errorf("the access grants %v; want the module's own account", grant)
|
||||
}
|
||||
own := got["importer."+OwnRuntimeID()]
|
||||
if fmt.Sprint(own["restart-on"]) == "" || !restartsOnIt(own["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Errorf("the module's own runtime is not restarted on its access: %v", own["restart-on"])
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the machine's runtime is restarted on an access of a module it does not serve")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatRunsNothingHereSendsAnEmptyGrant(t *testing.T) {
|
||||
m := mod("spool", nil, nil, nil)
|
||||
m.Accesses = []Access{{Path: "/storage/downloads", Mode: AccessReadWrite}}
|
||||
out, err := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, has := byID(out)["spool."+AccessID("/storage/downloads")][GrantField].(map[string]any)
|
||||
if !has || len(grant) != 0 {
|
||||
t.Errorf("a read-write access of a module running nothing here is sent %v (sent %v); want an empty grant, "+
|
||||
"so its group is still judged", grant, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoGrantIsSentToAnEngineOlderThanTheField(t *testing.T) {
|
||||
r := Resolution{Node: "ace", Account: "jochen", Modules: []Manifest{anImporter(t), theRuntime(t)}}
|
||||
got := composeImporter(t, r, Rendering{})
|
||||
for id, res := range got {
|
||||
if _, has := res[GrantField]; has {
|
||||
t.Errorf("%s names a grant to an engine that does not read it", id)
|
||||
}
|
||||
}
|
||||
if restartsOnIt(got[RuntimeModule+"."+RuntimeProcessID()]["restart-on"], "importer."+AccessID("/storage/media/music")) {
|
||||
t.Error("the tool runner is restarted on an access an older engine grants nothing for")
|
||||
}
|
||||
}
|
||||
|
||||
func restartsOnIt(list any, want string) bool {
|
||||
items, _ := list.([]any)
|
||||
for _, i := range items {
|
||||
if fmt.Sprint(i) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func keysOf(m map[string]map[string]any) []string {
|
||||
var out []string
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Only a container that mounts the access's path, or below it, is named: a path that begins with the same letters is
|
||||
// another path. A read-only mount of a read-write access is still named — the access is read-write, and the grant
|
||||
// follows what mounts it, not how.
|
||||
func TestOnlyAContainerMountingThePathIsNamedReadOnlyIncluded(t *testing.T) {
|
||||
m := mod("library", nil, nil, nil)
|
||||
m.Accesses = []Access{{ID: "media", Path: "/storage/media", Mode: AccessReadWrite}}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "beside", "type": "container", "name": "beside", "image": anImage, "volumes": []any{"/storage/media2:/x"}},
|
||||
{"id": "reader", "type": "container", "name": "reader", "image": anImage, "volumes": []any{"${access:media}:/media:ro"}},
|
||||
}
|
||||
out, err := Resolution{Node: "ace", Modules: []Manifest{m}}.Declaration(Rendering{GrantsGroups: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant, _ := byID(out)["library."+AccessID("/storage/media")][GrantField].(map[string]any)
|
||||
if fmt.Sprint(grant["containers"]) != "[library.reader]" {
|
||||
t.Errorf("the access grants %v; want the read-only mount and not /storage/media2", grant)
|
||||
}
|
||||
}
|
||||
@@ -1693,8 +1693,6 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
problems = append(problems, LogProblems(m)...)
|
||||
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262).
|
||||
problems = append(problems, SettingProblems(m)...)
|
||||
// And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326).
|
||||
problems = append(problems, TakenSettingsProblems(m)...)
|
||||
wellFormed := true
|
||||
for _, c := range m.Claims {
|
||||
if !name.MatchString(c.Name) {
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// A mergeable file names the setting keys it takes (novox/hq ADR 0326, issue 168).
|
||||
//
|
||||
// Before this, every key of a module's layers merged into every one of its mergeable files: the
|
||||
// mesh's own words, a key meant for a `${setting:<key>}` in another file, a key for a second
|
||||
// configuration file. So a module could not own two configurable files, and every receiver had to
|
||||
// ignore what it did not know. ADR 0164 §11 decided that a setting names the files that read it; this
|
||||
// is that relation, stated from the file's side, so the operator's own values stay declared nowhere
|
||||
// (ADR 0262 §2).
|
||||
|
||||
// TakesSettingsField is the key a mergeable file carries to name the top-level setting keys it takes.
|
||||
const TakesSettingsField = "takes-settings"
|
||||
|
||||
// takenKeys is the keys a resource names in takes-settings, and whether it names any list at all. An
|
||||
// empty list is a list: that file takes no setting.
|
||||
func takenKeys(resource map[string]any) (map[string]bool, bool) {
|
||||
raw, said := resource[TakesSettingsField]
|
||||
if !said {
|
||||
return nil, false
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, k := range stringsOf(raw) {
|
||||
keys[k] = true
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// onlyTaken is the layers with every key the file does not name left out. The file's own defaults and
|
||||
// what the mesh reads itself are not touched here: settle already keeps both out of a file.
|
||||
func onlyTaken(layers []Layer, taken map[string]bool) []Layer {
|
||||
kept := make([]Layer, 0, len(layers))
|
||||
for _, layer := range layers {
|
||||
values := map[string]any{}
|
||||
for key, value := range layer.Values {
|
||||
if taken[key] {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
kept = append(kept, Layer{From: layer.From, Values: values, Default: layer.Default})
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// namesItsKeys says whether a module's mergeable files name the keys they take (ADR 0326 §2): true
|
||||
// only when it has one and every one carries takes-settings. A module with a silent mergeable file
|
||||
// keeps today's form (§3), where every key reaches every mergeable file.
|
||||
func namesItsKeys(m Manifest) bool {
|
||||
merging := 0
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
merging++
|
||||
if _, said := r[TakesSettingsField]; !said {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return merging > 0
|
||||
}
|
||||
|
||||
// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in
|
||||
// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh
|
||||
// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another
|
||||
// that names none.
|
||||
func TakenSettingsProblems(m Manifest) []string {
|
||||
// Not yet here: refusing a module that declares a setting and owns a mergeable file naming no keys
|
||||
// (ADR 0326 §3). betterbird is one, and the catalogue cannot name its keys before this controller
|
||||
// runs, so that refusal follows once the catalogue does (novox/hq issue 574).
|
||||
return takenSettingsProblems(m)
|
||||
}
|
||||
|
||||
// takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and
|
||||
// where a stored one is composed.
|
||||
func takenSettingsProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
var naming, silent []string
|
||||
for _, r := range m.Resources {
|
||||
id := fmt.Sprint(r["id"])
|
||||
how, _ := r["merge"].(string)
|
||||
raw, said := r[TakesSettingsField]
|
||||
if how == "" {
|
||||
if said {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+
|
||||
"file takes settings by key (novox/hq ADR 0326)", m.Module, id, TakesSettingsField))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !said {
|
||||
silent = append(silent, id)
|
||||
continue
|
||||
}
|
||||
naming = append(naming, id)
|
||||
list, isList := raw.([]any)
|
||||
if !isList {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T",
|
||||
m.Module, id, TakesSettingsField, raw))
|
||||
continue
|
||||
}
|
||||
protected := map[string]bool{}
|
||||
for _, k := range stringsOf(r["protected"]) {
|
||||
protected[k] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range list {
|
||||
key, isString := item.(string)
|
||||
switch {
|
||||
case !isString || !usableKey(key):
|
||||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key",
|
||||
m.Module, id, item, TakesSettingsField))
|
||||
case seen[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s",
|
||||
m.Module, id, key, TakesSettingsField))
|
||||
case meshWords[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
|
||||
"(novox/hq ADR 0164 §14)", m.Module, id, key, TakesSettingsField, key))
|
||||
case protected[key]:
|
||||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
|
||||
"key is the file's or the setting's, never both", m.Module, id, key, TakesSettingsField))
|
||||
}
|
||||
if isString {
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(naming) > 0 {
|
||||
for _, id := range silent {
|
||||
problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+
|
||||
"mergeable file of a module names the keys it takes, every one does, an empty list for none "+
|
||||
"(novox/hq ADR 0326)", m.Module, id, TakesSettingsField, strings.Join(naming, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// usableKey is a key a file can be said to take: not empty, and no space, line end or control
|
||||
// character. Not the lower-case rule of a `${setting:<key>}`: a file's own keys are its software's
|
||||
// spelling, such as `timeZone`.
|
||||
func usableKey(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range key {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// takenSaid is what a stray key's message adds for a module whose files name their keys: which keys
|
||||
// each file takes, so the person sees where the key would have had to be named.
|
||||
func takenSaid(m Manifest) string {
|
||||
if !namesItsKeys(m) {
|
||||
return ""
|
||||
}
|
||||
var files []string
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how == "" {
|
||||
continue
|
||||
}
|
||||
keys := stringsOf(r[TakesSettingsField])
|
||||
if len(keys) == 0 {
|
||||
files = append(files, fmt.Sprintf("%v takes none", r["id"]))
|
||||
continue
|
||||
}
|
||||
files = append(files, fmt.Sprintf("%v takes %s", r["id"], strings.Join(keys, ", ")))
|
||||
}
|
||||
return " (" + strings.Join(files, "; ") + ")"
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// twoFiles is a module that owns two configurable files, which issue 168 said no module could:
|
||||
// every key of every layer reached both.
|
||||
func twoFiles(app, sidecar []any) Manifest {
|
||||
appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": MergeJSON,
|
||||
"content": `{"server":{"port":8080},"search":{"safe":1}}`}
|
||||
sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": MergeJSON,
|
||||
"content": `{"url":"http://127.0.0.1:8080"}`}
|
||||
if app != nil {
|
||||
appFile[TakesSettingsField] = app
|
||||
}
|
||||
if sidecar != nil {
|
||||
sideFile[TakesSettingsField] = sidecar
|
||||
}
|
||||
return Manifest{Module: "search", Resources: []map[string]any{
|
||||
appFile, sideFile,
|
||||
{"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"},
|
||||
}}
|
||||
}
|
||||
|
||||
func settledContent(t *testing.T, resource map[string]any, layers []Layer) map[string]any {
|
||||
t.Helper()
|
||||
out, err := ApplySettings(resource, layers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, kept := out[TakesSettingsField]; kept {
|
||||
t.Errorf("%v still carries %s; the host knows no such field", out["id"], TakesSettingsField)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil {
|
||||
t.Fatalf("%v is not JSON after settling: %v", out["id"], err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestEachMergeableFileTakesOnlyTheKeysItNames(t *testing.T) {
|
||||
// novox/hq ADR 0326 §1, issue 168: two configurable files, each taking its own keys. A key for
|
||||
// the other file, for a ${setting:…} elsewhere or for the mesh itself lands in neither.
|
||||
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{
|
||||
"server": map[string]any{"base_url": "https://search.example.tld/"},
|
||||
"url": "http://127.0.0.1:9090", "site-name": "Search",
|
||||
"endpoints": map[string]any{"web": map[string]any{"reach": "public"}}}},
|
||||
{From: "node-a", Values: map[string]any{"search": map[string]any{"safe": 0.0}}},
|
||||
}
|
||||
|
||||
app := settledContent(t, m.Resources[0], layers)
|
||||
for _, stray := range []string{"url", "site-name", "endpoints"} {
|
||||
if _, in := app[stray]; in {
|
||||
t.Errorf("app took %q, which it does not name: %v", stray, app)
|
||||
}
|
||||
}
|
||||
server, _ := app["server"].(map[string]any)
|
||||
if server["base_url"] != "https://search.example.tld/" || server["port"] != 8080.0 {
|
||||
t.Errorf("app's server block is %v; the setting should merge into the file's own", server)
|
||||
}
|
||||
if search, _ := app["search"].(map[string]any); search["safe"] != 0.0 {
|
||||
t.Errorf("app's search block is %v; the node's layer should win", search)
|
||||
}
|
||||
|
||||
side := settledContent(t, m.Resources[1], layers)
|
||||
if side["url"] != "http://127.0.0.1:9090" {
|
||||
t.Errorf("sidecar's url is %v; it names url", side["url"])
|
||||
}
|
||||
if len(side) != 1 {
|
||||
t.Errorf("sidecar took keys it does not name: %v", side)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyListTakesNoSetting(t *testing.T) {
|
||||
// ADR 0164 §12: a file that names no setting takes none.
|
||||
m := twoFiles([]any{"server"}, []any{})
|
||||
side := settledContent(t, m.Resources[1], []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"server": map[string]any{"port": 1.0}, "url": "x"}}})
|
||||
if len(side) != 1 || side["url"] != "http://127.0.0.1:8080" {
|
||||
t.Errorf("a file taking none was changed: %v", side)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThatNamesNothingKeepsTodaysForm(t *testing.T) {
|
||||
// ADR 0326 §3, ADR 0164 §15: a module none of whose files names keys is left as it was.
|
||||
m := twoFiles(nil, nil)
|
||||
app := settledContent(t, m.Resources[0], []Layer{{From: "the mesh", Values: map[string]any{"url": "x"}}})
|
||||
if app["url"] != "x" {
|
||||
t.Errorf("a silent file should still take every key until its module names them: %v", app)
|
||||
}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"anything": 1}}}); len(unused) != 0 {
|
||||
t.Errorf("today's form has nothing stray, and said: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyNoFileNamesIsStray(t *testing.T) {
|
||||
// ADR 0326 §5: once the files name their keys, a key none of them names — and nothing else
|
||||
// takes — is stray, and is said with what each file takes.
|
||||
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
||||
unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"server": 1, "url": "x", "site-name": "Search", "ui": map[string]any{"theme": "dark"}}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], `"ui"`) {
|
||||
t.Fatalf("only ui reaches nothing; named: %v", unused)
|
||||
}
|
||||
if !strings.Contains(unused[0], "app takes server, search") || !strings.Contains(unused[0], "sidecar takes url") {
|
||||
t.Errorf("the stray key's message does not say what each file takes: %s", unused[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) {
|
||||
// ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the
|
||||
// silent one — the silent one would otherwise still take every key.
|
||||
m := twoFiles([]any{"server"}, nil)
|
||||
problems := TakenSettingsProblems(m)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") {
|
||||
t.Fatalf("problems: %v", problems)
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") {
|
||||
t.Errorf("composing it should refuse it too, and said: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") {
|
||||
t.Errorf("the module check does not say it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) {
|
||||
// ADR 0326 §4.
|
||||
cases := map[string]struct {
|
||||
resource map[string]any
|
||||
says string
|
||||
}{
|
||||
"on a file that does not merge": {
|
||||
map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
||||
TakesSettingsField: []any{"a"}},
|
||||
"does not merge"},
|
||||
"not a list": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: "server"},
|
||||
"is a list of setting keys"},
|
||||
"a mesh word": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"endpoints"}},
|
||||
"the mesh reads"},
|
||||
"a protected key": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
"protected": []any{"secret"}, TakesSettingsField: []any{"secret"}},
|
||||
"keeps it as protected"},
|
||||
"named twice": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"a", "a"}},
|
||||
"twice"},
|
||||
"not a key": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"two words"}},
|
||||
"not a usable setting key"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}})
|
||||
if !containsText(problems, c.says) {
|
||||
t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems)
|
||||
}
|
||||
}
|
||||
// And a file that does not merge is refused where it is settled, not handed to the host.
|
||||
plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
||||
TakesSettingsField: []any{"a"}}
|
||||
if _, err := ApplySettings(plain, nil); err == nil {
|
||||
t.Error("a file that does not merge and names keys was settled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASoftwareSpelledKeyIsUsable(t *testing.T) {
|
||||
// A file's keys are its software's: nodered's timeZone is not lower case and is still a key.
|
||||
m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file",
|
||||
"path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}}
|
||||
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
||||
t.Errorf("timeZone was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(list []string, text string) bool {
|
||||
for _, s := range list {
|
||||
if strings.Contains(s, text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -46,24 +46,11 @@ type Layer struct {
|
||||
// rather than by repeating everything the rest already say.
|
||||
func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, error) {
|
||||
how, _ := resource["merge"].(string)
|
||||
taken, names := takenKeys(resource)
|
||||
if how == "" {
|
||||
if names {
|
||||
// Refused by the module check too (TakenSettingsProblems); said here as well, for a
|
||||
// definition stored by an older binary, rather than handed to the host, which knows no
|
||||
// such field.
|
||||
return nil, fmt.Errorf("%v says %s and does not merge: only a mergeable file takes "+
|
||||
"settings by key (novox/hq ADR 0326)", resource["id"], TakesSettingsField)
|
||||
}
|
||||
// Not a mergeable file. Settings for it are ignored rather than silently doing nothing
|
||||
// somewhere else — see UnusedSettings, which is what says so.
|
||||
return resource, nil
|
||||
}
|
||||
if names {
|
||||
// **The file takes the keys it names, and no other** (novox/hq ADR 0326, issue 168): a key
|
||||
// for another file, for a `${setting:<key>}` or for the mesh itself does not land here.
|
||||
layers = onlyTaken(layers, taken)
|
||||
}
|
||||
if how != MergeJSON {
|
||||
return nil, fmt.Errorf(
|
||||
"%v says it merges as %q, and this control plane knows how to merge %q",
|
||||
@@ -104,7 +91,6 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
||||
delete(out, "merge")
|
||||
delete(out, "protected")
|
||||
delete(out, TrustedField)
|
||||
delete(out, TakesSettingsField)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -338,11 +324,8 @@ func sortedKeysAny(m map[string]any) []string {
|
||||
// (novox/hq 04-ISSUES/173); and the mesh's own words — `expose`, `ports`, `reach`, `endpoints` —
|
||||
// are read by the mesh. A key none of those takes is stray, and is said so rather than dropped.
|
||||
func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
named := namesItsKeys(m)
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how != "" && !named {
|
||||
// Today's form (novox/hq ADR 0326 §3, ADR 0164 §15): a mergeable file that names no
|
||||
// keys takes every key, so nothing is stray.
|
||||
if how, _ := r["merge"].(string); how != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -354,13 +337,6 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
|
||||
lands := settingKeysUsedBy(m)
|
||||
// A mergeable file takes the keys it names (novox/hq ADR 0326 §5).
|
||||
for _, r := range m.Resources {
|
||||
taken, _ := takenKeys(r)
|
||||
for key := range taken {
|
||||
lands[key] = true
|
||||
}
|
||||
}
|
||||
// A key the module gives a default reaches what asks for it (novox/hq ADR 0262): setting it
|
||||
// overrides the default, which is how a running module gains a setting without a gap between.
|
||||
for key := range m.Settings {
|
||||
@@ -425,8 +401,8 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
}
|
||||
unused = append(unused, fmt.Sprintf(
|
||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
||||
"declares no %q in what it contributes or serves%s",
|
||||
layer.From, key, m.Module, key, key, takenSaid(m)))
|
||||
"declares no %q in what it contributes or serves",
|
||||
layer.From, key, m.Module, key, key))
|
||||
}
|
||||
}
|
||||
sort.Strings(unused)
|
||||
@@ -536,11 +512,6 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||
if err := settingsHoldOneLine(m.Module, layers); err != nil {
|
||||
return err
|
||||
}
|
||||
// Which keys each mergeable file takes is the definition's to say right (novox/hq ADR 0326);
|
||||
// judged here too for a definition stored by an older binary.
|
||||
if problems := takenSettingsProblems(m); len(problems) > 0 {
|
||||
return fmt.Errorf("%s", problems[0])
|
||||
}
|
||||
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
||||
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
||||
if _, err := GivenPorts(m, layers); err != nil {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// MachineAccount is what a machine said it is made of (novox/hq issue 160), from the profile it last
|
||||
// reported: the architecture and kernel family the profile always carried, and the facts a node-engine that
|
||||
// knows how says beside them. Read by `node`, so what a machine says of itself is visible in one place —
|
||||
// collected and shown in the same breath, not collected and read by nothing (issue 160's pattern).
|
||||
type MachineAccount struct {
|
||||
Architecture string `json:"architecture"`
|
||||
Kernel string `json:"kernel"`
|
||||
// Machine is nil when the node-engine that reported is older than the one that says it.
|
||||
Machine *MadeOf `json:"machine"`
|
||||
}
|
||||
|
||||
// MadeOf is the node-engine's account of what it is made of, as it put it on the wire. A fact it could
|
||||
// not determine is absent here and named in Unknown with why — never a zero.
|
||||
type MadeOf struct {
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
KernelRelease string `json:"kernel_release"`
|
||||
Distribution string `json:"distribution"`
|
||||
Version string `json:"version"`
|
||||
Processor string `json:"processor"`
|
||||
Cores int `json:"cores"`
|
||||
Threads int `json:"threads"`
|
||||
MemoryBytes int64 `json:"memory_bytes"`
|
||||
Virtual string `json:"virtual"`
|
||||
BootedAt *time.Time `json:"booted_at"`
|
||||
Timezone string `json:"timezone"`
|
||||
Filesystems []MountedFilesystem `json:"filesystems"`
|
||||
Unknown map[string]string `json:"unknown"`
|
||||
}
|
||||
|
||||
// MountedFilesystem is one local filesystem a machine has mounted.
|
||||
type MountedFilesystem struct {
|
||||
Mount string `json:"mount"`
|
||||
Device string `json:"device"`
|
||||
Type string `json:"type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
}
|
||||
|
||||
// MachineOf is what a node last said it is made of. had is false for a node that has never reported a
|
||||
// profile at all, which is a different answer from one whose node-engine does not say the facts yet.
|
||||
func (i *Inventory) MachineOf(ctx context.Context, nodeName string) (account MachineAccount, had bool, err error) {
|
||||
var raw []byte
|
||||
err = i.store.Pool().QueryRow(ctx, `select profile from node where name = $1`, nodeName).Scan(&raw)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return MachineAccount{}, false, fmt.Errorf("%w: %s", ErrNoSuchNode, nodeName)
|
||||
}
|
||||
if err != nil {
|
||||
return MachineAccount{}, false, err
|
||||
}
|
||||
return machineFrom(raw)
|
||||
}
|
||||
|
||||
func machineFrom(raw []byte) (MachineAccount, bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return MachineAccount{}, false, nil
|
||||
}
|
||||
var account MachineAccount
|
||||
if err := json.Unmarshal(raw, &account); err != nil {
|
||||
return MachineAccount{}, false, err
|
||||
}
|
||||
return account, true, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The node-engine's words for a machine's facts (novox/hq issue 160), as mesh-host's profile.Machine puts
|
||||
// them on the wire. A rename on either side fails here rather than as a blank on a real machine.
|
||||
func TestAMachinesAccountIsReadInTheNodeEnginesWords(t *testing.T) {
|
||||
raw := []byte(`{"architecture":"amd64","kernel":"linux","capabilities":[],
|
||||
"machine":{"observed_at":"2026-10-12T15:00:00Z","kernel_release":"7.2.8-arch1-2",
|
||||
"distribution":"Arch Linux","version":"rolling","processor":"AMD Ryzen 9","cores":8,"threads":16,
|
||||
"memory_bytes":32891494400,"virtual":"physical","booted_at":"2026-10-09T08:50:34Z",
|
||||
"timezone":"Europe/Brussels",
|
||||
"filesystems":[{"mount":"/","device":"/dev/nvme0n1p3","type":"ext4","size_bytes":100,"free_bytes":40}],
|
||||
"unknown":{"public address":"not decided"}}}`)
|
||||
a, had, err := machineFrom(raw)
|
||||
if err != nil || !had {
|
||||
t.Fatalf("had %v, err %v", had, err)
|
||||
}
|
||||
m := a.Machine
|
||||
if a.Architecture != "amd64" || a.Kernel != "linux" || m == nil {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if m.KernelRelease != "7.2.8-arch1-2" || m.Distribution != "Arch Linux" || m.Version != "rolling" ||
|
||||
m.Processor != "AMD Ryzen 9" || m.Cores != 8 || m.Threads != 16 || m.MemoryBytes != 32891494400 ||
|
||||
m.Virtual != "physical" || m.BootedAt == nil || m.Timezone != "Europe/Brussels" ||
|
||||
len(m.Filesystems) != 1 || m.Filesystems[0].FreeBytes != 40 || m.Unknown["public address"] == "" ||
|
||||
m.ObservedAt.IsZero() {
|
||||
t.Errorf("%+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) {
|
||||
a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux","capabilities":[]}`))
|
||||
if err != nil || !had || a.Machine != nil || a.Architecture != "amd64" {
|
||||
t.Errorf("%+v had %v err %v", a, had, err)
|
||||
}
|
||||
if _, had, _ := machineFrom(nil); had {
|
||||
t.Error("a node that never reported read as having reported")
|
||||
}
|
||||
}
|
||||
@@ -436,6 +436,12 @@ const ReadinessContract = 2
|
||||
// parses strictly and would refuse the whole declaration for it.
|
||||
const RootContract = 3
|
||||
|
||||
// GrantContract is the statement of an engine that also reads an access's `grant` (novox/hq ADR 0312): it grants a
|
||||
// read-write access's path group to the containers and the account the grant names, takes it back, and states the
|
||||
// access unhealthy while the group cannot write. Only to such an engine is the field sent: an older one parses
|
||||
// strictly and would refuse the whole declaration for it.
|
||||
const GrantContract = 4
|
||||
|
||||
// ReasonRoot starts the reason of an account verdict that found a way to root (ADR 0266); the node-engine's
|
||||
// own words (mesh-host internal/accounts ReasonRoot).
|
||||
const ReasonRoot = "can become root without a person"
|
||||
@@ -487,6 +493,11 @@ const KindAccount = "account"
|
||||
// ReasonUsedAsFound.
|
||||
const KindDirectory = "directory"
|
||||
|
||||
// KindAccess is a read-write access that grants its path's group (novox/hq ADR 0312): healthy while the group may
|
||||
// write the path, unhealthy while it cannot — the operator's to decide, since the mesh changes nothing on the path
|
||||
// — or while the path is owned by a group that is never granted. The node-engine's own (mesh-host internal/link).
|
||||
const KindAccess = "access"
|
||||
|
||||
// ReasonUsedAsFound starts the reason of a directory used as found.
|
||||
const ReasonUsedAsFound = "used as found:"
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -8,7 +8,7 @@ private addresses. That is acceptable while mesh-controller is private; if it ev
|
||||
a second place to clean besides the catalogue itself.
|
||||
|
||||
mesh-catalog b9de001833b1b61b297182b8e3bcdae1cbdeace9 modules/*/module.json, modules/nats/Dockerfile
|
||||
mesh-host a76baa4b47f07a8b2bab065603414530bd02b53a examples/foundation-first-node-nats.lock
|
||||
mesh-host 3f7f797168d33ec22241bdd6ef2c64de86eea914 examples/foundation-first-node-nats.lock
|
||||
mesh-sdk 33917f91ac3cc213b879b32f3c70e2aa70395f98 conformance/events/module-event.json
|
||||
|
||||
To move them, from this repository's root, with the two repositories checked out beside it:
|
||||
|
||||
+78
@@ -464,6 +464,35 @@ type Access struct {
|
||||
Path string `json:"path"`
|
||||
// Mode is how this module reaches the path: read or read-write. Absent narrows to read.
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// Grant is who the path's own group is granted to, for a read-write access (novox/hq ADR 0312 §1): the
|
||||
// identities the module's code runs as. The controller says who; the node-engine reads which group owns
|
||||
// the path when it applies, because only the machine knows, and never changes the path (§2). Absent is a
|
||||
// controller older than the grant, or an access that grants nothing — and a read access always is one (§3).
|
||||
Grant *AccessGrant `json:"grant,omitempty"`
|
||||
}
|
||||
|
||||
// AccessGrant is the identities a read-write access grants its path's group to (novox/hq ADR 0312 §1).
|
||||
type AccessGrant struct {
|
||||
// Containers are the module's containers that mount the path, by id: each is created with the group as a
|
||||
// supplementary group of its process, and recreated when the group moves (§6).
|
||||
Containers []string `json:"containers,omitempty"`
|
||||
// Account is the account that runs the module's tools: put in the group as an account group, as ADR 0252
|
||||
// §1 puts one, and taken out again as §3 takes back (§5). Empty for a module whose code runs nowhere as
|
||||
// an account.
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads a grant as strictly as a resource is read: a field the engine does not know is a
|
||||
// thing the controller believes it asked for, and is refused rather than ignored.
|
||||
func (g *AccessGrant) UnmarshalJSON(raw []byte) error {
|
||||
type plain AccessGrant
|
||||
var p plain
|
||||
if err := strictDecode(raw, &p); err != nil {
|
||||
return fmt.Errorf("a grant: %w", err)
|
||||
}
|
||||
*g = AccessGrant(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Access) Identity() string { return a.ID }
|
||||
@@ -482,6 +511,24 @@ func (a *Access) validate(where string, _ bool) []string {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s: an access is %q or %q, not %q", where, AccessRead, AccessReadWrite, a.Mode))
|
||||
}
|
||||
if a.Grant != nil && a.Mode != AccessReadWrite {
|
||||
// A read access reads as any account may, and gains no group (novox/hq ADR 0312 §3).
|
||||
problems = append(problems, where+": a read access grants nothing, and this one names a grant; "+
|
||||
"only a read-write access grants its path's group (novox/hq ADR 0312 §3)")
|
||||
}
|
||||
if a.Grant != nil {
|
||||
for _, id := range a.Grant.Containers {
|
||||
if id == "" {
|
||||
problems = append(problems, where+": a grant names a container with no id")
|
||||
}
|
||||
}
|
||||
if a.Grant.Account != "" && !accountName.MatchString(a.Grant.Account) {
|
||||
// Handed to id, usermod and gpasswd as an argument: a name starting with a dash would be read as an
|
||||
// option by each of them.
|
||||
problems = append(problems, fmt.Sprintf("%s: a grant's account is an account's name — letters, digits, "+
|
||||
"dots, underscores and dashes, not starting with a dash — and %q is not one", where, a.Grant.Account))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -1668,6 +1715,7 @@ func parse(raw []byte, allowActions bool) (*Declaration, error) {
|
||||
d.Resources = append(d.Resources, resource)
|
||||
}
|
||||
problems = append(problems, checkWhileStopped(d.Resources)...)
|
||||
problems = append(problems, checkGrants(d.Resources)...)
|
||||
problems = append(problems, checkAdoption(env.Adoption, d.Resources, allowActions)...)
|
||||
problems = append(problems, checkReplaces(d.Resources)...)
|
||||
if env.Adoption == nil {
|
||||
@@ -1732,6 +1780,36 @@ func checkWhileStopped(resources []Resource) []string {
|
||||
return problems
|
||||
}
|
||||
|
||||
// accountName is what a grant's account may be: the portable account name the user tools take, optionally ending
|
||||
// in `$`, never starting with a dash.
|
||||
var accountName = regexp.MustCompile(`^[A-Za-z0-9_.][A-Za-z0-9_.-]*\$?$`)
|
||||
|
||||
// checkGrants judges each access's grant against the whole declaration (novox/hq ADR 0312): a container it
|
||||
// names is a container of this declaration, as a maintenance window's are. A grant to a container the machine
|
||||
// is not told about would be a group given to nothing, said as granted.
|
||||
func checkGrants(resources []Resource) []string {
|
||||
containers := map[string]bool{}
|
||||
for _, r := range resources {
|
||||
if r.Kind() == TypeContainer {
|
||||
containers[r.Identity()] = true
|
||||
}
|
||||
}
|
||||
var problems []string
|
||||
for _, r := range resources {
|
||||
a, ok := r.(*Access)
|
||||
if !ok || a.Grant == nil {
|
||||
continue
|
||||
}
|
||||
for _, id := range a.Grant.Containers {
|
||||
if id != "" && !containers[id] {
|
||||
problems = append(problems, fmt.Sprintf("resource %q: its grant names %q, and this declaration "+
|
||||
"has no container by that id", a.ID, id))
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func strictDecode(raw []byte, into any) error {
|
||||
// DisallowUnknownFields is the whole point rather than strictness for its own sake: a
|
||||
// field the host does not know is a thing the control plane believes it asked for.
|
||||
|
||||
Vendored
+2
-2
@@ -78,7 +78,7 @@ github.com/nats-io/nkeys
|
||||
# github.com/nats-io/nuid v1.0.1
|
||||
## explicit
|
||||
github.com/nats-io/nuid
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host v0.0.0 => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
## explicit; go 1.26.0
|
||||
github.com/novox/mesh-host/internal/declaration
|
||||
github.com/novox/mesh-host/rootsearch
|
||||
@@ -135,4 +135,4 @@ golang.org/x/text/width
|
||||
# golang.org/x/time v0.15.0
|
||||
## explicit; go 1.25.0
|
||||
golang.org/x/time/rate
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f
|
||||
# github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261012112256-3f7f797168d3
|
||||
|
||||
Reference in New Issue
Block a user