Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef76b9f2ea |
@@ -1,124 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
|
||||
// put or get in the consumer's bucket, and with which methods. Not the NFS shares of shares.go (ADR 0263).
|
||||
//
|
||||
// "requires": [{"name": "s3-bucket", "shares": [{"to": "seat:node-files", "methods": ["put"]},
|
||||
// {"to": "print-and-scan", "methods": ["get"]}]}]
|
||||
//
|
||||
// `to` is a module name, or `seat:<seat name>`. `methods` is a non-empty subset of get and put, without a repeat.
|
||||
// Shares are allowed on `s3-bucket` only, and no two entries name the same `to`.
|
||||
type BucketShare struct {
|
||||
To string `json:"to"`
|
||||
Methods []string `json:"methods"`
|
||||
}
|
||||
|
||||
// BucketRequirement is the requirement `shares` may be written on.
|
||||
const BucketRequirement = "s3-bucket"
|
||||
|
||||
// RequireSharesKey is where a stored manifest keeps the shares of its requirements: beside `requires`, which stays a
|
||||
// plain list of names, for the reason OptionalRequiresKey is beside it. An older controller sets the unknown key
|
||||
// aside instead of failing the catalogue.
|
||||
const RequireSharesKey = "requires-shares"
|
||||
|
||||
// SharesValue is the key of the value a consumer gives the provider of s3-bucket in its grant: the list of
|
||||
// {to, methods} sorted by `to`. A consumer's own `contributes` may not name it.
|
||||
const SharesValue = "shares"
|
||||
|
||||
var shareTarget = regexp.MustCompile(`^(seat:)?[a-z0-9][a-z0-9-]*$`)
|
||||
|
||||
// checkBucketShares refuses a malformed `shares` of one requirement, naming the entry and ADR 0468 §8.
|
||||
func checkBucketShares(requirement string, shares []BucketShare) error {
|
||||
if len(shares) == 0 {
|
||||
return nil
|
||||
}
|
||||
if requirement != BucketRequirement {
|
||||
return fmt.Errorf("requires %s: `shares` is allowed on %s only (novox/hq ADR 0468 §8)", requirement, BucketRequirement)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for i, s := range shares {
|
||||
at := fmt.Sprintf("requires %s: shares[%d] (to %q)", requirement, i, s.To)
|
||||
if !shareTarget.MatchString(s.To) {
|
||||
return fmt.Errorf("%s: `to` is a module name or seat:<seat name> (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
if seen[s.To] {
|
||||
return fmt.Errorf("%s: a second entry for the same `to` (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
seen[s.To] = true
|
||||
if len(s.Methods) == 0 {
|
||||
return fmt.Errorf("%s: `methods` is empty; name get, put or both (novox/hq ADR 0468 §8)", at)
|
||||
}
|
||||
used := map[string]bool{}
|
||||
for _, method := range s.Methods {
|
||||
if method != "get" && method != "put" {
|
||||
return fmt.Errorf("%s: method %q is not get or put (novox/hq ADR 0468 §8)", at, method)
|
||||
}
|
||||
if used[method] {
|
||||
return fmt.Errorf("%s: method %q twice (novox/hq ADR 0468 §8)", at, method)
|
||||
}
|
||||
used[method] = true
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRequireShares reads the stored form's side key.
|
||||
func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) {
|
||||
var out map[string][]BucketShare
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("%s: an object of requirement to a list of {to, methods}: %w", RequireSharesKey, err)
|
||||
}
|
||||
for requirement, shares := range out {
|
||||
if err := checkBucketShares(requirement, shares); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
|
||||
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
|
||||
func sortedShares(shares []BucketShare) []BucketShare {
|
||||
out := make([]BucketShare, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
methods := append([]string(nil), s.Methods...)
|
||||
sort.Strings(methods)
|
||||
out = append(out, BucketShare{To: s.To, Methods: methods})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
|
||||
return out
|
||||
}
|
||||
|
||||
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
|
||||
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
|
||||
func withShares(m Manifest, requirement string, values map[string]any) (map[string]any, error) {
|
||||
shares := m.RequireShares[requirement]
|
||||
if _, named := values[SharesValue]; named && requirement == BucketRequirement {
|
||||
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
|
||||
m.Module, requirement)
|
||||
}
|
||||
if len(shares) == 0 {
|
||||
return values, nil
|
||||
}
|
||||
out := make(map[string]any, len(values)+1)
|
||||
for k, v := range values {
|
||||
out[k] = v
|
||||
}
|
||||
listed := []any{}
|
||||
for _, s := range sortedShares(shares) {
|
||||
methods := []any{}
|
||||
for _, method := range s.Methods {
|
||||
methods = append(methods, method)
|
||||
}
|
||||
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
|
||||
}
|
||||
out[SharesValue] = listed
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1591: `shares` on a requirement of s3-bucket (novox/hq ADR 0468 §8).
|
||||
|
||||
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
|
||||
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
|
||||
|
||||
func minioLike() Manifest {
|
||||
return Manifest{Module: "minio", Version: "1", Provides: Offers("s3-bucket"),
|
||||
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
|
||||
}
|
||||
|
||||
func givenToMinio(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("minio was given no file: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sharesOf(t *testing.T, values map[string]any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(values["shares"])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
|
||||
|
||||
// 1591/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them.
|
||||
func TestSharesParseAndAreStoredBesideRequires(t *testing.T) {
|
||||
m := mustParse(t, filesWithShares)
|
||||
want := []BucketShare{{To: "seat:node-files", Methods: []string{"put"}}, {To: "print-and-scan", Methods: []string{"get", "put"}}}
|
||||
if !reflect.DeepEqual(m.RequireShares["s3-bucket"], want) {
|
||||
t.Fatalf("the shares read as %+v", m.RequireShares)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(keys["requires"]) != `["shell","s3-bucket"]` {
|
||||
t.Errorf("the stored requires is not plain names: %s", keys["requires"])
|
||||
}
|
||||
if !strings.Contains(string(keys[RequireSharesKey]), `"seat:node-files"`) {
|
||||
t.Errorf("the side key does not hold the shares: %s", raw)
|
||||
}
|
||||
var again Manifest
|
||||
if err := json.Unmarshal(raw, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(again, m) {
|
||||
t.Fatalf("not lossless:\n%+v\n%+v", again, m)
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC2: a malformed share is refused, naming the entry and ADR 0468 §8.
|
||||
func TestAMalformedShareIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"unknown method": `{"name":"s3-bucket","shares":[{"to":"a","methods":["delete"]}]}`,
|
||||
"empty to": `{"name":"s3-bucket","shares":[{"to":"","methods":["get"]}]}`,
|
||||
"malformed to": `{"name":"s3-bucket","shares":[{"to":"Seat:X y","methods":["get"]}]}`,
|
||||
"empty methods": `{"name":"s3-bucket","shares":[{"to":"a","methods":[]}]}`,
|
||||
"duplicate to": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]},{"to":"a","methods":["put"]}]}`,
|
||||
"duplicate verb": `{"name":"s3-bucket","shares":[{"to":"a","methods":["get","get"]}]}`,
|
||||
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
|
||||
}
|
||||
for name, entry := range cases {
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
|
||||
if err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ADR 0468 §8") || !strings.Contains(err.Error(), "requires") {
|
||||
t.Errorf("%s: the error does not name the entry and the ADR: %v", name, err)
|
||||
}
|
||||
}
|
||||
// The stored form is held to the same rules.
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
|
||||
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
t.Errorf("the stored form was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
|
||||
func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
|
||||
m := mustParse(t, filesWithShares)
|
||||
remote := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
||||
values, asks, err := remote.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
||||
if err != nil || !asks {
|
||||
t.Fatalf("asks %v, err %v", asks, err)
|
||||
}
|
||||
if got := sharesOf(t, values); got != sortedSharesJSON {
|
||||
t.Fatalf("the grant values carry %s", got)
|
||||
}
|
||||
// As the provider's file shows it, through a grant.
|
||||
provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := provider.Declaration(Rendering{Grants: []Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := givenToMinio(t, out)
|
||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
|
||||
t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
|
||||
}
|
||||
|
||||
// Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
|
||||
co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
|
||||
"requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
|
||||
both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = both.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given = givenToMinio(t, out)
|
||||
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
|
||||
t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC4: only the shares change, so the module is not recreated and held for no one; the provider's file changes.
|
||||
func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) {
|
||||
was := mustParse(t, filesWithShares)
|
||||
now := mustParse(t, `{"module":"files","version":"1","requires":["shell",
|
||||
{"name":"s3-bucket","shares":[{"to":"print-and-scan","methods":["get"]}]}]}`)
|
||||
if said := Recreates(was, now).Say("files"); said != "" {
|
||||
t.Errorf("a change of shares recreates the module: %s", said)
|
||||
}
|
||||
if said := Holds(was, now); said != "" {
|
||||
t.Errorf("a change of shares holds the module: %s", said)
|
||||
}
|
||||
before := Resolution{Node: "laptop", Modules: []Manifest{was}}
|
||||
after := Resolution{Node: "laptop", Modules: []Manifest{now}}
|
||||
a, _, _ := before.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
||||
b, _, _ := after.ContributionsFrom("s3-bucket", "files", SettingsBy{})
|
||||
if sharesOf(t, a) == sharesOf(t, b) {
|
||||
t.Fatalf("the provider's values did not change: %s", sharesOf(t, a))
|
||||
}
|
||||
}
|
||||
|
||||
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
|
||||
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"files","version":"1",
|
||||
"contributes":{"s3-bucket":{"shares":"x"}},
|
||||
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
|
||||
t.Errorf("both written, accepted: %v", err)
|
||||
}
|
||||
// Without the field the grant is still refused, so the provider is never told something the manifest did not say.
|
||||
m := Manifest{Module: "files", Requires: []string{"s3-bucket"},
|
||||
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
||||
if _, _, err := r.ContributionsFrom("s3-bucket", "files", SettingsBy{}); err == nil {
|
||||
t.Error("a contributed `shares` reached the grant")
|
||||
}
|
||||
}
|
||||
@@ -482,29 +482,3 @@ func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
||||
"default": "", "runtime": true}
|
||||
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
||||
}
|
||||
|
||||
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
|
||||
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "m", "version": "1",
|
||||
"config": map[string]any{
|
||||
"k": map[string]any{"type": "string", "description": "a word the item says", "default": "x"},
|
||||
},
|
||||
"items": []any{map[string]any{"collection": "a", "content": "k = ${config:k}\n"}},
|
||||
}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a value placed only in an item's text is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused.
|
||||
func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "m", "version": "1",
|
||||
"config": map[string]any{
|
||||
"k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"},
|
||||
},
|
||||
"items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}},
|
||||
}
|
||||
refused(t, m, `"k"`, "placed nowhere")
|
||||
}
|
||||
|
||||
@@ -1783,10 +1783,6 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8).
|
||||
if values, err = withShares(m, to, values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||
@@ -2173,19 +2169,11 @@ func (r Resolution) ContributionsFrom(requirement, module string, settings Setti
|
||||
mine = append(mine, g.Values)
|
||||
}
|
||||
}
|
||||
var asker Manifest
|
||||
for _, m := range r.Modules {
|
||||
if m.Module == module {
|
||||
asker = m
|
||||
}
|
||||
}
|
||||
if len(mine) == 1 {
|
||||
// Its shares are already in, put there where its contribution was composed (contributions).
|
||||
return mine[0], true, nil
|
||||
}
|
||||
if len(mine) > 1 {
|
||||
values, err := withShares(asker, requirement, map[string]any{})
|
||||
return values, err == nil, err
|
||||
return map[string]any{}, true, nil
|
||||
}
|
||||
// It contributes no payload — but a require-only consumer of a parameterless provision (one whose
|
||||
// `serves` names no consumer-supplied key: `redis-cache`, `amqp`) still ASKS for it and must be
|
||||
@@ -2198,8 +2186,7 @@ func (r Resolution) ContributionsFrom(requirement, module string, settings Setti
|
||||
}
|
||||
for _, req := range m.Requires {
|
||||
if req == requirement {
|
||||
values, err := withShares(m, requirement, map[string]any{})
|
||||
return values, err == nil, err
|
||||
return map[string]any{}, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// 1694/AC5: the capability a node-engine reports for files by digest (novox/hq ADR 0486 §5) is a known one.
|
||||
func TestFileReferencesIsAKnownCapability(t *testing.T) {
|
||||
if !oneOf(KnownCapabilities, "file-references") {
|
||||
t.Fatal("file-references is not a known capability")
|
||||
}
|
||||
}
|
||||
@@ -74,22 +74,6 @@ func filesVerbs() []Verb {
|
||||
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
|
||||
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
|
||||
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"),
|
||||
Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
|
||||
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
|
||||
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
|
||||
"before anything is sent. Read " + filesAsAccount + ".",
|
||||
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
|
||||
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})},
|
||||
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
|
||||
// switches, which a hash and a reason are not (issue 1719).
|
||||
Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
|
||||
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
|
||||
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
|
||||
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
|
||||
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
|
||||
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
|
||||
"reason": "why the file is changed, for the audit trail",
|
||||
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})},
|
||||
Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
|
||||
"original path and when it went.",
|
||||
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}},
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -23,8 +22,7 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
|
||||
}
|
||||
// upload and download (ADR 0468 §9, issue 1718) move bytes through the object store: no command is theirs.
|
||||
if len(v.Replaces) == 0 && v.Name != "upload" && v.Name != "download" {
|
||||
if len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
|
||||
}
|
||||
for _, c := range v.Replaces {
|
||||
@@ -32,7 +30,7 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
||||
"delete", "mkdir", "chmod", "chown", "upload", "download", "trash", "restore"} {
|
||||
"delete", "mkdir", "chmod", "chown", "trash", "restore"} {
|
||||
if !have[name] {
|
||||
t.Errorf("%s serves no verb %s", FilesSeat, name)
|
||||
}
|
||||
@@ -111,93 +109,3 @@ func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
|
||||
}
|
||||
t.Error("no restore verb")
|
||||
}
|
||||
|
||||
// 1718/AC1: node-files names upload and download, in mesh-files' order, before trash.
|
||||
func TestNodeFilesNamesUploadAndDownloadBeforeTrash(t *testing.T) {
|
||||
s, _ := SeatNamed(FilesSeat)
|
||||
var names []string
|
||||
for _, v := range s.Serves {
|
||||
names = append(names, v.Name)
|
||||
}
|
||||
joined := strings.Join(names, ",")
|
||||
if !strings.Contains(joined, "chown,upload,download,trash") {
|
||||
t.Errorf("1718/AC1: node-files' verbs are %s, want upload and download between chown and trash", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// 1718/AC2: a holder serving every verb including upload and download is accepted; one naming a verb outside the
|
||||
// contract is still refused, and one leaving upload out is refused.
|
||||
func TestAHolderOfNodeFilesServesUploadAndDownloadAndNothingElse(t *testing.T) {
|
||||
s, _ := SeatNamed(FilesSeat)
|
||||
var verbs []string
|
||||
for _, v := range s.Serves {
|
||||
verbs = append(verbs, v.Name)
|
||||
}
|
||||
hold := func(serves []string) error {
|
||||
return CanHold(Manifest{Module: "mesh-files", Version: "1",
|
||||
Claims: []Claim{{Name: FilesSeat, Scope: ScopeNode, Serves: serves}}}, s)
|
||||
}
|
||||
if err := hold(verbs); err != nil {
|
||||
t.Errorf("1718/AC2: a holder serving every verb was refused: %v", err)
|
||||
}
|
||||
if err := hold(append(append([]string{}, verbs...), "sync")); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Errorf("1718/AC2: a verb outside the contract was accepted: %v", err)
|
||||
}
|
||||
var without []string
|
||||
for _, v := range verbs {
|
||||
if v != "upload" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
if err := hold(without); err == nil || !strings.Contains(err.Error(), "upload") {
|
||||
t.Errorf("1718/AC2: a holder leaving upload out was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1718/AC3: upload requires path and bucket; download requires bucket, key, path and reason; sha256, key (upload)
|
||||
// and override are plain strings, not true/false switches.
|
||||
func TestNodeFilesUploadAndDownloadInputs(t *testing.T) {
|
||||
s, _ := SeatNamed(FilesSeat)
|
||||
want := map[string]struct {
|
||||
required string
|
||||
strs []string
|
||||
}{
|
||||
"upload": {"path,bucket", []string{"path", "bucket", "key"}},
|
||||
"download": {"bucket,key,path,reason", []string{"bucket", "key", "path", "sha256", "reason", "override"}},
|
||||
}
|
||||
seen := 0
|
||||
for _, v := range s.Serves {
|
||||
w, ok := want[v.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
req, _ := v.Input["required"].([]string)
|
||||
got := append([]string{}, req...)
|
||||
sortStrings(got)
|
||||
exp := strings.Split(w.required, ",")
|
||||
sortStrings(exp)
|
||||
if strings.Join(got, ",") != strings.Join(exp, ",") {
|
||||
t.Errorf("1718/AC3: %s requires %v, want %s", v.Name, req, w.required)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if len(props) != len(w.strs) {
|
||||
t.Errorf("1718/AC3: %s has %d inputs, want %v", v.Name, len(props), w.strs)
|
||||
}
|
||||
for _, name := range w.strs {
|
||||
p, has := props[name].(map[string]any)
|
||||
if !has {
|
||||
t.Errorf("1718/AC3: %s has no input %s", v.Name, name)
|
||||
continue
|
||||
}
|
||||
if _, enum := p["enum"]; enum || p["type"] != "string" {
|
||||
t.Errorf("1718/AC3: %s input %s is %v, want a plain string", v.Name, name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen != 2 {
|
||||
t.Errorf("1718/AC3: found %d of upload and download", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func sortStrings(s []string) { sort.Strings(s) }
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11,
|
||||
// issue 1636): `groups: ["${config:media-gid}"]`, each element a whole placeholder or a whole number, filled at
|
||||
// composition with the number the config value holds.
|
||||
|
||||
func aContainerWithGroups(groups ...any) map[string]any {
|
||||
return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64),
|
||||
"groups": groups}
|
||||
}
|
||||
|
||||
// 1636/AC3: a manifest container with groups: ["${setting:media-gid}"] resolves to the number the setting says.
|
||||
func TestAContainersGroupIsFilledFromTheSetting(t *testing.T) {
|
||||
r := aContainerWithGroups("${setting:media-gid}", float64(2001))
|
||||
layers := []Layer{{From: "ace", Values: map[string]any{"media-gid": float64(2000)}}}
|
||||
if err := settingInto(r, layers, "n8n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []any{2000, 2001}; !reflect.DeepEqual(r["groups"], want) {
|
||||
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
|
||||
}
|
||||
// A value the operator wrote as text is read as the number it spells.
|
||||
r = aContainerWithGroups("${setting:media-gid}")
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"media-gid": "2000"}}}, "n8n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []any{2000}; !reflect.DeepEqual(r["groups"], want) {
|
||||
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC3: an unset value refuses the module by name, and the key and the remedy are named; nothing is dropped.
|
||||
func TestAnUnsetGroupRefusesTheModuleByName(t *testing.T) {
|
||||
r := aContainerWithGroups("${setting:media-gid}")
|
||||
err := settingInto(r, nil, "n8n")
|
||||
if err == nil || !strings.Contains(err.Error(), "n8n") || !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Fatalf("want a refusal naming n8n and media-gid, got %v", err)
|
||||
}
|
||||
if got, _ := r["groups"].([]any); len(got) != 1 || got[0] != "${setting:media-gid}" {
|
||||
t.Fatalf("the resource was changed: %#v", r["groups"])
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC2: a value that is not a whole number above zero cannot be a group.
|
||||
func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
|
||||
for _, bad := range []any{"media", "-1", "0", float64(0), float64(2000.5), "2000:2000", "", true} {
|
||||
r := aContainerWithGroups("${setting:media-gid}")
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"media-gid": bad}}}, "n8n")
|
||||
if err == nil || !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Errorf("value %#v: want a refusal naming the key, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's
|
||||
// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read.
|
||||
func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "n8n", "version": "1",
|
||||
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
|
||||
"description": "the group that owns the media library on this machine"}},
|
||||
"resources": []any{aContainerWithGroups("${config:media-gid}")},
|
||||
}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} {
|
||||
m["resources"] = []any{aContainerWithGroups(shape)}
|
||||
if _, err := parseRaw(t, m); err == nil {
|
||||
t.Errorf("groups %q registered", shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
|
||||
// says, written as a number, and the module is left out by name when the machine says none.
|
||||
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
|
||||
manifest, err := parseRaw(t, map[string]any{
|
||||
"module": "n8n", "version": "1",
|
||||
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
|
||||
"description": "the group that owns the media library on this machine"}},
|
||||
"resources": []any{aContainerWithGroups("${config:media-gid}")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, manifest)
|
||||
with := anchorRendering(false)
|
||||
with.Settings["n8n"] = []Layer{{From: "anchor", Values: map[string]any{"media-gid": float64(2000)}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["n8n"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
if got := byID(composed.Resources)["n8n.server"]["groups"]; !reflect.DeepEqual(got, []any{2000}) {
|
||||
t.Fatalf("composed groups %#v", got)
|
||||
}
|
||||
|
||||
without := anchorRendering(false)
|
||||
composed, err = r.Compose(without)
|
||||
if err == nil {
|
||||
why, left := composed.LeftOut["n8n"]
|
||||
if !left || !strings.Contains(why, "media-gid") {
|
||||
t.Fatalf("a machine that says no group kept n8n or left it out without the key: %q (%v)", why, left)
|
||||
}
|
||||
} else if !strings.Contains(err.Error(), "media-gid") {
|
||||
t.Fatalf("refused without the key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The node-engine reads a container's groups only when it reports the capability (novox/hq ADR 0480 clause 7, issue 1638):
|
||||
// a module with a container that names groups is hosted on a machine that reports container-groups, and left out of one
|
||||
// that does not, the capability named, so the field is never sent to an engine that would refuse it.
|
||||
func TestAModuleWhoseContainerNamesGroupsIsHeldOnAMachineThatDoesNotReadThem(t *testing.T) {
|
||||
n8n := Manifest{Module: "n8n", Resources: []map[string]any{aContainerWithGroups("${config:media-gid}")}}
|
||||
plain := Manifest{Module: "plain", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "p", "image": "x@sha256:" + strings.Repeat("b", 64)}}}
|
||||
for _, m := range []Manifest{n8n, plain} {
|
||||
if got := impliedCapabilities(m); (m.Module == "n8n") != reflect.DeepEqual(got, []string{ContainerGroups}) {
|
||||
t.Errorf("%s implies %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
shelf := shelf(n8n, plain)
|
||||
reads := Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, ContainerGroups: true}}
|
||||
got, err := Resolve(shelf, []string{"n8n", "plain"}, reads, World{})
|
||||
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 2 {
|
||||
t.Fatalf("a machine that reads groups: %v, held %+v, hosted %v", err, got.Unhostable, names(got))
|
||||
}
|
||||
old := Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
got, err = Resolve(shelf, []string{"n8n", "plain"}, old, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "n8n" || !reflect.DeepEqual(got.Unhostable[0].Missing, []string{ContainerGroups}) {
|
||||
t.Fatalf("n8n was not held, naming the capability: %+v", got.Unhostable)
|
||||
}
|
||||
if h := names(got); len(h) != 1 || h[0] != "plain" {
|
||||
t.Errorf("the module without groups is not the one hosted: %v", h)
|
||||
}
|
||||
}
|
||||
@@ -494,9 +494,6 @@ type Manifest struct {
|
||||
// by UnmarshalJSON from the long form of a `requires` entry, {name, optional}, or from the stored
|
||||
// form's `optional-requires` list; never written by hand.
|
||||
OptionalRequires map[string]bool `json:"-"`
|
||||
// RequireShares is who else may use the bucket a module requires, by requirement (BucketShare, novox/hq ADR 0468
|
||||
// §8): filled from the long form's `shares`, or from the stored form's `requires-shares`; never written by hand.
|
||||
RequireShares map[string][]BucketShare `json:"-"`
|
||||
|
||||
// Emits are the events this module publishes, named **locally**: `order.placed`, not a subject
|
||||
// and not a routing key. The mesh derives where it lands (design 29 §1), so reorganising the
|
||||
@@ -1541,9 +1538,8 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
||||
delete(keys, "contributes")
|
||||
}
|
||||
var optionalRequires map[string]bool
|
||||
var requireShares map[string][]BucketShare
|
||||
if requires, ok := keys["requires"]; ok && string(requires) != "null" {
|
||||
names, optional, shares, err := readRequires(requires)
|
||||
names, optional, err := readRequires(requires)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1553,23 +1549,6 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
||||
}
|
||||
keys["requires"] = plainNames
|
||||
optionalRequires = optional
|
||||
requireShares = shares
|
||||
}
|
||||
// The stored form's shares (RequireSharesKey).
|
||||
if stored, ok := keys[RequireSharesKey]; ok {
|
||||
if string(stored) != "null" {
|
||||
stood, err := readRequireShares(stored)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for requirement, shares := range stood {
|
||||
if requireShares == nil {
|
||||
requireShares = map[string][]BucketShare{}
|
||||
}
|
||||
requireShares[requirement] = shares
|
||||
}
|
||||
}
|
||||
delete(keys, RequireSharesKey)
|
||||
}
|
||||
// The stored form: `requires` plain, and which are optional beside it (OptionalRequiresKey).
|
||||
if stored, ok := keys[OptionalRequiresKey]; ok {
|
||||
@@ -1620,7 +1599,6 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
||||
m.unknown = unknown
|
||||
m.forms = forms
|
||||
m.OptionalRequires = optionalRequires
|
||||
m.RequireShares = requireShares
|
||||
if len(plain) > 0 {
|
||||
m.Secrets = plain
|
||||
}
|
||||
@@ -1633,21 +1611,6 @@ func (m *Manifest) UnmarshalJSON(raw []byte) error {
|
||||
if len(contributesMany) > 0 {
|
||||
m.ContributesMany = contributesMany
|
||||
}
|
||||
if err := m.checkSharesNotContributed(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkSharesNotContributed refuses a module whose own `contributes` to s3-bucket names `shares` (ADR 0468 §8).
|
||||
func (m Manifest) checkSharesNotContributed() error {
|
||||
if len(m.RequireShares[BucketRequirement]) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, named := m.Contributes[BucketRequirement][SharesValue]; named {
|
||||
return fmt.Errorf("contributes.%s names `shares` and so does its requirement; keep one, the requirement's (novox/hq ADR 0468 §8)",
|
||||
BucketRequirement)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1692,7 +1655,7 @@ func (m Manifest) marshalFields() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 && len(m.RequireShares) == 0 {
|
||||
if len(m.SecretsMany) == 0 && len(m.ContributesMany) == 0 && len(m.OptionalRequires) == 0 {
|
||||
return raw, nil
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
@@ -1708,13 +1671,6 @@ func (m Manifest) marshalFields() ([]byte, error) {
|
||||
}
|
||||
keys[OptionalRequiresKey] = listed
|
||||
}
|
||||
if len(m.RequireShares) > 0 {
|
||||
listed, err := json.Marshal(m.RequireShares)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keys[RequireSharesKey] = listed
|
||||
}
|
||||
if len(m.ContributesMany) > 0 {
|
||||
mergedContributes := map[string]any{}
|
||||
for to, values := range m.Contributes {
|
||||
@@ -2214,7 +2170,6 @@ func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) {
|
||||
problems = append(problems, replacesProblems(m)...)
|
||||
problems = append(problems, UnitSettingProblems(m)...)
|
||||
problems = append(problems, OwnerSettingProblems(m)...)
|
||||
problems = append(problems, GroupSettingProblems(m)...)
|
||||
problems = append(problems, endpointNameProblems(m)...)
|
||||
problems = append(problems, RouteProblems(m)...)
|
||||
for _, port := range m.Guards {
|
||||
|
||||
@@ -32,20 +32,17 @@ const OnlyWhenBound = "only-when-bound"
|
||||
type requirementForm struct {
|
||||
Name string `json:"name"`
|
||||
Optional bool `json:"optional,omitempty"`
|
||||
// Shares is who else may use the consumer's bucket (BucketShare, novox/hq ADR 0468 §8), on s3-bucket only.
|
||||
Shares []BucketShare `json:"shares,omitempty"`
|
||||
}
|
||||
|
||||
// readRequires reads `requires` in both of its forms — a bare name, or {name, optional} — into the
|
||||
// names and which of them are optional.
|
||||
func readRequires(raw json.RawMessage) ([]string, map[string]bool, map[string][]BucketShare, error) {
|
||||
func readRequires(raw json.RawMessage) ([]string, map[string]bool, error) {
|
||||
var entries []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
|
||||
return nil, nil, fmt.Errorf("requires: a list of names, or of {name, optional}: %w", err)
|
||||
}
|
||||
var names []string
|
||||
optional := map[string]bool{}
|
||||
var shares map[string][]BucketShare
|
||||
for i, e := range entries {
|
||||
var plain string
|
||||
if err := json.Unmarshal(e, &plain); err == nil {
|
||||
@@ -56,21 +53,12 @@ func readRequires(raw json.RawMessage) ([]string, map[string]bool, map[string][]
|
||||
dec := json.NewDecoder(bytes.NewReader(e))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&full); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
|
||||
return nil, nil, fmt.Errorf("requires[%d]: a name, or {name, optional} (novox/hq ADR 0330): %w", i, err)
|
||||
}
|
||||
if strings.TrimSpace(full.Name) == "" {
|
||||
return nil, nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
|
||||
return nil, nil, fmt.Errorf("requires[%d] names no requirement: {name, optional}", i)
|
||||
}
|
||||
names = append(names, full.Name)
|
||||
if err := checkBucketShares(full.Name, full.Shares); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("requires[%d]: %w", i, err)
|
||||
}
|
||||
if len(full.Shares) > 0 {
|
||||
if shares == nil {
|
||||
shares = map[string][]BucketShare{}
|
||||
}
|
||||
shares[full.Name] = full.Shares
|
||||
}
|
||||
if full.Optional {
|
||||
optional[full.Name] = true
|
||||
}
|
||||
@@ -78,7 +66,7 @@ func readRequires(raw json.RawMessage) ([]string, map[string]bool, map[string][]
|
||||
if len(optional) == 0 {
|
||||
optional = nil
|
||||
}
|
||||
return names, optional, shares, nil
|
||||
return names, optional, nil
|
||||
}
|
||||
|
||||
// OptionalRequiresKey is where a stored manifest keeps which of its requirements are optional: beside
|
||||
|
||||
+1
-18
@@ -904,7 +904,7 @@ func isModule(catalogue map[string]Manifest, want string) bool {
|
||||
// machine can host the module as far as its capabilities go.
|
||||
func missingCapabilities(m Manifest, node Node) []string {
|
||||
var missing []string
|
||||
for _, c := range append(append([]string(nil), m.Capabilities...), impliedCapabilities(m)...) {
|
||||
for _, c := range m.Capabilities {
|
||||
if !node.Capabilities[c] {
|
||||
missing = append(missing, c)
|
||||
}
|
||||
@@ -1457,20 +1457,3 @@ func packageNames(m map[string][]string) []string {
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// ContainerGroups is the capability a node-engine reports when it reads a container's `groups` and passes each as a
|
||||
// supplementary group (novox/hq ADR 0480 clause 7, issue 1638). An older engine refuses the key (strict decoding).
|
||||
const ContainerGroups = "container-groups"
|
||||
|
||||
// impliedCapabilities are the capabilities a module needs for what its resources say, beyond those it declares: a
|
||||
// container that names groups needs an engine that reads them, so the module is left out of a machine whose report
|
||||
// lacks the capability, naming it, and the field is sent to no engine that would refuse it. None for a module whose
|
||||
// containers name no groups.
|
||||
func impliedCapabilities(m Manifest) []string {
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) == "container" && len(groupsOf(r)) > 0 {
|
||||
return []string{ContainerGroups}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,8 +66,7 @@ var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
// names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its
|
||||
// contribution out on every machine, silently, so it is refused. A detector added there is added here.
|
||||
var KnownCapabilities = []string{
|
||||
"battery", "container-groups", "container-runtime", "file-references", "firewall", "graphical-session", "overlay",
|
||||
"package-manager",
|
||||
"battery", "container-runtime", "firewall", "graphical-session", "overlay", "package-manager",
|
||||
"power-meter", "privileged", "seat", "service-manager", "uplink-dhcpcd", "uplink-networkmanager",
|
||||
"uplink-systemd-networkd", "user-namespaces", "virtualisation",
|
||||
}
|
||||
|
||||
@@ -85,9 +85,6 @@ func settingInto(resource map[string]any, layers []Layer, module string) error {
|
||||
if fmt.Sprint(resource["type"]) == "directory" {
|
||||
return settingIntoOwner(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) == "container" {
|
||||
return settingIntoGroups(resource, layers, module)
|
||||
}
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -327,7 +324,7 @@ func settingKeysUsedBy(m Manifest) map[string]bool {
|
||||
return used
|
||||
}
|
||||
|
||||
// settingKeysAskedOutsideOwners is every key a module's files, units, items, contributions and served facts
|
||||
// settingKeysAskedOutsideOwners is every key a module's files, units, contributions and served facts
|
||||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||||
func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
used := map[string]bool{}
|
||||
@@ -346,18 +343,8 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
if unit, ok := r["unit"].(string); ok {
|
||||
note(unit)
|
||||
}
|
||||
case "container":
|
||||
for _, g := range groupsOf(r) {
|
||||
if s, ok := g.(string); ok {
|
||||
note(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// An item's text, filled from its template (WithTemplatesFrom), places a key as a file's does (issue 1658).
|
||||
for _, it := range m.Items {
|
||||
note(it.Content)
|
||||
}
|
||||
inValues := func(values map[string]any) {
|
||||
for _, v := range values {
|
||||
if s, ok := v.(string); ok {
|
||||
@@ -378,100 +365,3 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool {
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11, issue 1636):
|
||||
// n8n joins the media library's group, whose number is the machine's, never the definition's (ADR 0112 §1). Each
|
||||
// element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the
|
||||
// config value holds, and the list reaches the node-engine as numbers.
|
||||
|
||||
// groupSide is one element of a container's groups: a whole placeholder or a whole number.
|
||||
var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`)
|
||||
|
||||
// groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none.
|
||||
func groupsOf(r map[string]any) []any {
|
||||
groups, _ := r["groups"].([]any)
|
||||
return groups
|
||||
}
|
||||
|
||||
// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole
|
||||
// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere.
|
||||
func GroupSettingProblems(m Manifest) []string {
|
||||
var problems []string
|
||||
for _, r := range m.Resources {
|
||||
if fmt.Sprint(r["type"]) != "container" {
|
||||
continue
|
||||
}
|
||||
for _, g := range groupsOf(r) {
|
||||
s, isText := g.(string)
|
||||
if !isText {
|
||||
continue // a number: the declaration judges its value
|
||||
}
|
||||
if !groupSide.MatchString(s) {
|
||||
problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+
|
||||
"${config:key} or a whole number: a supplementary group is a number, and nothing else is "+
|
||||
"written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s))
|
||||
continue
|
||||
}
|
||||
if m.Templated() {
|
||||
continue
|
||||
}
|
||||
for _, key := range settingsUsed(s) {
|
||||
if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() {
|
||||
continue // the settings form judges its own keys
|
||||
}
|
||||
if _, declared := m.Settings[key]; !declared {
|
||||
problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+
|
||||
"declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as
|
||||
// it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that
|
||||
// results is numbers alone.
|
||||
func settingIntoGroups(resource map[string]any, layers []Layer, module string) error {
|
||||
groups := groupsOf(resource)
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
filled := make([]any, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
s, isText := g.(string)
|
||||
if !isText {
|
||||
if f, isNumber := g.(float64); isNumber && f == float64(int(f)) {
|
||||
g = int(f) // a number the manifest wrote, as the numbers a placeholder gives
|
||||
}
|
||||
filled = append(filled, g)
|
||||
continue
|
||||
}
|
||||
keys := settingsUsed(s)
|
||||
if len(keys) != 1 || settingRef.ReplaceAllString(s, "") != "" {
|
||||
filled = append(filled, g) // not a whole placeholder: the manifest check refuses it
|
||||
continue
|
||||
}
|
||||
key := keys[0]
|
||||
value, set := settingValue(layers, key)
|
||||
if !set {
|
||||
return &UnsetSettingError{Module: module, Setting: key,
|
||||
where: fmt.Sprintf("a container %v whose groups", resource["id"]), setToday: orNoSettings(layers),
|
||||
said: fmt.Sprintf(
|
||||
"%s has a container %v whose groups say ${setting:%s}, and nothing sets %q for it — an operator's "+
|
||||
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
|
||||
"`settings set %s <file>` with {%q: …}%s",
|
||||
module, resource["id"], key, key, module, key, orNoSettings(layers))}
|
||||
}
|
||||
v := plainly(value)
|
||||
id, whole := wholeID(v)
|
||||
if !whole || id == 0 {
|
||||
return fmt.Errorf("%s: the setting %q is %q, which cannot be a group of the container %v: a supplementary "+
|
||||
"group is a whole number above zero, never a name or other text (novox/hq ADR 0480 clause 7, ADR 0328 §15)",
|
||||
module, key, v, resource["id"])
|
||||
}
|
||||
filled = append(filled, int(id))
|
||||
}
|
||||
resource["groups"] = filled
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0477 §4 (issue 1573): the go and nodejs modules own the toolchain packages and provide
|
||||
// them with the machine's reach, so a node without one refuses the module that needs it at resolution, naming
|
||||
// the provision, and does not find out at its first build. The manifests are the catalogue's, as they are.
|
||||
|
||||
// builderNode is a machine that can run a build module: the package manager and user namespaces it asks of
|
||||
// the machine, and the forge account (mesh-scope, provided by the forge) stood in for.
|
||||
func builderNode() Node {
|
||||
return Node{Name: "builder", Site: "house",
|
||||
Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}}
|
||||
}
|
||||
|
||||
func toolchainShelf(t *testing.T) map[string]Manifest {
|
||||
t.Helper()
|
||||
m := map[string]Manifest{
|
||||
"forge": {Module: "forge", Version: "1", Provides: []Offer{{Name: "forge-account"}, {Name: "npm-package-registry"}}},
|
||||
}
|
||||
for _, name := range []string{"go", "nodejs", "mesh-build-go", "mesh-build-node", "dev-tools"} {
|
||||
m[name] = catalogueManifest(t, name)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestAModuleNeedingAToolchainIsRefusedOnANodeWithoutIt(t *testing.T) {
|
||||
for _, c := range []struct{ module, provision string }{
|
||||
{"mesh-build-go", "go-toolchain"},
|
||||
{"mesh-build-node", "node-toolchain"},
|
||||
{"dev-tools", "go-toolchain"},
|
||||
} {
|
||||
_, err := Resolve(toolchainShelf(t), []string{"forge", c.module}, builderNode(), World{})
|
||||
if err == nil {
|
||||
t.Fatalf("%s composed on a node with no toolchain module", c.module)
|
||||
}
|
||||
for _, want := range []string{`"` + c.provision + `" is wanted by ` + c.module,
|
||||
"usable only on the machine that provides it"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: the refusal does not say %q:\n%v", c.module, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNeedingAToolchainComposesOnANodeWithIt(t *testing.T) {
|
||||
for _, c := range []struct{ module, owner string }{
|
||||
{"mesh-build-go", "go"},
|
||||
{"mesh-build-node", "nodejs"},
|
||||
{"dev-tools", "go"},
|
||||
} {
|
||||
assigned := []string{"forge", c.owner, c.module}
|
||||
if c.module == "dev-tools" {
|
||||
assigned = append(assigned, "nodejs")
|
||||
}
|
||||
got, err := Resolve(toolchainShelf(t), assigned, builderNode(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("%s with %s did not compose: %v", c.module, c.owner, err)
|
||||
}
|
||||
in := map[string]bool{}
|
||||
for _, n := range names(got) {
|
||||
in[n] = true
|
||||
}
|
||||
if !in[c.module] || !in[c.owner] {
|
||||
t.Errorf("%s with %s composed %v", c.module, c.owner, names(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the owners declare the packages: a build module that declared go again would compose without the
|
||||
// owner's provision being met by anything, which is the gap the provision closes.
|
||||
func TestOnlyTheToolchainModulesDeclareTheToolchainPackages(t *testing.T) {
|
||||
owner := map[string]string{"go": "go", "nodejs": "nodejs", "npm": "nodejs"}
|
||||
for name, m := range toolchainShelf(t) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "package" {
|
||||
continue
|
||||
}
|
||||
pkg, _ := r["package"].(string)
|
||||
if o, ok := owner[pkg]; ok && o != name {
|
||||
t.Errorf("%s declares the package %s, which only %s may", name, pkg, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ type filler struct {
|
||||
// seatInto, machineInto and contributionsInto. A pass that comes to read another field adds it here,
|
||||
// or the sweep refuses the placeholder it would have filled.
|
||||
var fillers = []filler{
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}},
|
||||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}}},
|
||||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||||
|
||||
@@ -106,7 +106,7 @@ func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
_, err = Ask(ctx, socket, aLineTheSocketCannotHold())
|
||||
@@ -117,7 +117,7 @@ func TestAFailedWriteWaitsNoLongerThanTheDeadline(t *testing.T) {
|
||||
if !errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
t.Fatalf("the error is not the deadline: %v", err)
|
||||
}
|
||||
if waited > time.Second {
|
||||
t.Fatalf("waited %s with a deadline of 50ms", waited)
|
||||
if waited > 3*time.Second {
|
||||
t.Fatalf("waited %s with a deadline of 1s", waited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,15 +324,6 @@ type File struct {
|
||||
// depend on a field somewhere else.
|
||||
Bytes string `json:"bytes,omitempty"`
|
||||
|
||||
// Digest and Size say the content is not carried but fetched: a file over FileReferenceThreshold travels as
|
||||
// the sha256 digest of its content, "sha256:<64 hex>", and its size in bytes, in place of Content or Bytes
|
||||
// (novox/hq ADR 0486 §1). The node-engine fetches the object of that name from FileBucketFor(node), checks what it
|
||||
// received against Digest and Size, and only then writes the file. Exclusive with Content, Sealed, Bytes,
|
||||
// VaultSealed and Secrets; sent only to an engine that reports FileReferencesCapability, as an older engine
|
||||
// refuses a field it does not know, whole.
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
|
||||
// Owner is the user this belongs to, by name. Absent means root.
|
||||
Owner string `json:"owner,omitempty"`
|
||||
|
||||
@@ -474,10 +465,6 @@ func (f *File) validate(where string, _ bool) []string {
|
||||
problems = append(problems, where+": a file the vault sealed whole has no holes for secrets")
|
||||
}
|
||||
}
|
||||
problems = append(problems, f.checkReference(where)...)
|
||||
if f.Digest != "" || f.Size != 0 {
|
||||
said = append(said, "digest")
|
||||
}
|
||||
if len(said) > 1 {
|
||||
sort.Strings(said)
|
||||
problems = append(problems, where+
|
||||
@@ -1467,14 +1454,6 @@ type Container struct {
|
||||
// container runs as its image says. Its key is `identity`, the manifest's word; the field is not, because
|
||||
// a resource's Identity is its id.
|
||||
RunAs *ContainerIdentity `json:"identity,omitempty"`
|
||||
|
||||
// Groups are the supplementary groups the container's process joins, as numbers, each passed to the runtime
|
||||
// as a group to add (novox/hq ADR 0480 clause 7, issue 1636): n8n writes the media library through the
|
||||
// library's group, which a stock image knows nothing of. Each is above zero and below the kernel's "no group"
|
||||
// (ADR 0328 §15: never root). Part of the spec, so a change recreates the container. Absent: none are added.
|
||||
// An older node-engine refuses the key (strict decoding), so the controller sends it only to a node that
|
||||
// reports it reads it.
|
||||
Groups []int64 `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// The ways an image is told who to run as (novox/hq ADR 0325 §2).
|
||||
@@ -1542,30 +1521,6 @@ func (i *ContainerIdentity) problems(where string, env map[string]string) []stri
|
||||
return problems
|
||||
}
|
||||
|
||||
// groupProblems refuses a supplementary group that is root's or below it, the kernel's "no group" number, or a
|
||||
// number named twice (novox/hq ADR 0480 clause 7, ADR 0328 §15).
|
||||
func (c *Container) groupProblems(where string) []string {
|
||||
var problems []string
|
||||
seen := map[int64]bool{}
|
||||
for _, g := range c.Groups {
|
||||
switch {
|
||||
case g == 0:
|
||||
problems = append(problems, fmt.Sprintf("%s: container %s adds group 0, and the mesh never makes or keeps anything "+
|
||||
"as root: a supplementary group is never root's (novox/hq ADR 0328 §15)", where, c.Name))
|
||||
case g < 0:
|
||||
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, below zero: a group is a whole number "+
|
||||
"above zero", where, c.Name, g))
|
||||
case g >= 1<<32-1:
|
||||
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d, which is the kernel's \"no group\" "+
|
||||
"number or beyond it", where, c.Name, g))
|
||||
case seen[g]:
|
||||
problems = append(problems, fmt.Sprintf("%s: container %s adds group %d twice", where, c.Name, g))
|
||||
}
|
||||
seen[g] = true
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func (c *Container) Identity() string { return c.ID }
|
||||
func (c *Container) Kind() Type { return TypeContainer }
|
||||
func (c *Container) Target() string { return c.Name }
|
||||
@@ -1648,7 +1603,6 @@ func (c *Container) validate(where string, _ bool) []string {
|
||||
"runs and exits, and joins nothing afterwards")
|
||||
}
|
||||
problems = append(problems, c.RunAs.problems(where+": container "+c.Name, c.Env)...)
|
||||
problems = append(problems, c.groupProblems(where)...)
|
||||
return append(problems, checkImage(where, c.Image)...)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// The three names of a file travelling by digest (novox/hq ADR 0486 §1, §2, §5), shared by the node-engine that
|
||||
// reads them and the controller that writes them.
|
||||
const (
|
||||
// FileReferenceThreshold is the size above which a file travels by digest: a file of this many bytes or fewer
|
||||
// stays in the declaration as it is.
|
||||
FileReferenceThreshold = 64 * 1024
|
||||
// FileBucketPrefix starts the name of a node's object bucket, FileBucketFor(node), which holds the contents of the
|
||||
// files of that node's declaration, each under the hex of its sha256 digest (ObjectName). One bucket per node,
|
||||
// so a grant names it and a literal `*` is never needed (ADR 0486 §2, changed by ADR 0488 §1 and §2). The controller alone writes it; the
|
||||
// node's engine reads an object by name.
|
||||
FileBucketPrefix = "declaration-files-"
|
||||
// FileStreamPrefix starts the name of the stream behind such a bucket, which the bus's grants name.
|
||||
FileStreamPrefix = "OBJ_"
|
||||
// FileReferencesCapability is what a node-engine reports when it reads a file given as a digest and size.
|
||||
FileReferencesCapability = "file-references"
|
||||
)
|
||||
|
||||
var nodeToken = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||
|
||||
// FileBucketFor is the name of the object bucket of a node's files, "declaration-files-<node>" (novox/hq ADR 0488
|
||||
// §4, which replaces the single bucket constant of issue 1694). The node name must be a name the bus accepts in a
|
||||
// bucket and in a subject token: letters, digits, '_' and '-', so no '.', '*', '>' or space.
|
||||
func FileBucketFor(node string) (string, error) {
|
||||
if !nodeToken.MatchString(node) {
|
||||
return "", fmt.Errorf("node %q; a node's bucket is named by letters, digits, '_' and '-' only", node)
|
||||
}
|
||||
return FileBucketPrefix + node, nil
|
||||
}
|
||||
|
||||
// FileStreamFor is the name of the stream behind a node's bucket, "OBJ_" and the bucket's name.
|
||||
func FileStreamFor(node string) (string, error) {
|
||||
b, err := FileBucketFor(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FileStreamPrefix + b, nil
|
||||
}
|
||||
|
||||
var digestForm = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
|
||||
|
||||
// DigestOf is the digest a reference names for a content: "sha256:<64 lower-case hex>".
|
||||
func DigestOf(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ObjectName is the name of the object holding the content a digest names: the hex without its "sha256:" prefix.
|
||||
func ObjectName(digest string) (string, error) {
|
||||
if !digestForm.MatchString(digest) {
|
||||
return "", fmt.Errorf("digest %q is not sha256:<64 lower-case hex characters>", digest)
|
||||
}
|
||||
return digest[len("sha256:"):], nil
|
||||
}
|
||||
|
||||
// IsReference reports whether the file gives its content as a digest and size instead of carrying it.
|
||||
func (f *File) IsReference() bool { return f.Digest != "" }
|
||||
|
||||
// checkReference judges the digest and size, and what a reference may not be given with.
|
||||
func (f *File) checkReference(where string) []string {
|
||||
if f.Digest == "" && f.Size == 0 {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if !digestForm.MatchString(f.Digest) {
|
||||
problems = append(problems, fmt.Sprintf("%s: digest %q; a file by reference names its content as "+
|
||||
"sha256:<64 lower-case hex characters>", where, f.Digest))
|
||||
}
|
||||
if f.Size <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("%s: size %d; a file by reference states its size in bytes, "+
|
||||
"more than zero", where, f.Size))
|
||||
}
|
||||
if f.Content != "" || f.Bytes != "" || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 {
|
||||
problems = append(problems, where+": a file by reference carries its content in the object its digest "+
|
||||
"names, so it says no content, bytes, sealed, vault-sealed or secrets")
|
||||
}
|
||||
if f.Into != "" {
|
||||
problems = append(problems, where+": a file by reference is written whole; a file written into says its "+
|
||||
"part in content")
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// Body is the bytes the file's content stands for: its content, or its bytes decoded.
|
||||
func (f *File) Body() ([]byte, error) {
|
||||
if f.Bytes == "" {
|
||||
return []byte(f.Content), nil
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(f.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file %s: bytes are not base64: %w", f.ID, err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// TravelsByDigest reports whether the controller may put this file's content in the bucket: it is plain content
|
||||
// or bytes (not sealed, not filled with secrets, not written into another file) of more than FileReferenceThreshold
|
||||
// bytes.
|
||||
func (f *File) TravelsByDigest() bool {
|
||||
if f.IsReference() || f.Sealed != "" || f.VaultSealed != nil || len(f.Secrets) > 0 || f.Into != "" {
|
||||
return false
|
||||
}
|
||||
if f.Bytes != "" {
|
||||
// DecodedLen counts padding, so the exact length is taken off the decoded bytes.
|
||||
body, err := f.Body()
|
||||
return err == nil && len(body) > FileReferenceThreshold
|
||||
}
|
||||
return len(f.Content) > FileReferenceThreshold
|
||||
}
|
||||
|
||||
// AsReference is the file as it is sent to an engine that reports FileReferencesCapability: a copy with the digest
|
||||
// and size in place of the content, and the content the controller puts into the bucket under ObjectName(digest).
|
||||
// A file that does not travel by digest is refused, not passed through, so a caller cannot send a small file empty.
|
||||
func (f *File) AsReference() (File, []byte, error) {
|
||||
if !f.TravelsByDigest() {
|
||||
return File{}, nil, fmt.Errorf("file %s does not travel by digest", f.ID)
|
||||
}
|
||||
body, err := f.Body()
|
||||
if err != nil {
|
||||
return File{}, nil, err
|
||||
}
|
||||
ref := *f
|
||||
ref.Content, ref.Bytes = "", ""
|
||||
ref.Digest, ref.Size = DigestOf(body), int64(len(body))
|
||||
return ref, body, nil
|
||||
}
|
||||
|
||||
// Verify checks what was fetched against the digest and size the file names; the node-engine writes the file only
|
||||
// after it returns nil. A mismatch is the apply's failure, never a partial apply (ADR 0486 §1).
|
||||
func (f *File) Verify(body []byte) error {
|
||||
if !f.IsReference() {
|
||||
return fmt.Errorf("file %s is not given by reference", f.ID)
|
||||
}
|
||||
if int64(len(body)) != f.Size {
|
||||
return fmt.Errorf("file %s: the object is %d bytes and the declaration says %d", f.ID, len(body), f.Size)
|
||||
}
|
||||
if got := DigestOf(body); got != f.Digest {
|
||||
return fmt.Errorf("file %s: the object hashes to %s and the declaration names %s", f.ID, got, f.Digest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func refFile(extra string) string {
|
||||
return `{"declaration":1,"resources":[{"id":"m.f","type":"file","path":"/etc/m","digest":"sha256:` +
|
||||
strings.Repeat("a", 64) + `","size":70000` + extra + `}]}`
|
||||
}
|
||||
|
||||
// 1694/AC1: a reference round trips, and an invalid one is refused naming the field.
|
||||
func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
|
||||
d, err := Parse([]byte(refFile("")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := d.Resources[0].(*File)
|
||||
if !f.IsReference() || f.Size != 70000 || f.Digest != "sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("read: %+v", f)
|
||||
}
|
||||
raw, _ := json.Marshal(d.Resources[0])
|
||||
d2, err := Parse([]byte(`{"declaration":1,"resources":[` + string(raw) + `]}`))
|
||||
if err != nil || !reflect.DeepEqual(d2.Resources[0], d.Resources[0]) {
|
||||
t.Fatalf("round trip: %v %s", err, raw)
|
||||
}
|
||||
for want, raw := range map[string]string{
|
||||
"digest": strings.Replace(refFile(""), strings.Repeat("a", 64), "abc", 1),
|
||||
"size": strings.Replace(refFile(""), `"size":70000`, `"size":0`, 1),
|
||||
"lower-case": strings.Replace(refFile(""), strings.Repeat("a", 64), strings.Repeat("A", 64), 1),
|
||||
"content": refFile(`,"content":"x"`),
|
||||
"bytes": refFile(`,"bytes":"eA=="`),
|
||||
"whole": refFile(`,"into":"block"`),
|
||||
} {
|
||||
_, err := Parse([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("want a refusal naming %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
// a size alone is not a reference
|
||||
if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.f","type":"file","path":"/e","size":5}]}`)); err == nil {
|
||||
t.Error("a size without a digest was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// 1694/AC2: a declaration as today's SDK writes it reads unchanged and its bytes carry no new key.
|
||||
func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
|
||||
const in = `{"id":"m.f","type":"file","path":"/e","content":"hello"}`
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[` + in + `]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, _ := json.Marshal(d.Resources[0])
|
||||
if string(out) != in {
|
||||
t.Fatalf("signed bytes changed: %s", out)
|
||||
}
|
||||
if d.Resources[0].(*File).IsReference() {
|
||||
t.Fatal("inline read as a reference")
|
||||
}
|
||||
}
|
||||
|
||||
// 1694/AC3: the controller's helper makes a reference of a big file and the engine's check refuses a changed byte.
|
||||
func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
|
||||
big := strings.Repeat("x", FileReferenceThreshold+1)
|
||||
f := &File{ID: "m.f", Type: TypeFile, Path: "/e", Content: big}
|
||||
if !f.TravelsByDigest() {
|
||||
t.Fatal("a file over the threshold stays inline")
|
||||
}
|
||||
ref, body, err := f.AsReference()
|
||||
if err != nil || ref.Content != "" || ref.Size != int64(len(big)) || string(body) != big {
|
||||
t.Fatalf("%v %+v", err, ref)
|
||||
}
|
||||
if p := ref.validate("f", false); len(p) > 0 {
|
||||
t.Fatalf("the reference is refused: %v", p)
|
||||
}
|
||||
if name, err := ObjectName(ref.Digest); err != nil || len(name) != 64 {
|
||||
t.Fatalf("object name %q %v", name, err)
|
||||
}
|
||||
if err := ref.Verify(body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := bytes.Clone(body)
|
||||
bad[7] ^= 1
|
||||
if err := ref.Verify(bad); err == nil || !strings.Contains(err.Error(), "hashes to") {
|
||||
t.Fatalf("a changed byte: %v", err)
|
||||
}
|
||||
if err := ref.Verify(body[1:]); err == nil || !strings.Contains(err.Error(), "bytes") {
|
||||
t.Fatalf("a short object: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1694/AC4: what stays inline, and bytes files travel decoded.
|
||||
func TestWhichFilesTravelByDigest(t *testing.T) {
|
||||
small := strings.Repeat("x", FileReferenceThreshold)
|
||||
big := strings.Repeat("x", FileReferenceThreshold+1)
|
||||
for name, f := range map[string]File{
|
||||
"at the threshold": {Content: small},
|
||||
"sealed": {Sealed: "s"},
|
||||
"secrets": {Content: big, Secrets: map[string]string{"a": "b"}},
|
||||
"into": {Content: big, Into: IntoBlock},
|
||||
} {
|
||||
if f.TravelsByDigest() {
|
||||
t.Errorf("%s travels by digest", name)
|
||||
}
|
||||
if _, _, err := f.AsReference(); err == nil {
|
||||
t.Errorf("%s was made a reference", name)
|
||||
}
|
||||
}
|
||||
exact := File{ID: "m.e", Bytes: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, FileReferenceThreshold))}
|
||||
if exact.TravelsByDigest() {
|
||||
t.Error("a bytes file of exactly the threshold travels by digest")
|
||||
}
|
||||
if _, _, err := exact.AsReference(); err == nil {
|
||||
t.Error("a bytes file of exactly the threshold was made a reference")
|
||||
}
|
||||
raw := bytes.Repeat([]byte{0, 1, 2}, FileReferenceThreshold)
|
||||
f := File{ID: "m.b", Bytes: base64.StdEncoding.EncodeToString(raw)}
|
||||
ref, body, err := f.AsReference()
|
||||
if err != nil || !bytes.Equal(body, raw) || ref.Size != int64(len(raw)) || ref.Digest != DigestOf(raw) {
|
||||
t.Fatalf("bytes file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSharedNames(t *testing.T) {
|
||||
if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 {
|
||||
t.Fatal("a name changed")
|
||||
}
|
||||
if _, err := ObjectName("sha256:zz"); err == nil {
|
||||
t.Fatal("bad digest named an object")
|
||||
}
|
||||
}
|
||||
|
||||
// 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2).
|
||||
func TestEachNodeHasABucketOfItsOwn(t *testing.T) {
|
||||
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
||||
b, err := FileBucketFor(n)
|
||||
if err != nil || b != "declaration-files-"+n {
|
||||
t.Errorf("bucket for %q: %q %v", n, b, err)
|
||||
}
|
||||
s, err := FileStreamFor(n)
|
||||
if err != nil || s != "OBJ_declaration-files-"+n {
|
||||
t.Errorf("stream for %q: %q %v", n, s, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "a.b", "a*", "*", ">", "a b", "a>", "a/b", "ä"} {
|
||||
if _, err := FileBucketFor(bad); err == nil {
|
||||
t.Errorf("bucket for %q accepted", bad)
|
||||
}
|
||||
if _, err := FileStreamFor(bad); err == nil {
|
||||
t.Errorf("stream for %q accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package declaration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A container may be given supplementary groups (novox/hq ADR 0480 clause 7, issue 1636): numbers, each above zero.
|
||||
|
||||
// 1636/AC1: a declaration with groups decodes, is written back with them, and a declaration without them has none.
|
||||
func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
|
||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||
{"id":"n8n.server","type":"container","name":"n8n","image":"` + anImage + `","groups":[2000,2001]},
|
||||
{"id":"n8n.plain","type":"container","name":"plain","image":"` + anImage + `"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := d.Resources[0].(*Container)
|
||||
if len(c.Groups) != 2 || c.Groups[0] != 2000 || c.Groups[1] != 2001 {
|
||||
t.Fatalf("groups read as %v", c.Groups)
|
||||
}
|
||||
raw, err := json.Marshal(c)
|
||||
if err != nil || !strings.Contains(string(raw), `"groups":[2000,2001]`) {
|
||||
t.Fatalf("written as %s (%v)", raw, err)
|
||||
}
|
||||
plain, _ := json.Marshal(d.Resources[1].(*Container))
|
||||
if strings.Contains(string(plain), "groups") {
|
||||
t.Fatalf("a container without groups is written with the key: %s", plain)
|
||||
}
|
||||
if g := d.Resources[1].(*Container).Groups; g != nil {
|
||||
t.Fatalf("a container without groups reads %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
// 1636/AC2: zero, a number below it, one the kernel keeps for "no group" and a number twice are refused, naming why.
|
||||
func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
|
||||
for want, groups := range map[string]string{
|
||||
"never root": `[0]`,
|
||||
"below zero": `[-5]`,
|
||||
"no group": `[4294967295]`,
|
||||
"twice": `[2000,2000]`,
|
||||
} {
|
||||
_, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.c","type":"container","name":"c","image":"` +
|
||||
anImage + `","groups":` + groups + `}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("groups %s: want a refusal saying %q, got %v", groups, want, err)
|
||||
}
|
||||
}
|
||||
// Text where a number stands is refused by the decoder itself.
|
||||
if _, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.c","type":"container","name":"c","image":"` +
|
||||
anImage + `","groups":["media"]}]}`)); err == nil {
|
||||
t.Error("a group named by text was accepted")
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,6 @@ type BuildRecord struct {
|
||||
At time.Time `json:"at,omitzero"`
|
||||
SourceFingerprint string `json:"source-fingerprint,omitempty"`
|
||||
SourceInputs *SourceInputs `json:"source-inputs,omitempty"`
|
||||
// Made is what the build made (Build.Made, records.go:110); the digest is the tail of an artifact's Reference.
|
||||
Made []Artifact `json:"made,omitempty"`
|
||||
}
|
||||
|
||||
// Artifact is one thing a build made: mesh-controller internal/chain/records.go Artifact, json keys name, kind and
|
||||
// reference, nothing else (novox/hq ADR 0478 clause 8).
|
||||
type Artifact struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Reference string `json:"reference"`
|
||||
}
|
||||
|
||||
// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101,
|
||||
|
||||
@@ -127,29 +127,3 @@ func TestBuildKeys(t *testing.T) { // 1223/AC3
|
||||
t.Errorf("NewestKey = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fixture's made artifacts are the controller's Artifact (mesh-controller internal/chain/records.go, json tags name,
|
||||
// kind, reference; the controller writes no digest key, the digest is the tail of the reference). The kinds and references
|
||||
// are those of the live record mesh-controller.builds printed for build-agent build-1792540091397811913 (2026-10-21 01:49).
|
||||
func TestBuildRecordMade(t *testing.T) { // 1588/AC2
|
||||
var r BuildRecord
|
||||
if err := json.Unmarshal(loadBuildFixture(t), &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Artifact{
|
||||
{Name: "server", Kind: "image", Reference: "artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"},
|
||||
}
|
||||
if len(r.Made) != len(want) {
|
||||
t.Fatalf("made = %+v, want %+v", r.Made, want)
|
||||
}
|
||||
for i := range want {
|
||||
if r.Made[i] != want[i] {
|
||||
t.Errorf("made[%d] = %+v, want %+v", i, r.Made[i], want[i])
|
||||
}
|
||||
}
|
||||
var none BuildRecord
|
||||
if err := json.Unmarshal([]byte(`{"id":"b","repository":"a/b"}`), &none); err != nil || none.Made != nil {
|
||||
t.Errorf("a record without made: %+v, %v", none, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,20 +389,8 @@ type Module struct {
|
||||
Src uint64 `json:"src,omitempty"`
|
||||
Repository string `json:"repository"`
|
||||
Commit string `json:"commit"`
|
||||
// Path is the module's directory in its repository, trimmed of slashes, and RootPath (".") when its manifest is the
|
||||
// repository's root (novox/hq ADR 0478 clause 9, issue 1615): the build tools need it. Empty means not known: a value
|
||||
// written before the indexer learned paths has none.
|
||||
Path string `json:"path,omitempty"`
|
||||
// Toolchains are the toolchains FP2 was computed with, by language (go, typescript): the version installed and the
|
||||
// build module's recipe version the build nodes agreed on then (novox/hq ADR 0477 clause 2, ADR 0478 clause 3). A builder
|
||||
// builds the fingerprint only with these. Empty when FP2 is empty or the module has no toolchain; a value written
|
||||
// before has none (issue 1615).
|
||||
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
|
||||
}
|
||||
|
||||
// RootPath is Module.Path of a module whose manifest is its repository's root.
|
||||
const RootPath = "."
|
||||
|
||||
// KeptInputs2 are SourceInputs2 without the module's name (the key has it) and without toolchains (those come from
|
||||
// the build modules' toolchain keys when the fingerprint is computed): what a Module keeps of a merge.
|
||||
type KeptInputs2 struct {
|
||||
@@ -430,11 +418,6 @@ func (m Module) check() error {
|
||||
case m.FP2 != "" && m.None2 != "":
|
||||
return fmt.Errorf("delivery: a module has both fp2 and none2")
|
||||
}
|
||||
for language, t := range m.Toolchains {
|
||||
if t.Version == "" || t.Recipe == "" {
|
||||
return fmt.Errorf("delivery: the module's toolchain of %q needs its version and its recipe version", language)
|
||||
}
|
||||
}
|
||||
if m.Inputs2 != nil && m.Manifest == "" {
|
||||
return fmt.Errorf("delivery: a module keeps new-form inputs but has no manifest digest")
|
||||
}
|
||||
|
||||
@@ -374,54 +374,3 @@ func TestForgeDirs(t *testing.T) {
|
||||
t.Error("dirs without dirs-known accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePath(t *testing.T) { // 1588/AC1
|
||||
data, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Path: "modules/build-agent"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"path":"modules/build-agent"`) {
|
||||
t.Errorf("encoded %s has no path", data)
|
||||
}
|
||||
m, err := DecodeModule(data)
|
||||
if err != nil || m.Path != "modules/build-agent" {
|
||||
t.Errorf("round trip = %+v, %v", m, err)
|
||||
}
|
||||
// A value written before has no path: it decodes, checks, and encodes without the key.
|
||||
old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c"}`))
|
||||
if err != nil || old.Path != "" {
|
||||
t.Fatalf("old shape = %+v, %v", old, err)
|
||||
}
|
||||
again, _ := EncodeModule(old)
|
||||
if strings.Contains(string(again), "path") {
|
||||
t.Errorf("a module without a path encodes %s", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModuleRootPathAndToolchains(t *testing.T) { // 1615/AC1, AC2
|
||||
tc := map[string]ToolchainUsed{"go": {Version: "go1.27.1", Recipe: "1"}, "typescript": {Version: "tsc5", Recipe: "2"}}
|
||||
data, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Path: RootPath, Toolchains: tc})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"path":"."`) || !strings.Contains(string(data), `"toolchains":{"go":{"version":"go1.27.1","recipe":"1"}`) {
|
||||
t.Errorf("encoded %s", data)
|
||||
}
|
||||
m, err := DecodeModule(data)
|
||||
if err != nil || m.Path != RootPath || !reflect.DeepEqual(m.Toolchains, tc) {
|
||||
t.Errorf("round trip = %+v, %v", m, err)
|
||||
}
|
||||
// A value written before has none: it decodes and encodes without the key.
|
||||
old, err := DecodeModule([]byte(`{"v":1,"fp":"src1:x","repository":"a/b","commit":"c","path":"modules/a"}`))
|
||||
if err != nil || old.Toolchains != nil {
|
||||
t.Fatalf("old shape = %+v, %v", old, err)
|
||||
}
|
||||
if again, _ := EncodeModule(old); strings.Contains(string(again), "toolchains") {
|
||||
t.Errorf("a module without toolchains encodes %s", again)
|
||||
}
|
||||
for _, bad := range []map[string]ToolchainUsed{{"go": {Recipe: "1"}}, {"go": {Version: "v"}}} {
|
||||
if _, err := EncodeModule(Module{FP: "src1:x", Repository: "a/b", Commit: "c", Toolchains: bad}); err == nil {
|
||||
t.Errorf("toolchains %v accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"id":"build-1792395447088936520","repository":"http://novox.internal:20000/novox/mesh-controller.git","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"artifact-store://build-agent/server@sha256:a79c13f1c727ff94959e80824cc704ea58b80d80ecdd00fd447698acebb35090"},{"name":"files","kind":"archive","reference":"artifact-store://build-agent/files/blobs/sha256:bccd3f47851436d1b45a3063cc2d7e34c5c109b2d030739bfbb4b9781703f9a9"}],"at":"2026-10-19T09:38:00Z"}
|
||||
{"id":"build-1792395447088936520","repository":"http://novox.internal:20000/novox/mesh-controller.git","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc","sha256:8ac98ca534ac3f51e1f420a1dd2c15e74c75cfa0f23f3ad27eb5d7236c349a0c"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"registry.example/build-agent@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"}],"at":"2026-10-19T09:38:00Z"}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
// Package objectlink signs and streams a link to one object in the mesh's object store (novox/hq ADR 0468 §3).
|
||||
//
|
||||
// Bytes move over HTTP between a party and the store, on a signed link of at most ten minutes for one object and one
|
||||
// method, signed for the name the `s3-bucket` binding gives; a document over 100 MiB is refused. Presign makes the
|
||||
// link (Signature Version 4, query form, path style, payload unsigned, `host` the only signed header); Put and Get
|
||||
// move the bytes without holding them in memory and count and hash them as they go.
|
||||
//
|
||||
// Standard library only: the SDK requires nothing of the mesh's forge.
|
||||
package objectlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxDocument is the largest document a link moves (ADR 0468 §3): the default limit of Put and Get.
|
||||
MaxDocument int64 = 100 << 20
|
||||
// MaxExpires is the longest a link lives (ADR 0468 §3).
|
||||
MaxExpires = 10 * time.Minute
|
||||
|
||||
defaultRegion = "us-east-1"
|
||||
algorithm = "AWS4-HMAC-SHA256"
|
||||
)
|
||||
|
||||
// Request is one link to sign.
|
||||
type Request struct {
|
||||
// Method is GET or PUT.
|
||||
Method string
|
||||
// Endpoint is the store as the party reaches it, "http://host:port". Its host is signed exactly as written.
|
||||
Endpoint string
|
||||
Bucket string
|
||||
Key string
|
||||
// AccessKey and SecretKey are the credential the s3-bucket binding gives.
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
// Region defaults to us-east-1.
|
||||
Region string
|
||||
// Expires is how long the link lives: at least one second, at most ten minutes.
|
||||
Expires time.Duration
|
||||
// Now is the signing time; the clock's when zero.
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// Presign returns the signed link for one object and one method.
|
||||
func Presign(r Request) (string, error) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodPut {
|
||||
return "", fmt.Errorf("objectlink: method %q: a link is for GET or PUT (ADR 0468 §3)", r.Method)
|
||||
}
|
||||
if r.Expires < time.Second || r.Expires > MaxExpires {
|
||||
return "", fmt.Errorf("objectlink: a link lives at least 1s and at most %s, not %s (ADR 0468 §3)", MaxExpires, r.Expires)
|
||||
}
|
||||
if r.Bucket == "" || r.Key == "" || r.AccessKey == "" || r.SecretKey == "" {
|
||||
return "", errors.New("objectlink: a bucket, a key and a credential are needed to sign a link")
|
||||
}
|
||||
u, err := url.Parse(r.Endpoint)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return "", fmt.Errorf("objectlink: endpoint %q is not http(s)://host[:port]", r.Endpoint)
|
||||
}
|
||||
region := r.Region
|
||||
if region == "" {
|
||||
region = defaultRegion
|
||||
}
|
||||
now := r.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
path := "/" + encode(r.Bucket, false) + "/" + encode(r.Key, false)
|
||||
query := sign(r.Method, u.Host, path, r.AccessKey, r.SecretKey, region, now.UTC(), int64(r.Expires/time.Second))
|
||||
return u.Scheme + "://" + u.Host + path + "?" + query, nil
|
||||
}
|
||||
|
||||
// sign returns the query string of a presigned link, signature last. The path is already encoded.
|
||||
func sign(method, host, path, accessKey, secretKey, region string, now time.Time, expires int64) string {
|
||||
date := now.Format("20060102")
|
||||
stamp := now.Format("20060102T150405Z")
|
||||
scope := date + "/" + region + "/s3/aws4_request"
|
||||
params := map[string]string{
|
||||
"X-Amz-Algorithm": algorithm,
|
||||
"X-Amz-Credential": accessKey + "/" + scope,
|
||||
"X-Amz-Date": stamp,
|
||||
"X-Amz-Expires": fmt.Sprint(expires),
|
||||
"X-Amz-SignedHeaders": "host",
|
||||
}
|
||||
names := make([]string, 0, len(params))
|
||||
for k := range params {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var query []string
|
||||
for _, k := range names {
|
||||
query = append(query, encode(k, true)+"="+encode(params[k], true))
|
||||
}
|
||||
canonicalQuery := strings.Join(query, "&")
|
||||
canonical := strings.Join([]string{method, path, canonicalQuery, "host:" + host + "\n", "host", "UNSIGNED-PAYLOAD"}, "\n")
|
||||
digest := sha256.Sum256([]byte(canonical))
|
||||
toSign := strings.Join([]string{algorithm, stamp, scope, hex.EncodeToString(digest[:])}, "\n")
|
||||
key := mac([]byte("AWS4"+secretKey), date)
|
||||
key = mac(key, region)
|
||||
key = mac(key, "s3")
|
||||
key = mac(key, "aws4_request")
|
||||
return canonicalQuery + "&X-Amz-Signature=" + hex.EncodeToString(mac(key, toSign))
|
||||
}
|
||||
|
||||
func mac(key []byte, data string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// encode is AWS's URI encoding: everything but A-Z a-z 0-9 - _ . ~ as %XX, and the slash kept in a path.
|
||||
func encode(s string, slash bool) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '_', c == '.', c == '~':
|
||||
b.WriteByte(c)
|
||||
case c == '/' && !slash:
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
fmt.Fprintf(&b, "%%%02X", c)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Result is what moved: its size and the SHA-256 of its bytes, in hex.
|
||||
type Result struct {
|
||||
Size int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
// StoreError is a refusal by the store: its status and the code and message of its XML error body.
|
||||
type StoreError struct {
|
||||
Status int
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *StoreError) Error() string {
|
||||
if e.Code == "" {
|
||||
return fmt.Sprintf("objectlink: the store answered %d", e.Status)
|
||||
}
|
||||
return fmt.Sprintf("objectlink: the store answered %d %s: %s", e.Status, e.Code, e.Message)
|
||||
}
|
||||
|
||||
func storeError(resp *http.Response) error {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
var parsed struct {
|
||||
Code string `xml:"Code"`
|
||||
Message string `xml:"Message"`
|
||||
}
|
||||
_ = xml.Unmarshal(body, &parsed)
|
||||
return &StoreError{Status: resp.StatusCode, Code: parsed.Code, Message: parsed.Message}
|
||||
}
|
||||
|
||||
// tooBig is the error of a document past its limit.
|
||||
func tooBig(limit int64) error {
|
||||
return fmt.Errorf("objectlink: the document is over %d bytes (ADR 0468 §3)", limit)
|
||||
}
|
||||
|
||||
// exact counts and hashes what passes, and fails a stream that is longer than declared, or ends before it.
|
||||
type exact struct {
|
||||
r io.Reader
|
||||
size int64
|
||||
n int64
|
||||
hash interface{ Write([]byte) (int, error) }
|
||||
}
|
||||
|
||||
func (l *exact) Read(p []byte) (int, error) {
|
||||
n, err := l.r.Read(p)
|
||||
l.n += int64(n)
|
||||
if l.n > l.size {
|
||||
return 0, fmt.Errorf("objectlink: the body is longer than the declared %d bytes", l.size)
|
||||
}
|
||||
l.hash.Write(p[:n])
|
||||
if err == io.EOF && l.n < l.size {
|
||||
return n, fmt.Errorf("objectlink: the body ended at %d bytes, short of the declared %d", l.n, l.size)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Put streams body to a link made for PUT, never holding it in memory. The caller gives the body's exact size, which
|
||||
// is always sent as the Content-Length (a store answers 411 to a PUT without one). A negative size is refused, and so
|
||||
// is one over limit (MaxDocument when limit is not above zero, ADR 0468 §3), both before any request. A body that
|
||||
// yields more or fewer bytes than size fails the request, so a PUT whose body does not end as declared stores nothing.
|
||||
func Put(ctx context.Context, client *http.Client, link string, body io.Reader, size, limit int64) (Result, error) {
|
||||
if limit <= 0 {
|
||||
limit = MaxDocument
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
if size < 0 {
|
||||
return Result{}, errors.New("objectlink: put needs the size of the body; a negative or unknown size is refused")
|
||||
}
|
||||
if size > limit {
|
||||
return Result{}, tooBig(limit)
|
||||
}
|
||||
hash := sha256.New()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
counted := &exact{r: body, size: size, hash: hash}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, link, io.NopCloser(counted))
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
req.ContentLength = size
|
||||
if size == 0 {
|
||||
req.Body = http.NoBody
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return Result{}, fmt.Errorf("objectlink: put: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return Result{}, storeError(resp)
|
||||
}
|
||||
if counted.n != size {
|
||||
return Result{}, fmt.Errorf("objectlink: put: sent %d bytes of the declared %d", counted.n, size)
|
||||
}
|
||||
return Result{Size: counted.n, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
// Get streams the object of a link made for GET into w, refusing more than limit bytes (MaxDocument when limit is
|
||||
// not above zero). When wantSHA256 is given and the bytes hash to another value the call fails: the caller removes
|
||||
// what it wrote.
|
||||
func Get(ctx context.Context, client *http.Client, link string, w io.Writer, limit int64, wantSHA256 string) (Result, error) {
|
||||
if limit <= 0 {
|
||||
limit = MaxDocument
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, link, nil)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("objectlink: get: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return Result{}, storeError(resp)
|
||||
}
|
||||
if resp.ContentLength > limit {
|
||||
return Result{}, tooBig(limit)
|
||||
}
|
||||
hash := sha256.New()
|
||||
n, err := io.Copy(io.MultiWriter(w, hash), io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("objectlink: get: %w", err)
|
||||
}
|
||||
if n > limit {
|
||||
return Result{}, tooBig(limit)
|
||||
}
|
||||
got := hex.EncodeToString(hash.Sum(nil))
|
||||
if wantSHA256 != "" && !strings.EqualFold(wantSHA256, got) {
|
||||
return Result{}, fmt.Errorf("objectlink: the document hashes to %s, not the %s expected", got, strings.ToLower(wantSHA256))
|
||||
}
|
||||
return Result{Size: n, SHA256: got}, nil
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package objectlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1592: one helper signs and streams a store link (novox/hq ADR 0468 §3).
|
||||
|
||||
type roundTrip func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func hexSum(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
|
||||
|
||||
// 1592/AC1: AWS's documented query-presign example (a GET of test.txt, 86400 s, 2013-05-24) gives the documented signature.
|
||||
func TestPresignMatchesAWSDocumentedExample(t *testing.T) {
|
||||
now := time.Date(2013, 5, 24, 0, 0, 0, 0, time.UTC)
|
||||
query := sign("GET", "examplebucket.s3.amazonaws.com", "/test.txt", "AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "us-east-1", now, 86400)
|
||||
const want = "aeeed9bbccd4d02ee5c0109b86d86835f995330da4c265957d157751f604d404"
|
||||
if !strings.HasSuffix(query, "&X-Amz-Signature="+want) {
|
||||
t.Fatalf("the signature differs from AWS's documented one: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-east-1%2Fs3%2Faws4_request") {
|
||||
t.Errorf("the credential is not encoded as documented: %s", query)
|
||||
}
|
||||
}
|
||||
|
||||
// 1592/AC2: an Expires of 0, below 0 or above 600 s is refused; the signed host is the endpoint's host.
|
||||
func TestPresignRefusesBadExpiresAndKeepsTheHost(t *testing.T) {
|
||||
r := Request{Method: "PUT", Endpoint: "http://minio.internal:9000", Bucket: "files", Key: "a b/c.pdf",
|
||||
AccessKey: "ak", SecretKey: "sk", Expires: 10 * time.Minute, Now: time.Unix(1700000000, 0)}
|
||||
link, err := Presign(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u, err := url.Parse(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Host != "minio.internal:9000" || u.Path != "/files/a b/c.pdf" || u.Query().Get("X-Amz-Expires") != "600" ||
|
||||
u.Query().Get("X-Amz-SignedHeaders") != "host" {
|
||||
t.Errorf("the link is %s", link)
|
||||
}
|
||||
for _, e := range []time.Duration{0, -time.Second, 500 * time.Millisecond, 601 * time.Second, time.Hour} {
|
||||
r.Expires = e
|
||||
if _, err := Presign(r); err == nil {
|
||||
t.Errorf("Expires %s was accepted", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1592/AC3: Put streams and answers size and SHA-256 as the server saw them, always with the declared Content-Length;
|
||||
// an unknown size, one past the limit, or a body not ending as declared is refused; a 403 with an S3 error names its code.
|
||||
func TestPutStreamsCountsAndRefuses(t *testing.T) {
|
||||
var gotBody []byte
|
||||
var lengths []int64
|
||||
store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotBody, _ = io.ReadAll(r.Body)
|
||||
lengths = append(lengths, r.ContentLength)
|
||||
if r.URL.Query().Get("deny") != "" {
|
||||
w.WriteHeader(403)
|
||||
io.WriteString(w, `<?xml version="1.0"?><Error><Code>SignatureDoesNotMatch</Code><Message>nope</Message></Error>`)
|
||||
}
|
||||
}))
|
||||
defer store.Close()
|
||||
data := bytes.Repeat([]byte("0123456789"), 100)
|
||||
|
||||
// A reader with no Len or Seek: still sent with the declared length.
|
||||
got, err := Put(context.Background(), store.Client(), store.URL+"/b/k", io.MultiReader(bytes.NewReader(data)), 1000, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Size != int64(len(gotBody)) || got.SHA256 != hexSum(gotBody) || got.Size != 1000 {
|
||||
t.Errorf("answered %+v, the server received %d bytes %s", got, len(gotBody), hexSum(gotBody))
|
||||
}
|
||||
if _, err := Put(context.Background(), store.Client(), store.URL+"/b/k", bytes.NewReader(data), 1000, 1000); err != nil {
|
||||
t.Errorf("exactly the limit: %v", err)
|
||||
}
|
||||
for _, l := range lengths {
|
||||
if l != 1000 {
|
||||
t.Errorf("a request carried Content-Length %d, not the declared 1000", l)
|
||||
}
|
||||
}
|
||||
if len(lengths) != 2 {
|
||||
t.Fatalf("the server saw %d requests", len(lengths))
|
||||
}
|
||||
|
||||
// Refused before any request: unknown (negative) size, and a size past the limit.
|
||||
if _, err := Put(context.Background(), store.Client(), store.URL+"/b/k", bytes.NewReader(data), -1, 0); err == nil || !strings.Contains(err.Error(), "size") {
|
||||
t.Errorf("an unknown size: %v", err)
|
||||
}
|
||||
if _, err := Put(context.Background(), store.Client(), store.URL+"/b/k", bytes.NewReader(data), 1000, 999); err == nil || !strings.Contains(err.Error(), "ADR 0468 §3") {
|
||||
t.Errorf("a size past the limit: %v", err)
|
||||
}
|
||||
if len(lengths) != 2 {
|
||||
t.Errorf("a refused call made a request: the server saw %d", len(lengths))
|
||||
}
|
||||
|
||||
// A body longer or shorter than declared fails, and the store is not told it succeeded.
|
||||
reads := &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
_, err := io.Copy(io.Discard, r.Body)
|
||||
if err == nil {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(""))}, nil
|
||||
}
|
||||
return nil, err
|
||||
})}
|
||||
for name, declared := range map[string]int64{"longer than declared": 999, "shorter than declared": 1001} {
|
||||
if _, err := Put(context.Background(), reads, store.URL+"/b/k", bytes.NewReader(data), declared, 0); err == nil {
|
||||
t.Errorf("a body %s was accepted", name)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = Put(context.Background(), store.Client(), store.URL+"/b/k?deny=1", bytes.NewReader(data), 1000, 0)
|
||||
e, ok := err.(*StoreError)
|
||||
if !ok || e.Status != 403 || e.Code != "SignatureDoesNotMatch" || !strings.Contains(e.Error(), "SignatureDoesNotMatch") {
|
||||
t.Fatalf("the refusal is %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1592/AC4: Get writes the body and answers size and SHA-256; a wrong digest and a document past the limit are errors.
|
||||
func TestGetStreamsChecksAndRefuses(t *testing.T) {
|
||||
data := bytes.Repeat([]byte("abcdefghij"), 100)
|
||||
store := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write(data)
|
||||
}))
|
||||
defer store.Close()
|
||||
var out bytes.Buffer
|
||||
got, err := Get(context.Background(), store.Client(), store.URL+"/b/k", &out, 0, hexSum(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Size != 1000 || got.SHA256 != hexSum(data) || !bytes.Equal(out.Bytes(), data) {
|
||||
t.Errorf("answered %+v, wrote %d bytes", got, out.Len())
|
||||
}
|
||||
if _, err := Get(context.Background(), store.Client(), store.URL+"/b/k", io.Discard, 0, hexSum([]byte("other"))); err == nil {
|
||||
t.Error("a wrong digest was accepted")
|
||||
}
|
||||
if _, err := Get(context.Background(), store.Client(), store.URL+"/b/k", io.Discard, 999, ""); err == nil {
|
||||
t.Error("a document past the limit was accepted")
|
||||
}
|
||||
chunked := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.(http.Flusher).Flush()
|
||||
w.Write(data)
|
||||
}))
|
||||
defer chunked.Close()
|
||||
if _, err := Get(context.Background(), chunked.Client(), chunked.URL+"/b/k", io.Discard, 999, ""); err == nil {
|
||||
t.Error("a chunked document past the limit was accepted")
|
||||
}
|
||||
if _, err := Get(context.Background(), chunked.Client(), chunked.URL+"/b/k", io.Discard, 1000, ""); err != nil {
|
||||
t.Errorf("exactly the limit: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,8 @@ package outward
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -166,33 +163,6 @@ func TestCheckSecretLetsACamelCaseNameThrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 1439/AC1: a name written in lowercase words and digits between `-`, `_` or `/`, every piece short, is a
|
||||
// name and not a secret: mesh-issues' owners list (the catalogue's 134 defaults plus okioki) was refused
|
||||
// whole as "a long random-looking string" for modules/asus-zephyrus-g14, i3status-rust and powerlevel10k.
|
||||
func TestCheckSecretLetsALowercaseNameWithDigitsThrough(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"mesh-catalog modules/asus-zephyrus-g14",
|
||||
"mesh-catalog modules/i3status-rust",
|
||||
"mesh-catalog modules/powerlevel10k",
|
||||
} {
|
||||
if r, ok := CheckSecret(text); !ok {
|
||||
t.Errorf("CheckSecret(%q) withheld it: %s", text, r)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var owners []string
|
||||
if err := json.Unmarshal(raw, &owners); err != nil || len(owners) != 135 {
|
||||
t.Fatalf("testdata holds %d owners (%v), want the 134 defaults plus okioki", len(owners), err)
|
||||
}
|
||||
compact, _ := json.Marshal(owners)
|
||||
if r, ok := CheckSecret("+ owners: " + string(compact)); !ok {
|
||||
t.Errorf("CheckSecret withheld the owners list: %s", r)
|
||||
}
|
||||
}
|
||||
|
||||
// 1439/AC2: what looks random is still refused: a mixed-case run, a base64url token with `-` and `_`, a
|
||||
// lowercase run of letters and digits with no separator, and a lowercase name with one long random piece.
|
||||
func TestCheckSecretStillRefusesRandomRunsBesideNames(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package outward
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReplay1439 is issue 1439, replayed (mesh-lab R1439, novox/hq ADR 0237 §5), written only with what the
|
||||
// package had before its fix: it fails on the commit before mesh-sdk #52 and passes on it.
|
||||
//
|
||||
// 1439/AC1: a name written in lowercase words and digits between `-`, `_` or `/`, every piece short, is a
|
||||
// name and not a secret: mesh-issues' owners list (the catalogue's 134 defaults plus okioki) was refused
|
||||
// whole as "a long random-looking string" for modules/asus-zephyrus-g14, i3status-rust and powerlevel10k.
|
||||
func TestReplay1439(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"mesh-catalog modules/asus-zephyrus-g14",
|
||||
"mesh-catalog modules/i3status-rust",
|
||||
"mesh-catalog modules/powerlevel10k",
|
||||
} {
|
||||
if r, ok := CheckSecret(text); !ok {
|
||||
t.Errorf("CheckSecret(%q) withheld it: %s", text, r)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile("replay1439_owners.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var owners []string
|
||||
if err := json.Unmarshal(raw, &owners); err != nil || len(owners) != 135 {
|
||||
t.Fatalf("testdata holds %d owners (%v), want the 134 defaults plus okioki", len(owners), err)
|
||||
}
|
||||
compact, _ := json.Marshal(owners)
|
||||
if r, ok := CheckSecret("+ owners: " + string(compact)); !ok {
|
||||
t.Errorf("CheckSecret withheld the owners list: %s", r)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Issue 1098: an assignment with `ended` set, or with a `state`, is not a standing assignment: the helper reads it
|
||||
// as a removed key, so the entry of its consumer is removed and the adapter hears it is gone. The fields the
|
||||
// controller adds change nothing.
|
||||
func TestAnEndedAssignmentIsReadAsARemovedKey(t *testing.T) {
|
||||
bus, m := newStandIn(), &maker{}
|
||||
v := &vault{values: map[string]string{"pair/novox/postgres-database/ace/letta": "s1"}}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
p := postgres(bus, v, m)
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); !slices.Equal(got, []string{"ace.letta.postgres-database"}) {
|
||||
t.Fatalf("entries %v", got)
|
||||
}
|
||||
for _, mark := range []map[string]any{{"ended": "2026-10-18T01:00:00Z"}, {"state": "pending"}} {
|
||||
raw := bus.get(AssignmentsBucket, "ace.letta")
|
||||
var a map[string]any
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range mark {
|
||||
a[k] = v
|
||||
}
|
||||
raw, _ = json.Marshal(a)
|
||||
_ = bus.Put(context.Background(), AssignmentsBucket, "ace.letta", raw)
|
||||
out, err := p.Pass(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := bus.keys(BucketOf("postgres")); len(got) != 0 || len(out) != 1 || out[0].Status != Removed {
|
||||
t.Fatalf("%v: entries %v, outcomes %+v", mark, got, out)
|
||||
}
|
||||
assign(t, bus, "ace", "letta", bind("postgres-database", "", "novox", "postgres"))
|
||||
if _, err := p.Pass(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandingIsFalseOnlyForAnEndedOrPendingValue(t *testing.T) {
|
||||
for raw, want := range map[string]bool{
|
||||
`{"node":"a","module":"b"}`: true,
|
||||
`{"node":"a","module":"b","ended":"","state":"","x":{"y":1}}`: true,
|
||||
`{"node":"a","module":"b","ended":null}`: true,
|
||||
`{"node":"a","module":"b","build":"b1","commit":"c","rollout":"r","since":"t","artifact":{"name":"n"}}`: true,
|
||||
`{"node":"a","module":"b","ended":"2026-10-18T01:00:00Z"}`: false,
|
||||
`{"node":"a","module":"b","state":"pending"}`: false,
|
||||
} {
|
||||
if got := Standing([]byte(raw)); got != want {
|
||||
t.Errorf("%s: %v, want %v", raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,9 +335,6 @@ func (p *Provider) pass(ctx context.Context, assignments, entries map[string][]b
|
||||
var out []Outcome
|
||||
unknown := map[string]bool{} // consumers whose assignment was skipped: their entries are not judged
|
||||
for _, key := range sortedKeys(assignments) {
|
||||
if !Standing(assignments[key]) {
|
||||
continue // ended or pending: read as a removed key (issue 1098)
|
||||
}
|
||||
var a planned
|
||||
if err := json.Unmarshal(assignments[key], &a); err != nil || a.Node+"."+a.Module != key {
|
||||
o := Outcome{Key: key, Status: Failed, Why: fmt.Sprintf("the assignment %s is not the controller's, so "+
|
||||
@@ -747,23 +744,3 @@ func (b stateBus) Delete(_ context.Context, bucket, key string) error {
|
||||
}
|
||||
return s.Delete(key)
|
||||
}
|
||||
|
||||
// Standing is whether an assignment's value is a standing assignment: false when `ended` is set (the module was
|
||||
// taken off its node, novox/hq ADR 0408 clause 4) or `state` is not empty (a pending one). Such a value is read
|
||||
// exactly as a removed key. Every other field, known or not, changes nothing (issue 1098).
|
||||
func Standing(value []byte) bool {
|
||||
var v struct {
|
||||
Ended json.RawMessage `json:"ended"`
|
||||
State json.RawMessage `json:"state"`
|
||||
}
|
||||
if json.Unmarshal(value, &v) != nil {
|
||||
return true // not decided here: the parse that follows refuses it
|
||||
}
|
||||
return !fieldSet(v.Ended) && !fieldSet(v.State)
|
||||
}
|
||||
|
||||
// fieldSet is whether a field carries something: not absent, null or empty.
|
||||
func fieldSet(f json.RawMessage) bool {
|
||||
s := strings.TrimSpace(string(f))
|
||||
return s != "" && s != "null" && s != `""`
|
||||
}
|
||||
|
||||
@@ -138,11 +138,11 @@ func text(t *testing.T, a map[string]any) (string, bool) {
|
||||
return content[0].(map[string]any)["text"].(string), isError
|
||||
}
|
||||
|
||||
// Two writers incrementing one counter two hundred times each end at four hundred: every number handed out
|
||||
// Two writers incrementing one counter a thousand times each end at two thousand: every number handed out
|
||||
// once, none lost, each writer's numbers rising — read, then written only at the revision read.
|
||||
func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) {
|
||||
r := &casRuntime{keys: map[string]StateEntry{}, writes: map[string]int{}}
|
||||
const each = 200 // enough to collide every run (about a third of writes are lost), cheap on a slow builder
|
||||
const each = 1000
|
||||
got := counting(t, r, func() (any, error) {
|
||||
var wg sync.WaitGroup
|
||||
numbers := make([][]uint64, 2)
|
||||
@@ -179,7 +179,7 @@ func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) {
|
||||
}
|
||||
return len(seen), nil
|
||||
})
|
||||
if out, isError := text(t, got); isError || out != fmt.Sprint(2*each) {
|
||||
if out, isError := text(t, got); isError || out != "2000" {
|
||||
t.Fatalf("two writers counted %s (error: %v)", out, isError)
|
||||
}
|
||||
var final uint64
|
||||
@@ -190,7 +190,7 @@ func TestTwoWritersIncrementingOneCounterLoseNoUpdate(t *testing.T) {
|
||||
if r.lost == 0 {
|
||||
t.Fatal("the two writers never collided: the test proved nothing about contention")
|
||||
}
|
||||
t.Logf("%d increments, %d writes lost to the other writer and tried again", 2*each, r.lost)
|
||||
t.Logf("2000 increments, %d writes lost to the other writer and tried again", r.lost)
|
||||
if r.writes["create"] != 1 || r.writes["update"] != 2*each-1 || r.writes["put"] != 0 {
|
||||
t.Fatalf("written by %v: one create, then updates at the revision read, never a put", r.writes)
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -7,10 +7,10 @@ The copy carries the catalogue's private details: domains, first names in module
|
||||
private addresses. That is acceptable while mesh-sdk is private; if it ever goes public, this copy is
|
||||
a second place to clean besides the catalogue itself.
|
||||
|
||||
Only what go/catalogue's tests read is captured (R4b, issue 1379). Refreshed 2026-10-21 from the mains of both
|
||||
Only what go/catalogue's tests read is captured (R4b, issue 1379). Refreshed 2026-10-20 from the mains of both
|
||||
repositories; before that it was mesh-controller's copy, which still held two modules that had left the catalogue.
|
||||
|
||||
mesh-catalog b5dbfbb784507e211372a7c9640d91f7d6dadd84 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json
|
||||
mesh-catalog bb0fe93bbdddd869ad808210352461efc506c3d3 modules/*/module.json, modules/*/files/**, modules/*/templates/**, predefined.json
|
||||
mesh-host cdcba9ca73755854d21e4e657d7e46c32b9b5265 internal/apply/placement_guard.go
|
||||
|
||||
To move them, from this repository's go/ directory, with the two repositories checked out beside mesh-sdk:
|
||||
|
||||
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"scanner": "",
|
||||
"scan-folder": "",
|
||||
"printer": "",
|
||||
"printer-certificate": "",
|
||||
"resolution": 300,
|
||||
"color-mode": "color",
|
||||
"crop": true
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"scheme": "${bound:s3-bucket:scheme}",
|
||||
"at": "${bound:s3-bucket:at}",
|
||||
"port": "${bound:s3-bucket:port}",
|
||||
"bucket": "${bound:s3-bucket:bucket}",
|
||||
"region": "${bound:s3-bucket:region}",
|
||||
"key": "${bound:s3-bucket:as}",
|
||||
"secret": "${secret:s3-bucket}"
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
{
|
||||
"module": "brother",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"s3-bucket"
|
||||
],
|
||||
"binds": {
|
||||
"s3-bucket": "${dir:state}/store-binding.json"
|
||||
},
|
||||
"secrets": {
|
||||
"s3-bucket": "${dir:state}/store.secret"
|
||||
},
|
||||
"config": {
|
||||
"scanner": {
|
||||
"type": "string",
|
||||
"description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.",
|
||||
"pattern": "^https?://.+",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"scan-folder": {
|
||||
"type": "string",
|
||||
"description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.",
|
||||
"pattern": "^/.+$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer": {
|
||||
"type": "string",
|
||||
"description": "The printer's host, such as 192.0.2.10: where its admin page (HTTPS) and its IPP service answer. The device is the scanner's own; the operator's, with no default.",
|
||||
"pattern": "^[A-Za-z0-9.:-]+$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"printer-certificate": {
|
||||
"type": "string",
|
||||
"description": "The SHA-256 fingerprint of the certificate the printer's admin page serves, as 32 upper-case hex pairs joined by colons; several, separated by commas, when the printer serves more than one (it serves an ECDSA and an RSA certificate for one name). The module trusts those certificates alone and refuses any other; the operator's, with no default.",
|
||||
"pattern": "^([0-9A-F]{2}:){31}[0-9A-F]{2}(, *([0-9A-F]{2}:){31}[0-9A-F]{2})*$",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"resolution": {
|
||||
"type": "number",
|
||||
"description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.",
|
||||
"enum": [
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
600
|
||||
],
|
||||
"trusted": false,
|
||||
"default": 300
|
||||
},
|
||||
"color-mode": {
|
||||
"type": "string",
|
||||
"description": "Whether the scan is in colour or in grays: color or gray.",
|
||||
"enum": [
|
||||
"color",
|
||||
"gray"
|
||||
],
|
||||
"trusted": false,
|
||||
"default": "color"
|
||||
},
|
||||
"crop": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen. A feeder scan is never cropped.",
|
||||
"trusted": false,
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"own-secrets": {
|
||||
"admin-password": {
|
||||
"path": "${dir:state}/admin-password.secret",
|
||||
"issued-by": "outside",
|
||||
"prompt": "the admin password of the Brother printer",
|
||||
"owner": "brother-device"
|
||||
},
|
||||
"broker-device": {
|
||||
"path": "${dir:state}/broker-device.secret",
|
||||
"owner": "brother-device"
|
||||
}
|
||||
},
|
||||
"streams": [
|
||||
{
|
||||
"name": "device-asks",
|
||||
"events": [
|
||||
"device.asked"
|
||||
],
|
||||
"max-age-days": 1,
|
||||
"max-mib": 8,
|
||||
"retention": "work-queue",
|
||||
"max-deliveries": 3,
|
||||
"duplicates": 24
|
||||
}
|
||||
],
|
||||
"state": [
|
||||
{
|
||||
"name": "device-answers",
|
||||
"ttl-seconds": 600
|
||||
}
|
||||
],
|
||||
"seats": [
|
||||
{
|
||||
"name": "scanner",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"scan",
|
||||
"status",
|
||||
"list",
|
||||
"document"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "printer",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"status",
|
||||
"settings",
|
||||
"print",
|
||||
"upload"
|
||||
]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "scanner",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"scan",
|
||||
"status",
|
||||
"list",
|
||||
"document"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "printer",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"status",
|
||||
"settings",
|
||||
"print",
|
||||
"upload"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
],
|
||||
"tools": [
|
||||
"scanner.scan",
|
||||
"scanner.status",
|
||||
"scanner.list",
|
||||
"scanner.document",
|
||||
"printer.status",
|
||||
"printer.settings",
|
||||
"printer.print",
|
||||
"printer.upload",
|
||||
"brother_health"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "brother-device",
|
||||
"shell": "/usr/bin/nologin"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0711",
|
||||
"owner": "${machine:account}",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"takes-config": [
|
||||
"scanner",
|
||||
"scan-folder",
|
||||
"printer",
|
||||
"printer-certificate",
|
||||
"resolution",
|
||||
"color-mode",
|
||||
"crop"
|
||||
],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json",
|
||||
"trusted": true
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/store.json",
|
||||
"owner": "brother-device",
|
||||
"mode": "0600",
|
||||
"template": "files/store.json"
|
||||
},
|
||||
{
|
||||
"id": "device",
|
||||
"type": "process",
|
||||
"name": "brother-device",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./brother",
|
||||
"device"
|
||||
],
|
||||
"user": "brother-device",
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json",
|
||||
"BROTHER_ADMIN_PASSWORD": "${dir:state}/admin-password.secret",
|
||||
"BROTHER_BROKER": "${dir:state}/broker-device.secret",
|
||||
"BROTHER_STORE": "${dir:state}/store.json"
|
||||
},
|
||||
"restart-on": [
|
||||
"settings",
|
||||
"store"
|
||||
],
|
||||
"bus": {
|
||||
"queues": [
|
||||
"device-asks"
|
||||
],
|
||||
"state": [
|
||||
"device-answers"
|
||||
],
|
||||
"emits": [
|
||||
"scan.added"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "watch",
|
||||
"type": "process",
|
||||
"name": "brother-watch",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./brother",
|
||||
"watch"
|
||||
],
|
||||
"user": "brother-device",
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "brother_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/brother",
|
||||
"binary": "brother",
|
||||
"loads": [
|
||||
"brother"
|
||||
],
|
||||
"env": {
|
||||
"BROTHER_SETTINGS": "${dir:state}/settings.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Vendored
Executable → Regular
@@ -4,11 +4,12 @@
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"go-toolchain",
|
||||
"node-toolchain"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "c-compiler",
|
||||
"type": "package",
|
||||
@@ -19,6 +20,16 @@
|
||||
"type": "package",
|
||||
"package": "make"
|
||||
},
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"type": "package",
|
||||
|
||||
@@ -540,8 +540,6 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"ssh-client.keys",
|
||||
"mesh-build-go.keys",
|
||||
"mesh-build-node.keys"
|
||||
"ssh-client.keys"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"module": "go",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "go-toolchain",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
Executable → Regular
Vendored
Executable → Regular
Vendored
Executable → Regular
@@ -6,9 +6,7 @@
|
||||
"virtualisation"
|
||||
],
|
||||
"requires": [
|
||||
"npm-package-registry",
|
||||
"go-toolchain",
|
||||
"node-toolchain"
|
||||
"npm-package-registry"
|
||||
],
|
||||
"contributes": {
|
||||
"npm-package-registry": {
|
||||
@@ -86,6 +84,16 @@
|
||||
"type": "package",
|
||||
"package": "iproute2"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
},
|
||||
{
|
||||
"id": "go",
|
||||
"type": "package",
|
||||
"package": "go"
|
||||
},
|
||||
{
|
||||
"id": "incus",
|
||||
"type": "package",
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
Host ${bound:forge-account:ssh_alias}
|
||||
HostName ${bound:forge-account:ssh_host}
|
||||
Port ${bound:forge-account:ssh_port}
|
||||
HostKeyAlias ${bound:forge-account:ssh_alias}
|
||||
User git
|
||||
IdentityFile ~/.ssh/mesh_ed25519
|
||||
IdentitiesOnly yes
|
||||
@@ -1,2 +0,0 @@
|
||||
[url "ssh://${bound:forge-account:ssh_alias}/"]
|
||||
insteadOf = ${bound:forge-account:https_base}
|
||||
@@ -1 +0,0 @@
|
||||
${bound:forge-account:ssh_alias} ${bound:forge-account:host_key}
|
||||
@@ -1,148 +0,0 @@
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"runs-as": "mesh-build-go",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"user-namespaces"
|
||||
],
|
||||
"state": [
|
||||
"toolchains",
|
||||
"keys",
|
||||
"steps"
|
||||
],
|
||||
"tools": [
|
||||
"build_go_fetch",
|
||||
"build_go_test",
|
||||
"build_go_digest",
|
||||
"build_go_build",
|
||||
"build_go_step",
|
||||
"build_go_steps",
|
||||
"build_go_toolchains",
|
||||
"build_go_status",
|
||||
"build_go_health"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "toolchains-go",
|
||||
"events": ["toolchain.**"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"forge-account",
|
||||
"go-toolchain"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-build-go",
|
||||
"runtime-health": {
|
||||
"kind": "tool",
|
||||
"tool": "build_go_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-build-go",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-build-go"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-go",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-build-go-work",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-go",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "cache",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-build-go-cache",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-go",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "ssh-dir",
|
||||
"type": "directory",
|
||||
"path": "${dir:state}/.ssh",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-go",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "ssh-config",
|
||||
"type": "file",
|
||||
"path": "${dir:ssh-dir}/config",
|
||||
"owner": "mesh-build-go",
|
||||
"mode": "0600",
|
||||
"template": "files/forge"
|
||||
},
|
||||
{
|
||||
"id": "known-hosts",
|
||||
"type": "file",
|
||||
"path": "${dir:ssh-dir}/known_hosts",
|
||||
"owner": "mesh-build-go",
|
||||
"mode": "0644",
|
||||
"template": "files/known_hosts"
|
||||
},
|
||||
{
|
||||
"id": "git-config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/.config/git/config",
|
||||
"owner": "mesh-build-go",
|
||||
"mode": "0644",
|
||||
"template": "files/gitconfig"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
{
|
||||
"id": "openssh",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
},
|
||||
{
|
||||
"id": "postgresql",
|
||||
"type": "package",
|
||||
"package": "postgresql"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-build-go",
|
||||
"binary": "mesh-build-go",
|
||||
"loads": [
|
||||
"mesh-build-go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
Host ${bound:forge-account:ssh_alias}
|
||||
HostName ${bound:forge-account:ssh_host}
|
||||
Port ${bound:forge-account:ssh_port}
|
||||
HostKeyAlias ${bound:forge-account:ssh_alias}
|
||||
User git
|
||||
IdentityFile ~/.ssh/mesh_ed25519
|
||||
IdentitiesOnly yes
|
||||
@@ -1,2 +0,0 @@
|
||||
[url "ssh://${bound:forge-account:ssh_alias}/"]
|
||||
insteadOf = ${bound:forge-account:https_base}
|
||||
@@ -1 +0,0 @@
|
||||
${bound:forge-account:ssh_alias} ${bound:forge-account:host_key}
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"runs-as": "mesh-build-node",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"user-namespaces"
|
||||
],
|
||||
"state": [
|
||||
"toolchains",
|
||||
"keys",
|
||||
"steps"
|
||||
],
|
||||
"tools": [
|
||||
"build_node_fetch",
|
||||
"build_node_test",
|
||||
"build_node_build",
|
||||
"build_node_step",
|
||||
"build_node_steps",
|
||||
"build_node_toolchains",
|
||||
"build_node_status",
|
||||
"build_node_health"
|
||||
],
|
||||
"streams": [
|
||||
{
|
||||
"name": "toolchains-node",
|
||||
"events": [
|
||||
"toolchain.**"
|
||||
]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"forge-account",
|
||||
"npm-package-registry",
|
||||
"node-toolchain"
|
||||
],
|
||||
"contributes": {
|
||||
"npm-package-registry": {
|
||||
"access": "read"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.json"
|
||||
},
|
||||
"secrets": {
|
||||
"npm-package-registry": "${dir:state}/package-registry.secret"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-build-node",
|
||||
"runtime-health": {
|
||||
"kind": "tool",
|
||||
"tool": "build_node_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-build-node",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-build-node"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-node",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "work",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-build-node-work",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-node",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "cache",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/mesh-build-node-cache",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-node",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "ssh-dir",
|
||||
"type": "directory",
|
||||
"path": "${dir:state}/.ssh",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-build-node",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "ssh-config",
|
||||
"type": "file",
|
||||
"path": "${dir:ssh-dir}/config",
|
||||
"owner": "mesh-build-node",
|
||||
"mode": "0600",
|
||||
"template": "files/forge"
|
||||
},
|
||||
{
|
||||
"id": "known-hosts",
|
||||
"type": "file",
|
||||
"path": "${dir:ssh-dir}/known_hosts",
|
||||
"owner": "mesh-build-node",
|
||||
"mode": "0644",
|
||||
"template": "files/known_hosts"
|
||||
},
|
||||
{
|
||||
"id": "git-config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/.config/git/config",
|
||||
"owner": "mesh-build-node",
|
||||
"mode": "0644",
|
||||
"template": "files/gitconfig"
|
||||
},
|
||||
{
|
||||
"id": "toolchain-dir",
|
||||
"type": "archive",
|
||||
"path": "${dir:state}/toolchain",
|
||||
"owner": "mesh-build-node",
|
||||
"artifact": "toolchain",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"type": "package",
|
||||
"package": "git"
|
||||
},
|
||||
{
|
||||
"id": "openssh",
|
||||
"type": "package",
|
||||
"package": "openssh"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-build-node",
|
||||
"binary": "mesh-build-node",
|
||||
"loads": [
|
||||
"mesh-build-node"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
},
|
||||
{
|
||||
"name": "toolchain",
|
||||
"kind": "archive",
|
||||
"from": "toolchain"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"module": "mesh-delivery",
|
||||
"version": "1",
|
||||
"slug": "deliver",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"serves": [
|
||||
"deliveries",
|
||||
"times",
|
||||
"show",
|
||||
"groups",
|
||||
"what-if",
|
||||
"checks",
|
||||
"table",
|
||||
"stalled",
|
||||
"recheck",
|
||||
"release",
|
||||
"stop",
|
||||
"close",
|
||||
"retire-history"
|
||||
]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"gitea.pull.updated",
|
||||
"gitea.pull.merged",
|
||||
"gitea.pull.closed",
|
||||
"gitea.forge.status",
|
||||
"gitea.forge.pull-request.edited",
|
||||
"gitea.forge.pull-request.closed",
|
||||
"mesh-controller.checked",
|
||||
"mesh-controller.plan-moved"
|
||||
],
|
||||
"emits": [
|
||||
"transition",
|
||||
"group",
|
||||
"merged"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mesh-controller.delivery-plan",
|
||||
"seat:mesh-controller.delivery-check",
|
||||
"seat:mesh-controller.deliver",
|
||||
"seat:mesh-controller.delivery-backups-again",
|
||||
"seat:mesh-controller.delivery-stop",
|
||||
"seat:mesh-controller.delivery-walks",
|
||||
"seat:mesh-controller.forget-checks",
|
||||
"gitea.gitea_note_append",
|
||||
"gitea.gitea_delivery_view",
|
||||
"gitea.gitea_commit_status",
|
||||
"gitea.gitea_commit_statuses",
|
||||
"gitea.gitea_contains",
|
||||
"gitea.gitea_open_pulls",
|
||||
"gitea.gitea_merge_pull_request",
|
||||
"gitea.gitea_add_comment",
|
||||
"seat:operator-channel.notify"
|
||||
],
|
||||
"uses": [
|
||||
"operator-channel"
|
||||
],
|
||||
"state": [
|
||||
"deliveries",
|
||||
"groups",
|
||||
"asked",
|
||||
"pulls"
|
||||
],
|
||||
"tools": [
|
||||
"delivery_status"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-delivery",
|
||||
"binary": "mesh-delivery",
|
||||
"loads": [
|
||||
"mesh-delivery"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
"consumes": [
|
||||
"mesh-issues.opened",
|
||||
"mesh-issues.moved",
|
||||
"mesh-delivery.transition",
|
||||
"mesh-delivery-gate.transition"
|
||||
],
|
||||
"invokes": [
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"callers": ["mesh-mail-web"]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
{
|
||||
"module": "mesh-mail-reader",
|
||||
"version": "1",
|
||||
"runs-as": "mesh-mail-reader",
|
||||
"claims": [
|
||||
{
|
||||
"name": "mail-reading",
|
||||
"scope": "mesh",
|
||||
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"mesh-mail.mail.*.message.added"
|
||||
],
|
||||
"invokes": [
|
||||
"seat:mail-store.read",
|
||||
"seat:mail-store.accounts",
|
||||
"claude-code.claude_code_judge",
|
||||
"claude-code.claude_code_judged",
|
||||
"messenger.messenger_check",
|
||||
"seat:operator-channel.tell"
|
||||
],
|
||||
"requires": ["postgres-database"],
|
||||
"contributes": {
|
||||
"postgres-database": {
|
||||
"name": "mail-reader"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"postgres-database": "${dir:state}/database.json"
|
||||
},
|
||||
"secrets": {
|
||||
"postgres-database": "${dir:state}/database.secret"
|
||||
},
|
||||
"config": {
|
||||
"callers": {
|
||||
"type": "list",
|
||||
"description": "Who may call one of mail-reading's six verbs (entries, groups, correct, lessons, undo, status): a module's name (on every machine), <machine>.<module>, or person.<name> (novox/hq ADR 0459 §2). mesh-mail-web alone by default.",
|
||||
"trusted": true,
|
||||
"default": ["mesh-mail-web"]
|
||||
}
|
||||
},
|
||||
"tools": [
|
||||
"mail-reading.entries",
|
||||
"mail-reading.groups",
|
||||
"mail-reading.correct",
|
||||
"mail-reading.lessons",
|
||||
"mail-reading.undo",
|
||||
"mail-reading.status",
|
||||
"mail_reader_health"
|
||||
],
|
||||
"runtime-health": {
|
||||
"kind": "tool",
|
||||
"tool": "mail_reader_health",
|
||||
"interval": "60s",
|
||||
"timeout": "10s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
},
|
||||
"own-secrets": {
|
||||
"broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-mail-reader",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-mail-reader",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-mail-reader"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-mail-reader",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "callers",
|
||||
"trusted": true,
|
||||
"type": "file",
|
||||
"path": "${dir:state}/callers.json",
|
||||
"mode": "0600",
|
||||
"owner": "mesh-mail-reader",
|
||||
"merge": "json",
|
||||
"template": "files/callers.json",
|
||||
"takes-config": ["callers"]
|
||||
},
|
||||
{
|
||||
"id": "database-url",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/database.url",
|
||||
"mode": "0600",
|
||||
"owner": "mesh-mail-reader",
|
||||
"template": "files/database-url"
|
||||
},
|
||||
{
|
||||
"id": "prepare",
|
||||
"type": "process",
|
||||
"name": "mesh-mail-reader-prepare",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./mesh-mail-reader",
|
||||
"prepare"
|
||||
],
|
||||
"run-once": true,
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url"
|
||||
},
|
||||
"restart-on": [
|
||||
"database-url"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-mail-reader",
|
||||
"binary": "mesh-mail-reader",
|
||||
"loads": [
|
||||
"mesh-mail-reader"
|
||||
],
|
||||
"env": {
|
||||
"DATABASE_URL_FILE": "${dir:state}/database.url",
|
||||
"MESH_MAIL_READER_CALLERS": "${dir:state}/callers.json"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,7 @@
|
||||
"seat:mail-store.search",
|
||||
"seat:mail-store.read",
|
||||
"seat:mail-store.send",
|
||||
"seat:mail-store.send-status",
|
||||
"seat:mail-reading.entries",
|
||||
"seat:mail-reading.groups",
|
||||
"seat:mail-reading.status",
|
||||
"seat:mail-reading.correct",
|
||||
"seat:mail-reading.lessons",
|
||||
"seat:mail-reading.undo"
|
||||
"seat:mail-store.send-status"
|
||||
],
|
||||
"reads": ["mesh-mail.accounts"],
|
||||
"requires": ["route", "oidc-client"],
|
||||
|
||||
@@ -18,11 +18,6 @@
|
||||
"scope": "node",
|
||||
"needs": ["node-notifier"],
|
||||
"serves": ["status"]
|
||||
},
|
||||
{
|
||||
"name": "mail-reading",
|
||||
"scope": "mesh",
|
||||
"serves": ["entries", "groups", "correct", "lessons", "undo", "status"]
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"time-zone": "",
|
||||
"only-what-needs-me": [],
|
||||
"messages-per-hour": 100
|
||||
"only-what-needs-me": []
|
||||
}
|
||||
|
||||
@@ -132,14 +132,6 @@
|
||||
"description": "The kinds of channel set to say only what needs the operator: a walk that delivered with nothing held and nothing open is not told there (ADR 0308 §4); empty is none.",
|
||||
"trusted": true,
|
||||
"default": []
|
||||
},
|
||||
"messages-per-hour": {
|
||||
"type": "number",
|
||||
"description": "messages per channel per hour before the rest are held as one; 0 turns the pause off. The tell verb has its own cap of 10 and is not counted here.",
|
||||
"minimum": 0,
|
||||
"maximum": 1000,
|
||||
"trusted": false,
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
@@ -163,7 +155,7 @@
|
||||
"type": "file",
|
||||
"path": "${dir:state}/settings.json",
|
||||
"mode": "0600",
|
||||
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me", "messages-per-hour"],
|
||||
"takes-config": ["time-zone", "desktop-machines", "only-what-needs-me"],
|
||||
"merge": "json",
|
||||
"template": "files/settings.json",
|
||||
"owner": "messenger"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"module": "nodejs",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "node-toolchain",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "nodejs",
|
||||
"type": "package",
|
||||
"package": "nodejs"
|
||||
},
|
||||
{
|
||||
"id": "npm",
|
||||
"type": "package",
|
||||
"package": "npm"
|
||||
}
|
||||
]
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push.
|
||||
[Unit]
|
||||
Description=Weekly full upgrade (the mesh's pacman module, ADR 0477)
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
# The upgrade pacman_upgrade runs (pacman -Syu --noconfirm), as a oneshot whose result is the module's health
|
||||
# (pacman_upgrade_health). The mesh restarts no machine for an upgrade: a new kernel waits for the next restart.
|
||||
Type=oneshot
|
||||
Nice=10
|
||||
ExecStart=/usr/bin/pacman -Syu --noconfirm
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
# Generated by the mesh. Do not edit — module pacman writes this file and replaces it at every push.
|
||||
[Unit]
|
||||
Description=Weekly full upgrade, Saturday 04:00 local time on every node (the mesh's pacman module, ADR 0477)
|
||||
|
||||
[Timer]
|
||||
# The same moment on every node: no random delay. A node that was off or asleep then upgrades at its next start.
|
||||
OnCalendar=Sat *-*-* 04:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -19,7 +19,6 @@
|
||||
"pacman_files",
|
||||
"pacman_updates",
|
||||
"pacman_upgrade",
|
||||
"pacman_upgrade_health",
|
||||
"pacman_orphans",
|
||||
"pacman_remove_orphans",
|
||||
"pacman_cache",
|
||||
@@ -64,53 +63,14 @@
|
||||
"type": "service",
|
||||
"unit": "reflector.timer",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "cache-cleaning",
|
||||
"type": "service",
|
||||
"unit": "paccache.timer",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "upgrade-service",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.service",
|
||||
"mode": "0644",
|
||||
"template": "files/mesh-pacman-weekly-upgrade.service"
|
||||
},
|
||||
{
|
||||
"id": "upgrade-timer",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/mesh-pacman-weekly-upgrade.timer",
|
||||
"mode": "0644",
|
||||
"template": "files/mesh-pacman-weekly-upgrade.timer"
|
||||
},
|
||||
{
|
||||
"id": "weekly-upgrade",
|
||||
"type": "service",
|
||||
"unit": "mesh-pacman-weekly-upgrade.timer",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"upgrade-service",
|
||||
"upgrade-timer"
|
||||
],
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "pacman_upgrade_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
|
||||
Vendored
Executable → Regular
Reference in New Issue
Block a user