Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b3e2e1b9 | ||
|
|
f94abb95aa | ||
|
|
e5d89c5eed | ||
|
|
3526e9ef2a | ||
|
|
be1ffdfedb | ||
|
|
d420491ca1 | ||
|
|
232f38de99 | ||
|
|
c54648e743 | ||
|
|
719956b745 | ||
|
|
eb2978b3ce | ||
|
|
5e67b548a0 | ||
|
|
aef9957450 | ||
|
|
c4de50e91e | ||
|
|
64e83c7ee2 | ||
|
|
7eba326d3d | ||
|
|
0cc28f5b1f | ||
|
|
c974a11c97 | ||
|
|
09cf1ea6fc | ||
|
|
28ce325c2e | ||
|
|
fe83d66b62 | ||
|
|
712b21bd74 | ||
|
|
dae61959b9 | ||
|
|
4827df4d48 | ||
|
|
c10ef07ea3 | ||
|
|
3218da4ee4 | ||
|
|
635aed57fd | ||
|
|
1a3ef8101f | ||
|
|
5bb0662d19 | ||
|
|
315d7e9b8c | ||
|
|
0424e87399 | ||
|
|
f9e035fbee | ||
|
|
85b31412b9 | ||
|
|
3273f5b219 | ||
|
|
54e1ecce45 | ||
|
|
c5fc2ee859 | ||
|
|
a0465bf0b7 | ||
|
|
e802e67f9a | ||
|
|
c1d7218b32 | ||
|
|
a410447ff5 | ||
|
|
7faa4a1b20 | ||
|
|
44a97d9ad6 | ||
|
|
ce592a94d0 | ||
|
|
5bc1bf9f0a | ||
|
|
772b441be3 | ||
|
|
d15c1bc814 | ||
|
|
de3af0cc65 | ||
|
|
3249b30e8d | ||
|
|
7efb17386d | ||
|
|
8d883c373e | ||
|
|
1a149d44be | ||
|
|
e39faa0f38 | ||
|
|
293df49377 | ||
|
|
fc50486190 | ||
|
|
0b25bce239 | ||
|
|
6fb778a889 | ||
|
|
3622c129f8 | ||
|
|
f30a53b242 | ||
|
|
c515074714 | ||
|
|
e1f4ac1050 | ||
|
|
42ac1a9a0a | ||
|
|
effd0c00a6 | ||
|
|
1027803e1d | ||
|
|
3ff694e425 | ||
|
|
da653ce593 | ||
|
|
0bb3a2c392 | ||
|
|
253bd06970 | ||
|
|
6bfd14b14b | ||
|
|
231566eba8 | ||
|
|
5e440129db | ||
|
|
640e68f86e | ||
|
|
f98e159446 | ||
|
|
2f732b22d0 | ||
|
|
c60a46aa6a | ||
|
|
400f4e0020 | ||
|
|
8a84711792 | ||
|
|
c9fec158cb | ||
|
|
df5cef0a67 | ||
|
|
46baac8286 | ||
|
|
63f983897c | ||
|
|
5e02dccb98 | ||
|
|
6c99075ab9 | ||
|
|
7d05a39c0c | ||
|
|
ba4220ad7d |
@@ -9,6 +9,15 @@ does not belong.* Per-module code (a service's API client, its tool implementati
|
|||||||
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
create-a-resource adapter) lives **in the module**, never here — that coupling is exactly what
|
||||||
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
turned the old SDK into constant maintenance and made every edit rebuild every module.
|
||||||
|
|
||||||
|
**A seat's code is not here** (novox/hq ADR 0625, ADR 0627). mesh-sdk is the runtime every module uses (the bus
|
||||||
|
connection, the tools bundle's runtime, state and its helpers) and a seat-neutral part two or more seats' clients or
|
||||||
|
holders share (sending a file in parts, `go/parts`). A seat defined whole has its generated contract, and its client
|
||||||
|
where callers share logic over it, in mesh-contracts (`go/seats/<seat>`, `go/clients/<seat>client`); the seat list is
|
||||||
|
mesh-contracts' `go/seats`, which `catalogue` alone imports. The value layouts of seats not yet defined whole stay
|
||||||
|
here until their domain moves, and no new seat-specific type is added. `packages_list_test.go` holds all of it:
|
||||||
|
mesh-sdk imports no contracts module but the seat set in `catalogue`, names none of the seats with a client outside
|
||||||
|
`catalogue`, and holds none of the packages that moved (`asks`, `asker`, `publish`).
|
||||||
|
|
||||||
## What is in it
|
## What is in it
|
||||||
|
|
||||||
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
Five small areas, each a thing a module's `tools/` or `provisioner/` imports:
|
||||||
@@ -106,21 +115,20 @@ runtime's (`node-tools/internal/launch`, mesh-tools).
|
|||||||
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
values and then every change. Through the runtime that launched the bundle, which refuses what the
|
||||||
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
module did not declare. Current values, never history and never a secret (novox/hq ADR 0201).
|
||||||
|
|
||||||
### `seats` and `asks` — asking a person, answering on a channel (Go)
|
### `seats` and `channel` — publishing on a seat, the channel's benches (Go)
|
||||||
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
|
A seat may name its caller or its kind (novox/hq ADR 0259): `stdio.SeatPublish` submits an accept or says
|
||||||
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
an event under the module's own name or kind, `stdio.SeatTake` takes a held seat's work, `stdio.SeatProve`
|
||||||
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
|
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
|
||||||
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
|
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
|
||||||
compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the
|
compare-and-set. The runtime does each only as far as the module's membership lists. `go/channel` holds the
|
||||||
contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves
|
shapes of the two benches a channel holds, `channel` and `intake` (the `Message` a channel takes, the `Choice`,
|
||||||
who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on
|
`LinkAsked`, `Failed`, `Standing` and `Code` it says, with the subjects each travels on): those benches are not
|
||||||
(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the
|
defined whole, so their layouts stay here (ADR 0625 §9).
|
||||||
subjects each travels on.
|
Asking a person is the `operator-channel` seat's, defined whole in mesh-contracts: its `Ask` and `Warrant` are its
|
||||||
`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then
|
generated contract (`go/seats/operatorchannel`), and the checks an ask is held to, the warrant's (`For`, `Says`,
|
||||||
publishes it under the module's name; `Cancel`; `Where` reads its state from the router's record; `Listen` and
|
`Performs`) and an asking module's client (`Ask`, `Cancel`, `Where`, `Listen`, `Take`, each warrant handed once,
|
||||||
`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
|
marked acted on by compare-and-set) are its client, `go/clients/operatorchannelclient` (ADR 0627 §1). A lost
|
||||||
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
|
compare-and-set is `stdio.ErrChanged`, told by the runtime's error code.
|
||||||
`stdio.ErrChanged`, told by the runtime's error code.
|
|
||||||
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
|
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
|
||||||
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
|
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
|
||||||
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
|
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
// Package asker is a module's client for asking a person (novox/hq ADR 0259 §10): it publishes an ask under
|
|
||||||
// the module's own name, cancels it, reads its state from the router's record, and hands the module each
|
|
||||||
// warrant once — only one for an ask it made, for an option it offered, at that option's level, before it
|
|
||||||
// expired. The module then acts with its own grants.
|
|
||||||
//
|
|
||||||
// It keeps what it asked, and which warrants it acted on, in a state of the module's own (declared in its
|
|
||||||
// manifest, by default `asked`): the ask is written before it is published, and a warrant is marked acted on
|
|
||||||
// by compare-and-set before the module's handler runs, so a redelivery, or a second instance, acts once.
|
|
||||||
package asker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client asks as one module.
|
|
||||||
type Client struct {
|
|
||||||
// Module is the asking module's name: the one the bus grants its asks under.
|
|
||||||
Module string
|
|
||||||
// State is the module's own state the asks are kept in.
|
|
||||||
State Store
|
|
||||||
// Now is the clock; nil is time.Now.
|
|
||||||
Now func() time.Time
|
|
||||||
// Publish, Record and Subscribe reach the bus; nil is the stdio SDK's, through the module's runtime.
|
|
||||||
Publish func(subject string, body any, id string) error
|
|
||||||
Record func(key string) (*stdio.StateEntry, error)
|
|
||||||
Subscribe func(pattern string, handle func(stdio.Envelope) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store is where the asks are kept: stdio.State(name) is one.
|
|
||||||
type Store interface {
|
|
||||||
Get(key string) (*stdio.StateEntry, error)
|
|
||||||
Create(key string, value any) (uint64, error)
|
|
||||||
Update(key string, value any, revision uint64) (uint64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// New is a client for the module the runtime serves, keeping its asks in the state named.
|
|
||||||
func New(state string) *Client {
|
|
||||||
return &Client{Module: os.Getenv("MESH_MODULE"), State: stdio.State(state)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) now() time.Time {
|
|
||||||
if c.Now != nil {
|
|
||||||
return c.Now()
|
|
||||||
}
|
|
||||||
return time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kept is one ask as the module keeps it.
|
|
||||||
type Kept struct {
|
|
||||||
Ask asks.Ask `json:"ask"`
|
|
||||||
Asked time.Time `json:"asked"`
|
|
||||||
Acted bool `json:"acted,omitempty"`
|
|
||||||
Ended asks.Outcome `json:"ended,omitempty"`
|
|
||||||
Answer *asks.Warrant `json:"answer,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func key(id string) string { return "ask." + id }
|
|
||||||
|
|
||||||
// Ask checks an ask, keeps it, and publishes it under the module's own name. An ask with an id already kept
|
|
||||||
// is refused: a new question is a new id.
|
|
||||||
func (c *Client) Ask(a asks.Ask) error {
|
|
||||||
if err := a.Check(c.now()); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := c.State.Create(key(a.ID), Kept{Ask: a, Asked: c.now()}); err != nil {
|
|
||||||
if errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return fmt.Errorf("an ask %s was already made: a new question is a new id", a.ID)
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.publish(asks.AskSubject(c.Module), a, a.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel takes an ask back: the router ends it and edits every copy.
|
|
||||||
func (c *Client) Cancel(id string) error {
|
|
||||||
return c.publish(asks.CancelSubject(c.Module), map[string]string{"id": id}, "cancel."+id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) publish(subject string, body any, id string) error {
|
|
||||||
if c.Publish != nil {
|
|
||||||
return c.Publish(subject, body, id)
|
|
||||||
}
|
|
||||||
return stdio.SeatPublish(subject, body, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Where is an ask's state as the router keeps it: its state and its warrant, if any — read under the
|
|
||||||
// module's own name and no other's.
|
|
||||||
func (c *Client) Where(id string) (state string, w *asks.Warrant, err error) {
|
|
||||||
read := c.Record
|
|
||||||
if read == nil {
|
|
||||||
read = func(k string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", k) }
|
|
||||||
}
|
|
||||||
e, err := read(id)
|
|
||||||
if err != nil || e == nil {
|
|
||||||
return "", nil, err
|
|
||||||
}
|
|
||||||
var rec struct {
|
|
||||||
State string `json:"state"`
|
|
||||||
Warrant *asks.Warrant `json:"warrant"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(e.Value, &rec); err != nil {
|
|
||||||
return "", nil, fmt.Errorf("the router's record of %s cannot be read: %w", id, err)
|
|
||||||
}
|
|
||||||
return rec.State, rec.Warrant, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handled is what came of a warrant: acted on, refused (and why), or already done.
|
|
||||||
type Handled string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Acted Handled = "acted"
|
|
||||||
Refused Handled = "refused"
|
|
||||||
Done Handled = "already acted on"
|
|
||||||
Recorded Handled = "ended without a choice"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Take hands act a warrant once, after holding it to the ask it answers. act performs the option with the
|
|
||||||
// module's own grants; its error is said and the warrant stays acted on (a person's decision is not
|
|
||||||
// performed twice; the module says the failure in its own record).
|
|
||||||
func (c *Client) Take(w asks.Warrant, act func(asks.Option, asks.Warrant) error) (Handled, error) {
|
|
||||||
if w.Asker != c.Module {
|
|
||||||
return Refused, fmt.Errorf("a warrant for %s reached %s", w.Asker, c.Module)
|
|
||||||
}
|
|
||||||
e, err := c.State.Get(key(w.Ask))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if e == nil {
|
|
||||||
return Refused, fmt.Errorf("%s holds no ask %s", c.Module, w.Ask)
|
|
||||||
}
|
|
||||||
var k Kept
|
|
||||||
if err := json.Unmarshal(e.Value, &k); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if k.Acted || k.Ended != "" {
|
|
||||||
return Done, nil
|
|
||||||
}
|
|
||||||
if w.Outcome != asks.OutcomeChosen {
|
|
||||||
k.Ended = w.Outcome
|
|
||||||
k.Answer = &w
|
|
||||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil && !errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return Recorded, nil
|
|
||||||
}
|
|
||||||
o, err := w.For(c.Module, k.Ask)
|
|
||||||
if err != nil {
|
|
||||||
return Refused, err
|
|
||||||
}
|
|
||||||
// Marked before acting: of two deliveries, or two instances, only the one whose write stands acts.
|
|
||||||
k.Acted, k.Ended, k.Answer = true, asks.OutcomeChosen, &w
|
|
||||||
if _, err := c.State.Update(key(w.Ask), k, e.Revision); err != nil {
|
|
||||||
if errors.Is(err, stdio.ErrChanged) {
|
|
||||||
return Done, nil
|
|
||||||
}
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return Acted, act(o, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Listen hands every warrant for this module to Take, with act. It asks the runtime for the module's
|
|
||||||
// events matching the router's `decided` subject for this module.
|
|
||||||
func (c *Client) Listen(act func(asks.Option, asks.Warrant) error, said func(Handled, asks.Warrant, error)) error {
|
|
||||||
subscribe := c.Subscribe
|
|
||||||
if subscribe == nil {
|
|
||||||
subscribe = stdio.Subscribe
|
|
||||||
}
|
|
||||||
return subscribe(asks.Seat+".decided."+c.Module, func(e stdio.Envelope) error {
|
|
||||||
if !strings.HasPrefix(e.Key, asks.Seat+".decided.") {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var w asks.Warrant
|
|
||||||
if err := json.Unmarshal(e.Body, &w); err != nil {
|
|
||||||
if said != nil {
|
|
||||||
said(Refused, w, fmt.Errorf("a warrant could not be read: %w", err))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
h, err := c.Take(w, act)
|
|
||||||
if h == "" && err != nil {
|
|
||||||
return err // the state could not be reached: offered again
|
|
||||||
}
|
|
||||||
if said != nil {
|
|
||||||
said(h, w, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package asker
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mem struct {
|
|
||||||
vals map[string]json.RawMessage
|
|
||||||
revs map[string]uint64
|
|
||||||
n uint64
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mem) Get(k string) (*stdio.StateEntry, error) {
|
|
||||||
v, ok := m.vals[k]
|
|
||||||
if !ok {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &stdio.StateEntry{Key: k, Value: v, Revision: m.revs[k]}, nil
|
|
||||||
}
|
|
||||||
func (m *mem) Create(k string, v any) (uint64, error) {
|
|
||||||
if _, ok := m.vals[k]; ok {
|
|
||||||
return 0, stdio.ErrChanged
|
|
||||||
}
|
|
||||||
return m.put(k, v)
|
|
||||||
}
|
|
||||||
func (m *mem) Update(k string, v any, rev uint64) (uint64, error) {
|
|
||||||
if rev == 0 || m.revs[k] != rev {
|
|
||||||
return 0, stdio.ErrChanged
|
|
||||||
}
|
|
||||||
return m.put(k, v)
|
|
||||||
}
|
|
||||||
func (m *mem) put(k string, v any) (uint64, error) {
|
|
||||||
raw, _ := json.Marshal(v)
|
|
||||||
m.n++
|
|
||||||
m.vals[k], m.revs[k] = raw, m.n
|
|
||||||
return m.n, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func client() (*Client, map[string][]string) {
|
|
||||||
sent := map[string][]string{}
|
|
||||||
c := &Client{Module: "mesh-delivery", State: &mem{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}},
|
|
||||||
Now: func() time.Time { return now },
|
|
||||||
Publish: func(subject string, body any, id string) error {
|
|
||||||
sent[subject] = append(sent[subject], id)
|
|
||||||
return nil
|
|
||||||
}}
|
|
||||||
return c, sent
|
|
||||||
}
|
|
||||||
|
|
||||||
// releaseHQ is the act the ask's one option binds.
|
|
||||||
var releaseHQ = map[string]string{"verb": "release", "delivery": "hq"}
|
|
||||||
|
|
||||||
func held() asks.Ask {
|
|
||||||
binds, _ := asks.ActDigest(releaseHQ)
|
|
||||||
return asks.Ask{ID: "d-1", Headline: "Delivery of hq held", Explanation: "Needs you: release it, or stop it.",
|
|
||||||
Who: asks.Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held",
|
|
||||||
Options: []asks.Option{{ID: "release", Label: "Release", Does: "it goes on", Level: asks.Approve, Binds: binds}}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func warrant() asks.Warrant {
|
|
||||||
return asks.Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: asks.OutcomeChosen, Option: "release",
|
|
||||||
Label: "Release", Level: asks.Approve, At: now.Add(time.Minute), AskDigest: held().Digest(),
|
|
||||||
By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskIsKeptThenPublishedUnderTheModulesOwnName(t *testing.T) {
|
|
||||||
c, sent := client()
|
|
||||||
if err := c.Ask(held()); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got := sent[asks.AskSubject("mesh-delivery")]; len(got) != 1 || got[0] != "d-1" {
|
|
||||||
t.Fatalf("%v", sent)
|
|
||||||
}
|
|
||||||
if err := c.Ask(held()); err == nil || !strings.Contains(err.Error(), "already made") {
|
|
||||||
t.Errorf("the same id was asked twice: %v", err)
|
|
||||||
}
|
|
||||||
bad := held()
|
|
||||||
bad.Who = "agent"
|
|
||||||
if err := c.Ask(bad); err == nil {
|
|
||||||
t.Error("an ask the contract refuses was published")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAWarrantIsActedOnOnceAndOnlyForItsOwnAsk(t *testing.T) {
|
|
||||||
c, _ := client()
|
|
||||||
_ = c.Ask(held())
|
|
||||||
acted := 0
|
|
||||||
act := func(o asks.Option, _ asks.Warrant) error {
|
|
||||||
if err := o.Performs(releaseHQ); err != nil {
|
|
||||||
t.Errorf("the bound act: %v", err)
|
|
||||||
}
|
|
||||||
acted++
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if h, err := c.Take(warrant(), act); h != Acted || err != nil || acted != 1 {
|
|
||||||
t.Fatalf("%s %v %d", h, err, acted)
|
|
||||||
}
|
|
||||||
if h, _ := c.Take(warrant(), act); h != Done || acted != 1 {
|
|
||||||
t.Errorf("a redelivery was acted on: %s", h)
|
|
||||||
}
|
|
||||||
for why, change := range map[string]func(*asks.Warrant){
|
|
||||||
"another asker": func(w *asks.Warrant) { w.Asker = "mesh-controller" },
|
|
||||||
"an ask not made": func(w *asks.Warrant) { w.Ask = "d-9" },
|
|
||||||
"another level": func(w *asks.Warrant) { w.Level = asks.Acknowledge },
|
|
||||||
"an option not offered": func(w *asks.Warrant) { w.Option = "delete" },
|
|
||||||
"after its expiry": func(w *asks.Warrant) { w.At = now.Add(2 * time.Hour) },
|
|
||||||
"another ask's digest": func(w *asks.Warrant) { w.AskDigest = "sha256:00" },
|
|
||||||
} {
|
|
||||||
c2, _ := client()
|
|
||||||
_ = c2.Ask(held())
|
|
||||||
w := warrant()
|
|
||||||
change(&w)
|
|
||||||
if h, _ := c2.Take(w, act); h == Acted {
|
|
||||||
t.Errorf("a warrant from %s was acted on", why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if acted != 1 {
|
|
||||||
t.Errorf("acted %d times", acted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskEndedWithoutAChoiceIsRecordedAndNothingDone(t *testing.T) {
|
|
||||||
c, _ := client()
|
|
||||||
_ = c.Ask(held())
|
|
||||||
w := warrant()
|
|
||||||
w.Outcome, w.Option, w.By = asks.OutcomeExpired, "", nil
|
|
||||||
if h, err := c.Take(w, func(asks.Option, asks.Warrant) error { t.Fatal("acted"); return nil }); h != Recorded || err != nil {
|
|
||||||
t.Fatalf("%s %v", h, err)
|
|
||||||
}
|
|
||||||
if h, _ := c.Take(warrant(), func(asks.Option, asks.Warrant) error { t.Fatal("acted after it ended"); return nil }); h != Done {
|
|
||||||
t.Errorf("%s", h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-578
@@ -1,578 +0,0 @@
|
|||||||
// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the
|
|
||||||
// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No
|
|
||||||
// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant
|
|
||||||
// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a
|
|
||||||
// channel shows a Message and says what was chosen and by whom, as its service authenticated it.
|
|
||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The seats (novox/hq ADR 0259 §3).
|
|
||||||
const (
|
|
||||||
// Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by
|
|
||||||
// the asker.
|
|
||||||
Seat = "operator-channel"
|
|
||||||
// ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind.
|
|
||||||
ChannelSeat = "channel"
|
|
||||||
// IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry
|
|
||||||
// the kind.
|
|
||||||
IntakeSeat = "intake"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it
|
|
||||||
// hears the warrant, or the ask's end without one.
|
|
||||||
func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker }
|
|
||||||
func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker }
|
|
||||||
func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker }
|
|
||||||
|
|
||||||
// The work a channel takes, on ChannelSubject.
|
|
||||||
const (
|
|
||||||
Show = "show" // a message offering answers
|
|
||||||
Edit = "edit" // a message shown before, replaced
|
|
||||||
Send = "send" // a message offering nothing
|
|
||||||
)
|
|
||||||
|
|
||||||
// ChannelSubject is where the router sends a channel of a kind its work.
|
|
||||||
func ChannelSubject(verb, kind string) string {
|
|
||||||
return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a channel says, on IntakeSubject.
|
|
||||||
const (
|
|
||||||
Chosen = "choice" // a button tapped
|
|
||||||
Link = "link" // somebody asked to be linked as the operator
|
|
||||||
)
|
|
||||||
|
|
||||||
// IntakeSubject is where a channel of a kind says what arrived.
|
|
||||||
func IntakeSubject(what, kind string) string {
|
|
||||||
return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept.
|
|
||||||
const CodeProof = "code"
|
|
||||||
|
|
||||||
// ProofSubject is where a channel of a kind asks a proof.
|
|
||||||
func ProofSubject(verb, kind string) string {
|
|
||||||
return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind
|
|
||||||
}
|
|
||||||
|
|
||||||
// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level).
|
|
||||||
type Level string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Acknowledge performs only what any granted principal may already do: silencing, details. No proof.
|
|
||||||
Acknowledge Level = "acknowledge"
|
|
||||||
// Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code.
|
|
||||||
Approve Level = "approve"
|
|
||||||
// Destroy needs two proofs, one of them a code.
|
|
||||||
Destroy Level = "destroy"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less.
|
|
||||||
func (l Level) Rank() int {
|
|
||||||
switch l {
|
|
||||||
case Acknowledge:
|
|
||||||
return 0
|
|
||||||
case Approve:
|
|
||||||
return 1
|
|
||||||
case Destroy:
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
return 3
|
|
||||||
}
|
|
||||||
|
|
||||||
// Operator is the one role an ask may be answered by today.
|
|
||||||
const Operator = "operator"
|
|
||||||
|
|
||||||
// Option is one answer an ask offers: a label for the button, what it does in plain words, its level.
|
|
||||||
type Option struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Does string `json:"does"`
|
|
||||||
Level Level `json:"level"`
|
|
||||||
// Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the
|
|
||||||
// machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant,
|
|
||||||
// which names the ask's digest, names it too: a warrant then authorises that act and no other, and an
|
|
||||||
// asker whose record of the act changed after it asked finds the digests differ and does nothing.
|
|
||||||
// Required on an option above acknowledge.
|
|
||||||
Binds string `json:"binds,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each
|
|
||||||
// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and
|
|
||||||
// values alone, never over how a language happens to encode a struct.
|
|
||||||
type Act map[string]string
|
|
||||||
|
|
||||||
// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical),
|
|
||||||
// written "sha256:<hex>". The same names and values give the same digest in any language, whatever order
|
|
||||||
// they were set in; a field renamed, added or emptied gives another.
|
|
||||||
func ActDigest(act Act) (string, error) {
|
|
||||||
if len(act) == 0 {
|
|
||||||
return "", fmt.Errorf("the act cannot be digested: it names nothing")
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(act))
|
|
||||||
for k := range act {
|
|
||||||
if k == "" {
|
|
||||||
return "", fmt.Errorf("the act cannot be digested: a field has no name")
|
|
||||||
}
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("novox.act.v1\n")
|
|
||||||
for _, k := range keys {
|
|
||||||
canonical(&b, k)
|
|
||||||
canonical(&b, act[k])
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(b.String()))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read
|
|
||||||
// as another's end or start, so two different sequences of values never encode the same.
|
|
||||||
func canonical(b *strings.Builder, v string) {
|
|
||||||
b.WriteString(strconv.Itoa(len(v)))
|
|
||||||
b.WriteByte(':')
|
|
||||||
b.WriteString(v)
|
|
||||||
b.WriteByte('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each
|
|
||||||
// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker
|
|
||||||
// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the
|
|
||||||
// person was shown it, and nothing published under the same id before or after.
|
|
||||||
//
|
|
||||||
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
|
|
||||||
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
|
|
||||||
// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow
|
|
||||||
// the options only when the ask gives either: an ask without them digests as it did before they existed, so
|
|
||||||
// a router and an asker of different builds still agree on every such ask.
|
|
||||||
func (a Ask) Digest() string {
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("novox.ask.v1\n")
|
|
||||||
for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who,
|
|
||||||
a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent),
|
|
||||||
strconv.Itoa(len(a.Options))} {
|
|
||||||
canonical(&b, v)
|
|
||||||
}
|
|
||||||
for _, o := range a.Options {
|
|
||||||
for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} {
|
|
||||||
canonical(&b, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever
|
|
||||||
// it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes —
|
|
||||||
// none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks
|
|
||||||
// encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and
|
|
||||||
// one without WholeDetails as before it existed.
|
|
||||||
if a.Whole != "" || a.Details != "" || a.WholeDetails != "" {
|
|
||||||
canonical(&b, a.Whole)
|
|
||||||
canonical(&b, a.Details)
|
|
||||||
}
|
|
||||||
if a.WholeDetails != "" {
|
|
||||||
canonical(&b, a.WholeDetails)
|
|
||||||
}
|
|
||||||
sum := sha256.Sum256([]byte(b.String()))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask is a request for a person's word (novox/hq ADR 0259 §4).
|
|
||||||
type Ask struct {
|
|
||||||
// ID is the asker's own, unique to it.
|
|
||||||
ID string `json:"id"`
|
|
||||||
// Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and
|
|
||||||
// what it means. Both are held to the plain rule and the content rule by the router.
|
|
||||||
Headline string `json:"headline"`
|
|
||||||
Explanation string `json:"explanation"`
|
|
||||||
Options []Option `json:"options"`
|
|
||||||
// Who may answer: Operator.
|
|
||||||
Who string `json:"who"`
|
|
||||||
// Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person
|
|
||||||
// is shown ("the delivery stays held"). An ask that authorises never defaults.
|
|
||||||
Expires time.Time `json:"expires"`
|
|
||||||
OnExpiry string `json:"on-expiry"`
|
|
||||||
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
|
|
||||||
About string `json:"about,omitempty"`
|
|
||||||
Urgent bool `json:"urgent,omitempty"`
|
|
||||||
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
|
|
||||||
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
|
|
||||||
// person approves — a mount point, a share, a private address — must be readable where they approve it.
|
|
||||||
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
|
|
||||||
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
|
|
||||||
Whole string `json:"whole,omitempty"`
|
|
||||||
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
|
|
||||||
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
|
|
||||||
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
|
|
||||||
// issue 639).
|
|
||||||
Details string `json:"details,omitempty"`
|
|
||||||
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
|
|
||||||
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
|
|
||||||
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
|
|
||||||
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
|
|
||||||
// Details are shown everywhere.
|
|
||||||
WholeDetails string `json:"whole-details,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
|
|
||||||
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
|
|
||||||
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
|
|
||||||
// the fingerprint and how to read it whole are Details, never the explanation.
|
|
||||||
const (
|
|
||||||
SummaryLength = 300
|
|
||||||
MostPoints = 3
|
|
||||||
PointLength = 160
|
|
||||||
)
|
|
||||||
|
|
||||||
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
|
|
||||||
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
|
|
||||||
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
|
|
||||||
// does not hold to it.
|
|
||||||
func Glance(explanation string) error {
|
|
||||||
var lines []string
|
|
||||||
for _, l := range strings.Split(explanation, "\n") {
|
|
||||||
if strings.TrimSpace(l) != "" {
|
|
||||||
lines = append(lines, strings.TrimSpace(l))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var problems []string
|
|
||||||
switch {
|
|
||||||
case len(lines) == 0:
|
|
||||||
problems = append(problems, "it has no summary")
|
|
||||||
case len([]rune(lines[0])) > SummaryLength:
|
|
||||||
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
|
|
||||||
}
|
|
||||||
if len(lines) > 1+MostPoints {
|
|
||||||
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
|
|
||||||
}
|
|
||||||
for i, l := range lines {
|
|
||||||
if i > 0 && len([]rune(l)) > PointLength {
|
|
||||||
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
|
|
||||||
const (
|
|
||||||
MostOptions = 4
|
|
||||||
MostOpen = 3
|
|
||||||
ApproveLasts = 24 * time.Hour
|
|
||||||
DestroyLasts = 10 * time.Minute
|
|
||||||
HeadlineLength = 60
|
|
||||||
LabelLength = 24
|
|
||||||
)
|
|
||||||
|
|
||||||
var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
|
|
||||||
|
|
||||||
// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both.
|
|
||||||
func UsableID(id string) bool { return usableID.MatchString(id) }
|
|
||||||
|
|
||||||
// Highest is the highest level among the ask's options.
|
|
||||||
func (a Ask) Highest() Level {
|
|
||||||
high := Acknowledge
|
|
||||||
for _, o := range a.Options {
|
|
||||||
if o.Level.Rank() > high.Rank() {
|
|
||||||
high = o.Level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return high
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option is the option of this id, or false.
|
|
||||||
func (a Ask) Option(id string) (Option, bool) {
|
|
||||||
for _, o := range a.Options {
|
|
||||||
if o.ID == id {
|
|
||||||
return o, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Option{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on.
|
|
||||||
func (a Ask) Check(now time.Time) error {
|
|
||||||
var problems []string
|
|
||||||
say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
|
|
||||||
if !UsableID(a.ID) {
|
|
||||||
say("its id %q is not letters, digits, - and _, at most 64", a.ID)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength {
|
|
||||||
say("its headline is empty or longer than %d characters", HeadlineLength)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(a.Explanation) == "" {
|
|
||||||
say("it explains nothing")
|
|
||||||
}
|
|
||||||
if a.Who != Operator {
|
|
||||||
say("it is answered by %q, and only the operator answers today", a.Who)
|
|
||||||
}
|
|
||||||
if len(a.Options) == 0 || len(a.Options) > MostOptions {
|
|
||||||
say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions)
|
|
||||||
}
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, o := range a.Options {
|
|
||||||
switch {
|
|
||||||
case !UsableID(o.ID):
|
|
||||||
say("an option's id %q is not letters, digits, - and _", o.ID)
|
|
||||||
case seen[o.ID]:
|
|
||||||
say("the option %s is offered twice", o.ID)
|
|
||||||
}
|
|
||||||
seen[o.ID] = true
|
|
||||||
if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength {
|
|
||||||
say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(o.Does) == "" {
|
|
||||||
say("the option %s does not say what it does", o.ID)
|
|
||||||
}
|
|
||||||
if o.Level.Rank() > Destroy.Rank() {
|
|
||||||
say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level)
|
|
||||||
}
|
|
||||||
if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") {
|
|
||||||
say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !a.Expires.After(now) {
|
|
||||||
say("it expires before it is asked")
|
|
||||||
}
|
|
||||||
switch a.Highest() {
|
|
||||||
case Approve:
|
|
||||||
if a.Expires.After(now.Add(ApproveLasts)) {
|
|
||||||
say("an ask that approves lasts at most %s", ApproveLasts)
|
|
||||||
}
|
|
||||||
case Destroy:
|
|
||||||
if a.Expires.After(now.Add(DestroyLasts)) {
|
|
||||||
say("an ask that destroys lasts at most %s", DestroyLasts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if a.Highest() != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" {
|
|
||||||
say("it does not say what happens when nobody answers, and an ask that authorises never defaults")
|
|
||||||
}
|
|
||||||
if a.About != "" && strings.ContainsAny(a.About, " \n") {
|
|
||||||
say("what it is about is a key, without spaces")
|
|
||||||
}
|
|
||||||
if len(problems) > 0 {
|
|
||||||
return errors.New("the ask is refused: " + strings.Join(problems, "; "))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outcome is how an ask ended.
|
|
||||||
type Outcome string
|
|
||||||
|
|
||||||
const (
|
|
||||||
OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant
|
|
||||||
OutcomeExpired Outcome = "expired" // nobody answered in time
|
|
||||||
OutcomeCancelled Outcome = "cancelled" // its asker took it back
|
|
||||||
OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it
|
|
||||||
OutcomeRefused Outcome = "refused" // it was never shown: Words says why
|
|
||||||
)
|
|
||||||
|
|
||||||
// Person is who chose, as the router verified them.
|
|
||||||
type Person struct {
|
|
||||||
// Who is the role: Operator.
|
|
||||||
Who string `json:"who"`
|
|
||||||
// Kind is the channel kind they answered on, Identity their account on that service, Display the name
|
|
||||||
// the service shows, and Verified how the router knew it was them.
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Display string `json:"display,omitempty"`
|
|
||||||
Verified string `json:"verified"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one
|
|
||||||
// (novox/hq ADR 0259 §6). It carries no secret.
|
|
||||||
type Warrant struct {
|
|
||||||
Ask string `json:"ask"`
|
|
||||||
Asker string `json:"asker"`
|
|
||||||
About string `json:"about,omitempty"`
|
|
||||||
Outcome Outcome `json:"outcome"`
|
|
||||||
// Option, Label and Level are the option chosen; By who chose it, Channel the module it came through,
|
|
||||||
// Proofs which proofs were present (P1, P2, P3).
|
|
||||||
Option string `json:"option,omitempty"`
|
|
||||||
Label string `json:"label,omitempty"`
|
|
||||||
Level Level `json:"level,omitempty"`
|
|
||||||
By *Person `json:"by,omitempty"`
|
|
||||||
Channel string `json:"channel,omitempty"`
|
|
||||||
Proofs []string `json:"proofs,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
// AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask
|
|
||||||
// alone, with the options it bound.
|
|
||||||
AskDigest string `json:"ask-digest,omitempty"`
|
|
||||||
// Words are why an ask ended without a choice, or what refused it.
|
|
||||||
Words string `json:"words,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id
|
|
||||||
// verified), chose Release".
|
|
||||||
func (w Warrant) Says() string {
|
|
||||||
if w.Outcome != OutcomeChosen || w.By == nil {
|
|
||||||
return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome)
|
|
||||||
}
|
|
||||||
via := w.By.Kind
|
|
||||||
if w.By.Verified != "" {
|
|
||||||
via += " (" + w.By.Verified + ")"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the
|
|
||||||
// same words, options and binds), a choice, an option the ask offered, at that option's level, before the
|
|
||||||
// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names.
|
|
||||||
func (w Warrant) For(asker string, a Ask) (Option, error) {
|
|
||||||
if w.Asker != asker || w.Ask != a.ID {
|
|
||||||
return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID)
|
|
||||||
}
|
|
||||||
if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome)
|
|
||||||
}
|
|
||||||
if d := a.Digest(); w.AskDigest != d {
|
|
||||||
return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+
|
|
||||||
"it was not the ask the person was shown", w.AskDigest, a.ID, d)
|
|
||||||
}
|
|
||||||
o, offered := a.Option(w.Option)
|
|
||||||
if !offered {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option)
|
|
||||||
}
|
|
||||||
if w.Level != o.Level {
|
|
||||||
return Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level)
|
|
||||||
}
|
|
||||||
// A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no
|
|
||||||
// expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time (the
|
|
||||||
// confirmation review of 2026-10-09).
|
|
||||||
if a.Expires.IsZero() {
|
|
||||||
return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID)
|
|
||||||
}
|
|
||||||
if w.At.IsZero() {
|
|
||||||
return Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID)
|
|
||||||
}
|
|
||||||
if w.At.After(a.Expires) {
|
|
||||||
return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s",
|
|
||||||
w.At.UTC().Format(time.RFC3339), a.ID, a.Expires.UTC().Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
return o, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Performs checks that the act an asker is about to perform is the one the chosen option bound when it
|
|
||||||
// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes.
|
|
||||||
func (o Option) Performs(act Act) error {
|
|
||||||
if o.Binds == "" && o.Level == Acknowledge {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
d, err := ActDigest(act)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if d != o.Binds {
|
|
||||||
return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Button is one answer a channel offers: its label and the router's one-time ticket for it.
|
|
||||||
type Button struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Ticket string `json:"ticket"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said
|
|
||||||
// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps
|
|
||||||
// which of its own messages that is. The words are the router's, shown as given.
|
|
||||||
type Message struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
Buttons []Button `json:"buttons,omitempty"`
|
|
||||||
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
|
|
||||||
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
|
|
||||||
// where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's
|
|
||||||
// length cuts the body first and the note only when the title and the note alone do not fit, saying each
|
|
||||||
// cut (novox/hq ADR 0347 §5).
|
|
||||||
Note string `json:"note,omitempty"`
|
|
||||||
Urgent bool `json:"urgent,omitempty"`
|
|
||||||
Silent bool `json:"silent,omitempty"`
|
|
||||||
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
|
||||||
Reply string `json:"reply,omitempty"`
|
|
||||||
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
|
||||||
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
|
|
||||||
To string `json:"to,omitempty"`
|
|
||||||
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
|
|
||||||
// copy of it — no state, no history of its own.
|
|
||||||
Secret bool `json:"secret,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the
|
|
||||||
// service authenticated it. The router alone judges whether that is the operator.
|
|
||||||
type Sender struct {
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Display string `json:"display,omitempty"`
|
|
||||||
Authenticated bool `json:"authenticated"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help.
|
|
||||||
type Failed struct {
|
|
||||||
Handle string `json:"handle"`
|
|
||||||
Why string `json:"why"`
|
|
||||||
Permanent bool `json:"permanent,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standing is what a channel says of itself, at least every five minutes and whenever it changes:
|
|
||||||
// whether it can send now, why not, and whether its edits notify nobody.
|
|
||||||
type Standing struct {
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
Why string `json:"why,omitempty"`
|
|
||||||
EditsSilently bool `json:"edits-silently,omitempty"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// What a channel says of its own delivery, on IntakeSubject.
|
|
||||||
const (
|
|
||||||
FailedWhat = "failed"
|
|
||||||
StandingWhat = "standing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Choice is a button chosen on a channel.
|
|
||||||
type Choice struct {
|
|
||||||
// ID is the channel's own for this arrival, unique, so the router acts on it once.
|
|
||||||
ID string `json:"id"`
|
|
||||||
Ticket string `json:"ticket"`
|
|
||||||
Handle string `json:"handle,omitempty"`
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkAsked is somebody on a channel asking to be linked as the operator.
|
|
||||||
type LinkAsked struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept.
|
|
||||||
type Code struct {
|
|
||||||
Sender Sender `json:"sender"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Purpose string `json:"purpose"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person.
|
|
||||||
type ProofAnswer struct {
|
|
||||||
Accepted bool `json:"accepted"`
|
|
||||||
Words string `json:"words"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
|
|
||||||
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
|
|
||||||
// node's engine account alone, so no engine acts on another node's warrant.
|
|
||||||
func EngineAsker(node string) string { return "node-engine-" + node }
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var now = time.Date(2026, 10, 8, 14, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
func held() Ask {
|
|
||||||
return Ask{ID: "d-1", Headline: "openrazer delivery held", Explanation: "Needs you: release it, or stop it.",
|
|
||||||
Who: Operator, Expires: now.Add(time.Hour), OnExpiry: "the delivery stays held", About: "delivery.openrazer",
|
|
||||||
Options: []Option{
|
|
||||||
{ID: "release", Label: "Release", Does: "the delivery goes on", Level: Approve, Binds: bind("release", "openrazer")},
|
|
||||||
{ID: "stop", Label: "Stop", Does: "the delivery ends", Level: Approve, Binds: bind("stop", "openrazer")},
|
|
||||||
{ID: "silence", Label: "Silence for a week", Does: "nothing more is said for a week", Level: Acknowledge},
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
|
|
||||||
func bind(verb, what string) string {
|
|
||||||
d, err := ActDigest(Act{"verb": verb, "delivery": what})
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestASoundAskPasses(t *testing.T) {
|
|
||||||
if err := held().Check(now); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskIsRefusedInWordsForEachFault(t *testing.T) {
|
|
||||||
for want, change := range map[string]func(*Ask){
|
|
||||||
"is not letters": func(a *Ask) { a.ID = "a.b" },
|
|
||||||
"headline is empty": func(a *Ask) { a.Headline = "" },
|
|
||||||
"explains nothing": func(a *Ask) { a.Explanation = " " },
|
|
||||||
"only the operator answers": func(a *Ask) { a.Who = "agent" },
|
|
||||||
"offers 0 options": func(a *Ask) { a.Options = nil },
|
|
||||||
"offers 5 options": func(a *Ask) { a.Options = append(a.Options, a.Options[0], a.Options[1]) },
|
|
||||||
"is offered twice": func(a *Ask) { a.Options[1].ID = "release" },
|
|
||||||
"does not say what it does": func(a *Ask) { a.Options[0].Does = "" },
|
|
||||||
"none of acknowledge": func(a *Ask) { a.Options[0].Level = "admin" },
|
|
||||||
"expires before it is asked": func(a *Ask) { a.Expires = now },
|
|
||||||
"approves lasts at most": func(a *Ask) { a.Expires = now.Add(25 * time.Hour) },
|
|
||||||
"destroys lasts at most": func(a *Ask) { a.Options[0].Level = Destroy },
|
|
||||||
"never defaults": func(a *Ask) { a.OnExpiry = "" },
|
|
||||||
"is a key, without spaces": func(a *Ask) { a.About = "a b" },
|
|
||||||
"does not bind what it": func(a *Ask) { a.Options[0].Binds = "" },
|
|
||||||
} {
|
|
||||||
a := held()
|
|
||||||
change(&a)
|
|
||||||
if err := a.Check(now); err == nil || !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("want a refusal saying %q, got %v", want, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ack := held()
|
|
||||||
ack.Options = ack.Options[2:]
|
|
||||||
ack.OnExpiry = ""
|
|
||||||
ack.Expires = now.Add(7 * 24 * time.Hour)
|
|
||||||
if err := ack.Check(now); err != nil {
|
|
||||||
t.Errorf("an ask that only acknowledges needs no expiry words and may last a week: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAnAskerActsOnlyOnAWarrantForItsOwnAskAndOption(t *testing.T) {
|
|
||||||
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
||||||
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"},
|
|
||||||
AskDigest: held().Digest(), At: now.Add(time.Minute)}
|
|
||||||
if o, err := w.For("mesh-delivery", held()); err != nil || o.ID != "release" {
|
|
||||||
t.Fatalf("a sound warrant was refused: %v", err)
|
|
||||||
}
|
|
||||||
if got := w.Says(); got != "the operator, via telegram (user id verified), chose Release" {
|
|
||||||
t.Errorf("the warrant says %q", got)
|
|
||||||
}
|
|
||||||
for why, change := range map[string]func(*Warrant){
|
|
||||||
"another asker": func(w *Warrant) { w.Asker = "mesh-controller" },
|
|
||||||
"another ask": func(w *Warrant) { w.Ask = "d-2" },
|
|
||||||
"no choice": func(w *Warrant) { w.Outcome = OutcomeExpired },
|
|
||||||
"nobody": func(w *Warrant) { w.By = nil },
|
|
||||||
"not offered": func(w *Warrant) { w.Option = "delete" },
|
|
||||||
"a lower level": func(w *Warrant) { w.Level = Acknowledge },
|
|
||||||
"another role": func(w *Warrant) { w.By = &Person{Who: "agent"} },
|
|
||||||
"no digest": func(w *Warrant) { w.AskDigest = "" },
|
|
||||||
"no time given": func(w *Warrant) { w.At = time.Time{} },
|
|
||||||
"after expiry": func(w *Warrant) { w.At = now.Add(2 * time.Hour) },
|
|
||||||
"another ask's digest": func(w *Warrant) {
|
|
||||||
other := held()
|
|
||||||
other.Options[0].Binds = bind("release", "another")
|
|
||||||
w.AskDigest = other.Digest()
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
c := w
|
|
||||||
by := *w.By
|
|
||||||
c.By = &by
|
|
||||||
change(&c)
|
|
||||||
if _, err := c.For("mesh-delivery", held()); err == nil {
|
|
||||||
t.Errorf("a warrant with %s was acted on", why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTheSubjectsNameTheirPublisher(t *testing.T) {
|
|
||||||
for got, want := range map[string]string{
|
|
||||||
AskSubject("mesh-delivery"): "mesh.seat.operator-channel.accept.ask.mesh-delivery",
|
|
||||||
DecidedSubject("mesh-delivery"): "mesh.seat.operator-channel.event.decided.mesh-delivery",
|
|
||||||
ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram",
|
|
||||||
IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop",
|
|
||||||
ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram",
|
|
||||||
} {
|
|
||||||
if got != want {
|
|
||||||
t.Errorf("%s, want %s", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A warrant answers one ask as it was shown, and authorises the act its option bound and no other
|
|
||||||
// (novox/hq ADR 0259 §6: the warrant is bound to the exact ask and its arguments).
|
|
||||||
func TestAWarrantAuthorisesOnlyTheActItsOptionBound(t *testing.T) {
|
|
||||||
a := held()
|
|
||||||
// The digest survives the trip through the bus: what the router decodes and digests is what was sent.
|
|
||||||
raw, err := json.Marshal(a)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var decoded Ask
|
|
||||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decoded.Digest() != a.Digest() {
|
|
||||||
t.Fatalf("the ask's digest changed on the way: %s, %s", decoded.Digest(), a.Digest())
|
|
||||||
}
|
|
||||||
local := a
|
|
||||||
local.Expires = a.Expires.In(time.FixedZone("CEST", 2*3600))
|
|
||||||
if local.Digest() != a.Digest() {
|
|
||||||
t.Error("the same instant in another zone digests differently")
|
|
||||||
}
|
|
||||||
// Changing anything the person was shown, or what an option binds, changes the digest.
|
|
||||||
for what, change := range map[string]func(*Ask){
|
|
||||||
"the headline": func(a *Ask) { a.Headline += "!" },
|
|
||||||
"the explanation": func(a *Ask) { a.Explanation += " " },
|
|
||||||
"a label": func(a *Ask) { a.Options[0].Label = "Go" },
|
|
||||||
"what it binds": func(a *Ask) { a.Options[0].Binds = bind("release", "another") },
|
|
||||||
"the expiry": func(a *Ask) { a.Expires = a.Expires.Add(time.Second) },
|
|
||||||
"what it is about": func(a *Ask) { a.About = "delivery.other" },
|
|
||||||
} {
|
|
||||||
c := held()
|
|
||||||
change(&c)
|
|
||||||
if c.Digest() == a.Digest() {
|
|
||||||
t.Errorf("changing %s leaves the digest", what)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
o, _ := a.Option("release")
|
|
||||||
if err := o.Performs(Act{"verb": "release", "delivery": "openrazer"}); err != nil {
|
|
||||||
t.Errorf("the bound act was refused: %v", err)
|
|
||||||
}
|
|
||||||
if err := o.Performs(Act{"verb": "release", "delivery": "another"}); err == nil {
|
|
||||||
t.Error("an act the option did not bind was let through")
|
|
||||||
}
|
|
||||||
ack, _ := a.Option("silence")
|
|
||||||
if err := ack.Performs(Act{"anything": "x"}); err != nil {
|
|
||||||
t.Errorf("an acknowledgement that bound nothing: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The digests are over named fields in a canonical encoding (the review of 2026-10-09): not over Go's
|
|
||||||
// encoding of a struct, so a field added to Ask or Option, a JSON tag renamed, or a map built in another
|
|
||||||
// order changes nothing it should not — and every field that is shown or bound is in it.
|
|
||||||
func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
|
|
||||||
a1, _ := ActDigest(Act{"verb": "release", "arg.delivery": "hq"})
|
|
||||||
b := Act{}
|
|
||||||
b["arg.delivery"] = "hq"
|
|
||||||
b["verb"] = "release"
|
|
||||||
a2, _ := ActDigest(b)
|
|
||||||
if a1 != a2 {
|
|
||||||
t.Error("the order an act was built in changed its digest")
|
|
||||||
}
|
|
||||||
// A value cannot be read as two: "a"+"b=c" is not "a=b"+"c".
|
|
||||||
x, _ := ActDigest(Act{"a": "b\n1:c"})
|
|
||||||
y, _ := ActDigest(Act{"a": "b", "c": ""})
|
|
||||||
if x == y {
|
|
||||||
t.Error("two different acts encode the same")
|
|
||||||
}
|
|
||||||
if _, err := ActDigest(Act{}); err == nil {
|
|
||||||
t.Error("an empty act was digested")
|
|
||||||
}
|
|
||||||
// The encoding, by hand: any language reproduces it.
|
|
||||||
sum := sha256.Sum256([]byte("novox.act.v1\n4:verb\n7:release\n"))
|
|
||||||
if got, _ := ActDigest(Act{"verb": "release"}); got != "sha256:"+hex.EncodeToString(sum[:]) {
|
|
||||||
t.Errorf("the act's encoding is not the one written down: %s", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every exported field of Ask and Option is in the digest: changing any one changes it.
|
|
||||||
base := held()
|
|
||||||
mutations := map[string]func(*Ask){
|
|
||||||
"ID": func(a *Ask) { a.ID = "other" },
|
|
||||||
"Headline": func(a *Ask) { a.Headline += "!" },
|
|
||||||
"Explanation": func(a *Ask) { a.Explanation += "!" },
|
|
||||||
"Who": func(a *Ask) { a.Who = "someone" },
|
|
||||||
"Expires": func(a *Ask) { a.Expires = a.Expires.Add(time.Nanosecond) },
|
|
||||||
"OnExpiry": func(a *Ask) { a.OnExpiry += "!" },
|
|
||||||
"About": func(a *Ask) { a.About += "!" },
|
|
||||||
"Urgent": func(a *Ask) { a.Urgent = !a.Urgent },
|
|
||||||
"Options": func(a *Ask) { a.Options = a.Options[:1] },
|
|
||||||
"Option.ID": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].ID = "x" },
|
|
||||||
"Option.Label": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Label += "!"
|
|
||||||
},
|
|
||||||
"Option.Does": func(a *Ask) { a.Options = append([]Option(nil), a.Options...); a.Options[0].Does += "!" },
|
|
||||||
"Option.Level": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Level = Destroy
|
|
||||||
},
|
|
||||||
"Option.Binds": func(a *Ask) {
|
|
||||||
a.Options = append([]Option(nil), a.Options...)
|
|
||||||
a.Options[0].Binds = "sha256:00"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for name, mutate := range mutations {
|
|
||||||
c := base
|
|
||||||
mutate(&c)
|
|
||||||
if c.Digest() == base.Digest() {
|
|
||||||
t.Errorf("changing %s left the digest the same", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests
|
|
||||||
// exactly as before they existed — the encoding by hand, so no build can drift from it unseen.
|
|
||||||
plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a",
|
|
||||||
Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}}
|
|
||||||
byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" +
|
|
||||||
"2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n"))
|
|
||||||
if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) {
|
|
||||||
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest())
|
|
||||||
}
|
|
||||||
withWhole, withDetails := plain, plain
|
|
||||||
withWhole.Whole = "e, whole"
|
|
||||||
withDetails.Details = "fingerprint 1234"
|
|
||||||
if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() {
|
|
||||||
t.Error("Whole or Details left the digest the same")
|
|
||||||
}
|
|
||||||
both := withWhole
|
|
||||||
both.Details = "fingerprint 1234"
|
|
||||||
changed := both
|
|
||||||
changed.Whole += "!"
|
|
||||||
if changed.Digest() == both.Digest() {
|
|
||||||
t.Error("changing Whole beside Details left the digest the same")
|
|
||||||
}
|
|
||||||
// A field added to Ask or Option without being added to Digest fails here.
|
|
||||||
if n := reflect.TypeOf(Ask{}).NumField(); n != 12 {
|
|
||||||
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
|
|
||||||
}
|
|
||||||
if n := reflect.TypeOf(Option{}).NumField(); n != 5 {
|
|
||||||
t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n)
|
|
||||||
}
|
|
||||||
// The same instant in another zone is the same ask.
|
|
||||||
z := base
|
|
||||||
z.Expires = z.Expires.In(time.FixedZone("x", 3600))
|
|
||||||
if z.Digest() != base.Digest() {
|
|
||||||
t.Error("the expiry's zone changed the digest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An ask that says no expiry is answered by no warrant (the confirmation review of 2026-10-09): nothing could
|
|
||||||
// show the answer was in time.
|
|
||||||
func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
|
|
||||||
a := held()
|
|
||||||
a.Expires = time.Time{}
|
|
||||||
w := Warrant{Ask: "d-1", Asker: "mesh-delivery", Outcome: OutcomeChosen, Option: "release", Label: "Release",
|
|
||||||
Level: Approve, By: &Person{Who: Operator, Kind: "telegram", Identity: "42"}, AskDigest: a.Digest(),
|
|
||||||
At: now.Add(time.Minute)}
|
|
||||||
if _, err := w.For("mesh-delivery", a); err == nil {
|
|
||||||
t.Error("a warrant for an ask with no expiry was taken")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
|
|
||||||
func TestAnExplanationReadsAtAGlance(t *testing.T) {
|
|
||||||
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
|
|
||||||
t.Fatalf("a summary and two points: %v", err)
|
|
||||||
}
|
|
||||||
var wall strings.Builder
|
|
||||||
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
|
|
||||||
for i := 1; i <= 13; i++ {
|
|
||||||
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
|
|
||||||
}
|
|
||||||
err := Glance(wall.String())
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "13 points") {
|
|
||||||
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
|
|
||||||
t.Fatalf("a long summary passes: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
|
|
||||||
t.Fatalf("a long point passes: %v", err)
|
|
||||||
}
|
|
||||||
if err := Glance(" \n"); err == nil {
|
|
||||||
t.Fatal("an empty explanation passes")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older
|
|
||||||
// than it reads every message it read before.
|
|
||||||
func TestAMessageCarriesItsNoteApart(t *testing.T) {
|
|
||||||
raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."})
|
|
||||||
if !strings.Contains(string(raw), `"note":"Asked by x."`) {
|
|
||||||
t.Fatalf("the note is not encoded: %s", raw)
|
|
||||||
}
|
|
||||||
raw, _ = json.Marshal(Message{Handle: "h", Title: "t"})
|
|
||||||
if strings.Contains(string(raw), "note") {
|
|
||||||
t.Fatalf("an empty note is encoded: %s", raw)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
|
|
||||||
// of different builds still agree on every such ask.
|
|
||||||
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
|
|
||||||
a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
|
|
||||||
before := a.Digest()
|
|
||||||
a.WholeDetails = "+ shares: media=/storage/media"
|
|
||||||
if a.Digest() == before {
|
|
||||||
t.Fatal("the whole details are not in the digest")
|
|
||||||
}
|
|
||||||
b := a
|
|
||||||
b.WholeDetails = "+ shares: media=/storage/other"
|
|
||||||
if a.Digest() == b.Digest() {
|
|
||||||
t.Fatal("two whole details digest the same")
|
|
||||||
}
|
|
||||||
a.WholeDetails = ""
|
|
||||||
if a.Digest() != before {
|
|
||||||
t.Fatal("an ask without whole details digests otherwise than before")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
|
|
||||||
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
|
|
||||||
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
|
|
||||||
base := Ask{ID: "a", Headline: "h", Explanation: "e"}
|
|
||||||
shapes := []Ask{}
|
|
||||||
for _, f := range []struct{ whole, details, wholeDetails string }{
|
|
||||||
{"", "", "+ x: 1"},
|
|
||||||
{"whole-details", "+ x: 1", ""},
|
|
||||||
{"", "+ x: 1", ""},
|
|
||||||
{"+ x: 1", "", ""},
|
|
||||||
{"", "", "whole-details"},
|
|
||||||
{"", "+ x: 1", "+ x: 1"},
|
|
||||||
{"+ x: 1", "+ x: 1", ""},
|
|
||||||
{"", "", ""},
|
|
||||||
} {
|
|
||||||
a := base
|
|
||||||
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
|
|
||||||
shapes = append(shapes, a)
|
|
||||||
}
|
|
||||||
seen := map[string]int{}
|
|
||||||
for i, a := range shapes {
|
|
||||||
if j, ok := seen[a.Digest()]; ok {
|
|
||||||
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
|
|
||||||
}
|
|
||||||
seen[a.Digest()] = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
|
|
||||||
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
|
|
||||||
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
|
|
||||||
// refused when empty or when it holds a dot, a wildcard or whitespace.
|
|
||||||
func ConditionSubject(publisher string) (string, error) {
|
|
||||||
if publisher == "" {
|
|
||||||
return "", errors.New("asks: the condition's publisher is empty")
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
|
|
||||||
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
|
|
||||||
}
|
|
||||||
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
|
|
||||||
const ConditionVersion = 1
|
|
||||||
|
|
||||||
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
|
|
||||||
// ConditionVersion. Test it with errors.Is.
|
|
||||||
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
|
|
||||||
|
|
||||||
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
|
|
||||||
const (
|
|
||||||
NothingToDo = "Nothing for you to do."
|
|
||||||
NeedsYou = "Needs you: "
|
|
||||||
)
|
|
||||||
|
|
||||||
// The severities and states of a condition.
|
|
||||||
const (
|
|
||||||
SeverityUrgent = "urgent"
|
|
||||||
SeverityWarning = "warning"
|
|
||||||
StateRaised = "raised"
|
|
||||||
StateCleared = "cleared"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scope is what a condition is about, under the controller's names.
|
|
||||||
type Scope struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
ID string `json:"id"`
|
|
||||||
Machine string `json:"machine,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Action is one answer a notification offers, under the controller's names.
|
|
||||||
type Action struct {
|
|
||||||
Label string `json:"label"`
|
|
||||||
Verb string `json:"verb"`
|
|
||||||
Machine string `json:"machine,omitempty"`
|
|
||||||
Arguments map[string]string `json:"arguments,omitempty"`
|
|
||||||
Level Level `json:"level,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
|
|
||||||
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
|
|
||||||
// only its key, subject and state. The router alone chooses the channels.
|
|
||||||
type Condition struct {
|
|
||||||
V int `json:"v"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Severity string `json:"severity,omitempty"`
|
|
||||||
Subject Scope `json:"subject"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Summary string `json:"summary,omitempty"`
|
|
||||||
Headline string `json:"headline,omitempty"`
|
|
||||||
Needs string `json:"needs,omitempty"`
|
|
||||||
Explanation string `json:"explanation,omitempty"`
|
|
||||||
Resolved string `json:"resolved,omitempty"`
|
|
||||||
Actions []Action `json:"actions,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c Condition) check() error {
|
|
||||||
if c.Key == "" {
|
|
||||||
return errors.New("asks: a condition has no key")
|
|
||||||
}
|
|
||||||
if c.Subject.Scope == "" || c.Subject.ID == "" {
|
|
||||||
return errors.New("asks: a condition's subject needs its scope and id")
|
|
||||||
}
|
|
||||||
switch c.State {
|
|
||||||
case StateCleared:
|
|
||||||
return nil
|
|
||||||
case StateRaised:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
|
|
||||||
}
|
|
||||||
switch c.Severity {
|
|
||||||
case SeverityUrgent, SeverityWarning:
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
|
|
||||||
}
|
|
||||||
if c.Headline == "" {
|
|
||||||
return errors.New("asks: a raised condition has no headline")
|
|
||||||
}
|
|
||||||
if n := len([]rune(c.Headline)); n > HeadlineLength {
|
|
||||||
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
|
|
||||||
}
|
|
||||||
verdict := NothingToDo
|
|
||||||
if c.Needs != "" {
|
|
||||||
verdict = NeedsYou + c.Needs
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(c.Explanation, verdict) {
|
|
||||||
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
|
|
||||||
func EncodeCondition(c Condition) ([]byte, error) {
|
|
||||||
if err := c.check(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
c.V = ConditionVersion
|
|
||||||
return json.Marshal(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
|
|
||||||
// EncodeCondition refuses.
|
|
||||||
func DecodeCondition(data []byte) (Condition, error) {
|
|
||||||
var probe struct {
|
|
||||||
V *int `json:"v"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(data, &probe); err != nil {
|
|
||||||
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
|
|
||||||
}
|
|
||||||
if probe.V == nil {
|
|
||||||
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
|
|
||||||
}
|
|
||||||
if *probe.V != ConditionVersion {
|
|
||||||
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
|
|
||||||
}
|
|
||||||
var c Condition
|
|
||||||
if err := json.Unmarshal(data, &c); err != nil {
|
|
||||||
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
|
|
||||||
}
|
|
||||||
if err := c.check(); err != nil {
|
|
||||||
return Condition{}, err
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SameCondition says whether two conditions are the same value.
|
|
||||||
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
package asks
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConditionSubject(t *testing.T) { // 1202/AC1
|
|
||||||
s, err := ConditionSubject("mesh-delivery-teller")
|
|
||||||
if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" {
|
|
||||||
t.Fatalf("subject = %q, %v", s, err)
|
|
||||||
}
|
|
||||||
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
|
|
||||||
parts := strings.Split(s, ".")
|
|
||||||
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" {
|
|
||||||
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
|
|
||||||
}
|
|
||||||
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
|
|
||||||
if _, err := ConditionSubject(bad); err == nil {
|
|
||||||
t.Errorf("publisher %q accepted", bad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func raisedCondition() Condition {
|
|
||||||
return Condition{
|
|
||||||
Key: "delivery.stuck.mesh-mail", Severity: "warning",
|
|
||||||
Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
|
|
||||||
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
|
|
||||||
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
|
|
||||||
Resolved: "The delivery of mesh-mail moved on.",
|
|
||||||
Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
|
|
||||||
Arguments: map[string]string{"why": ""}, Level: Approve}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearedCondition() Condition {
|
|
||||||
return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
|
|
||||||
for _, c := range []Condition{raisedCondition(), clearedCondition()} {
|
|
||||||
data, err := EncodeCondition(c)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("%s: %v", c.State, err)
|
|
||||||
}
|
|
||||||
var probe map[string]any
|
|
||||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
|
||||||
t.Fatalf("%s: %s does not carry v:1", c.State, data)
|
|
||||||
}
|
|
||||||
c.V = ConditionVersion
|
|
||||||
got, err := DecodeCondition(data)
|
|
||||||
if err != nil || !SameCondition(got, c) {
|
|
||||||
t.Errorf("%s round trip: %+v %v", c.State, got, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, _ := EncodeCondition(raisedCondition())
|
|
||||||
var probe map[string]any
|
|
||||||
_ = json.Unmarshal(data, &probe)
|
|
||||||
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
|
|
||||||
if _, ok := probe[name]; !ok {
|
|
||||||
t.Errorf("the JSON has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
subject := probe["subject"].(map[string]any)
|
|
||||||
for _, name := range []string{"scope", "id", "machine"} {
|
|
||||||
if _, ok := subject[name]; !ok {
|
|
||||||
t.Errorf("the subject has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
action := probe["actions"].([]any)[0].(map[string]any)
|
|
||||||
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
|
|
||||||
if _, ok := action[name]; !ok {
|
|
||||||
t.Errorf("the action has no %q: %s", name, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
|
|
||||||
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
|
|
||||||
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionVerdict(t *testing.T) { // 1202/AC3
|
|
||||||
c := raisedCondition()
|
|
||||||
c.Needs, c.Explanation = "", "The plan waits."
|
|
||||||
_, err := EncodeCondition(c)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
|
|
||||||
t.Errorf("empty needs without its verdict: %v", err)
|
|
||||||
}
|
|
||||||
c.Explanation = "Nothing for you to do. The plan waits."
|
|
||||||
if _, err := EncodeCondition(c); err != nil {
|
|
||||||
t.Errorf("empty needs with its verdict refused: %v", err)
|
|
||||||
}
|
|
||||||
c = raisedCondition()
|
|
||||||
c.Explanation = "Nothing for you to do. The plan waits."
|
|
||||||
_, err = EncodeCondition(c)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
|
|
||||||
t.Errorf("needs set with the wrong verdict: %v", err)
|
|
||||||
}
|
|
||||||
c.Explanation = "Needs you: something else. The plan waits."
|
|
||||||
if _, err = EncodeCondition(c); err == nil {
|
|
||||||
t.Error("needs set with other needs in the verdict accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConditionRefused(t *testing.T) { // 1202/AC4
|
|
||||||
mutate := func(f func(*Condition)) error {
|
|
||||||
c := raisedCondition()
|
|
||||||
f(&c)
|
|
||||||
_, err := EncodeCondition(c)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for name, f := range map[string]func(*Condition){
|
|
||||||
"severity": func(c *Condition) { c.Severity = "critical" },
|
|
||||||
"state": func(c *Condition) { c.State = "open" },
|
|
||||||
"key": func(c *Condition) { c.Key = "" },
|
|
||||||
"headline": func(c *Condition) { c.Headline = "" },
|
|
||||||
"long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
|
|
||||||
} {
|
|
||||||
if err := mutate(f); err == nil {
|
|
||||||
t.Errorf("a condition with a bad %s accepted", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
|
|
||||||
t.Errorf("a headline of the greatest length refused: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := EncodeCondition(clearedCondition()); err != nil {
|
|
||||||
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
|
|
||||||
}
|
|
||||||
c := clearedCondition()
|
|
||||||
c.Key = ""
|
|
||||||
if _, err := EncodeCondition(c); err == nil {
|
|
||||||
t.Error("a cleared condition without its key accepted")
|
|
||||||
}
|
|
||||||
c = clearedCondition()
|
|
||||||
c.State = "gone"
|
|
||||||
if _, err := EncodeCondition(c); err == nil {
|
|
||||||
t.Error("a condition with an unknown state accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,7 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
|
|||||||
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
|
||||||
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
|
||||||
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
|
||||||
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
|
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "main"}}, "neither a full commit nor a Go semantic version"},
|
||||||
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
|
||||||
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package buscalls
|
||||||
|
|
||||||
|
import "encoding/json"
|
||||||
|
|
||||||
|
// Unwrap reads a tool's answer whatever wraps it on its way from the tool runner (the lead's ruling 2026-11-04 13:12:
|
||||||
|
// one reading, not a copy per module): a JSON string holding the answer; the protocol's content list, whose first
|
||||||
|
// text is the answer and whose isError says it failed; the {output, ok, answer} form; the tool runner's {result,
|
||||||
|
// error} envelope, whose error is the call's. It answers the answer itself, the words said with it (the output, the
|
||||||
|
// error, or the answer's own text), and whether the tool says it succeeded. A bare answer is itself, succeeded.
|
||||||
|
func Unwrap(raw json.RawMessage) (answer json.RawMessage, said string, ok bool) {
|
||||||
|
var s string
|
||||||
|
if json.Unmarshal(raw, &s) == nil {
|
||||||
|
return Unwrap(json.RawMessage(s))
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if json.Unmarshal(raw, &m) != nil {
|
||||||
|
return raw, string(raw), true
|
||||||
|
}
|
||||||
|
if content, has := m["content"]; has {
|
||||||
|
var items []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
var e struct {
|
||||||
|
IsError bool `json:"isError"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(raw, &e)
|
||||||
|
if json.Unmarshal(content, &items) == nil && len(items) > 0 {
|
||||||
|
inner, out, ok := Unwrap(json.RawMessage(items[0].Text))
|
||||||
|
return inner, out, ok && !e.IsError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
OK *bool `json:"ok"`
|
||||||
|
Answer json.RawMessage `json:"answer"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(raw, &v) == nil {
|
||||||
|
switch {
|
||||||
|
case v.OK != nil:
|
||||||
|
return v.Answer, v.Output, *v.OK
|
||||||
|
case v.Error != "":
|
||||||
|
return nil, v.Error, false
|
||||||
|
case len(v.Result) > 0:
|
||||||
|
return Unwrap(v.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw, string(raw), true
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/membership"
|
"git.novox.be/novox/mesh-sdk/go/membership"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
|
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
|
||||||
@@ -32,4 +33,10 @@ func TestTheBusNamesAgree(t *testing.T) {
|
|||||||
if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" {
|
if err := ledger.Valid(); err != nil || ledger.OwnSecret() != "ledgers" {
|
||||||
t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret())
|
t.Fatalf("a wanted ledger account: %v, held in %q", err, ledger.OwnSecret())
|
||||||
}
|
}
|
||||||
|
// A seat's family is the shared events stream filtered to its subjects: a definition says the stream's age.
|
||||||
|
for _, st := range MeshStreams() {
|
||||||
|
if st.Name == EventsStream && int64(st.MaxAge) != seatdef.SharedEventsMaxAgeDays*24*60*60 {
|
||||||
|
t.Errorf("the shared events stream keeps %d s, a seat's definition says %d days", st.MaxAge, seatdef.SharedEventsMaxAgeDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package buscompose
|
package buscompose
|
||||||
|
|
||||||
|
import "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
|
||||||
// Declared is one module on one node, as composing its authority needs it.
|
// Declared is one module on one node, as composing its authority needs it.
|
||||||
type Declared struct {
|
type Declared struct {
|
||||||
Module string
|
Module string
|
||||||
@@ -102,4 +104,4 @@ type ProcessPrincipal struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
|
||||||
const VaultSeat = "mesh-vault"
|
const VaultSeat = catalogue.VaultSeat
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package stdio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// busStateCalls are the method prefixes of the calls a bundle makes on its own bus state through the runtime (novox/hq
|
||||||
|
// ADR 0595 §9, design 25 § 12.5): their shapes and names are package buscalls's, and package busstate the typed way
|
||||||
|
// to make them.
|
||||||
|
var busStateCalls = []string{"mesh/objects.", "mesh/stream.", "mesh/workqueue.", "mesh/ledger."}
|
||||||
|
|
||||||
|
// BusState asks the runtime one call on this bundle's own bus state, an entry its module asks for in `requires`, named
|
||||||
|
// by its local name in params; the runtime serves it from the module's own bindings only. A method that is none of
|
||||||
|
// those calls is refused here, before the runtime is asked.
|
||||||
|
func BusState(method string, params any) (json.RawMessage, error) {
|
||||||
|
known := false
|
||||||
|
for _, p := range busStateCalls {
|
||||||
|
known = known || strings.HasPrefix(method, p)
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
return nil, fmt.Errorf("%s is no call on a bundle's own bus state (%s)", method, strings.Join(busStateCalls, ", "))
|
||||||
|
}
|
||||||
|
c, err := live()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return c.ask(method, params, 60*time.Second)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// Package busstate is how a bundle uses its own bus state through the runtime (novox/hq ADR 0595 §9, design 25 §
|
||||||
|
// 12.5): the object store's put, get, list, delete and fill, each naming an entry by the local name its module asks for
|
||||||
|
// it by in `requires`. The runtime serves the calls from the module's own bindings, its own entries only. The shapes
|
||||||
|
// are package buscalls's; this package makes the calls.
|
||||||
|
package busstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Objects is one object store the module asks for in `requires` (the template objects, ADR 0595 §2), by its local name.
|
||||||
|
type Objects struct{ local string }
|
||||||
|
|
||||||
|
// ObjectsOf is the object store of that local name.
|
||||||
|
func ObjectsOf(local string) Objects { return Objects{local: local} }
|
||||||
|
|
||||||
|
func call[T any](method string, params any) (T, error) {
|
||||||
|
var out T
|
||||||
|
raw, err := stdio.BusState(method, params)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return out, fmt.Errorf("the runtime's answer to %s: %w", method, err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put stores one object whole under its name, replacing one of that name, and answers it as the store holds it.
|
||||||
|
func (o Objects) Put(name string, data []byte, description string) (buscalls.ObjectInfo, error) {
|
||||||
|
return call[buscalls.ObjectInfo](buscalls.ObjectsPut, buscalls.ObjectsPutRequest{Objects: o.local, Name: name,
|
||||||
|
Data: data, Description: description})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get is one object, its info and its bytes.
|
||||||
|
func (o Objects) Get(name string) (buscalls.ObjectData, error) {
|
||||||
|
return call[buscalls.ObjectData](buscalls.ObjectsGet, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List is every object the store holds.
|
||||||
|
func (o Objects) List() ([]buscalls.ObjectInfo, error) {
|
||||||
|
l, err := call[buscalls.ObjectsListed](buscalls.ObjectsList, buscalls.ObjectsRequest{Objects: o.local})
|
||||||
|
return l.Objects, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes one object.
|
||||||
|
func (o Objects) Delete(name string) error {
|
||||||
|
_, err := call[buscalls.Done](buscalls.ObjectsDelete, buscalls.ObjectsNameRequest{Objects: o.local, Name: name})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill is how much the store holds against its cap.
|
||||||
|
func (o Objects) Fill() (buscalls.Fill, error) {
|
||||||
|
return call[buscalls.Fill](buscalls.ObjectsInfo, buscalls.ObjectsRequest{Objects: o.local})
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package busstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A bundle keeps objects in its own object store through the runtime (ADR 0595 §2, §9; A3's request for distribution's
|
||||||
|
// incoming): each call is the runtime's method of buscalls, naming the store by its local name, and answers as the
|
||||||
|
// runtime answered; a method that is no bus-state call is refused before the runtime is asked.
|
||||||
|
func TestABundleKeepsObjectsThroughTheRuntime(t *testing.T) {
|
||||||
|
t.Setenv(stdio.ServedAs, "distribution")
|
||||||
|
inR, inW := io.Pipe()
|
||||||
|
outR, outW := io.Pipe()
|
||||||
|
type result struct {
|
||||||
|
what string
|
||||||
|
err error
|
||||||
|
got any
|
||||||
|
}
|
||||||
|
results := make(chan result, 8)
|
||||||
|
go func() {
|
||||||
|
_ = stdio.ServeOn("", []stdio.Tool{{Name: "keep", Run: func(map[string]any) (any, error) {
|
||||||
|
go func() {
|
||||||
|
in := ObjectsOf("incoming")
|
||||||
|
info, err := in.Put("t1.part0", []byte("hello"), "a part")
|
||||||
|
results <- result{"put", err, info.Digest}
|
||||||
|
data, err := in.Get("t1.part0")
|
||||||
|
results <- result{"get", err, string(data.Data)}
|
||||||
|
list, err := in.List()
|
||||||
|
results <- result{"list", err, len(list)}
|
||||||
|
results <- result{"delete", in.Delete("t1.part0"), nil}
|
||||||
|
fill, err := in.Fill()
|
||||||
|
results <- result{"fill", err, fill.MaxBytes}
|
||||||
|
_, err = stdio.BusState("mesh/state.get", nil)
|
||||||
|
results <- result{"refused", err, nil}
|
||||||
|
}()
|
||||||
|
return "started", nil
|
||||||
|
}}}, inR, outW)
|
||||||
|
}()
|
||||||
|
lines := bufio.NewScanner(outR)
|
||||||
|
next := func() map[string]any {
|
||||||
|
done := make(chan map[string]any, 1)
|
||||||
|
go func() {
|
||||||
|
if lines.Scan() {
|
||||||
|
var m map[string]any
|
||||||
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
||||||
|
done <- m
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case m := <-done:
|
||||||
|
return m
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the bundle said nothing")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
||||||
|
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "keep"}})
|
||||||
|
answers := map[string]any{
|
||||||
|
buscalls.ObjectsPut: buscalls.ObjectInfo{Name: "t1.part0", Size: 5, Digest: "SHA-256=x"},
|
||||||
|
buscalls.ObjectsGet: buscalls.ObjectData{Info: buscalls.ObjectInfo{Name: "t1.part0"}, Data: []byte("hello")},
|
||||||
|
buscalls.ObjectsList: buscalls.ObjectsListed{Objects: []buscalls.ObjectInfo{{Name: "t1.part0"}}},
|
||||||
|
buscalls.ObjectsDelete: buscalls.Done{},
|
||||||
|
buscalls.ObjectsInfo: buscalls.Fill{Count: 0, MaxBytes: 20 << 30},
|
||||||
|
}
|
||||||
|
asked := map[string]map[string]any{}
|
||||||
|
for len(asked) < len(answers) {
|
||||||
|
m := next()
|
||||||
|
method, _ := m["method"].(string)
|
||||||
|
if method == "" {
|
||||||
|
continue // the tool's own answer
|
||||||
|
}
|
||||||
|
p, _ := m["params"].(map[string]any)
|
||||||
|
asked[method] = p
|
||||||
|
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": answers[method]})
|
||||||
|
}
|
||||||
|
want := map[string]any{"put": "SHA-256=x", "get": "hello", "list": 1, "delete": nil, "fill": uint64(20 << 30)}
|
||||||
|
for range 5 {
|
||||||
|
r := <-results
|
||||||
|
if r.err != nil || r.got != want[r.what] {
|
||||||
|
t.Errorf("%s answered %v, %v; want %v", r.what, r.got, r.err, want[r.what])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := <-results; r.err == nil {
|
||||||
|
t.Error("a call that is not on the bundle's bus state was asked of the runtime")
|
||||||
|
}
|
||||||
|
for method, p := range asked {
|
||||||
|
if p["objects"] != "incoming" {
|
||||||
|
t.Errorf("%s named the store %v, not its local name", method, p["objects"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p := asked[buscalls.ObjectsPut]; p["name"] != "t1.part0" || p["data"] != "aGVsbG8=" || p["description"] != "a part" {
|
||||||
|
t.Errorf("put asked %v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-6
@@ -22,8 +22,19 @@ const MetaCaller = "mesh/caller"
|
|||||||
// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven.
|
// parts are safe in a subject. Anything else the runtime named is not a caller and reads as Unproven.
|
||||||
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
var busUser = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
|
||||||
|
|
||||||
// calls holds the caller of every call while its tool runs, by the arguments map that call was handed.
|
// MetaCall is where in a `tools/call`'s `_meta` the runtime names the id of the call in flight, the id a ledger's
|
||||||
var calls sync.Map // unsafe.Pointer → string
|
// apply names so the runtime stamps the caller it proved as the operation's author (novox/hq ADR 0597 §7):
|
||||||
|
// buscalls.MetaCall, written here too so this package imports nothing of buscalls.
|
||||||
|
const MetaCall = "mesh/call"
|
||||||
|
|
||||||
|
// callID is the shape of a call's id: one subject-safe token, as the runtime makes it. Anything else reads as none.
|
||||||
|
var callID = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||||
|
|
||||||
|
// inFlight is what the runtime said of one call while its tool runs.
|
||||||
|
type inFlight struct{ caller, call string }
|
||||||
|
|
||||||
|
// calls holds the caller and the id of every call while its tool runs, by the arguments map that call was handed.
|
||||||
|
var calls sync.Map // unsafe.Pointer → inFlight
|
||||||
|
|
||||||
func argsKey(args map[string]any) unsafe.Pointer {
|
func argsKey(args map[string]any) unsafe.Pointer {
|
||||||
return reflect.ValueOf(args).UnsafePointer()
|
return reflect.ValueOf(args).UnsafePointer()
|
||||||
@@ -37,11 +48,32 @@ func CallerOf(args map[string]any) string {
|
|||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
if c, ok := calls.Load(argsKey(args)); ok {
|
if c, ok := calls.Load(argsKey(args)); ok {
|
||||||
return c.(string)
|
return c.(inFlight).caller
|
||||||
}
|
}
|
||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CallIDOf is the id of the call that was handed args, as the runtime named it under MetaCall: what a ledger's apply
|
||||||
|
// names (buscalls.ApplyRequest.Call) so the operation's author is the caller the runtime proved. Empty for arguments
|
||||||
|
// no call was handed, or a call the runtime named no id for; read from `_meta`, never from the arguments.
|
||||||
|
func CallIDOf(args map[string]any) string {
|
||||||
|
if args == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if c, ok := calls.Load(argsKey(args)); ok {
|
||||||
|
return c.(inFlight).call
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// callIn is the call id a `tools/call`'s `_meta` names, or empty.
|
||||||
|
func callIn(meta map[string]any) string {
|
||||||
|
if s, ok := meta[MetaCall].(string); ok && callID.MatchString(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven.
|
// callerIn is the caller a `tools/call`'s `_meta` names, or Unproven.
|
||||||
func callerIn(meta map[string]any) string {
|
func callerIn(meta map[string]any) string {
|
||||||
if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) {
|
if s, ok := meta[MetaCaller].(string); ok && busUser.MatchString(s) {
|
||||||
@@ -50,10 +82,10 @@ func callerIn(meta map[string]any) string {
|
|||||||
return Unproven
|
return Unproven
|
||||||
}
|
}
|
||||||
|
|
||||||
// runAs runs a tool with the caller held for its arguments for as long as it runs.
|
// runAs runs a tool with the caller and the call's id held for its arguments for as long as it runs.
|
||||||
func runAs(t Tool, caller string, args map[string]any) (any, error) {
|
func runAs(t Tool, caller, call string, args map[string]any) (any, error) {
|
||||||
key := argsKey(args)
|
key := argsKey(args)
|
||||||
calls.Store(key, caller)
|
calls.Store(key, inFlight{caller, call})
|
||||||
defer calls.Delete(key)
|
defer calls.Delete(key)
|
||||||
return t.Run(args)
|
return t.Run(args)
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-5
@@ -6,25 +6,33 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||||
)
|
)
|
||||||
|
|
||||||
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
|
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
|
||||||
// with the params given, and returns the caller it answered.
|
// with the params given, and returns the caller it answered.
|
||||||
func callWith(t *testing.T, params map[string]any) string {
|
func callWith(t *testing.T, params map[string]any) string {
|
||||||
|
caller, _ := callAnswering(t, params)
|
||||||
|
return caller
|
||||||
|
}
|
||||||
|
|
||||||
|
// callAnswering is callWith answering the call's id CallIDOf read too.
|
||||||
|
func callAnswering(t *testing.T, params map[string]any) (string, string) {
|
||||||
servesAlone(t)
|
servesAlone(t)
|
||||||
t.Helper()
|
t.Helper()
|
||||||
inR, inW := io.Pipe()
|
inR, inW := io.Pipe()
|
||||||
outR, outW := io.Pipe()
|
outR, outW := io.Pipe()
|
||||||
go func() {
|
go func() {
|
||||||
_ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) {
|
_ = ServeOn("tracker", []Tool{{Name: "who", Run: func(args map[string]any) (any, error) {
|
||||||
return map[string]any{"caller": CallerOf(args)}, nil
|
return map[string]any{"caller": CallerOf(args), "call": CallIDOf(args)}, nil
|
||||||
}}}, inR, outW)
|
}}}, inR, outW)
|
||||||
}()
|
}()
|
||||||
defer inW.Close()
|
defer inW.Close()
|
||||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})
|
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": params})
|
||||||
_, _ = inW.Write(append(b, '\n'))
|
_, _ = inW.Write(append(b, '\n'))
|
||||||
lines := bufio.NewScanner(outR)
|
lines := bufio.NewScanner(outR)
|
||||||
done := make(chan string, 1)
|
done := make(chan [2]string, 1)
|
||||||
go func() {
|
go func() {
|
||||||
if lines.Scan() {
|
if lines.Scan() {
|
||||||
var m struct {
|
var m struct {
|
||||||
@@ -37,19 +45,20 @@ func callWith(t *testing.T, params map[string]any) string {
|
|||||||
_ = json.Unmarshal(lines.Bytes(), &m)
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
||||||
var answer struct {
|
var answer struct {
|
||||||
Caller string `json:"caller"`
|
Caller string `json:"caller"`
|
||||||
|
Call string `json:"call"`
|
||||||
}
|
}
|
||||||
if len(m.Result.Content) > 0 {
|
if len(m.Result.Content) > 0 {
|
||||||
_ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer)
|
_ = json.Unmarshal([]byte(m.Result.Content[0].Text), &answer)
|
||||||
}
|
}
|
||||||
done <- answer.Caller
|
done <- [2]string{answer.Caller, answer.Call}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
select {
|
select {
|
||||||
case got := <-done:
|
case got := <-done:
|
||||||
return got
|
return got[0], got[1]
|
||||||
case <-time.After(3 * time.Second):
|
case <-time.After(3 * time.Second):
|
||||||
t.Fatal("the bundle did not answer the call")
|
t.Fatal("the bundle did not answer the call")
|
||||||
return ""
|
return "", ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +71,28 @@ func TestAToolReadsTheCallerTheRuntimeNamed(t *testing.T) {
|
|||||||
if got != "shanks.node-tools" {
|
if got != "shanks.node-tools" {
|
||||||
t.Fatalf("the tool read caller %q, want the one the runtime named", got)
|
t.Fatalf("the tool read caller %q, want the one the runtime named", got)
|
||||||
}
|
}
|
||||||
|
// The call's id is the runtime's too (ADR 0597 §7): read from `_meta`, never from the arguments, and none when it
|
||||||
|
// is not one token.
|
||||||
|
for _, c := range []struct {
|
||||||
|
args, meta map[string]any
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools", MetaCall: "c-7Hq2"}, "c-7Hq2"},
|
||||||
|
{map[string]any{}, map[string]any{MetaCaller: "shanks.node-tools"}, ""},
|
||||||
|
{map[string]any{MetaCall: "c-forged"}, map[string]any{}, ""},
|
||||||
|
{map[string]any{}, map[string]any{MetaCall: "a b"}, ""},
|
||||||
|
{map[string]any{}, map[string]any{MetaCall: 7}, ""},
|
||||||
|
} {
|
||||||
|
if _, call := callAnswering(t, map[string]any{"name": "who", "arguments": c.args, "_meta": c.meta}); call != c.want {
|
||||||
|
t.Errorf("args %v, meta %v: the tool read call %q, want %q", c.args, c.meta, call, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if CallIDOf(map[string]any{"x": 1}) != "" || CallIDOf(nil) != "" {
|
||||||
|
t.Error("arguments no call was handed have a call id")
|
||||||
|
}
|
||||||
|
if MetaCall != buscalls.MetaCall {
|
||||||
|
t.Errorf("the call id's place is %q here and %q in buscalls", MetaCall, buscalls.MetaCall)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A caller named in the arguments is not the caller: an argument is the caller's to write.
|
// A caller named in the arguments is not the caller: an argument is the caller's to write.
|
||||||
@@ -91,6 +122,39 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
|
|||||||
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
|
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
|
||||||
t.Error("a list with a field it does not know was read")
|
t.Error("a list with a field it does not know was read")
|
||||||
}
|
}
|
||||||
|
// A holder's list per verb (ADR 0627 §2): each verb's entries as above, plus every proved caller (AnyCaller) or
|
||||||
|
// every operator's session (AnyPerson); an unproven caller never, a verb the holder does not serve refused.
|
||||||
|
verbs, err := ParseVerbCallers(`{"publish":["mesh-build-go"],"versions":["*"],"release":["operator","sweep"]}`,
|
||||||
|
[]string{"publish", "versions", "release", "remove"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
verb, caller string
|
||||||
|
want bool
|
||||||
|
}{{"publish", "ace.mesh-build-go", true}, {"publish", "ace.mesh-build-node", false}, {"versions", "g14.anything", true},
|
||||||
|
{"versions", Unproven, false}, {"release", "person.jo", true}, {"release", "ace.operator", false},
|
||||||
|
{"release", "novox.sweep", true}, {"remove", "novox.sweep", false}} {
|
||||||
|
if got := verbs.Allowed(c.verb, c.caller) == nil; got != c.want {
|
||||||
|
t.Errorf("%s by %q allowed: %v, want %v", c.verb, c.caller, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := ParseVerbCallers(`{"mirror":["x"]}`, []string{"publish"}, nil); err == nil {
|
||||||
|
t.Error("a list naming a verb the holder does not serve was read")
|
||||||
|
}
|
||||||
|
if d, _ := ParseVerbCallers(" ", nil, VerbCallers{"publish": {"x"}}); len(d["publish"]) != 1 {
|
||||||
|
t.Error("an empty value is not the holder's defaults")
|
||||||
|
}
|
||||||
|
// As mesh-config resolves the value: the object itself, or written as a string; null is the defaults.
|
||||||
|
for _, raw := range []string{`{"publish":["a"]}`, `"{\"publish\":[\"a\"]}"`, `null`} {
|
||||||
|
c, err := parseConfiguredVerbCallers(json.RawMessage(raw), []string{"publish"}, VerbCallers{"publish": {"a"}})
|
||||||
|
if err != nil || c.Allowed("publish", "ace.a") != nil {
|
||||||
|
t.Errorf("the config value %s read as %v, %v", raw, c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ModuleOf("ace.mesh-build-go") != "mesh-build-go" {
|
||||||
|
t.Error("ModuleOf does not read the module of a proved caller")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,3 +47,85 @@ func (c Callers) Allows(caller string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VerbCallers is a holder's trusted list per verb (novox/hq ADR 0627 §2, design 51 *Who may call which verb*): for each
|
||||||
|
// verb of the seats it holds, the callers that may call it, each entry read as Callers reads it. Two entries more stand
|
||||||
|
// for a whole class: AnyCaller lets every proved caller call the verb (a read the mesh keeps open), AnyPerson every
|
||||||
|
// operator's session the runtime proved as person.<name>. It names no seat and no verb: the holder gives its verbs and
|
||||||
|
// its defaults, and keeps any rule of its own seat (who may never be on a list) beside it.
|
||||||
|
type VerbCallers map[string][]string
|
||||||
|
|
||||||
|
// The entries of a VerbCallers list that stand for a class of callers.
|
||||||
|
const (
|
||||||
|
AnyCaller = "*"
|
||||||
|
AnyPerson = "operator"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseVerbCallers reads a holder's config value of verb to callers; an empty value is defaults. A verb not among verbs
|
||||||
|
// is refused, so a list that names a verb the holder does not serve is said at once.
|
||||||
|
func ParseVerbCallers(raw string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return defaults, nil
|
||||||
|
}
|
||||||
|
var c VerbCallers
|
||||||
|
if err := json.Unmarshal([]byte(raw), &c); err != nil {
|
||||||
|
return nil, fmt.Errorf("callers is not a JSON object of verb to callers: %w", err)
|
||||||
|
}
|
||||||
|
for verb := range c {
|
||||||
|
if !slices.Contains(verbs, verb) {
|
||||||
|
return nil, fmt.Errorf("callers names the verb %q, which the holder does not serve", verb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed answers nil when caller, as the runtime proved it, may call verb; else the refusal naming both. An unproven
|
||||||
|
// caller is never allowed, not even by AnyCaller.
|
||||||
|
func (c VerbCallers) Allowed(verb, caller string) error {
|
||||||
|
if caller == "" || caller == Unproven {
|
||||||
|
return fmt.Errorf("%s: refused: the request carries no caller the tool runner proved", verb)
|
||||||
|
}
|
||||||
|
person := strings.HasPrefix(caller, "person.")
|
||||||
|
for _, entry := range c[verb] {
|
||||||
|
switch entry {
|
||||||
|
case AnyCaller:
|
||||||
|
return nil
|
||||||
|
case AnyPerson:
|
||||||
|
if person {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (Callers{entry}).Allows(caller) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: refused for %s: not on the holder's caller list for %s", verb, caller, verb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleOf is the module of a proved module caller, which the runtime names <machine>.<module>.
|
||||||
|
func ModuleOf(proved string) string {
|
||||||
|
if _, module, ok := strings.Cut(proved, "."); ok {
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
return proved
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfiguredVerbCallers is the holder's list as mesh-config resolved its config value key for this machine (Config):
|
||||||
|
// a JSON object of verb to callers, or that object written as a string; an unset or empty value is defaults. A value
|
||||||
|
// that cannot be read is an error, never the defaults, so a broken list is said, not widened.
|
||||||
|
func ConfiguredVerbCallers(key string, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
raw, err := Config(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("the config value %s cannot be read: %w", key, err)
|
||||||
|
}
|
||||||
|
return parseConfiguredVerbCallers(raw, verbs, defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseConfiguredVerbCallers(raw json.RawMessage, verbs []string, defaults VerbCallers) (VerbCallers, error) {
|
||||||
|
var text string
|
||||||
|
if err := json.Unmarshal(raw, &text); err == nil || len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||||
|
return ParseVerbCallers(text, verbs, defaults)
|
||||||
|
}
|
||||||
|
return ParseVerbCallers(string(raw), verbs, defaults)
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,9 +50,7 @@ func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
|||||||
fset := token.NewFileSet()
|
fset := token.NewFileSet()
|
||||||
files, _ := filepath.Glob("*.go")
|
files, _ := filepath.Glob("*.go")
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
// kept_until_delivered.go keeps, deprecated, the names removed while running parts still use them; the second
|
if strings.HasSuffix(f, "_test.go") {
|
||||||
// group removes it and this exemption together.
|
|
||||||
if strings.HasSuffix(f, "_test.go") || f == "kept_until_delivered.go" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parsed, err := parser.ParseFile(fset, f, nil, 0)
|
parsed, err := parser.ParseFile(fset, f, nil, 0)
|
||||||
|
|||||||
@@ -1,43 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
|
||||||
// distribution, and not the provision artifact-store it delivers.
|
// distribution, and not the provision artifact-store it delivers.
|
||||||
const ArtifactSweepSeat = "mesh-artifact-sweep"
|
const ArtifactSweepSeat = contractseats.MeshArtifactSweep
|
||||||
|
|
||||||
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
|
|
||||||
func artifactSweepVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
|
|
||||||
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
|
|
||||||
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
|
|
||||||
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
|
|
||||||
"collected": "\"true\": list the collected references too (counted always)",
|
|
||||||
}, nil, "collected")},
|
|
||||||
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
|
|
||||||
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
|
|
||||||
"Reads only.",
|
|
||||||
Input: listed(schema(map[string]string{
|
|
||||||
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
|
|
||||||
"or one text separated by commas",
|
|
||||||
}, []string{"references"}), "references")},
|
|
||||||
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
|
|
||||||
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
|
|
||||||
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
|
|
||||||
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
|
|
||||||
"seconds; what is left is said. Never touches a digest the mesh did not make.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
|
|
||||||
"why": "with confirm: why, required and recorded",
|
|
||||||
"most": "let go of at most this many (500 by default, at most 5000)",
|
|
||||||
}, nil, "confirm")},
|
|
||||||
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
|
|
||||||
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
|
|
||||||
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
|
|
||||||
"naming it: what the machine keeps when it prunes its images. Reads only.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
|
||||||
@@ -17,7 +19,7 @@ const ArtifactStoreScheme = ArtifactStoreProvision + "://"
|
|||||||
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
|
// in it — an image id from a genesis build that had nowhere to publish, a package version — is
|
||||||
// what it was.
|
// what it was.
|
||||||
func Recorded(reference string) string {
|
func Recorded(reference string) string {
|
||||||
if strings.HasPrefix(reference, ArtifactStoreScheme) {
|
if _, _, seat := InRegistrySeat(reference); seat || strings.HasPrefix(reference, ArtifactStoreScheme) {
|
||||||
return reference
|
return reference
|
||||||
}
|
}
|
||||||
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
if rest, isURL := strings.CutPrefix(reference, "http://"); isURL {
|
||||||
@@ -39,11 +41,97 @@ func isRegistryHost(component string) bool {
|
|||||||
return component == "localhost" || strings.ContainsAny(component, ".:")
|
return component == "localhost" || strings.ContainsAny(component, ".:")
|
||||||
}
|
}
|
||||||
|
|
||||||
// InArtifactStore reports whether a reference is a kept one, and what it names there.
|
// InArtifactStore reports whether a reference is a kept one, and what it names there: `<module>/<artifact>@<digest>`
|
||||||
|
// for an image, `<module>/<artifact>/blobs/<digest>` for a bundle or an archive. **It reads both forms** (novox/hq ADR
|
||||||
|
// 0605 §9): the older `artifact-store://<path>`, and the form a registry seat answers, `<seat>/<module>/<artifact>@<digest>`
|
||||||
|
// through container-image-registry or oci-artifact-registry, whose blob it names the way the older form did.
|
||||||
func InArtifactStore(reference string) (path string, kept bool) {
|
func InArtifactStore(reference string) (path string, kept bool) {
|
||||||
|
if seat, rest, ok := InRegistrySeat(reference); ok {
|
||||||
|
if seat == OciArtifactRegistrySeat {
|
||||||
|
name, digest, _ := strings.Cut(rest, "@")
|
||||||
|
return name + "/blobs/" + digest, true
|
||||||
|
}
|
||||||
|
return rest, true
|
||||||
|
}
|
||||||
return strings.CutPrefix(reference, ArtifactStoreScheme)
|
return strings.CutPrefix(reference, ArtifactStoreScheme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegistrySeats are the seats a reference of the registry form names first, the two that front the artifact store
|
||||||
|
// (novox/hq ADR 0605 §1, §9).
|
||||||
|
var RegistrySeats = []string{ContainerImageRegistrySeat, OciArtifactRegistrySeat}
|
||||||
|
|
||||||
|
// The four registry seats (novox/hq ADR 0605 §1), named here so the rest of mesh-sdk takes them from the seat set
|
||||||
|
// through catalogue alone (ADR 0625 §3).
|
||||||
|
const (
|
||||||
|
NpmPackageRegistrySeat = contractseats.NpmPackageRegistry
|
||||||
|
GoPackageRegistrySeat = contractseats.GoPackageRegistry
|
||||||
|
ContainerImageRegistrySeat = contractseats.ContainerImageRegistry
|
||||||
|
OciArtifactRegistrySeat = contractseats.OciArtifactRegistry
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublishSeats are the seats an artifact is published through (ADR 0605 §1).
|
||||||
|
var PublishSeats = []string{NpmPackageRegistrySeat, GoPackageRegistrySeat, ContainerImageRegistrySeat, OciArtifactRegistrySeat}
|
||||||
|
|
||||||
|
// The seats of the vault, the provisioner, the hand acts' record and the conditions' keeper, as the seat set names them,
|
||||||
|
// for the rest of mesh-sdk (ADR 0625 §3).
|
||||||
|
const (
|
||||||
|
VaultSeat = contractseats.MeshVault
|
||||||
|
ProvisionerSeat = contractseats.MeshProvisioner
|
||||||
|
)
|
||||||
|
|
||||||
|
// SeatReference is the reference a build names an image, bundle, archive or upstream copy by once it published it
|
||||||
|
// through a registry seat (novox/hq ADR 0605 §9): `<seat>/<module>/<artifact>@sha256:<digest>`, as InRegistrySeat reads
|
||||||
|
// it back. The store's address is left out, as in the older form: a reader routes it (Routed).
|
||||||
|
func SeatReference(seat, module, artifact, digest string) string {
|
||||||
|
return seat + "/" + module + "/" + artifact + "@" + digest
|
||||||
|
}
|
||||||
|
|
||||||
|
// InRegistrySeat reads a reference in the form a registry seat answers, `<seat>/<module>/<artifact>@sha256:<digest>`:
|
||||||
|
// the seat it came through and the rest. A seat's name holds no dot or port, so no image of an outside registry reads
|
||||||
|
// as one, and a kept `artifact-store://` reference is not one.
|
||||||
|
//
|
||||||
|
// It also reads the holder's own naming of an upstream copy through container-image-registry,
|
||||||
|
// `container-image-registry/upstream/<host>/<path>@sha256:<digest>`, whose rest InUpstream reads.
|
||||||
|
func InRegistrySeat(reference string) (seat, path string, ok bool) {
|
||||||
|
first, rest, found := strings.Cut(reference, "/")
|
||||||
|
if !found || !strings.Contains(rest, "@sha256:") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if _, _, upstream := InUpstream(rest); upstream && first == ContainerImageRegistrySeat {
|
||||||
|
return first, rest, true
|
||||||
|
}
|
||||||
|
if strings.Count(rest, "/") != 1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
for _, s := range RegistrySeats {
|
||||||
|
if first == s {
|
||||||
|
return s, rest, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamPrefix opens the holder's own name for an upstream copy it keeps: `upstream/<host>/<path>@<digest>`, after
|
||||||
|
// `container-image-registry/` in the seat's form and after `artifact-store://` in the older one.
|
||||||
|
const UpstreamPrefix = "upstream/"
|
||||||
|
|
||||||
|
// InUpstream reads a path in the holder's naming of an upstream copy, `upstream/<host>/<path>@sha256:<digest>` (the path
|
||||||
|
// InRegistrySeat or InArtifactStore gives back): the upstream registry host and the image's path there, with its digest.
|
||||||
|
// A host is read as the runtime reads one (a dot, a port, or localhost).
|
||||||
|
func InUpstream(path string) (host, image string, ok bool) {
|
||||||
|
rest, found := strings.CutPrefix(path, UpstreamPrefix)
|
||||||
|
if !found {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
host, image, found = strings.Cut(rest, "/")
|
||||||
|
name, digest, pinned := strings.Cut(image, "@")
|
||||||
|
if !found || !isRegistryHost(host) || name == "" || strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") ||
|
||||||
|
strings.Contains(name, "//") || !pinned || !strings.HasPrefix(digest, "sha256:") || strings.Contains(digest, "/") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return host, image, true
|
||||||
|
}
|
||||||
|
|
||||||
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
|
// Routed is a kept reference as a machine fetches it, through the artifact store at `address`
|
||||||
// (host:port). A reference that is not a kept one is what it was.
|
// (host:port). A reference that is not a kept one is what it was.
|
||||||
func Routed(reference, address string) string {
|
func Routed(reference, address string) string {
|
||||||
|
|||||||
@@ -20,10 +20,51 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
|||||||
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
|
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
|
||||||
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
|
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
|
||||||
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
|
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
|
||||||
|
// The form a registry seat answers is recorded as it is (ADR 0605 §9).
|
||||||
|
"container-image-registry/gitea/server@" + digest: "container-image-registry/gitea/server@" + digest,
|
||||||
|
"oci-artifact-registry/gitea/config@" + digest: "oci-artifact-registry/gitea/config@" + digest,
|
||||||
}
|
}
|
||||||
for announced, want := range cases {
|
for announced, want := range cases {
|
||||||
if got := Recorded(announced); got != want {
|
if got := Recorded(announced); got != want {
|
||||||
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
|
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// And every reader reads both forms: a kept reference and the seat's are fetched from the same place.
|
||||||
|
for reference, want := range map[string]string{
|
||||||
|
ArtifactStoreScheme + "gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest,
|
||||||
|
"container-image-registry/gitea/server@" + digest: "10.0.0.1:5000/gitea/server@" + digest,
|
||||||
|
ArtifactStoreScheme + "gitea/config/blobs/" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest,
|
||||||
|
"oci-artifact-registry/gitea/config@" + digest: "http://10.0.0.1:5000/v2/gitea/config/blobs/" + digest,
|
||||||
|
"registry.example/gitea/server@" + digest: "registry.example/gitea/server@" + digest,
|
||||||
|
"mesh-artifact-store/gitea/server@" + digest: "mesh-artifact-store/gitea/server@" + digest,
|
||||||
|
// An upstream copy under the holder's own naming, in both forms.
|
||||||
|
"container-image-registry/upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest,
|
||||||
|
ArtifactStoreScheme + "upstream/quay.io:443/org/app@" + digest: "10.0.0.1:5000/upstream/quay.io:443/org/app@" + digest,
|
||||||
|
} {
|
||||||
|
if got := Routed(reference, "10.0.0.1:5000"); got != want {
|
||||||
|
t.Errorf("Routed(%q) = %q, want %q", reference, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A build module names what it published in the seat's form, and reads it back as the seat it came through.
|
||||||
|
for _, seat := range RegistrySeats {
|
||||||
|
ref := SeatReference(seat, "gitea", "config", digest)
|
||||||
|
if got, path, ok := InRegistrySeat(ref); !ok || got != seat || path != "gitea/config@"+digest {
|
||||||
|
t.Errorf("SeatReference(%s) = %q, read back as %q %q %v", seat, ref, got, path, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The holder's naming of an upstream copy reads through the image seat only, and InUpstream reads its host and path.
|
||||||
|
for reference, want := range map[string]bool{
|
||||||
|
"container-image-registry/upstream/quay.io:443/org/app@" + digest: true,
|
||||||
|
"oci-artifact-registry/upstream/quay.io:443/org/app@" + digest: false,
|
||||||
|
"container-image-registry/upstream/org/app@" + digest: false,
|
||||||
|
"container-image-registry/upstream/quay.io/app": false,
|
||||||
|
} {
|
||||||
|
_, path, ok := InRegistrySeat(reference)
|
||||||
|
if ok != want {
|
||||||
|
t.Errorf("InRegistrySeat(%q) read %v, want %v", reference, ok, want)
|
||||||
|
}
|
||||||
|
if host, image, up := InUpstream(path); ok && (!up || host != "quay.io:443" || image != "org/app@"+digest) {
|
||||||
|
t.Errorf("InUpstream(%q) = %q %q %v", path, host, image, up)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
// CatalogSeat is the seat of the catalog: what the mesh holds of every module (novox/hq ADR 0604 §1). Its states are
|
// CatalogSeat is the seat of the catalog: what the mesh holds of every module (novox/hq ADR 0604 §1). Its states are
|
||||||
@@ -8,29 +10,7 @@ import "encoding/json"
|
|||||||
// no verb. It names no event. What must be built again is no verb: a module whose fingerprint differs from its newest
|
// no verb. It names no event. What must be built again is no verb: a module whose fingerprint differs from its newest
|
||||||
// build, which the delivery view derives. The verbs' answers are CatalogModules, CatalogOne, CatalogProvides and
|
// build, which the delivery view derives. The verbs' answers are CatalogModules, CatalogOne, CatalogProvides and
|
||||||
// CatalogDependents, whose field names are those of the catalog module's tools they replace.
|
// CatalogDependents, whose field names are those of the catalog module's tools they replace.
|
||||||
const CatalogSeat = "mesh-catalog"
|
const CatalogSeat = contractseats.MeshCatalog
|
||||||
|
|
||||||
// catalogVerbs are the seat's four verbs, each read from its holder's states. Each is optional until the holder that
|
|
||||||
// runs serves it (design 33 §7): the module that claimed the seat before serves none of them, and a judge holding them
|
|
||||||
// required would read it as outgrown for as long as both claims stand (novox/hq ADR 0604 §6).
|
|
||||||
func catalogVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "modules", Optional: true, Description: "Every module the trunk holds, each with its repository, its " +
|
|
||||||
"directory there, the merge commit it was last changed at and its fingerprint (or why it has none): the " +
|
|
||||||
"catalog of the mesh, read from its holder's state. Reads only.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "module", Optional: true, Description: "One module whole: its repository, directory, commit and " +
|
|
||||||
"fingerprint, and its manifest as the trunk holds it. Older versions are in its repository's history. Reads only.",
|
|
||||||
Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})},
|
|
||||||
{Name: "provides", Optional: true, Description: "Which modules provide a provision (a module provides its own " +
|
|
||||||
"name too) and which require it, from the module graph's provision edges. Reads only.",
|
|
||||||
Input: schema(map[string]string{"provision": "the provision, such as postgres-database"}, []string{"provision"})},
|
|
||||||
{Name: "dependents", Optional: true, Description: "The modules whose build edges reach a module, directly or " +
|
|
||||||
"through others (a base, a mesh package it publishes, the build module that builds them): what a change of it " +
|
|
||||||
"builds again, from the module graph. Reads only.",
|
|
||||||
Input: schema(map[string]string{"module": "the module's name"}, []string{"module"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it
|
// CatalogEntry is one module of the catalog: its repository, its directory there ("." for the root), the merge commit it
|
||||||
// was last changed at, and its new-form fingerprint or why it has none.
|
// was last changed at, and its new-form fingerprint or why it has none.
|
||||||
|
|||||||
@@ -1,32 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
// CheckerSeat is the seat mesh-delivery-checker holds.
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
const CheckerSeat = "mesh-delivery-checker"
|
|
||||||
|
|
||||||
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
|
// CheckerSeat is the seat mesh-delivery-checker holds.
|
||||||
// everywhere.
|
//
|
||||||
func checkerVerbs() []Verb {
|
// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshDeliveryChecker); kept for its importers until their
|
||||||
return []Verb{
|
// next change takes it from there (testdata/seat-names.txt).
|
||||||
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
|
const CheckerSeat = contractseats.MeshDeliveryChecker
|
||||||
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
|
|
||||||
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
|
|
||||||
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
|
|
||||||
"4 wait or run at once, mesh-wide.",
|
|
||||||
Input: schema(map[string]string{"repository": "<owner>/<name>",
|
|
||||||
"head": "the full commit to try, 40 or 64 hex digits",
|
|
||||||
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
|
|
||||||
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
|
|
||||||
"level": "unit or integration: only this level; both when absent"},
|
|
||||||
[]string{"repository", "head"}),
|
|
||||||
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
|
|
||||||
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
|
|
||||||
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
|
|
||||||
"it was merged onto, the time it took and what failed first. Read-only.",
|
|
||||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
|
|
||||||
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
|
|
||||||
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
|
|
||||||
"id. A try that ended already is refused.",
|
|
||||||
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ type configDeclaration struct {
|
|||||||
Default any `json:"default,omitempty"`
|
Default any `json:"default,omitempty"`
|
||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
Runtime bool `json:"runtime,omitempty"`
|
Runtime bool `json:"runtime,omitempty"`
|
||||||
|
RenamedFrom string `json:"renamed-from,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
// MarshalJSON writes a settings declaration exactly as before, and a config declaration without the settings
|
||||||
@@ -125,7 +126,7 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
return json.Marshal(configDeclaration{Type: d.Type, Description: d.Description, Enum: d.Enum,
|
||||||
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
Minimum: d.Minimum, Maximum: d.Maximum, Pattern: d.Pattern, Trusted: d.Trusted, Predefined: d.Predefined,
|
||||||
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
|
Default: d.Default, Required: d.Required, Runtime: d.Runtime, RenamedFrom: d.RenamedFrom})
|
||||||
}
|
}
|
||||||
|
|
||||||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||||||
|
|||||||
@@ -132,6 +132,33 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
|||||||
if !bytes.Equal(raw, twice) {
|
if !bytes.Equal(raw, twice) {
|
||||||
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
|
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The registry milestone's words round-trip the same (design 32): a config value's old name, an artifact's series
|
||||||
|
// and contracts marker, the series a module accepts of a package; absent, a series is 0.1.
|
||||||
|
declared := []byte(`{"module":"c","version":"1","config":{"font":{"type":"string","renamed-from":"font-name"}},
|
||||||
|
"packages":{"mesh-sdk":"0.2"},
|
||||||
|
"build":{"artifacts":[{"name":"go","kind":"package","language":"go","paths":["go"],"series":"0.2",
|
||||||
|
"generated-from":"seats"},{"name":"lib","kind":"package","language":"typescript"}]}}`)
|
||||||
|
m, err = decodeForTest(declared)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if m.Settings["font"].RenamedFrom != "font-name" || m.AcceptedSeries("mesh-sdk") != "0.2" ||
|
||||||
|
m.AcceptedSeries("mesh-contracts") != DefaultSeries || m.Build.Artifacts[0].SeriesOf() != "0.2" ||
|
||||||
|
m.AcceptedSeries("mesh-sdk", "0.1") != "0.2" || m.AcceptedSeries("mesh-contracts", "0.10", "x", "0.3", "1.0") != "0.3" ||
|
||||||
|
m.Build.Artifacts[0].GeneratedFrom != "seats" || m.Build.Artifacts[1].SeriesOf() != "0.1" {
|
||||||
|
t.Fatalf("read %+v", m)
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(m)
|
||||||
|
for _, said := range []string{`"renamed-from":"font-name"`, `"packages":{"mesh-sdk":"0.2"}`, `"series":"0.2"`,
|
||||||
|
`"generated-from":"seats"`} {
|
||||||
|
if !strings.Contains(string(raw), said) {
|
||||||
|
t.Errorf("written back without %s: %s", said, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Count(string(raw), `"series"`) != 1 {
|
||||||
|
t.Errorf("a series nobody said is written: %s", raw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A module in the settings form is written back byte for byte as before.
|
// A module in the settings form is written back byte for byte as before.
|
||||||
|
|||||||
@@ -1,54 +1,12 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// The two seats.
|
// The two seats.
|
||||||
const (
|
const (
|
||||||
WantsSeat = "mesh-wants"
|
WantsSeat = contractseats.MeshWants
|
||||||
HandActsSeat = "mesh-hand-acts"
|
HandActsSeat = contractseats.MeshHandActs
|
||||||
)
|
)
|
||||||
|
|
||||||
// controlRecordsDecision is the record that made the two seats.
|
// controlRecordsDecision is the record that made the two seats.
|
||||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||||
|
|
||||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
|
||||||
func wantsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
|
|
||||||
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
|
|
||||||
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
|
|
||||||
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "one module",
|
|
||||||
"policy": "with module: roll, together, record, or default to take a person's choice back",
|
|
||||||
"why": "with policy: why, required and kept",
|
|
||||||
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
|
|
||||||
}, nil, "compare")},
|
|
||||||
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
|
|
||||||
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
|
|
||||||
"the step itself is the put on the operator's warrant.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
|
|
||||||
func handActsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
|
|
||||||
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
|
|
||||||
"runtime proves, never said by the caller. Said as act-recorded.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"what": "what was done, in a line",
|
|
||||||
"why": "why it had to be done by hand",
|
|
||||||
"cause": "the cause in a word, or a condition's kind",
|
|
||||||
"condition": "the key of the condition it addressed, if any (optional)",
|
|
||||||
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
|
|
||||||
}, []string{"what", "why"})},
|
|
||||||
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
|
|
||||||
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
|
|
||||||
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
|
|
||||||
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
|
|
||||||
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
|
|
||||||
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
|
|
||||||
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
|
|
||||||
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -238,6 +238,11 @@ type Rendering struct {
|
|||||||
// when no holder is reachable yet; see seat_into.go for which seats are answered.
|
// when no holder is reachable yet; see seat_into.go for which seats are answered.
|
||||||
SeatReach map[string]string
|
SeatReach map[string]string
|
||||||
|
|
||||||
|
// SeatHolder is the module holding each seat SeatReach answers, by seat, from the holdings the composition has (novox/hq
|
||||||
|
// design 51, issue 1966 ENG-10): written beside an image routed through the seat, for the node-engine to wait on when its
|
||||||
|
// pull fails. Absent for a seat with no holder on record.
|
||||||
|
SeatHolder map[string]string
|
||||||
|
|
||||||
// Built is every `<module>/<artifact>` the mesh has built. What tells a reference recorded
|
// Built is every `<module>/<artifact>` the mesh has built. What tells a reference recorded
|
||||||
// with an address — before references were kept without one — from an image a module runs
|
// with an address — before references were kept without one — from an image a module runs
|
||||||
// straight from a public registry.
|
// straight from a public registry.
|
||||||
@@ -273,8 +278,8 @@ type Rendering struct {
|
|||||||
Hands bool
|
Hands bool
|
||||||
Provisioner string
|
Provisioner string
|
||||||
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
|
// Assigned is, by module, whether the vault judged it handed (AssignmentHands of the plan in its assignment), as the
|
||||||
// vault answered the node composing itself; where it names a module, Handing follows it alone (issue 774). Nil, or a
|
// vault answered the node composing itself. Never the rule: the node hands by the manifest, its build and the machine,
|
||||||
// module it does not name, is judged by the manifest and the machine, as the composer of the plans does.
|
// and reads this only to say where the vault disagrees (issue 1966). Nil where the vault said nothing.
|
||||||
Assigned map[string]bool
|
Assigned map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+53
-1
@@ -1,7 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,15 +11,65 @@ import (
|
|||||||
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
|
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
|
||||||
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
|
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
|
||||||
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
|
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
|
||||||
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape.
|
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, a field of the wrong shape, or one field
|
||||||
|
// given twice in one object at any depth, named by its path: a decoder keeps the last of two equal keys without a word,
|
||||||
|
// so a merge of two branches that each added the field would lose one side silently (as mesh-catalog's checks/manifests
|
||||||
|
// refuses it).
|
||||||
func Decode(raw []byte) (Manifest, error) {
|
func Decode(raw []byte) (Manifest, error) {
|
||||||
var m Manifest
|
var m Manifest
|
||||||
if err := json.Unmarshal(raw, &m); err != nil {
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
|
if twice := givenTwice(raw); twice != "" {
|
||||||
|
return Manifest{}, fmt.Errorf("the field %s is given twice; a manifest gives each field once", twice)
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// givenTwice is the first field an object of raw gives twice, as its path (`invokes`, `build.artifacts[0].env`), or empty.
|
||||||
|
func givenTwice(raw []byte) string {
|
||||||
|
d := json.NewDecoder(bytes.NewReader(raw))
|
||||||
|
var walk func(path string) string
|
||||||
|
walk = func(path string) string {
|
||||||
|
t, err := d.Token()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch t {
|
||||||
|
case json.Delim('{'):
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for d.More() {
|
||||||
|
k, err := d.Token()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key, _ := k.(string)
|
||||||
|
at := key
|
||||||
|
if path != "" {
|
||||||
|
at = path + "." + key
|
||||||
|
}
|
||||||
|
if seen[key] {
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if twice := walk(at); twice != "" {
|
||||||
|
return twice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = d.Token()
|
||||||
|
case json.Delim('['):
|
||||||
|
for i := 0; d.More(); i++ {
|
||||||
|
if twice := walk(fmt.Sprintf("%s[%d]", path, i)); twice != "" {
|
||||||
|
return twice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = d.Token()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return walk("")
|
||||||
|
}
|
||||||
|
|
||||||
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
|
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
|
||||||
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
|
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
|
||||||
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
|
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// DeliverySeat is the seat mesh-delivery holds.
|
// DeliverySeat is the seat mesh-delivery holds.
|
||||||
const DeliverySeat = "mesh-delivery"
|
const DeliverySeat = contractseats.MeshDelivery
|
||||||
|
|
||||||
// RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the
|
// RemovedDeliveryVerbs are the delivery seat's verbs release 6 removed (novox/hq ADR 0539 clause 17): with no plan the
|
||||||
// gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row
|
// gate keeps the merge's states and derives nothing after the merge. Each stays in deliveryVerbs as an optional row
|
||||||
@@ -15,76 +17,6 @@ var RemovedDeliveryVerbs = map[string]string{
|
|||||||
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
|
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
|
||||||
func deliveryRemovedInR6(name string) Verb {
|
|
||||||
return Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " +
|
|
||||||
RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
|
|
||||||
// on a merge (ADR 0539 clause 17).
|
|
||||||
func deliveryVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
|
|
||||||
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
|
|
||||||
"by state, repository or group.",
|
|
||||||
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
|
|
||||||
"repository": "owner/repository", "group": "a group's id (its branch name)",
|
|
||||||
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
|
|
||||||
deliveryRemovedInR6("times"),
|
|
||||||
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
|
|
||||||
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
|
|
||||||
"steps of its walk, its group and its order.",
|
|
||||||
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
|
|
||||||
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
|
|
||||||
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
|
|
||||||
"state, derived from its members.",
|
|
||||||
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
|
|
||||||
deliveryRemovedInR6("what-if"),
|
|
||||||
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
|
|
||||||
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
|
|
||||||
"mesh/delivery, …) with its state, " +
|
|
||||||
"description and when it was set; the merge check's full verdict as the controller said it — each " +
|
|
||||||
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
|
|
||||||
"protection would let it merge, every required status being success.",
|
|
||||||
Input: schema(map[string]string{"repository": "owner/repository",
|
|
||||||
"number": "a pull request's number: its head is read",
|
|
||||||
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
|
|
||||||
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
|
|
||||||
Optional: true},
|
|
||||||
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
|
|
||||||
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
|
|
||||||
"lets healer H2 take for it, or none.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
|
|
||||||
"controller is asked for its check. With why.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
deliveryRemovedInR6("release"),
|
|
||||||
deliveryRemovedInR6("stop"),
|
|
||||||
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
|
|
||||||
"past its bound. Refused for any other.",
|
|
||||||
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
|
|
||||||
[]string{"id", "why"})},
|
|
||||||
deliveryRemovedInR6("retire-history"),
|
|
||||||
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
|
|
||||||
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
|
|
||||||
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
|
|
||||||
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
|
|
||||||
"reason, naming why.",
|
|
||||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
|
|
||||||
"properties": map[string]any{
|
|
||||||
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
|
|
||||||
"description": "the held deliveries' ids (owner/repository@commit)"},
|
|
||||||
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
|
|
||||||
}},
|
|
||||||
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
|
|
||||||
Optional: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
|
// SeatSays is whether a consumed `<seat>.<event>` names one of the seat's own events: the seat emits it,
|
||||||
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
|
// exactly or by one of its patterns (`log.*`). A consumed name whose emitter is a seat and whose event the
|
||||||
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
|
// seat does not emit is the event of the module of that name (novox/hq ADR 0239): mesh-delivery is a seat
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package catalogue
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a switch.
|
// The node-engine's compose-candidate verb (ADR 0593 §7) is optional, asks the candidate, and takes input as a boolean, as the engine and the checker read it.
|
||||||
func TestTheEngineServesComposeCandidate(t *testing.T) {
|
func TestTheEngineServesComposeCandidate(t *testing.T) {
|
||||||
for _, v := range EngineVerbs() {
|
for _, v := range EngineVerbs() {
|
||||||
if v.Name != "compose-candidate" {
|
if v.Name != "compose-candidate" {
|
||||||
@@ -10,7 +10,7 @@ func TestTheEngineServesComposeCandidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
props, _ := v.Input["properties"].(map[string]any)
|
props, _ := v.Input["properties"].(map[string]any)
|
||||||
input, _ := props["input"].(map[string]any)
|
input, _ := props["input"].(map[string]any)
|
||||||
if !v.Optional || props["candidate"] == nil || input == nil || input["enum"] == nil {
|
if !v.Optional || props["candidate"] == nil || input == nil || input["type"] != "boolean" {
|
||||||
t.Fatalf("compose-candidate reads as %+v", v)
|
t.Fatalf("compose-candidate reads as %+v", v)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|||||||
+26
-79
@@ -1,85 +1,32 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
// EngineSeat is the node seat every node's engine holds.
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
const EngineSeat = "node-engine"
|
|
||||||
|
|
||||||
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them.
|
// EngineSeat is the node seat every node's engine holds, from the seat set. The seat is defined whole in mesh-contracts
|
||||||
|
// (its contract, go/seats/nodeengine, and its client, go/clients/nodeengineclient): its verbs' and events' names and
|
||||||
|
// message types are taken from there (ADR 0627 §4).
|
||||||
|
const EngineSeat = contractseats.NodeEngine
|
||||||
|
|
||||||
|
// EngineVerbs are the node-engine seat's verbs, in the order ADR 0563 §1 names them: its definition's, read from the
|
||||||
|
// generated seat list (design 26), each with its description and its input's schema as discovery shows them.
|
||||||
func EngineVerbs() []Verb {
|
func EngineVerbs() []Verb {
|
||||||
return []Verb{
|
s, _ := seatIn(defaultSeats, EngineSeat)
|
||||||
{Name: "hand-over", Description: "Hand a directory this machine uses as found to the mesh (novox/hq ADR 0563 §3, " +
|
return append([]Verb(nil), s.Serves...)
|
||||||
"§5): the engine judges the path as it does every hand-over (absolute, as it states it, used as found, " +
|
}
|
||||||
"reached through no link) and refuses in words, or asks the operator on their channel at the level approve, " +
|
|
||||||
"binding the owner, group and mode it will set. Nothing changes on the call: only the operator's yes hands " +
|
// EngineEvents are what the seat says: of an act, each naming the act, the path or access, the caller as the runtime
|
||||||
"it over. One ask is open per path; acts shows where it stands.",
|
// proved it and the ask (ADR 0563 §1), and that what its engine reads of a manifest changed (ADR 0618 §4).
|
||||||
Input: schema(map[string]string{"path": "the directory's absolute path, as the engine states it",
|
var EngineEvents = func() []string {
|
||||||
"why": "why it is asked, in words the operator reads"}, []string{"path"})},
|
s, _ := seatIn(defaultSeats, EngineSeat)
|
||||||
{Name: "access-act", Description: "Ask the operator for the take act on a library whose root holds data outside its " +
|
return append([]string(nil), s.Emits...)
|
||||||
"group (novox/hq ADR 0328 §7, ADR 0563 §3, §4), on the library's home engine: the access as declared and its " +
|
}()
|
||||||
"root as last declared, judged and refused in words or asked at the level approve. Only the operator's yes " +
|
|
||||||
"sets the library's group, group write and setgid under its root. One ask is open per access.",
|
// seatIn is the seat of that name in a list.
|
||||||
Input: schema(map[string]string{"act": "the act: take", "access": "the access, library:<name>",
|
func seatIn(list []Seat, name string) (Seat, bool) {
|
||||||
"why": "why it is asked, in words the operator reads"}, []string{"act", "access"})},
|
for _, s := range list {
|
||||||
{Name: "judge-directories", Description: "Which of this machine's declared directories the engine would refuse to " +
|
if s.Name == name {
|
||||||
"make or keep, one line each (novox/hq ADR 0563 §2). Read-only: changes no owner or mode.",
|
return s, true
|
||||||
Input: schema(map[string]string{}, nil)},
|
}
|
||||||
{Name: "setuid-search", Description: "Throw away the engine's last search for setuid programs and start a full one " +
|
|
||||||
"(novox/hq ADR 0563 §2); until it completes root-free answers that the machine is not judged yet. Refused while " +
|
|
||||||
"one runs, and when one was started within the hour, naming when the next may start.",
|
|
||||||
Input: schema(map[string]string{"why": "why it is asked, in words"}, nil)},
|
|
||||||
{Name: "root-free", Description: "Whether this machine is root-free (novox/hq ADR 0259 §8, ADR 0563 §6): free only " +
|
|
||||||
"on the engine's own verdict, at most fifteen minutes old, that the account agents run as cannot become root " +
|
|
||||||
"without a person, and the login shell's execute not served here. Anything else is no pass, and says why. " +
|
|
||||||
"Answered by this machine's engine alone.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "acts", Description: "Every act this machine's engine was asked through this seat (novox/hq ADR 0563 §9): " +
|
|
||||||
"each hand-over, access act and setuid search, open, done or refused, with who asked, the ask and the " +
|
|
||||||
"operator's warrant. Read-only.",
|
|
||||||
Input: schema(map[string]string{"limit": "at most this many, newest", "id": "one act, by its id"}, nil)},
|
|
||||||
// The packages this node's compositions declare (novox/hq ADR 0565 §6, §15): optional until every engine serves it.
|
|
||||||
{Name: "packages", Optional: true, Description: "The packages this machine's compositions declare, present or absent, " +
|
|
||||||
"each with the modules that declare it, as the engine keeps them in its key of `packages`; until the controller " +
|
|
||||||
"hands its lease away, beside the controller's mirror for this machine and their difference. With write, the " +
|
|
||||||
"key written again from what the engine composed.",
|
|
||||||
Input: schema(map[string]string{"write": "\"true\": write this machine's key again from what the engine composed"}, nil, "write")},
|
|
||||||
// Read-only (issue 774): why a module waits for a yes, or what its next apply would change. Optional until every
|
|
||||||
// engine serves it.
|
|
||||||
{Name: "composition", Optional: true, Description: "One module's composition on this machine beside what runs of " +
|
|
||||||
"it, read-only: the composition its want's build has in the bucket composed-<node> (the latest revision, or the " +
|
|
||||||
"one whose digest is given, from the revisions the bucket keeps), the digest of each kept revision, what runs " +
|
|
||||||
"(applied, its have, or the kept declaration), its want and have (phase running, failed, held or waiting-yes, " +
|
|
||||||
"and the digest that waits for a yes), and the move's judgement with its words: why a record module waits for " +
|
|
||||||
"a yes. With diff, every resource added, removed or changed, a provider's received file naming the consumers " +
|
|
||||||
"that join or leave.",
|
|
||||||
Input: schema(map[string]string{"module": "the module, as placed on this machine; node for the node's own",
|
|
||||||
"digest": "a composition's digest (sha256:…), to read that revision rather than the latest",
|
|
||||||
"diff": "\"true\": list the resources that differ from what runs"}, []string{"module"})},
|
|
||||||
// The recovery of a composition that failed (issue 774, 2026-10-31: ace kept route-proxy failed for a vault that
|
|
||||||
// answered again a minute later). Optional until every engine serves it.
|
|
||||||
{Name: "compose", Optional: true, Description: "Compose this machine's modules now and say how it went, so a " +
|
|
||||||
"composition that failed, or a module held, recovers without a restart: what the pass refused or held, each " +
|
|
||||||
"with why, and when the engine composes again on its own after a seat that did not answer (a deadline, no " +
|
|
||||||
"responders: retried with a growing wait; a refusal never). With module, that module's apply is also tried " +
|
|
||||||
"again. Changes only what a pass the engine makes on its own would change.",
|
|
||||||
Input: schema(map[string]string{"module": "a module placed on this machine whose apply is also tried again (optional)"}, nil)},
|
|
||||||
// A composition that does not exist yet (novox/hq ADR 0593 §7). Optional until every engine serves it: a node
|
|
||||||
// whose engine does not is a node that does not answer (§9).
|
|
||||||
{Name: "compose-candidate", Optional: true, Description: "Compose a check's candidate on this machine in shadow, " +
|
|
||||||
"with its own sealed values and the indexer's manifest verdicts, and say per module placed here whether it " +
|
|
||||||
"composes (with its digest and whether the candidate changes it), is refused by the candidate or is held " +
|
|
||||||
"without it, each with why, and the verdict of the machine's own composition (novox/hq ADR 0593 §7). Read-only: " +
|
|
||||||
"applies nothing and writes nothing. Answered by this machine's engine alone, within the asker's bound.",
|
|
||||||
Input: schema(map[string]string{"candidate": "the key of the indexer's candidate, <group>.<heads hash>",
|
|
||||||
"input": "\"true\": also answer the composition input the candidate was composed from, for the sandbox path"},
|
|
||||||
[]string{"candidate"}, "input")},
|
|
||||||
}
|
}
|
||||||
}
|
return Seat{}, false
|
||||||
|
|
||||||
// EngineEvents are what the seat says of an act, each naming the act, the path or access, the caller as the runtime
|
|
||||||
// proved it and the ask (ADR 0563 §1).
|
|
||||||
var EngineEvents = []string{"asked", "done", "refused"}
|
|
||||||
|
|
||||||
// engineSeat is the seat's row in the closed set.
|
|
||||||
func engineSeat() Seat {
|
|
||||||
return Seat{Name: EngineSeat, Scope: ScopeNode, Serves: EngineVerbs(), Emits: append([]string(nil), EngineEvents...),
|
|
||||||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// EnrolmentSeat is the seat mesh-enrolment.
|
// EnrolmentSeat is the seat mesh-enrolment.
|
||||||
const EnrolmentSeat = "mesh-enrolment"
|
const EnrolmentSeat = contractseats.MeshEnrolment
|
||||||
|
|
||||||
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
// The seat's six verbs (ADR 0587 §1, §4-§8).
|
||||||
const (
|
const (
|
||||||
@@ -29,62 +31,3 @@ var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, Enrolmen
|
|||||||
|
|
||||||
// enrolmentDecision is the record that made the seat.
|
// enrolmentDecision is the record that made the seat.
|
||||||
const enrolmentDecision = "novox/hq ADR 0587"
|
const enrolmentDecision = "novox/hq ADR 0587"
|
||||||
|
|
||||||
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
|
|
||||||
// the seat is new with its holder, so no holder of before serves fewer.
|
|
||||||
func enrolmentVerbs() []Verb {
|
|
||||||
str := func(description string) map[string]any {
|
|
||||||
return map[string]any{"type": "string", "description": description}
|
|
||||||
}
|
|
||||||
return []Verb{
|
|
||||||
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
|
|
||||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
|
|
||||||
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
|
|
||||||
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
|
|
||||||
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
|
|
||||||
"The token itself is read only through tokens.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"node": "the new machine's name in the mesh",
|
|
||||||
"why": "why it joins, shown to the operator with the ask",
|
|
||||||
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
|
|
||||||
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
|
|
||||||
// enters placement with it and the hub peers it before redeem.
|
|
||||||
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
|
|
||||||
}, []string{"node", "why", "overlay_key"})},
|
|
||||||
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
|
|
||||||
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
|
|
||||||
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
|
|
||||||
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
|
|
||||||
"it finishes what is not done and answers the same; with other keys it is refused.",
|
|
||||||
Input: map[string]any{"type": "object", "additionalProperties": false,
|
|
||||||
"required": []string{"node", "public_key", "sealing_key", "proof"},
|
|
||||||
"properties": map[string]any{
|
|
||||||
"node": str("the node the token was issued for"),
|
|
||||||
"public_key": str("the identity key's public half, base64"),
|
|
||||||
"overlay_key": str("the private network key's public half"),
|
|
||||||
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
|
|
||||||
"serving_key": str("the serving key's public half, which the certificate binds"),
|
|
||||||
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
|
|
||||||
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
|
|
||||||
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
|
|
||||||
}}},
|
|
||||||
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
|
|
||||||
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
|
|
||||||
"enrolment.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
|
|
||||||
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
|
|
||||||
"it, records the act as a hand act and says enrolment-revoked.",
|
|
||||||
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
|
|
||||||
[]string{"node", "why"})},
|
|
||||||
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
|
|
||||||
"the bucket's fill against its cap. For a node at live, its " +
|
|
||||||
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
|
|
||||||
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
|
|
||||||
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
|
|
||||||
nil, "history")},
|
|
||||||
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
|
|
||||||
"and nothing more.",
|
|
||||||
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
||||||
// contributed: the account's environment, and the login shell's code.
|
// contributed: the account's environment, and the login shell's code.
|
||||||
const (
|
const (
|
||||||
EnvironmentSeat = "node-environment"
|
EnvironmentSeat = contractseats.NodeEnvironment
|
||||||
LoginShellSeat = "node-login-shell"
|
LoginShellSeat = contractseats.NodeLoginShell
|
||||||
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
|
// PowerSeat is the seat whose holder places code for the power moments (novox/hq ADR 0211).
|
||||||
PowerSeat = "node-power"
|
PowerSeat = contractseats.NodePower
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
// Where an environment entry on PATH goes: before the account's existing PATH, or after it.
|
||||||
|
|||||||
+2
-74
@@ -1,11 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
|
// FilesSeat is the role that reads and manages a node's files and accounts (novox/hq ADR 0394).
|
||||||
const FilesSeat = "node-files"
|
const FilesSeat = contractseats.NodeFiles
|
||||||
|
|
||||||
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
|
// FilesOwnedPaths is the word the holder of node-files is given beside the secret places: a JSON object from
|
||||||
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
|
// each absolute path a module owns on its node to "module (tool to use)" (novox/hq ADR 0394 §5).
|
||||||
@@ -16,73 +14,3 @@ const (
|
|||||||
"secret is kept, or a known credential file, is refused by name"
|
"secret is kept, or a known credential file, is refused by name"
|
||||||
overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
||||||
)
|
)
|
||||||
|
|
||||||
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
|
|
||||||
// with their descriptions restated for the operator's account, and the writes.
|
|
||||||
func filesVerbs() []Verb {
|
|
||||||
var verbs []Verb
|
|
||||||
for _, v := range readsVerbs() {
|
|
||||||
v.Description = strings.Replace(v.Description,
|
|
||||||
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
|
|
||||||
"", 1)
|
|
||||||
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
|
|
||||||
verbs = append(verbs, v)
|
|
||||||
}
|
|
||||||
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) Verb {
|
|
||||||
in["reason"] = "why the file is changed, for the audit trail"
|
|
||||||
in["override"] = overrideWord
|
|
||||||
return Verb{Name: name, Description: description + " 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(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
|
|
||||||
}
|
|
||||||
return append(verbs,
|
|
||||||
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
|
|
||||||
map[string]string{"path": "an absolute path", "content": "the text to write",
|
|
||||||
"mode": "the file's mode, such as 0644 (optional)"},
|
|
||||||
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
|
|
||||||
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
|
|
||||||
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
|
|
||||||
"kept in the node's trash for 7 days.",
|
|
||||||
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
|
|
||||||
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
|
|
||||||
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
|
|
||||||
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
|
|
||||||
map[string]string{"from": "an absolute path", "to": "an absolute path"},
|
|
||||||
[]string{"from", "to"}, nil, "mv"),
|
|
||||||
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
|
|
||||||
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
|
|
||||||
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
|
|
||||||
write("mkdir", "Make a directory.",
|
|
||||||
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
|
|
||||||
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
|
|
||||||
write("chmod", "Change the mode of a file or directory.",
|
|
||||||
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
|
|
||||||
"recursive": "\"true\": also everything under a directory"},
|
|
||||||
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
|
|
||||||
write("chown", "Change the owner and group of a file or directory.",
|
|
||||||
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"}},
|
|
||||||
write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
|
|
||||||
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
// The graphical session's seats (novox/hq ADR 0208): one module per piece of software, each piece's
|
||||||
@@ -10,130 +12,19 @@ import (
|
|||||||
// a role rather than each inventing one — and a machine running two of one role is refused at
|
// a role rather than each inventing one — and a machine running two of one role is refused at
|
||||||
// assignment instead of found by two bars on one screen.
|
// assignment instead of found by two bars on one screen.
|
||||||
const (
|
const (
|
||||||
LoginManagerSeat = "node-login-manager"
|
LoginManagerSeat = contractseats.NodeLoginManager
|
||||||
DisplayServerSeat = "node-display-server"
|
DisplayServerSeat = contractseats.NodeDisplayServer
|
||||||
DisplaySessionSeat = "node-display-session"
|
DisplaySessionSeat = contractseats.NodeDisplaySession
|
||||||
TerminalEmulatorSeat = "node-terminal-emulator"
|
TerminalEmulatorSeat = contractseats.NodeTerminalEmulator
|
||||||
LauncherSeat = "node-launcher"
|
LauncherSeat = contractseats.NodeLauncher
|
||||||
NotifierSeat = "node-notifier"
|
NotifierSeat = contractseats.NodeNotifier
|
||||||
LockScreenSeat = "node-lock-screen"
|
LockScreenSeat = contractseats.NodeLockScreen
|
||||||
ClipboardSeat = "node-clipboard"
|
ClipboardSeat = contractseats.NodeClipboard
|
||||||
BarSeat = "node-bar"
|
BarSeat = contractseats.NodeBar
|
||||||
CompositorSeat = "node-compositor"
|
CompositorSeat = contractseats.NodeCompositor
|
||||||
SecretServiceSeat = "node-secret-service"
|
SecretServiceSeat = contractseats.NodeSecretService
|
||||||
)
|
)
|
||||||
|
|
||||||
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
|
|
||||||
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
|
|
||||||
// ADR 0255), the compositor and the secret
|
|
||||||
// service are roles a second holder competes for, and nothing has needed to ask them anything.
|
|
||||||
func graphicalSessionSeats() []Seat {
|
|
||||||
const decided = "novox/hq ADR 0208"
|
|
||||||
return []Seat{
|
|
||||||
{Name: LoginManagerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
|
|
||||||
"one the operator account starts by default.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: DisplayServerSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
|
|
||||||
"where it is placed; and the layout profile in force, if one matches.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
|
|
||||||
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
|
|
||||||
"identities: list them, save the current arrangement under a name, or apply one.",
|
|
||||||
Input: withEnum(schema(map[string]string{
|
|
||||||
"action": "list, save or apply",
|
|
||||||
"name": "the profile to save or apply (save and apply only)",
|
|
||||||
}, []string{"action"}), "action", "list", "save", "apply")},
|
|
||||||
}},
|
|
||||||
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
|
|
||||||
// bindings, start-up commands, rules — placed by the session's holder, never written into
|
|
||||||
// its directory by the contributor.
|
|
||||||
{Name: DisplaySessionSeat, Scope: ScopeNode, Decision: decided,
|
|
||||||
Receives: []Receivable{{Kind: "config", Comment: "#"}}, Serves: []Verb{
|
|
||||||
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
|
|
||||||
"it is visible or focused.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
|
|
||||||
"whether it has focus; narrowed to one workspace when named.",
|
|
||||||
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: TerminalEmulatorSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
|
|
||||||
"or the login shell, in a directory or the account's home.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "what to run in it (optional; the login shell when absent)",
|
|
||||||
"directory": "where it starts (optional; the account's home when absent)",
|
|
||||||
}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: LauncherSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
|
|
||||||
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
|
|
||||||
Input: map[string]any{"type": "object", "required": []string{"choices"},
|
|
||||||
"properties": map[string]any{
|
|
||||||
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
|
||||||
"description": "the lines to choose between, in order"},
|
|
||||||
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
|
|
||||||
}}},
|
|
||||||
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
|
|
||||||
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
|
|
||||||
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
|
|
||||||
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
|
|
||||||
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
|
|
||||||
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
|
|
||||||
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
|
|
||||||
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
|
|
||||||
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module whose own secret is asked for",
|
|
||||||
"secret": "the own secret's name",
|
|
||||||
"node": "the machine the module runs on",
|
|
||||||
"seal_to": "the asker's public sealing key: the answer is sealed to it",
|
|
||||||
"timeout_seconds": "give up after this long (optional)",
|
|
||||||
}, []string{"module", "secret", "node", "seal_to"})},
|
|
||||||
}},
|
|
||||||
{Name: NotifierSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "send", Description: "Show the operator a notification.",
|
|
||||||
Input: withEnum(schema(map[string]string{
|
|
||||||
"title": "the notification's summary",
|
|
||||||
"body": "its text (optional)",
|
|
||||||
"urgency": "low, normal (the default) or critical",
|
|
||||||
}, []string{"title"}), "urgency", "low", "normal", "critical")},
|
|
||||||
{Name: "history", Description: "The notifications shown lately, newest first.",
|
|
||||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: LockScreenSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "lock", Description: "Lock the operator's session now.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}},
|
|
||||||
{Name: ClipboardSeat, Scope: ScopeNode, Decision: decided, Serves: []Verb{
|
|
||||||
{Name: "history", Description: "What the clipboard held lately, newest first.",
|
|
||||||
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
|
|
||||||
{Name: "copy", Description: "Put text on the operator's clipboard.",
|
|
||||||
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
|
|
||||||
}},
|
|
||||||
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
|
|
||||||
// the holder renders in its own grammar. A module that knows something about the machine — the
|
|
||||||
// power module its battery — gives the block; the bar knows no machine's hardware.
|
|
||||||
{Name: BarSeat, Scope: ScopeNode, Decision: decided,
|
|
||||||
Receives: []Receivable{{Kind: BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
|
||||||
{Name: CompositorSeat, Scope: ScopeNode, Decision: decided},
|
|
||||||
{Name: SecretServiceSeat, Scope: ScopeNode, Decision: decided},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
|
||||||
// the choices by the schema rather than by a refusal.
|
|
||||||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
|
||||||
props := s["properties"].(map[string]any)
|
|
||||||
p := props[property].(map[string]any)
|
|
||||||
p["enum"] = values
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
|
// BarKindBlock is the bar's one received kind: a block (novox/hq ADR 0255).
|
||||||
const BarKindBlock = "block"
|
const BarKindBlock = "block"
|
||||||
|
|
||||||
@@ -171,34 +62,6 @@ type barOption struct {
|
|||||||
required bool
|
required bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
|
|
||||||
// what it shows, and that kind's options.
|
|
||||||
//
|
|
||||||
// - bar: top or bottom;
|
|
||||||
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
|
|
||||||
// clock) — the two places every holder keeps for contributed blocks;
|
|
||||||
// - order: 0–99 within the place, 50 when absent;
|
|
||||||
// - shows: battery or command; options: what that kind takes.
|
|
||||||
func barBlockShape() *Shape {
|
|
||||||
return &Shape{
|
|
||||||
Text: true,
|
|
||||||
Places: []PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
|
||||||
{Field: "place", Values: []string{"resources", "status"}}},
|
|
||||||
Order: "order",
|
|
||||||
Check: barBlockProblems,
|
|
||||||
Reads: barBlockReads,
|
|
||||||
Examples: []map[string]any{
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "battery"},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "command",
|
|
||||||
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
|
|
||||||
{"bar": "bottom", "place": "status", "shows": "state",
|
|
||||||
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func barBlockProblems(data map[string]any) []string {
|
func barBlockProblems(data map[string]any) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
for field := range data {
|
for field := range data {
|
||||||
@@ -245,3 +108,11 @@ func barBlockProblems(data map[string]any) []string {
|
|||||||
}
|
}
|
||||||
return problems
|
return problems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withShapeCode gives a data-shaped kind of the generated seat list the checks its definition cannot carry, being code:
|
||||||
|
// the bar's block (novox/hq ADR 0255, ADR 0260).
|
||||||
|
func withShapeCode(seat, kind string, sh *Shape) {
|
||||||
|
if seat == BarSeat && kind == BarKindBlock {
|
||||||
|
sh.Check, sh.Reads = barBlockProblems, barBlockReads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
|||||||
t.Errorf("layout's actions are %v", action["enum"])
|
t.Errorf("layout's actions are %v", action["enum"])
|
||||||
}
|
}
|
||||||
// And they survive the store's JSON, which is where the live set comes from.
|
// And they survive the store's JSON, which is where the live set comes from.
|
||||||
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
|
if _, err := json.Marshal(DefaultSeats()); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,8 +233,10 @@ func handedBy(m Manifest, archive Built) *Handed {
|
|||||||
|
|
||||||
// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape,
|
// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape,
|
||||||
// `{"provisioning": <Plan>}`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no
|
// `{"provisioning": <Plan>}`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no
|
||||||
// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node
|
// plan. A value that does not read is an error, never "not handed". What the vault judges `own` by: the plan is what the
|
||||||
// composing itself follows (Rendering.Assigned, issue 774).
|
// node's last composition made, so it follows that node's rule (the manifest, its build and the machine) one
|
||||||
|
// composition later. Never the rule a node composing itself hands by: with no plan yet it would never make one (issue
|
||||||
|
// 1966); the node reads it only to say where the two disagree (Rendering.Assigned).
|
||||||
func AssignmentHands(raw []byte) (bool, error) {
|
func AssignmentHands(raw []byte) (bool, error) {
|
||||||
var a struct {
|
var a struct {
|
||||||
Provisioning *Plan `json:"provisioning"`
|
Provisioning *Plan `json:"provisioning"`
|
||||||
|
|||||||
@@ -1,108 +1,14 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// The four health seats.
|
// The four health seats.
|
||||||
const (
|
const (
|
||||||
ConditionsSeat = "mesh-conditions"
|
ConditionsSeat = contractseats.MeshConditions
|
||||||
WatchdogsSeat = "mesh-watchdogs"
|
WatchdogsSeat = contractseats.MeshWatchdogs
|
||||||
SelfCheckSeat = "mesh-self-check"
|
SelfCheckSeat = contractseats.MeshSelfCheck
|
||||||
HealersSeat = "mesh-healers"
|
HealersSeat = contractseats.MeshHealers
|
||||||
)
|
)
|
||||||
|
|
||||||
// healthDecision is the record that made the four seats.
|
// healthDecision is the record that made the four seats.
|
||||||
const healthDecision = "novox/hq ADR 0564"
|
const healthDecision = "novox/hq ADR 0564"
|
||||||
|
|
||||||
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
|
|
||||||
func conditionsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
|
|
||||||
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
|
|
||||||
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
|
|
||||||
"lately.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"key": "a condition's key: that one whole; with history, only its transitions",
|
|
||||||
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
|
|
||||||
"severity": "only urgent, or only warning",
|
|
||||||
"machine": "only those about this machine",
|
|
||||||
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
|
|
||||||
"days": "with history: how many days back (default 7, at most 90)",
|
|
||||||
}, nil, "history")},
|
|
||||||
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
|
|
||||||
"status, and the silence is kept as its transition in history.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"key": "the condition's key",
|
|
||||||
"for": "how long: 30m, 4h, 2d; at most 7d",
|
|
||||||
"why": "why, required and recorded",
|
|
||||||
"cause": "the cause in a word (the condition's kind when absent)",
|
|
||||||
}, []string{"key", "for", "why"})},
|
|
||||||
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
|
|
||||||
"conditions: the recovery of a keeper that missed a change.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
|
|
||||||
func watchdogsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
|
|
||||||
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
|
|
||||||
"expiry wrote.",
|
|
||||||
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
|
|
||||||
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
|
|
||||||
"when and the words of its finding.",
|
|
||||||
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
|
|
||||||
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
|
|
||||||
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
|
|
||||||
Input: schema(map[string]string{"machine": "one machine"}, nil)},
|
|
||||||
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
|
|
||||||
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
|
|
||||||
"whole. With deliver or drop, a hand act with why.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
|
|
||||||
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
|
|
||||||
"limit": "how many to list, newest first (default 50); only when listing",
|
|
||||||
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
|
|
||||||
"drop": "a dead letter's id: drop it for good (needs why)",
|
|
||||||
"why": "with deliver or drop: why, required and recorded",
|
|
||||||
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
|
|
||||||
}, nil)},
|
|
||||||
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
|
|
||||||
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
|
|
||||||
func selfCheckVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
|
|
||||||
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
|
|
||||||
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"run": "\"true\": run every probe now and answer the verdict",
|
|
||||||
"probe": "one probe's id (D6): its verdict alone, with every finding",
|
|
||||||
}, nil, "run")},
|
|
||||||
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
|
|
||||||
func healersVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
|
|
||||||
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
|
|
||||||
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
|
|
||||||
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"key": "the condition's key",
|
|
||||||
"why": "why now, kept with the act",
|
|
||||||
}, []string{"key", "why"})},
|
|
||||||
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
|
|
||||||
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"what": "what was done on purpose, in a line",
|
|
||||||
"why": "what the drill tests",
|
|
||||||
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
|
|
||||||
}, []string{"what", "why"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,9 +18,15 @@ func busSeatDelivering(t *testing.T, delivers string) {
|
|||||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
|
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brokerVerbs are the bus seat's verbs as a literal: mesh-sdk imports no seat's contract module, a test neither
|
||||||
|
// (ADR 0625 §3).
|
||||||
|
func brokerVerbs() []string {
|
||||||
|
return []string{"list", "show", "check", "orphans", "create", "apply", "recreate", "drop"}
|
||||||
|
}
|
||||||
|
|
||||||
func newBroker() Manifest {
|
func newBroker() Manifest {
|
||||||
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanHold is the one judgement registration and the handover share, against the store's row.
|
// CanHold is the one judgement registration and the handover share, against the store's row.
|
||||||
@@ -41,7 +47,7 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
|||||||
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
|
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
|
||||||
}
|
}
|
||||||
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
|
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
|
||||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh, Serves: brokerVerbs()}}}
|
||||||
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||||
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
package catalogue
|
|
||||||
|
|
||||||
// This file keeps, deprecated, the exported names feat/l3b-bus removed from this package while a running part still
|
|
||||||
// uses them: an exported name is removed only after every user of it is delivered. The checker builds the running
|
|
||||||
// node-engine's composer (mesh-host main) beside this mesh-sdk (ADR 0548 §6), and mesh-host main names them
|
|
||||||
// (RuntimeModule, CoreCredentialModules, FromBus). Nothing in the final L3 release's heads uses any of them, but that
|
|
||||||
// release still builds beside mesh-host 798d73e, so it keeps this file. The next release that changes mesh-sdk, once
|
|
||||||
// the final L3 release's node-engine runs on every node, removes this file and its exemption in
|
|
||||||
// TestNoCatalogueManifestNamesAmqp.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Deprecated: kept until its users are delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// EndedControllerName is the name the ended controller had, as its module and as its seat: no manifest may name it.
|
|
||||||
const EndedControllerName = "mesh-controller"
|
|
||||||
|
|
||||||
// The kinds of a declaration that names the controller.
|
|
||||||
//
|
|
||||||
// Deprecated: kept until their users are delivered (the head of this file).
|
|
||||||
const (
|
|
||||||
DependsRead = "read"
|
|
||||||
DependsInvoke = "invoke"
|
|
||||||
DependsConsume = "consume"
|
|
||||||
DependsUses = "uses"
|
|
||||||
DependsWaits = "waits"
|
|
||||||
DependsClaims = "claims"
|
|
||||||
DependsRequires = "requires"
|
|
||||||
DependsIs = "is"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Deprecated: kept until its users are delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// Dependency is one declaration of a manifest that names the controller.
|
|
||||||
type Dependency struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
|
|
||||||
By string `json:"by,omitempty"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
What string `json:"what"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// onController says whether a name `<owner>.<rest>` is the controller's.
|
|
||||||
func onController(name string) bool { return strings.HasPrefix(name, EndedControllerName+".") }
|
|
||||||
|
|
||||||
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
|
|
||||||
// it is the console's grant to every tool.
|
|
||||||
func invokesController(invoke string) bool {
|
|
||||||
if seat, _, ok := SeatInvoke(invoke); ok {
|
|
||||||
return seat == EndedControllerName
|
|
||||||
}
|
|
||||||
return onController(invoke)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: kept until its users are delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// Dependencies are every declaration naming the controller in the manifests given, by module.
|
|
||||||
func Dependencies(manifests map[string]Manifest) []Dependency {
|
|
||||||
var out []Dependency
|
|
||||||
add := func(module, by, kind, what string) {
|
|
||||||
if d := (Dependency{Module: module, By: by, Kind: kind, What: what}); !slices.Contains(out, d) {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for name, m := range manifests {
|
|
||||||
if name == EndedControllerName || m.Module == EndedControllerName {
|
|
||||||
add(name, "", DependsIs, EndedControllerName)
|
|
||||||
}
|
|
||||||
for _, r := range m.Reads {
|
|
||||||
if onController(r) {
|
|
||||||
add(name, "", DependsRead, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, i := range m.Invokes {
|
|
||||||
if invokesController(i) {
|
|
||||||
add(name, "", DependsInvoke, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range m.Consumes {
|
|
||||||
if onController(c) {
|
|
||||||
add(name, "", DependsConsume, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, w := range m.Waits {
|
|
||||||
if onController(w) {
|
|
||||||
add(name, "", DependsWaits, w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, u := range m.Uses {
|
|
||||||
if u == EndedControllerName {
|
|
||||||
add(name, "", DependsUses, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, c := range m.Claims {
|
|
||||||
if c.Name == EndedControllerName {
|
|
||||||
add(name, "", DependsClaims, c.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, r := range m.Requires {
|
|
||||||
if r == EndedControllerName || onController(r) {
|
|
||||||
add(name, "", DependsRequires, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, a := range m.BusAccounts() {
|
|
||||||
by := "process " + a.ID
|
|
||||||
if a.Tools {
|
|
||||||
by = "tools " + a.ID
|
|
||||||
}
|
|
||||||
for _, i := range a.Bus.Invokes {
|
|
||||||
if invokesController(i) {
|
|
||||||
add(name, by, DependsInvoke, i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
a, b := out[i], out[j]
|
|
||||||
if a.Module != b.Module {
|
|
||||||
return a.Module < b.Module
|
|
||||||
}
|
|
||||||
if a.Kind != b.Kind {
|
|
||||||
return a.Kind < b.Kind
|
|
||||||
}
|
|
||||||
if a.What != b.What {
|
|
||||||
return a.What < b.What
|
|
||||||
}
|
|
||||||
return a.By < b.By
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: kept until its users are delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// ManifestRefusals is the check over a repository's manifests, by module name: one reason per declaration naming the
|
|
||||||
// controller, none when it passes. None given fails it, since nothing read can hide a declaration.
|
|
||||||
func ManifestRefusals(manifests map[string]Manifest) []string {
|
|
||||||
if len(manifests) == 0 {
|
|
||||||
return []string{"no manifest was read: with nothing read, nothing naming the controller can be judged absent"}
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for _, d := range Dependencies(manifests) {
|
|
||||||
who := d.Module
|
|
||||||
if d.By != "" {
|
|
||||||
who += " (" + d.By + ")"
|
|
||||||
}
|
|
||||||
out = append(out, fmt.Sprintf("%s declares %s %s: the controller ended by its unassignment and nothing may name it "+
|
|
||||||
"(ADR 0590 clause 5)", who, d.Kind, d.What))
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: a manifest says it (Manifest.CoreCredentials, CarriesCoreCredentials); kept until its users are
|
|
||||||
// delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
|
||||||
// its client.
|
|
||||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
|
||||||
|
|
||||||
// Deprecated: a manifest says it (Manifest.ToolRunner, IsToolRunner); kept until its users are delivered (the head
|
|
||||||
// of this file).
|
|
||||||
//
|
|
||||||
// RuntimeModule is the module that is the node's tool runtime.
|
|
||||||
const RuntimeModule = "node-tools"
|
|
||||||
|
|
||||||
// Deprecated: the kept streams are the indexer's judge's; kept until its users are delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// StreamsKeptUntilMoved are the streams declared before the check that a module keeps its data in the bus's
|
|
||||||
// predefined forms, each allowed by name until its module moves it.
|
|
||||||
var StreamsKeptUntilMoved = map[string][]string{
|
|
||||||
"brother": {"device-asks"},
|
|
||||||
"mesh-build-go": {"toolchains-go"},
|
|
||||||
"mesh-build-node": {"toolchains-node"},
|
|
||||||
"mesh-mail": {"mail", "mail-sends"},
|
|
||||||
"only-office": {"convert-asks"},
|
|
||||||
"mesh-delivery-assigner": {"placement"},
|
|
||||||
"mesh-delivery-checker": {"checks"},
|
|
||||||
"mesh-delivery-forge-bridge": {"forge"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deprecated: nothing derives it now (DerivedUpgrade reads the bus's own manifest); kept until its users are
|
|
||||||
// delivered (the head of this file).
|
|
||||||
//
|
|
||||||
// FromBus is the source an older DerivedUpgrade gave the bus's policy.
|
|
||||||
const FromBus = "the bus"
|
|
||||||
@@ -469,6 +469,11 @@ type Manifest struct {
|
|||||||
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
|
// this is for the rest — `zsh` provides `shell`, `xorg` provides `display-server`.
|
||||||
Provides []Offer `json:"provides,omitempty"`
|
Provides []Offer `json:"provides,omitempty"`
|
||||||
|
|
||||||
|
// Packages are the series of each mesh package this module accepts, by the package's name (novox/hq ADR 0606,
|
||||||
|
// design 32): its fingerprint takes the newest version of that series, and a publish of another series moves it
|
||||||
|
// not. A package it uses and does not name is accepted in DefaultSeries (AcceptedSeries).
|
||||||
|
Packages map[string]string `json:"packages,omitempty"`
|
||||||
|
|
||||||
// Requires are names that must be provided by something assigned to the same node.
|
// Requires are names that must be provided by something assigned to the same node.
|
||||||
Requires []string `json:"requires,omitempty"`
|
Requires []string `json:"requires,omitempty"`
|
||||||
// OptionalRequires is which of Requires the module can run without (novox/hq ADR 0330): filled
|
// OptionalRequires is which of Requires the module can run without (novox/hq ADR 0330): filled
|
||||||
@@ -1224,6 +1229,64 @@ type Artifact struct {
|
|||||||
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
// paths and constants composed with ${dir:…} and ${port:…} exactly as a container's environment
|
||||||
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
// is, never a secret's content. The node's runtime hands it to this bundle and to no other.
|
||||||
Env map[string]string `json:"env,omitempty"`
|
Env map[string]string `json:"env,omitempty"`
|
||||||
|
|
||||||
|
// Series is the series this artifact's versions are numbered in, `<major>.<minor>` (novox/hq ADR 0606, design 32):
|
||||||
|
// the builder numbers `<series>.<n>`, so a change of series is a change a user accepts by its own `packages`.
|
||||||
|
// Said on a package, an image, a bundle or an archive; the judge refuses it on any other kind. Absent is
|
||||||
|
// DefaultSeries (SeriesOf).
|
||||||
|
Series string `json:"series,omitempty"`
|
||||||
|
|
||||||
|
// GeneratedFrom marks a package of a contracts module (novox/hq ADR 0608 §5, design 26 *Contracts modules*): the
|
||||||
|
// directory, relative to the module's, of the seat definitions every file of the package is generated from. A
|
||||||
|
// module carrying it holds contracts only and claims no seat, which the judge refuses naming the seat; the build
|
||||||
|
// modules' test step regenerates the package and fails on a file out of step.
|
||||||
|
GeneratedFrom string `json:"generated-from,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcceptedSeries is the series of the package of that name this module accepts: the one Packages names, or else the
|
||||||
|
// series of the package's first numbered version (design 32), the lowest of numbered, the series the package has a
|
||||||
|
// numbered version in (the indexer's to say; a series is only ever raised); DefaultSeries while none is numbered.
|
||||||
|
func (m Manifest) AcceptedSeries(name string, numbered ...string) string {
|
||||||
|
if s := m.Packages[name]; s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
first := ""
|
||||||
|
for _, s := range numbered {
|
||||||
|
if seriesOrder(s) != nil && (first == "" || compareSeries(s, first) < 0) {
|
||||||
|
first = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first == "" {
|
||||||
|
return DefaultSeries
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
// seriesOrder is a series' major and minor, or nil when it is no `<major>.<minor>`.
|
||||||
|
func seriesOrder(s string) []int {
|
||||||
|
major, minor, ok := strings.Cut(s, ".")
|
||||||
|
a, err1 := strconv.Atoi(major)
|
||||||
|
b, err2 := strconv.Atoi(minor)
|
||||||
|
if !ok || err1 != nil || err2 != nil || a < 0 || b < 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []int{a, b}
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareSeries orders two well-formed series by major, then minor.
|
||||||
|
func compareSeries(a, b string) int {
|
||||||
|
return slices.Compare(seriesOrder(a), seriesOrder(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultSeries is the series of an artifact or an accepted package that names none (design 32).
|
||||||
|
const DefaultSeries = "0.1"
|
||||||
|
|
||||||
|
// SeriesOf is the series the artifact's versions are numbered in: its own, or DefaultSeries.
|
||||||
|
func (a Artifact) SeriesOf() string {
|
||||||
|
if a.Series == "" {
|
||||||
|
return DefaultSeries
|
||||||
|
}
|
||||||
|
return a.Series
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kinds an artifact may be.
|
// Kinds an artifact may be.
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ const (
|
|||||||
var referenceForms = []string{"access", "backup", "bound", "collected", "config", "consumer", "consumers",
|
var referenceForms = []string{"access", "backup", "bound", "collected", "config", "consumer", "consumers",
|
||||||
"contribution", "dir", "machine", "port", "seat", "secret", "setting"}
|
"contribution", "dir", "machine", "port", "seat", "secret", "setting"}
|
||||||
|
|
||||||
|
// ReferenceFormSeat names the reference form a registry seat answers for a build's artifact,
|
||||||
|
// `<seat>/<module>/<artifact>@sha256:<digest>` (novox/hq ADR 0605 §9; InRegistrySeat), as the reader capability
|
||||||
|
// `form:seat-reference` a node-engine built with this contract states (ADR 0618 §1, ADR 0624 §1): the builder writes it
|
||||||
|
// into a build key only for the nodes whose engines state it. The older reference forms a node-engine read before any
|
||||||
|
// stated what it reads are the baseline's; they are the builder's to name (delivery's artifact forms).
|
||||||
|
const ReferenceFormSeat = "seat-reference"
|
||||||
|
|
||||||
|
// artifactReferenceForms are the reference forms of a build's artifacts this contract reads beyond the baseline's.
|
||||||
|
var artifactReferenceForms = []string{ReferenceFormSeat}
|
||||||
|
|
||||||
// manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each
|
// manifestTopWords are the top-level keys Manifest.UnmarshalJSON reads apart from the fields of manifestFields: each
|
||||||
// read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the
|
// read whole, its inside a map of the module's own names (requirements, local names) and values, no words of the
|
||||||
// contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds).
|
// contract's beneath it. `requires` and `config` are read as their fields are (manifestNeeds).
|
||||||
@@ -60,6 +70,9 @@ func ManifestReads() []string {
|
|||||||
for _, f := range referenceForms {
|
for _, f := range referenceForms {
|
||||||
set[ReadForm+f] = true
|
set[ReadForm+f] = true
|
||||||
}
|
}
|
||||||
|
for _, f := range artifactReferenceForms {
|
||||||
|
set[ReadForm+f] = true
|
||||||
|
}
|
||||||
for _, s := range DefaultSeats() {
|
for _, s := range DefaultSeats() {
|
||||||
set[ReadSeat+s.Name] = true
|
set[ReadSeat+s.Name] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,10 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
||||||
const ResolverSeat = "node-resolver"
|
const ResolverSeat = contractseats.NodeResolver
|
||||||
|
|
||||||
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
|
// ResolverFile is the machine's resolver file: the uplink holder's, or the node-resolver holder's where
|
||||||
// one is held.
|
// one is held.
|
||||||
const ResolverFile = "/etc/resolv.conf"
|
const ResolverFile = "/etc/resolv.conf"
|
||||||
|
|
||||||
// resolverVerbs is the contract every holder of node-resolver serves (novox/hq ADR 0247): what is routed
|
|
||||||
// where, route a set of domains, and take a route away. The same verbs are served on the machine itself to
|
|
||||||
// the modules there, so what a VPN pushed never crosses the bus to be routed; on the mesh they are the
|
|
||||||
// operator's way to read and correct the same.
|
|
||||||
func resolverVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "routes", Description: "What this machine's own resolver sends where: the mesh's resolvers, " +
|
|
||||||
"which answer every name not routed elsewhere, and each link given servers of its own with the " +
|
|
||||||
"domains routed to them. Also the resolver file's outside writes it kept, newest first: when, who " +
|
|
||||||
"wrote it as far as the file says, whether a module took it, and when the resolver's own file was " +
|
|
||||||
"put back.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"resolvectl status", "resolvectl dns", "resolvectl domain"}},
|
|
||||||
{Name: "route", Description: "Send these domains, and every name under them, to these servers over " +
|
|
||||||
"this link — and only them: the link is never the machine's default route for names, and the " +
|
|
||||||
"mesh's own domain is refused. Replaces whatever the link was given before. A link that goes away " +
|
|
||||||
"takes its route with it.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"link": "the network link the servers are reached over, by name (a VPN's tunnel interface)",
|
|
||||||
"domains": "the domains to route there, separated by spaces or commas",
|
|
||||||
"servers": "the servers' addresses, separated by spaces or commas",
|
|
||||||
}, []string{"link", "domains", "servers"}),
|
|
||||||
Replaces: []string{"resolvectl dns", "resolvectl domain", "resolvectl default-route"}},
|
|
||||||
{Name: "unroute", Description: "Take one link's route away: its domains go back to the mesh's " +
|
|
||||||
"resolvers. Nothing changes when the link has none.",
|
|
||||||
Input: schema(map[string]string{"link": "the network link, by name"}, []string{"link"}),
|
|
||||||
Replaces: []string{"resolvectl revert"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -64,21 +64,32 @@ func TestARequirementsLongForm(t *testing.T) {
|
|||||||
names string
|
names string
|
||||||
busState []BusStateEntry
|
busState []BusStateEntry
|
||||||
aside []string
|
aside []string
|
||||||
|
refused string
|
||||||
}{
|
}{
|
||||||
// 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions,
|
// 1966/AC1: a plain name and a bus-state entry: Requires names the plain one only, since it names provisions,
|
||||||
// and BusState holds the entry whole.
|
// and BusState holds the entry whole.
|
||||||
{"a plain name and a bus-state entry", `["postgres-database",
|
{"a plain name and a bus-state entry", `["postgres-database",
|
||||||
{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`,
|
{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024}}]`,
|
||||||
"postgres-database", []BusStateEntry{records}, nil},
|
"postgres-database", []BusStateEntry{records}, nil, ""},
|
||||||
// 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes.
|
// 1966/AC2: an unknown key is set aside and named, never refused; the entry decodes.
|
||||||
{"a bus-state entry with a key it does not know", `[
|
{"a bus-state entry with a key it does not know", `[
|
||||||
{"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`,
|
{"name":"records","storage":"ledger","template":"ledger","colour":"red","options":{"max-mib":1024}}]`,
|
||||||
"", []BusStateEntry{records}, []string{"requires[0].colour"}},
|
"", []BusStateEntry{records}, []string{"requires[0].colour"}, ""},
|
||||||
// 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged.
|
// 1966/AC3: a ledger type with each of the four effects round-trips through JSON unchanged.
|
||||||
{"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024},
|
{"a ledger with its type", `[{"name":"records","storage":"ledger","template":"ledger","options":{"max-mib":1024},
|
||||||
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil},
|
"types":[` + string(typeJSON) + `]}]`, "", []BusStateEntry{ledger}, nil, ""},
|
||||||
|
// A field given twice at any depth is refused, named by its path: a decoder would keep the last without a word.
|
||||||
|
{"a field given twice", `[], "requires": ["postgres-database"]`, "", nil, nil, "the field requires is given twice"},
|
||||||
|
{"a field given twice deeper", `[{"name":"records","storage":"ledger","name":"other","template":"ledger"}]`,
|
||||||
|
"", nil, nil, "the field requires[0].name is given twice"},
|
||||||
} {
|
} {
|
||||||
got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`))
|
got, err := decodeForTest([]byte(`{"module":"x","version":"1","requires":` + c.requires + `}`))
|
||||||
|
if c.refused != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
||||||
|
t.Errorf("%s: read as %+v, %v; want refused: %s", c.name, got, err, c.refused)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("%s: refused: %v", c.name, err)
|
t.Errorf("%s: refused: %v", c.name, err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mesh-sdk's own manifest is judged by the controller that runs when it is checked, and that controller reads it
|
// mesh-sdk's own manifest declares its two packages (design 32, SDK-10): the TypeScript `lib` and the Go `go`, built
|
||||||
// strictly: a manifest word ships one release ahead of its first user. R4e's group is judged by today's controller
|
// from the directory go, each in series 0.1, so the Go build module publishes mesh-sdk's Go package through the Go seat
|
||||||
// (novox/hq ADR 0503 §4), which does not know `paths` (ADR 0499), so mesh-sdk's lib names no paths until a release
|
// (design 51). Its words are judged by the group's own indexer, delivered with them (ADR 0621 §4).
|
||||||
// after R4e's delivery adds them back (ADR 0499 §6, issue 1758).
|
|
||||||
func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
|
func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
|
||||||
raw, err := os.ReadFile("../../module.json")
|
raw, err := os.ReadFile("../../module.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -22,11 +21,19 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
|
|||||||
if m.Build == nil || len(m.Build.Artifacts) == 0 {
|
if m.Build == nil || len(m.Build.Artifacts) == 0 {
|
||||||
t.Fatal("mesh-sdk's module.json declares no artifact")
|
t.Fatal("mesh-sdk's module.json declares no artifact")
|
||||||
}
|
}
|
||||||
|
packages := map[string]Artifact{}
|
||||||
for _, a := range m.Build.Artifacts {
|
for _, a := range m.Build.Artifacts {
|
||||||
if a.Paths != nil {
|
packages[a.Language] = a
|
||||||
t.Errorf("artifact %q names paths %v: the running controller refuses the word until R4e is delivered", a.Name, a.Paths)
|
if a.Kind != ArtifactPackage || a.Series != DefaultSeries {
|
||||||
|
t.Errorf("artifact %q is a %s in series %q, want a package in %s", a.Name, a.Kind, a.Series, DefaultSeries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if g := packages["go"]; g.Name != "go" || !slices.Equal(g.Paths, []string{"go"}) {
|
||||||
|
t.Errorf("mesh-sdk's Go package is %+v, want go built from go", g)
|
||||||
|
}
|
||||||
|
if l := packages["typescript"]; l.Name != "lib" || l.Paths != nil {
|
||||||
|
t.Errorf("mesh-sdk's TypeScript package is %+v", l)
|
||||||
|
}
|
||||||
|
|
||||||
// What a reader reads (novox/hq ADR 0614 §4): the baseline is the contract of mesh-sdk f02f142, which every running
|
// What a reader reads (novox/hq ADR 0614 §4): the baseline is the contract of mesh-sdk f02f142, which every running
|
||||||
// node-engine was built with, and this contract reads all of it; the words added since are the only ones it lacks.
|
// node-engine was built with, and this contract reads all of it; the words added since are the only ones it lacks.
|
||||||
@@ -42,7 +49,9 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
|
|||||||
t.Errorf("the baseline's %s is no reader capability of this contract", b)
|
t.Errorf("the baseline's %s is no reader capability of this contract", b)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage"} {
|
for _, want := range []string{"word:tool-runner", "word:core-credentials", "word:bus-state", "word:bus-state.storage",
|
||||||
|
// The reference form a registry seat answers (ADR 0605 §9), and the seats the registry milestone adds.
|
||||||
|
ReadForm + ReferenceFormSeat, "seat:go-package-registry", "seat:container-image-registry", "seat:oci-artifact-registry"} {
|
||||||
if !slices.Contains(added, want) {
|
if !slices.Contains(added, want) {
|
||||||
t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added)
|
t.Errorf("%s is in the baseline, or not read: added since it are %v", want, added)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// PlacementSeat is the seat the assigner holds for placement.
|
// PlacementSeat is the seat the assigner holds for placement.
|
||||||
const PlacementSeat = "mesh-placement"
|
const PlacementSeat = contractseats.MeshPlacement
|
||||||
|
|
||||||
// The seat's five verbs (ADR 0567 §10).
|
// The seat's five verbs (ADR 0567 §10).
|
||||||
const (
|
const (
|
||||||
@@ -11,57 +13,3 @@ const (
|
|||||||
PlacementVerbWithdraw = "withdraw"
|
PlacementVerbWithdraw = "withdraw"
|
||||||
PlacementVerbAgain = "again"
|
PlacementVerbAgain = "again"
|
||||||
)
|
)
|
||||||
|
|
||||||
// placementVerbs are the seat's verbs: what reads all it keeps and does by hand all it does by itself (ADR 0567 §10).
|
|
||||||
// Optional while the holder that serves them is not yet on every node (design 33 §7): the seat is new in the same
|
|
||||||
// delivery as its holder.
|
|
||||||
func placementVerbs() []Verb {
|
|
||||||
changes := map[string]any{"type": "array", "minItems": 1, "description": "the changes, each an object with `op`: " +
|
|
||||||
"place (node, module, ports, bindings), take-off (node, module), pin (node, provision, provider {node, module}), " +
|
|
||||||
"unpin (node, provision), hold (node, module, claim, scope, kind, site), unhold (node, module, claim), decide " +
|
|
||||||
"(node, field, value: a decided field of the node, its address among them), config (node, module, mesh-wide, " +
|
|
||||||
"layer, mesh-wide-values, values), add-node (node, decided, keys: a joining node, mesh-enrolment's alone, the " +
|
|
||||||
"proposal's one change, with the enrolment's warrant, its tunnel key alone at the operator's yes; ADR 0587 §7), " +
|
|
||||||
"remove-node (node: a joining node never confirmed, taken off when its enrolment ends; mesh-enrolment's alone, " +
|
|
||||||
"on the same warrant)",
|
|
||||||
"items": map[string]any{"type": "object", "required": []string{"op", "node"}}}
|
|
||||||
return []Verb{
|
|
||||||
{Name: PlacementVerbPlacement, Optional: true, Description: "Read placement: one node's key, the nodes one " +
|
|
||||||
"module is placed on, or every key, each with its revision, and how full the state is against its cap. With " +
|
|
||||||
"history, one node's or one module's changes in order: a revision the state keeps whole, an older one as the " +
|
|
||||||
"proposal's changes from its placement-changed event.",
|
|
||||||
Input: schema(map[string]string{"node": "one node", "module": "one module: the nodes it is placed on",
|
|
||||||
"history": "\"true\": the changes in order, from the stream and the state's revisions",
|
|
||||||
// ADR 0571 clause 4: who holds which seat on which node, from the state seat-holders.
|
|
||||||
"seats": "\"true\": who holds each seat, and on which node (the state seat-holders); with node, that node's"},
|
|
||||||
nil, "history", "seats")},
|
|
||||||
{Name: PlacementVerbProposals, Optional: true, Description: "Every open proposal of placement: its changes, " +
|
|
||||||
"the revision of each key it was laid over, its candidates, every node's answer, the differences computed and " +
|
|
||||||
"answered, its verdict and its ask. Narrowed to one by id.",
|
|
||||||
Input: schema(map[string]string{"id": "one proposal's id"}, nil)},
|
|
||||||
{Name: PlacementVerbPropose, Optional: true, Description: "Propose a change of placement, with why: laid over " +
|
|
||||||
"the current keys and refused at once, naming every finding, when the result cannot be placed (two modules " +
|
|
||||||
"on one host port, a provider not placed, a seat held once held twice, two nodes with one address, two " +
|
|
||||||
"hubs, a module the indexer does not hold, a node with no record). Otherwise the nodes it touches compose " +
|
|
||||||
"it in shadow and answer, and on a passing verdict the operator is asked to approve it. Who proposes is the " +
|
|
||||||
"caller, as the runtime proves it. An add-node from mesh-enrolment, whose warrant names its node and address, " +
|
|
||||||
"is written with no ask of its own: its Approve is the enrolment's (ADR 0587 §7).",
|
|
||||||
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"why", "changes"},
|
|
||||||
"properties": map[string]any{
|
|
||||||
"why": map[string]any{"type": "string", "description": "why, shown to the operator with the proposal"},
|
|
||||||
"changes": changes,
|
|
||||||
// ADR 0587 §7: an add-node carries the warrant of the enrolment that issued the node's token, whose
|
|
||||||
// Approve is the proposal's; the holder writes it with no ask of its own.
|
|
||||||
"warrant": map[string]any{"type": "object", "description": "for an add-node alone: the enrolment's " +
|
|
||||||
"warrant {node, address, ask, warrant}, naming the change's node and address",
|
|
||||||
"required": []string{"node", "address", "ask", "warrant"}},
|
|
||||||
}}},
|
|
||||||
{Name: PlacementVerbWithdraw, Optional: true, Description: "Withdraw an open proposal: its key and its " +
|
|
||||||
"candidates are deleted and placement-withdrawn carries it whole. With why.",
|
|
||||||
Input: schema(map[string]string{"id": "the proposal's id", "why": "why, kept in the event"}, []string{"id", "why"})},
|
|
||||||
{Name: PlacementVerbAgain, Optional: true, Description: "Judge an open proposal now with the answers that " +
|
|
||||||
"stand, and raise its ask again when its verdict passes and the operator's channel does not hold the ask. " +
|
|
||||||
"Nothing more: the recovery of a proposal whose judgement or ask was lost.",
|
|
||||||
Input: schema(map[string]string{"id": "the proposal's id"}, []string{"id"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ type ProcessBus struct {
|
|||||||
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
|
// Removes are what a tools bundle's account may remove from the bus, from a closed set (BusRemovals; novox/hq ADR
|
||||||
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
|
// 0564 Limits 4): declared alone, by the module holding mesh-broker, never by a process.
|
||||||
Removes []string `json:"removes,omitempty"`
|
Removes []string `json:"removes,omitempty"`
|
||||||
|
// Keeps says this process is the bus module's keeper (novox/hq ADR 0626 §1): the controller composes the bus's
|
||||||
|
// create, update and snapshot subjects for modules' entries into its account. Refused on any other process or
|
||||||
|
// module by the judge (ADR 0626 §3); read here so a manifest declaring it decodes.
|
||||||
|
Keeps bool `json:"keeps,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts
|
// What a bus account may remove (novox/hq ADR 0564 Limits 4), each its own grant: keys of the node-engine's acts
|
||||||
@@ -93,7 +97,7 @@ func (b *ProcessBus) UnmarshalJSON(raw []byte) error {
|
|||||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&p); err != nil {
|
if err := dec.Decode(&p); err != nil {
|
||||||
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes}: %w", typedUnknown(err))
|
return fmt.Errorf("a process's bus is {emits, queues, state, asks, invokes, keeps}: %w", typedUnknown(err))
|
||||||
}
|
}
|
||||||
*b = ProcessBus(p)
|
*b = ProcessBus(p)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+3
-59
@@ -1,66 +1,10 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
// ReadsSeat is the role that reads a node's files and accounts (novox/hq ADR 0310).
|
||||||
const ReadsSeat = "node-reads"
|
const ReadsSeat = contractseats.NodeReads
|
||||||
|
|
||||||
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
|
// ReadsSecretPlaces is the word the holder of node-reads is given: the places on its node where a secret is
|
||||||
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
|
// kept, as a JSON list of absolute paths, each refused by name before any read (novox/hq ADR 0310 §5).
|
||||||
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
|
const ReadsSecretPlaces = "MESH_SECRET_PLACES"
|
||||||
|
|
||||||
// readsVerbs is the contract every holder of node-reads serves (novox/hq ADR 0310 §2). The bounds are the
|
|
||||||
// build's to set and each verb's description to state (ADR 0310, Consequences); the holder states them in
|
|
||||||
// every answer it cuts.
|
|
||||||
func readsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "stat", Description: "Each component of one path or several, from / down: its type, owner and group " +
|
|
||||||
"(name and number), mode, whether it is a link and to what, and whether it is a mount point and of which " +
|
|
||||||
"file system. A link is not followed unless follow is \"true\". Read as the holder's own account, which " +
|
|
||||||
"holds no secret: a component it may not reach says so. At most 200 paths in one call.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"paths": "one absolute path, or several separated by line breaks",
|
|
||||||
"follow": "\"true\": also say where the path leads once every link is followed",
|
|
||||||
}, []string{"paths"}, "follow"),
|
|
||||||
Replaces: []string{"stat", "ls -ld", "namei -l", "findmnt -T"}},
|
|
||||||
{Name: "readlink", Description: "What one link points to, as written, and where the path leads once every " +
|
|
||||||
"link is followed, and whether that exists.",
|
|
||||||
Input: schema(map[string]string{"path": "an absolute path"}, []string{"path"}),
|
|
||||||
Replaces: []string{"readlink", "readlink -f", "realpath"}},
|
|
||||||
{Name: "list", Description: "Every entry of one directory with its type, owner, group, mode, size and, for a " +
|
|
||||||
"link, its target. At most 2000 entries; a longer directory is cut and says so. A place a secret is kept " +
|
|
||||||
"is refused by name.",
|
|
||||||
Input: schema(map[string]string{"path": "an absolute path to a directory"}, []string{"path"}),
|
|
||||||
Replaces: []string{"ls", "ls -la"}},
|
|
||||||
{Name: "read", Description: "One text file, or a part of it by line: at most 256 KiB in one answer " +
|
|
||||||
"(64 KiB unless more is asked), cut at a line and said so. A binary file is refused; a place a secret is " +
|
|
||||||
"kept, or a known credential file, is refused by name; a line shaped like a secret is withheld and its " +
|
|
||||||
"number said. /proc and /sys are read as far as the holder's account may.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"path": "an absolute path to a file",
|
|
||||||
"from": "the first line to answer, counted from 1 (optional)",
|
|
||||||
"lines": "how many lines to answer (optional)",
|
|
||||||
"bytes": "the most bytes to answer, up to 262144 (optional, 65536 unless given)",
|
|
||||||
}, []string{"path"}),
|
|
||||||
Replaces: []string{"cat", "head", "tail", "less"}},
|
|
||||||
{Name: "search", Description: "Find a fixed string (never a pattern) in one file or under one directory: " +
|
|
||||||
"every matching line with its file and number. Bounded: at most 500 matches (100 unless more is asked), " +
|
|
||||||
"8 directories deep (4 unless more is asked), 5000 files and 4 MiB per file; links are not followed and " +
|
|
||||||
"binary files are skipped. A place a secret is kept is skipped and named; a matching line shaped like a " +
|
|
||||||
"secret is withheld. The answer says which bound it met.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"text": "the fixed string to find",
|
|
||||||
"path": "an absolute path to a file or a directory",
|
|
||||||
"depth": "how many directories deep, up to 8 (optional)",
|
|
||||||
"matches": "the most matches to answer, up to 500 (optional)",
|
|
||||||
"case": "\"false\": match whatever the case (optional)",
|
|
||||||
}, []string{"text", "path"}, "case"),
|
|
||||||
Replaces: []string{"grep", "grep -r", "grep -F"}},
|
|
||||||
{Name: "account", Description: "One account or group by name or number, as this node's account files say: " +
|
|
||||||
"an account's number, group, home, shell and every group it is in; a group's number and members. With " +
|
|
||||||
"neither, every account and group. Never a password field.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"name": "an account's name or number (optional)",
|
|
||||||
"group": "a group's name or number (optional)",
|
|
||||||
}, nil),
|
|
||||||
Replaces: []string{"getent passwd", "getent group", "id", "groups"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
|
// HotkeysSeat is the machine's hotkey daemon (novox/hq ADR 0212 §5).
|
||||||
const HotkeysSeat = "node-hotkeys"
|
const HotkeysSeat = contractseats.NodeHotkeys
|
||||||
|
|
||||||
// MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215).
|
// MessageBusSeat is the machine's D-Bus (novox/hq ADR 0215).
|
||||||
const MessageBusSeat = "node-message-bus"
|
const MessageBusSeat = contractseats.NodeMessageBus
|
||||||
|
|
||||||
// BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43).
|
// BackupSeat is the machine's backups (novox/hq ADR 0214, to-be 43).
|
||||||
const BackupSeat = "node-backup"
|
const BackupSeat = contractseats.NodeBackup
|
||||||
|
|
||||||
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
|
// SeatContribution is one piece of configuration a module gives a seat's holder to place.
|
||||||
type SeatContribution struct {
|
type SeatContribution struct {
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
// The three seats that apply resources (novox/hq ADR 0207 §1). Named once, because the derivation,
|
||||||
// the seed and the messages all turn on these strings.
|
// the seed and the messages all turn on these strings.
|
||||||
const (
|
const (
|
||||||
ServiceManagerSeat = "node-service-manager"
|
ServiceManagerSeat = contractseats.NodeServiceManager
|
||||||
PackageManagerSeat = "node-package-manager"
|
PackageManagerSeat = contractseats.NodePackageManager
|
||||||
ContainerRuntimeSeat = "node-container-runtime"
|
ContainerRuntimeSeat = contractseats.NodeContainerRuntime
|
||||||
)
|
)
|
||||||
|
|
||||||
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
// appliedThrough is which seat applies a resource of each type. **Only these three**: ADR 0207
|
||||||
|
|||||||
+42
-600
@@ -3,6 +3,8 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Seat is one role the mesh defines.
|
// Seat is one role the mesh defines.
|
||||||
@@ -84,428 +86,48 @@ type Receivable struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
|
// defaultSeats is the set the mesh ships with — the seed for the control plane's seat table and the
|
||||||
// fallback when it has none (novox/hq ADR 0122). It is the one place the closed set 0110 defines is
|
// fallback when it has none (novox/hq ADR 0122). **It is generated, never written here** (novox/hq ADR 0608 §5,
|
||||||
// written; the store's table is seeded from it and thereafter is the live, editable copy.
|
// ADR 0613 §2, design 26 *The seat set has one source*): every seat's definition lives in mesh-contracts, and its
|
||||||
//
|
// generated seat list is what this runtime reads, in the order a person reads it: the mesh's own, then a node's. What
|
||||||
// In the order a person reads it: the mesh's own, then a node's.
|
// a definition cannot say, being code, is attached here: a contribution kind's checks (withShapeCode).
|
||||||
var defaultSeats = append([]Seat{
|
var defaultSeats = fromContracts(contractseats.List())
|
||||||
// The store's first verbs (novox/hq ADR 0159): the smallest set that makes the store askable,
|
|
||||||
// served by whichever module holds the seat with tools of these names.
|
// fromContracts is the generated seat list in this runtime's types, each data-shaped kind given its checks.
|
||||||
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079",
|
func fromContracts(list []contractseats.Seat) []Seat {
|
||||||
Serves: []Verb{
|
out := make([]Seat, 0, len(list))
|
||||||
{Name: "databases", Description: "Every database the store holds, with its on-disk size.",
|
for _, c := range list {
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"psql -l"}},
|
s := Seat{Name: c.Name, Scope: c.Scope, Replicated: c.Replicated, Delivers: c.Delivers, Accepts: c.Accepts,
|
||||||
{Name: "query", Description: "One read-only statement against one database the store holds.",
|
Emits: c.Emits, PulledByKind: c.PulledByKind, Yields: c.Yields, HeldByTheEngine: c.HeldByTheEngine,
|
||||||
Input: schema(map[string]string{"database": "the database to query", "sql": "the read-only statement"},
|
Decision: c.Decision}
|
||||||
[]string{"database", "sql"}), Replaces: []string{"psql"}},
|
for _, v := range c.Serves {
|
||||||
}},
|
s.Serves = append(s.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input, Output: v.Output,
|
||||||
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
|
Optional: v.Optional, Replaces: v.Replaces})
|
||||||
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
|
}
|
||||||
// the mesh's own transport. ADR 0128 then made that connection something a module requires
|
for _, r := range c.Receives {
|
||||||
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
|
rv := Receivable{Kind: r.Kind, Comment: r.Comment, Dirs: r.Dirs, Offered: r.Offered}
|
||||||
// connection would mint a credential for each.
|
if r.Shape != nil {
|
||||||
{Name: BrokerSeat, Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
|
sh := &Shape{Order: r.Shape.Order, Examples: r.Shape.Examples, Text: r.Shape.Text}
|
||||||
// The vault: the controller seals every minted credential with what it provides, which is the
|
for _, f := range r.Shape.Places {
|
||||||
// test for a seat of the mesh's own (novox/hq ADR 0161) — a second provider of `secret` is a
|
sh.Places = append(sh.Places, PlaceField{Field: f.Field, Values: f.Values})
|
||||||
// second claimant, refused by name, rather than a candidate for a pin.
|
}
|
||||||
//
|
withShapeCode(c.Name, r.Kind, sh)
|
||||||
// Its verb `get` hands a secret on request and reply, sealed to the key the asker sends and only to an
|
rv.Shape = sh
|
||||||
// asker the vault allows (novox/hq ADR 0349 §11, issue 674). `accept`, `secret-ask`, `issued` and `rotate`
|
}
|
||||||
// take a value in and make one anew (ADR 0349 clauses 8 to 10, issue 675): the first three in two steps from
|
s.Receives = append(s.Receives, rv)
|
||||||
// the controller alone, which carries the value sealed to a key the vault made for the call and cannot open
|
}
|
||||||
// it. All optional until the vault that runs serves them (design 33 §7).
|
for _, p := range c.Paths {
|
||||||
{Name: "mesh-vault", Scope: ScopeMesh, Delivers: "secret", Decision: "novox/hq ADR 0161",
|
s.Paths = append(s.Paths, SeatPath{Path: p.Path, StepsBackFor: p.StepsBackFor})
|
||||||
Serves: []Verb{
|
}
|
||||||
{Name: "get", Optional: true, Description: "One secret, sealed to the public key the asker sends (the " +
|
out = append(out, s)
|
||||||
"sealed box of ADR 0206), with its generation; answered only to an asker the vault allows, as the " +
|
}
|
||||||
"bus proved it. Never the value in the clear, and never to a private key.",
|
return out
|
||||||
Input: schema(map[string]string{"name": "the secret's name",
|
}
|
||||||
"public_key": "the asker's X25519 public key as SPKI PEM — never a private key"},
|
|
||||||
[]string{"name", "public_key"})},
|
|
||||||
{Name: "accept", Optional: true, Description: "Take a value given at the controller's terminal, from the " +
|
|
||||||
"controller alone: name the secret and get a call and a key made for it, then send the call and the " +
|
|
||||||
"value sealed to that key. The vault alone opens it, and never remakes an accepted credential.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine, for a module's own secret",
|
|
||||||
"module": "the module, for a module's own secret", "name": "the secret's name in the module",
|
|
||||||
"provider": "the provider's machine, for a provision's credential", "provision": "the provision",
|
|
||||||
"by": "who gave it at the terminal",
|
|
||||||
"origin": "issued for a bus account the controller minted for a core or moved module and hands " +
|
|
||||||
"at once (ADR 0374, issue 787); left out for a value given at the terminal"}), nil)},
|
|
||||||
{Name: "secret-ask", Optional: true, Description: "Ask the operator for a module's own secret at a desk, " +
|
|
||||||
"from the controller or a caller the vault's manifest names, named as the bus proved it (novox/hq ADR " +
|
|
||||||
"0563 §8): the ask is recorded in the vault's store and bounded before the prompt " +
|
|
||||||
"opens, and what is typed is sealed to a key the vault made for the call and opens there alone.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "the machine the secret is for",
|
|
||||||
"module": "the module", "name": "the secret's name in the module",
|
|
||||||
"desk": "the machine whose prompt asks", "asked_by": "who asked, as the bus named its caller",
|
|
||||||
"outcome": "instead of sealed: how the prompt ended"}), nil)},
|
|
||||||
{Name: "issued", Optional: true, Description: "Take the credential a provider's software issued for a " +
|
|
||||||
"provision, from the controller alone, which asks the provider's tool with a key the vault made for " +
|
|
||||||
"the call; kept as the provision's one credential, or, when the tool answers a value per holder named, " +
|
|
||||||
"each as that holder's own; the same value again writes nothing.",
|
|
||||||
Input: withHolders(schema(vaultIntake(map[string]string{"provision": "the provision",
|
|
||||||
"provider": "the provider's machine", "module": "the provider's module"}), nil))},
|
|
||||||
{Name: "rotate", Optional: true, Description: "Make a secret anew. Refused for a value a provider's " +
|
|
||||||
"software issued and for an accepted credential. Never answers the value.",
|
|
||||||
Input: schema(map[string]string{"node": "the machine, for a module's own secret", "module": "the module",
|
|
||||||
"name": "the secret's name in the module", "provider": "the provider's machine",
|
|
||||||
"provision": "the provision", "by": "who asked, when the controller carries the act"}, nil)},
|
|
||||||
// The move of the controller's secrets (design 57's V3, issue 676): each machine's node-engine hands in what
|
|
||||||
// was sealed to its machine, which the controller cannot open; the controller copies its record of asks.
|
|
||||||
{Name: "move-in", Optional: true, Description: "Take one value of the controller's store in, as it is, " +
|
|
||||||
"from the node-engine of a machine it was sealed to and no other caller: name the row, its origin " +
|
|
||||||
"and generation and get a call and a key made for it, then send the call, the value sealed to that " +
|
|
||||||
"key and a key to read back to. Never replaces a value the vault holds; answers what it kept, read " +
|
|
||||||
"back sealed to that key.",
|
|
||||||
Input: schema(vaultIntake(map[string]string{"node": "a module's own secret: its machine",
|
|
||||||
"module": "its module", "name": "its name in the module",
|
|
||||||
"provider": "a pair credential: the provider's machine", "provision": "the provision",
|
|
||||||
"consumer": "the consumer's machine", "consumer_module": "the consuming module",
|
|
||||||
"local": "the credential's local name, when it has one",
|
|
||||||
"origin": "made, accepted or issued, as the controller's store says",
|
|
||||||
"generation": "the controller's generation for the value", "made_at": "when it was made (RFC 3339)",
|
|
||||||
"read_back_to": "the second step: a raw X25519 public key the read-back is sealed to"}), nil)},
|
|
||||||
// A machine's node-engine states its own sealing key (novox/hq ADR 0372, issue 786).
|
|
||||||
{Name: "node-key", Optional: true, Description: "State this machine's sealing key to the vault, in its " +
|
|
||||||
"own name, from the node-engine of the machine alone: at its start and whenever the key changes. A " +
|
|
||||||
"first or changed key is recorded and the machine's core secrets are said changed; the same key " +
|
|
||||||
"again changes nothing.",
|
|
||||||
Input: schema(map[string]string{"key": "the machine's sealing key, a raw X25519 public key in base64"}, nil)},
|
|
||||||
// A machine's node-engine composes its own modules and asks their secrets sealed to its key (novox/hq ADR 0547
|
|
||||||
// §2, ADR 0550 §5, issue 1126); granted as buscompose's Principal.AsksItsOwnSecrets says.
|
|
||||||
{Name: "own", Optional: true, Description: "Answer the node-engine of one machine, about its own machine " +
|
|
||||||
"alone (as the bus proved the caller, never an argument), its modules' own secrets and the pair " +
|
|
||||||
"credentials it consumes or provides that it names, each sealed to the key it stated with node-key. A " +
|
|
||||||
"credential the vault does not hold is named missing; never a value in the clear.",
|
|
||||||
Input: map[string]any{"type": "object", "properties": map[string]any{"credentials": map[string]any{
|
|
||||||
"type": "array", "items": map[string]any{"type": "object"},
|
|
||||||
"description": "the pair credentials the machine's composition reads, each {role: consumer|provider, " +
|
|
||||||
"provision, consumer, consumer_module, provider, local}"}}}},
|
|
||||||
{Name: "move-asks", Optional: true, Description: "Copy rows of the controller's record of asks at a desk " +
|
|
||||||
"into the vault's, as they are and once, from the controller alone. No value is in any of them.",
|
|
||||||
Input: map[string]any{"type": "object", "properties": map[string]any{"asks": map[string]any{
|
|
||||||
"type": "array", "items": map[string]any{"type": "object"},
|
|
||||||
"description": "the controller's rows: id, node, module, name, desk, asked_by, opened_at, ended_at, outcome"}}}},
|
|
||||||
{Name: "issue-account", Optional: true, Description: "Mint one bus account the grants part wants, now (novox/hq " +
|
|
||||||
"ADR 0561 §9): only while the controller's lease is handed away, never a node's account or the " +
|
|
||||||
"controller's, never anew for one the vault holds a value or an adopted hash for. Never the value.",
|
|
||||||
Input: schema(map[string]string{"user": "the bus user, e.g. ace.mesh-build-go"}, []string{"user"})},
|
|
||||||
{Name: "sign", Optional: true, Description: "Sign with the vault's key or authority (novox/hq ADR 0550 §2): " +
|
|
||||||
"kind node-certificate signs the certificate of a machine's internal name, asked by that machine's " +
|
|
||||||
"node-engine for the serving key its record states, built from bus state alone, or by mesh-enrolment for " +
|
|
||||||
"a node it enrols, for the serving key the node presented at redeem (ADR 0587 §3); kind enrolment signs a " +
|
|
||||||
"redeem answer under its own context, for mesh-enrolment alone. Answers the certificate or the signature " +
|
|
||||||
"and the signer's fingerprint, and says signed; never a key.",
|
|
||||||
Input: schema(map[string]string{"kind": "node-certificate or enrolment",
|
|
||||||
"node": "the machine, for a node certificate: the caller's own, or the node mesh-enrolment enrols",
|
|
||||||
"serving_key": "from mesh-enrolment: the serving key's public half the enrolling node presented",
|
|
||||||
"reply": "the redeem answer's bytes in base64, for an enrolment"}, []string{"kind"})},
|
|
||||||
// Enrolment (novox/hq ADR 0561 §5, ADR 0587 §3, §5; issue 774): mesh-enrolment is the only caller the vault's
|
|
||||||
// manifest names for these two.
|
|
||||||
{Name: "enrolment-token", Optional: true, Description: "Make a one-time token for a node mesh-enrolment " +
|
|
||||||
"issues on the operator's warrant: its secret kept with its copy sealed to the operator key and its hash " +
|
|
||||||
"written as the bus user enrol.<node>. Answers only what the joining machine must trust (the bus's " +
|
|
||||||
"address, the bus certificate's fingerprint, the vault's public signing key), never the token; with " +
|
|
||||||
"sealed, the token sealed to the operator key, for tokens to relay; with drop, the token and its hash " +
|
|
||||||
"dropped, at the enrolment's end. Asked by mesh-enrolment alone, only from the lease write. The token " +
|
|
||||||
"carries the first tunnel mesh-enrolment built at issue (ADR 0587 §3, insight of 2026-11-02).",
|
|
||||||
Input: withObject(schema(map[string]string{"node": "the node the token is for",
|
|
||||||
"expires": "when the token expires (RFC 3339), as the enrolment's key says",
|
|
||||||
"sealed": "\"true\": answer the token sealed to the operator key",
|
|
||||||
"drop": "\"true\": drop the node's token and its hash"}, []string{"node"}, "sealed", "drop"),
|
|
||||||
"tunnel", "the node's first tunnel, built by mesh-enrolment at issue and carried in the token: the hub's "+
|
|
||||||
"public key, the hub's endpoint, the node's tunnel address, its own tunnel key and the network's range",
|
|
||||||
map[string]string{"hub_key": "the hub's tunnel public key", "hub_endpoint": "the hub's endpoint, host:port",
|
|
||||||
"address": "the joining node's tunnel address",
|
|
||||||
"key": "the joining machine's own tunnel public key, as given at issue",
|
|
||||||
"range": "the private network's range, a prefix"})},
|
|
||||||
{Name: "node-account", Optional: true, Description: "Mint a joining node's bus account node.<node>, sealed " +
|
|
||||||
"to the sealing key it presented, and write its hash; asked again for the same node and keys, answers the " +
|
|
||||||
"account it already minted; with retire, the account it minted for a revoked enrolment retired. Writes the " +
|
|
||||||
"node's node-logins key naming node.<node>. Asked by mesh-enrolment alone. Never the value in the clear.",
|
|
||||||
Input: schema(map[string]string{"node": "the joining node",
|
|
||||||
"sealing_key": "the sealing key's public half the node presented, which the account is sealed to",
|
|
||||||
"keys_digest": "the digest of every public key the node presented (delivery.KeysDigest)",
|
|
||||||
"retire": "\"true\": retire the account minted for this node"}, []string{"node"}, "retire")},
|
|
||||||
// A node's account rotates over two logins (novox/hq ADR 0583 §2-§5; issue 774), one node at a time, the bus
|
|
||||||
// node last; the state node-logins says where each node stands.
|
|
||||||
{Name: "rotate-node", Optional: true, Description: "Begin a node's account rotation to its other login: " +
|
|
||||||
"answered only for a call carrying a hand act recorded through mesh-hand-acts, which the vault reads back by " +
|
|
||||||
"its id. Makes the other login's value sealed to that node, writes it rotating in node-logins and says " +
|
|
||||||
"rotation-begun; says node-account-ready once the bus node's have shows a list holding both logins. One " +
|
|
||||||
"node at a time across the mesh, the bus node last: a call while a rotation runs waits until it is done.",
|
|
||||||
Input: schema(map[string]string{"node": "the node whose account rotates", "why": "why, in a sentence",
|
|
||||||
"act": "the id of the hand act recorded for it in mesh-hand-acts"}, []string{"node", "why", "act"})},
|
|
||||||
{Name: "node-account-confirm", Optional: true, Description: "Confirm a node's switch to its new login: " +
|
|
||||||
"asked by the node-engine dialled as that login, the runtime's proof of the caller being the confirmation. " +
|
|
||||||
"The old login's hash is then removed, and rotation-done said once the bus node's have shows the list " +
|
|
||||||
"without it.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "abandon", Optional: true, Description: "Abandon a node's rotation before its old login is retired: " +
|
|
||||||
"the new login and its value dropped, the old one kept; a node that switched switches back first.",
|
|
||||||
Input: schema(map[string]string{"node": "the node whose rotation is abandoned"}, []string{"node"})},
|
|
||||||
}},
|
|
||||||
// The owner of every config value (novox/hq ADR 0348 §9, design 57 K3): its holder alone writes the buckets
|
|
||||||
// mesh-config_values, _resolved, _predefined and _proposals, and serves the one verb `config` (§1, §10).
|
|
||||||
// Optional until the mesh-config that runs serves it (design 33 §7).
|
|
||||||
{Name: "mesh-config", Scope: ScopeMesh, Decision: "novox/hq ADR 0348, ADR 0565",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "config", Optional: true, Description: "Set, clear, list or read the history of config values: " +
|
|
||||||
"mesh config or node config for a predefined key, or a module's own value on one node, each set " +
|
|
||||||
"with why, validated, and naming each module on each node whose value moves.",
|
|
||||||
Input: configInput()},
|
|
||||||
}},
|
|
||||||
// The provisioner's server (novox/hq ADR 0349 clause 3, design 57 P4): its holder keeps the registry of which
|
|
||||||
// domain answers which kind of placeholder, and the buckets mesh-provisioner_needs, _applied and _collect,
|
|
||||||
// which it and each machine's provisioner client (for its own machine's keys) alone write. Its one verb reads
|
|
||||||
// them. Optional until the mesh-provisioner that runs serves it (design 33 §7).
|
|
||||||
{Name: "mesh-provisioner", Scope: ScopeMesh, Decision: "novox/hq ADR 0349",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "provisioning", Optional: true, Description: "Read what the provisioner keeps: the registry " +
|
|
||||||
"(which domain answers which kind of placeholder), the needs each module on each node recorded, what " +
|
|
||||||
"is applied where, or the items of every collection, narrowed by node and module. Reads only.",
|
|
||||||
Input: schema(map[string]string{"what": "registry (when not given), needs, applied or collect",
|
|
||||||
"node": "a node", "module": "a module: for collect, a contributor", "collection": "for collect: a collection"}, nil)},
|
|
||||||
}},
|
|
||||||
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
|
|
||||||
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
|
|
||||||
// images and archives, by digest — which is why the provision is the artifact store and not an
|
|
||||||
// image registry.
|
|
||||||
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
|
|
||||||
// The catalog (novox/hq ADR 0604): what the mesh holds of every module, its verbs read from its holder's states (CatalogSeat).
|
|
||||||
{Name: CatalogSeat, Scope: ScopeMesh, Serves: catalogVerbs(), Decision: "novox/hq ADR 0604"},
|
|
||||||
// The delivery's owner (novox/hq ADR 0239): one commit's journey from its pull request's head to every
|
|
||||||
// machine, and a group of them, recorded and ordered by its holder, which asks the controller for each act.
|
|
||||||
// What it says it says as its module (`mesh-delivery.transition`), through the runtime that launches it.
|
|
||||||
{Name: DeliverySeat, Scope: ScopeMesh, Serves: deliveryVerbs(), Decision: "novox/hq ADR 0239"},
|
|
||||||
// Placement (novox/hq ADR 0567): which module runs on which node, state on the bus held by the assigner, changed only by
|
|
||||||
// a proposal the nodes answer and the operator approves. Its states and events are the holder module's.
|
|
||||||
{Name: PlacementSeat, Scope: ScopeMesh, Serves: placementVerbs(), Decision: "novox/hq ADR 0567"},
|
|
||||||
// The delivery's checker (novox/hq ADR 0547 §10, issue 774): the try is its verb, not the controller's.
|
|
||||||
{Name: CheckerSeat, Scope: ScopeMesh, Serves: checkerVerbs(), Decision: "novox/hq ADR 0547"},
|
|
||||||
// The mesh's health, out of the controller (novox/hq ADR 0564 clause 1, health_seats.go): the condition keeper, the
|
|
||||||
// watchman's watchdogs, the self-check and the healers.
|
|
||||||
{Name: ConditionsSeat, Scope: ScopeMesh, Decision: healthDecision,
|
|
||||||
Emits: []string{"condition-raised", "condition-changed", "condition-cleared"}, Serves: conditionsVerbs()},
|
|
||||||
{Name: WatchdogsSeat, Scope: ScopeMesh, Decision: healthDecision, Serves: watchdogsVerbs()},
|
|
||||||
{Name: SelfCheckSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"checked"}, Serves: selfCheckVerbs()},
|
|
||||||
{Name: HealersSeat, Scope: ScopeMesh, Decision: healthDecision, Emits: []string{"healer-acted"}, Serves: healersVerbs()},
|
|
||||||
// The control records, out of the controller (novox/hq ADR 0565 clause 1, control_records_seats.go): a person's
|
|
||||||
// policy and the bus step at the assigner, and the hand-act log.
|
|
||||||
{Name: WantsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"policy-changed"}, Serves: wantsVerbs()},
|
|
||||||
{Name: HandActsSeat, Scope: ScopeMesh, Decision: controlRecordsDecision, Emits: []string{"act-recorded"}, Serves: handActsVerbs()},
|
|
||||||
// Joining a machine (novox/hq ADR 0587 §1, enrolment_seat.go): held once by the module mesh-enrolment.
|
|
||||||
{Name: EnrolmentSeat, Scope: ScopeMesh, Decision: enrolmentDecision, Emits: EnrolmentEvents, Serves: enrolmentVerbs()},
|
|
||||||
// The artifact sweep (novox/hq ADR 0571 clause 2, artifact_sweep_seat.go).
|
|
||||||
{Name: ArtifactSweepSeat, Scope: ScopeMesh, Decision: "novox/hq ADR 0571", Emits: []string{"artifacts-gone"},
|
|
||||||
Serves: artifactSweepVerbs()},
|
|
||||||
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
|
|
||||||
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
|
|
||||||
// They keep their names until that migration is done deliberately, apart from the node-* pass.
|
|
||||||
{Name: "npm-package-registry", Scope: ScopeMesh, Delivers: "npm-package-registry", Decision: "novox/hq ADR 0109"},
|
|
||||||
{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0111"},
|
|
||||||
// **A build node's work, one seat per build language** (novox/hq ADR 0525 §2, §10; ADR 0536 §4; issue 1887). The
|
|
||||||
// build module of each language claims its seat on every node it runs on; the builder and the checker put their
|
|
||||||
// asks on the seat's accepted kinds, which the broker derives as one work queue with a shared worker per kind, and
|
|
||||||
// each holder pulls delivery first, then check, then recheck, at most its node's build capacity at once. Its verbs
|
|
||||||
// say what the node runs and pause or resume it there: every holder pulls from the same worker, so a node's pause is
|
|
||||||
// its holder taking nothing, kept on the module's own pause key (issue 1887's design limit 2). They replace
|
|
||||||
// node-build-agent, retired with build-agent (ADR 0524 §5).
|
|
||||||
{Name: "node-build-go", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Go"),
|
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
|
||||||
{Name: "node-build-node", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("Node"),
|
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
|
||||||
{Name: "node-build-image", Scope: ScopeNode, Accepts: buildAskKinds(), PulledByKind: true, Serves: buildSeatVerbs("image"),
|
|
||||||
Yields: true, Decision: "novox/hq ADR 0525, ADR 0536, ADR 0553"},
|
|
||||||
// **The mesh's resolvers** (novox/hq ADR 0194, 0196, 0223): every node's internal domain, and
|
|
||||||
// every node and container asks them and nothing else. Replicated since ADR 0223: held on more
|
|
||||||
// than one machine, each answering the same names from the same roster, and every machine's
|
|
||||||
// resolver file lists every holder — its own first — and no public resolver, so whichever answers
|
|
||||||
// first gives the one answer. Delivers what a machine's resolver configuration requires, so that
|
|
||||||
// requirement resolves to a holder wherever they are placed.
|
|
||||||
{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution", Replicated: true,
|
|
||||||
Decision: "novox/hq ADR 0194, ADR 0223"},
|
|
||||||
// **A machine's names are one module's** (novox/hq ADR 0199, ADR 0223): its holder writes
|
|
||||||
// /etc/hostname and the machine's own lines in /etc/hosts, and keeps every other line of the hosts
|
|
||||||
// file as the operator's, changed through these three verbs on that machine alone. The controller
|
|
||||||
// holds none of it. Named node-hosts-file until ADR 0223; the former name resolves to it as an
|
|
||||||
// alias on a mesh that knew it.
|
|
||||||
{Name: "node-hostname", Scope: ScopeNode, Decision: "novox/hq ADR 0199, ADR 0223",
|
|
||||||
// The machine's names are the role's files, whatever program fills it (novox/hq ADR 0287).
|
|
||||||
Paths: []SeatPath{{Path: "/etc/hosts"}, {Path: "/etc/hostname"}},
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "entries", Description: "Every line of this machine's /etc/hosts, each marked whose it is: " +
|
|
||||||
"the operator's, or the block of the module or tool that writes it.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"cat /etc/hosts", "getent hosts"}},
|
|
||||||
{Name: "add", Description: "Add one address and its names to the operator's lines of this machine's " +
|
|
||||||
"/etc/hosts — a name for this machine's own programs, not the mesh's.",
|
|
||||||
Input: schema(map[string]string{"address": "the IPv4 or IPv6 address",
|
|
||||||
"names": "the names for it, separated by spaces"}, []string{"address", "names"}),
|
|
||||||
Replaces: []string{"edit /etc/hosts", "HOSTALIASES"}},
|
|
||||||
{Name: "remove", Description: "Remove one name, or every line of one address, from the operator's " +
|
|
||||||
"lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
|
||||||
Input: schema(map[string]string{"name": "a host name, or an address to remove every line of"},
|
|
||||||
[]string{"name"}), Replaces: []string{"sed -i /etc/hosts"}},
|
|
||||||
}},
|
|
||||||
// The intrusion prevention's verbs (novox/hq ADR 0179): what a person asks a machine's ban list
|
|
||||||
// whatever keeps it — who is banned and why, ban one address, let one go. Every holder serves all
|
|
||||||
// four; the jails themselves are composed from the modules the machine runs (to-be 31).
|
|
||||||
{Name: "node-intrusion-prevention", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "status", Description: "Every jail on this machine with how many it is watching and " +
|
|
||||||
"holding now, and the totals since the jail started; one jail's detail when named.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client status"}},
|
|
||||||
{Name: "banned", Description: "Every address banned on this machine right now, with the jail " +
|
|
||||||
"that holds it and when the ban ends.",
|
|
||||||
Input: schema(map[string]string{"jail": "one jail (optional)"}, nil), Replaces: []string{"fail2ban-client banned"}},
|
|
||||||
{Name: "ban", Description: "Ban one address in one jail now, for the jail's ban time — an " +
|
|
||||||
"operator's act on the live ban list, which the mesh never writes itself.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "the jail to hold it"}, []string{"ip", "jail"}),
|
|
||||||
Replaces: []string{"fail2ban-client set banip"}},
|
|
||||||
{Name: "unban", Description: "Let one address go, from one jail or from every jail when none is named.",
|
|
||||||
Input: schema(map[string]string{"ip": "the address", "jail": "one jail (optional)"}, []string{"ip"}),
|
|
||||||
Replaces: []string{"fail2ban-client unban", "fail2ban-client set unbanip"}},
|
|
||||||
}},
|
|
||||||
// The packet filter's verbs (novox/hq ADR 0170): what a person asks a machine's filter whatever
|
|
||||||
// filter answers — the rules as enforced, reload the mesh's own, remove one thing the mesh did
|
|
||||||
// not write. Every holder serves all three; what differs by filter is the holder's own tools.
|
|
||||||
{Name: "node-packet-filter", Scope: ScopeNode, Decision: "novox/hq ADR 0121",
|
|
||||||
Serves: []Verb{
|
|
||||||
{Name: "rules", Description: "The packet filter as this machine enforces it now: the nftables " +
|
|
||||||
"ruleset and, where the tool exists, the legacy filter's listings. Narrowed to one table or " +
|
|
||||||
"chain when asked.",
|
|
||||||
Input: schema(map[string]string{"table": "one nftables table, as `family name` (optional)",
|
|
||||||
"chain": "one chain of that table (optional)"}, nil),
|
|
||||||
Replaces: []string{"nft list ruleset", "iptables -L", "iptables-save"}},
|
|
||||||
{Name: "reload", Description: "Load the mesh's own filter again from the file the mesh writes, " +
|
|
||||||
"and answer with the mesh's table as loaded.",
|
|
||||||
Input: schema(map[string]string{}, nil), Replaces: []string{"nft -f"}},
|
|
||||||
{Name: "remove", Description: "Remove one rule set the mesh did not write, named exactly as the " +
|
|
||||||
"host reports it (novox/hq ADR 0168) — `chain X (iptables-legacy)` or `table ip6 filter, chain " +
|
|
||||||
"DOCKER-USER`. Refuses the mesh's tables, the runtime's own chains, a built-in chain and an " +
|
|
||||||
"active found firewall's chains. An operator's act, by name, never a flush.",
|
|
||||||
Input: schema(map[string]string{"where": "the rule set, as `node show` lists it"}, []string{"where"}),
|
|
||||||
Replaces: []string{"nft delete", "iptables -X"}},
|
|
||||||
}},
|
|
||||||
// The machine's service manager (novox/hq ADR 0177). The host applies every declared unit,
|
|
||||||
// system or user scope; the holder answers questions and operator acts about them, each verb
|
|
||||||
// taking the unit and an optional scope. The holder runs nothing of its own: its verbs are
|
|
||||||
// served by the node tools runtime (ADR 0175).
|
|
||||||
{Name: ServiceManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0177",
|
|
||||||
Serves: serviceManagerVerbs()},
|
|
||||||
// The machine's package manager (novox/hq ADR 0207). A module declaring a `package` depends on
|
|
||||||
// it being held on its node, as one declaring a `service` depends on node-service-manager: the
|
|
||||||
// mesh's word for "something on this machine answers for installing", where a capability only
|
|
||||||
// says the software is there. No verbs yet — the seat says who answers, and what may be asked
|
|
||||||
// of it is decided when someone needs to ask.
|
|
||||||
{Name: PackageManagerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0207"},
|
|
||||||
// The machine's container runtime (novox/hq ADR 0166, seeded now by ADR 0207): a module
|
|
||||||
// declaring a `container` depends on it being held on its node. Its verbs, and the host creating
|
|
||||||
// containers through its holder, wait for ADR 0166's acceptance — seeded without them so the
|
|
||||||
// dependency has a seat to name and the runtime's module has one to claim.
|
|
||||||
{Name: ContainerRuntimeSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0166, ADR 0207"},
|
|
||||||
// The operator account's environment (novox/hq ADR 0203): one module per machine writes it, and
|
|
||||||
// every module contributes to it. No verbs — the seat says who places the environment's files,
|
|
||||||
// and their path is its protocol: a shell sources ~/.config/mesh/environment.sh without knowing
|
|
||||||
// which module wrote it.
|
|
||||||
{Name: EnvironmentSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0203"},
|
|
||||||
// The provisioner client on every machine (novox/hq ADR 0349 clause 3, design 57 P5): a service of its own on a
|
|
||||||
// bus account of its own (mesh-controller internal/broker NodeProvisionerSeat, design 57 P3), which fills the
|
|
||||||
// machine's templates. It serves no verb an agent calls.
|
|
||||||
{Name: "node-provisioner", Scope: ScopeNode, Decision: "novox/hq ADR 0349"},
|
|
||||||
// The login shell (novox/hq ADR 0204, replacing the module-declared `login-shell` of ADR 0176):
|
|
||||||
// the mesh's, so a second shell module claims the seat rather than declaring a second one, and
|
|
||||||
// the seat exists whether or not zsh's definition is registered. `execute` is the contract a caller
|
|
||||||
// may call where the machine serves it (optional: ADR 0268); the holder places every module's shell
|
|
||||||
// code in its slots.
|
|
||||||
{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
|
||||||
Serves: loginShellVerbs()},
|
|
||||||
// A machine's power (novox/hq ADR 0211): its holder owns logind's power handling, places the
|
|
||||||
// code modules contribute for the power moments, and publishes the machine's power states as
|
|
||||||
// its events. Every machine has one — every machine boots and shuts down. No verbs yet.
|
|
||||||
{Name: PowerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0211"},
|
|
||||||
// The machine's hotkeys (novox/hq ADR 0212): the daemon that sees the keys the window manager
|
|
||||||
// does not — a laptop's vendor keys — run by one module per machine, which owns its
|
|
||||||
// configuration. Every other module with keys contributes trigger lines to it.
|
|
||||||
{Name: HotkeysSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0212",
|
|
||||||
Receives: []Receivable{{Kind: "trigger", Comment: "#"}}},
|
|
||||||
// The machine's message bus (novox/hq ADR 0215): its holder owns the D-Bus implementation and its
|
|
||||||
// system service, never restarts it live, and publishes curated events about it, never its traffic.
|
|
||||||
// It receives nothing yet: packages ship their own policies.
|
|
||||||
{Name: MessageBusSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0215"},
|
|
||||||
// The machine's backups (novox/hq ADR 0214, to-be 43): its holder keeps nightly restore points of
|
|
||||||
// the data every module on the machine declares, on the machine, against mistakes rather than
|
|
||||||
// disasters. A module contributes `backup` lines — what to run to take a consistent copy, and
|
|
||||||
// which of its directories to keep.
|
|
||||||
{Name: BackupSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0214",
|
|
||||||
Serves: backupVerbs(),
|
|
||||||
// `backup` is what to run and which paths to keep; `data` every item a module declares, with its
|
|
||||||
// class, for the holder to measure (novox/hq ADR 0233). Both derived from modules' data sections.
|
|
||||||
Receives: []Receivable{{Kind: BackupKindBackup, Comment: "#", Dirs: true}, {Kind: BackupKindData, Comment: "#", Dirs: true}}},
|
|
||||||
// Deferred (novox/hq ADR 0121): renaming to mesh-private-network is a scope + server/client
|
|
||||||
// model change, not a rename, so it stays until that is built.
|
|
||||||
{Name: "the-private-network", Scope: ScopeNode, Decision: "novox/hq ADR 0110"},
|
|
||||||
// The program that manages the machine's own network. It delivers nothing: its holder keeps the
|
|
||||||
// manager and the mesh from contradicting each other — the private network's interface left
|
|
||||||
// alone — and writes the machine's resolver file itself, because the manager is what would
|
|
||||||
// otherwise rewrite it (novox/hq ADR 0223, which retired node-resolver-config into this seat).
|
|
||||||
// It never declares a link, an address or a wireless network, because the link is the only
|
|
||||||
// channel a fix could arrive on. A seat
|
|
||||||
// rather than a condition in the resolver's module, so a machine running two managers is
|
|
||||||
// refused at assignment instead of found by the resolver being rewritten (novox/hq ADR 0117).
|
|
||||||
//
|
|
||||||
// Its verbs say what the machine resolves through and over which links (novox/hq ADR 0241): the
|
|
||||||
// resolver file as it is and who wrote it, and every link with its addresses, routes and the resolvers
|
|
||||||
// the manager knows for it — the same for every holder, so a question about a machine's names is asked
|
|
||||||
// the same way whatever manages its network. Optional until both holders serve them.
|
|
||||||
//
|
|
||||||
// It owns the resolver file, stepping back for node-resolver where that is held (novox/hq ADR 0287,
|
|
||||||
// ADR 0247).
|
|
||||||
{Name: "node-uplink", Scope: ScopeNode, Decision: "novox/hq ADR 0117", Serves: uplinkVerbs(),
|
|
||||||
Paths: []SeatPath{{Path: ResolverFile, StepsBackFor: ResolverSeat}}},
|
|
||||||
// The machine's own resolver (novox/hq ADR 0247): held only where something requires `split-dns` — a
|
|
||||||
// VPN client whose domains must go to its own servers while every other name goes to the mesh's.
|
|
||||||
// Where it is held, its holder writes the resolver file and the uplink's holder steps back from it
|
|
||||||
// (node_resolver.go). It knows nothing of any VPN: its verbs route domains to servers over a link.
|
|
||||||
{Name: ResolverSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0247", Serves: resolverVerbs(),
|
|
||||||
Paths: []SeatPath{{Path: ResolverFile}}},
|
|
||||||
// A machine's shares and the shares it mounts (novox/hq ADR 0263): the holder of node-nfs-server
|
|
||||||
// exports a machine's folders to the private network and provides each as `nfs-share`; the holder of
|
|
||||||
// node-mounts writes a mount and an automount unit per share on a machine that asks (shares.go).
|
|
||||||
{Name: NFSServerSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: nfsServerVerbs()},
|
|
||||||
{Name: MountsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0263", Serves: mountsVerbs()},
|
|
||||||
// A node's files and accounts, read (novox/hq ADR 0310, reads.go): by an account of the holder's own that holds
|
|
||||||
// no secret, on every node, so the reads ADR 0268 §5 takes over from the login shell's execute have a tool.
|
|
||||||
{Name: ReadsSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0310", Serves: readsVerbs()},
|
|
||||||
// A node's files, read and managed (novox/hq ADR 0394, files.go): beside node-reads until mesh-files is assigned
|
|
||||||
// on every node, then node-reads goes (ADR 0394 §9).
|
|
||||||
{Name: FilesSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0394", Serves: filesVerbs()},
|
|
||||||
// The node's engine's own verbs and asks (novox/hq ADR 0563 §1): held by the engine on every node, by no module.
|
|
||||||
engineSeat(),
|
|
||||||
},
|
|
||||||
// The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's.
|
|
||||||
graphicalSessionSeats()...)
|
|
||||||
|
|
||||||
// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by
|
// seats is the working set the lookups read. It starts as the compiled defaults and is replaced by
|
||||||
// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a
|
// what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a
|
||||||
// change to data, not to this code.
|
// change to data, not to this code.
|
||||||
var seats = defaultSeats
|
var seats = defaultSeats
|
||||||
|
|
||||||
// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its
|
|
||||||
// text), and a limit that is a number; every other argument a string.
|
|
||||||
func configInput() map[string]any {
|
|
||||||
in := schema(map[string]string{"action": "list (when not given), set, clear or history",
|
|
||||||
"key": "the config key", "value": "for set: the value, of the key's type",
|
|
||||||
"module": "a module: its own value, with node", "node": "a node",
|
|
||||||
"why": "for set and clear: why", "by": "who gave the value at the controller's terminal",
|
|
||||||
"limit": "for history: at most this many, newest"}, nil)
|
|
||||||
props := in["properties"].(map[string]any)
|
|
||||||
props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " +
|
|
||||||
"a boolean, or for a trusted key an object or a list"}
|
|
||||||
props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
|
// DefaultSeats is the set the mesh ships with, for seeding the store's seat table.
|
||||||
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
|
func DefaultSeats() []Seat { return append([]Seat(nil), defaultSeats...) }
|
||||||
|
|
||||||
@@ -704,189 +326,9 @@ func SeatsWithAProtocol() []Seat {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// serviceManagerVerbs is the contract every holder of node-service-manager serves (novox/hq ADR
|
|
||||||
// 0177): the units on the machine in both scopes, read and acted on by name. Every verb takes an
|
|
||||||
// optional scope — "system" when absent, "user" for the operator account's own manager — so a
|
|
||||||
// caller asks for a user unit the way it asks for a system one; `failed` alone reads both managers
|
|
||||||
// when none is named. `failed`, `reset-failed`, `wanted-by` and `unlink-dangling` are optional
|
|
||||||
// (Verb.Optional) until every holder serves them (ADR 0246).
|
|
||||||
func serviceManagerVerbs() []Verb {
|
|
||||||
scoped := func(more map[string]string, required []string) map[string]any {
|
|
||||||
props := map[string]string{"scope": "\"system\" (the default) or \"user\": the operator account's own manager"}
|
|
||||||
for k, v := range more {
|
|
||||||
props[k] = v
|
|
||||||
}
|
|
||||||
return schema(props, required)
|
|
||||||
}
|
|
||||||
unit := map[string]string{"unit": "the unit's name, as the service manager knows it"}
|
|
||||||
return []Verb{
|
|
||||||
{Name: "units", Description: "The units the service manager knows in a scope, each with its load, active and sub state; narrowed to a pattern when asked.",
|
|
||||||
Input: scoped(map[string]string{"pattern": "a glob the unit's name must match (optional)"}, nil), Replaces: []string{"systemctl list-units"}},
|
|
||||||
{Name: "status", Description: "One unit as the service manager sees it now: its states, whether it starts at boot, its main process, and whether the mesh declares it.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl status", "systemctl is-active"}},
|
|
||||||
{Name: "start", Description: "Start one unit. For a unit the mesh declares, the answer says the host will restore what its declaration says at the next apply.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl start"}},
|
|
||||||
{Name: "stop", Description: "Stop one unit; for a mesh-declared unit the answer says the host will restore its declared state.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl stop"}},
|
|
||||||
{Name: "restart", Description: "Restart one unit.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl restart"}},
|
|
||||||
{Name: "enable", Description: "Make one unit start at boot (or at the account's login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl enable"}},
|
|
||||||
{Name: "disable", Description: "Stop one unit starting at boot (or at login, in user scope).",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl disable"}},
|
|
||||||
// **A window, not only a tail** (the operator's direction 2026-10-07): an incident is read for the
|
|
||||||
// minutes it happened in, and with no window on the verb a person reached for a shell. Every
|
|
||||||
// argument is the holder's to validate — passed to journalctl as one word of its own, never through
|
|
||||||
// a shell — and what the unit printed of a secret is redacted before it is answered.
|
|
||||||
{Name: "journal", Description: "The last lines of one unit's journal (at most 2000), in a time window and " +
|
|
||||||
"narrowed to a priority and to lines holding a text when asked. A secret the unit printed is shown as " +
|
|
||||||
"[redacted: <what it was>].",
|
|
||||||
Input: scoped(map[string]string{
|
|
||||||
"unit": unit["unit"],
|
|
||||||
"lines": "how many lines from the end of what matches (default 100, at most 2000)",
|
|
||||||
"since": "the window's start: an RFC 3339 time (2026-10-07T09:30:00Z) or relative to now (-30min, -2h, yesterday) (optional)",
|
|
||||||
"until": "the window's end, in the same forms (optional; now when absent)",
|
|
||||||
"match": "only the lines holding this text, as written — a fixed string, not a pattern (optional)",
|
|
||||||
"priority": "only entries this severe or more: 0-7 or emerg, alert, crit, err, warning, notice, info, debug (optional)",
|
|
||||||
}, []string{"unit"}),
|
|
||||||
Replaces: []string{"journalctl"}},
|
|
||||||
// What has failed, on the seat rather than as one holder's own tool: whatever holds the role answers
|
|
||||||
// it, so a caller asks every machine the same way. **Optional while its holders catch up**: the
|
|
||||||
// systemd module running today serves it as its own systemd_failed, and a required verb would
|
|
||||||
// refuse it before the version serving `failed` could be delivered.
|
|
||||||
{Name: "failed", Optional: true, Description: "Every failed unit on this machine, in the system manager and in the operator " +
|
|
||||||
"account's; a manager that does not answer is reported with its error, never as nothing failed.",
|
|
||||||
Input: schema(map[string]string{"scope": "\"system\" or \"user\": only that manager (both when absent)"}, nil),
|
|
||||||
Replaces: []string{"systemctl --failed"}},
|
|
||||||
// **Clearing a failed record, and finding what starts a unit** (novox/hq issue 332): a unit whose
|
|
||||||
// file is gone stays failed in its manager until the record is reset, and what keeps asking for it
|
|
||||||
// is a dependency or an enable link no verb could show. Required since every holder serves them
|
|
||||||
// (ADR 0246 step 3).
|
|
||||||
{Name: "reset-failed", Description: "Clear one unit's failed record in its manager — " +
|
|
||||||
"its failed state and its start-limit count — and answer its state after. It removes no file and " +
|
|
||||||
"starts nothing.",
|
|
||||||
Input: scoped(unit, []string{"unit"}), Replaces: []string{"systemctl reset-failed"}},
|
|
||||||
{Name: "wanted-by", Description: "What wants, requires or triggers one unit, read-only: the " +
|
|
||||||
"manager's reverse dependencies (WantedBy, RequiredBy, UpheldBy, BoundBy, TriggeredBy and the rest) and " +
|
|
||||||
"every enable link naming it in the scope's configuration directories (*.wants, *.requires, *.upholds), " +
|
|
||||||
"each with where it points and whether it dangles — the account's own directory included in user scope.",
|
|
||||||
Input: scoped(unit, []string{"unit"}),
|
|
||||||
Replaces: []string{"systemctl list-dependencies --reverse", "systemctl show -p WantedBy",
|
|
||||||
"ls ~/.config/systemd/user/*.wants"}},
|
|
||||||
// **A dangling enable link removed on the person's word** (novox/hq issue 332): `systemctl disable`
|
|
||||||
// leaves a link whose unit file is gone, so the manager keeps asking for the unit. Required since
|
|
||||||
// every holder serves it (ADR 0246 step 3).
|
|
||||||
{Name: "unlink-dangling", Description: "Remove the dangling enable links named for one " +
|
|
||||||
"unit — links in a *.wants, *.requires or *.upholds directory of the account's or the machine's own " +
|
|
||||||
"configuration whose target does not exist, which `disable` leaves once the unit's file is gone — on " +
|
|
||||||
"the person's word: `why` is required. Each is recorded (its path, its target, the reason) before it is " +
|
|
||||||
"removed, and the manager reloaded; a link whose target exists, or one a package, a generator or the " +
|
|
||||||
"runtime placed, is left and said.",
|
|
||||||
Input: scoped(map[string]string{"unit": unit["unit"],
|
|
||||||
"why": "the person's reason for removing the links, kept in the record"}, []string{"unit", "why"}),
|
|
||||||
Replaces: []string{"rm ~/.config/systemd/user/*.wants/<unit>", "unlink /etc/systemd/system/*.wants/<unit>"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uplinkVerbs is the contract every holder of node-uplink serves (novox/hq ADR 0241): read-only answers
|
|
||||||
// about what the machine resolves through and over which links, whatever program manages its network.
|
|
||||||
func uplinkVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "resolvers", Description: "The machine's resolver file as it is now: the resolvers, search " +
|
|
||||||
"domains and options it lists, whether it is the file the mesh declares, and when it is not, who " +
|
|
||||||
"wrote it as far as the machine shows — its header, a backup a VPN client left beside it, a link " +
|
|
||||||
"in its place, a known writer running.",
|
|
||||||
Input: schema(nil, nil), Optional: true},
|
|
||||||
{Name: "links", Description: "Every network link on the machine: its state, its addresses, whether " +
|
|
||||||
"the default route leaves through it, and the resolvers and search domains the network manager " +
|
|
||||||
"knows for it — a VPN's tunnel included.",
|
|
||||||
Input: schema(nil, nil), Optional: true},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// backupVerbs is the node-backup seat's protocol (novox/hq to-be 43): what is kept, take one now,
|
|
||||||
// and restore beside the live data — never over it.
|
|
||||||
func backupVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "backed-up", Description: "What this machine backs up: each module, what it declared, " +
|
|
||||||
"its last good night, how many restore points are kept and the repository's size.",
|
|
||||||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic snapshots"}},
|
|
||||||
{Name: "now", Description: "Take a backup now, of one module or of every module on this " +
|
|
||||||
"machine — before a migration, a retirement or anything else that could go wrong.",
|
|
||||||
Input: schema(map[string]string{"module": "one module (optional)"}, nil), Replaces: []string{"restic backup"}},
|
|
||||||
{Name: "restore", Description: "Restore one module's data from a restore point BESIDE the live " +
|
|
||||||
"data, never over it: each directory as <path>.restored-<date>. Swapping it in is a " +
|
|
||||||
"person's act. Lists the restore points when none is named.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"module": "the module",
|
|
||||||
"snapshot": "the restore point (from `backed-up`; the newest when omitted)",
|
|
||||||
"path": "one of the module's directories (all of them when omitted)",
|
|
||||||
}, []string{"module"}), Replaces: []string{"restic restore"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// loginShellVerbs is the contract of node-login-shell (novox/hq ADR 0176, ADR 0204): one command, run
|
|
||||||
// the way the operator's own terminal would run it, bounded below the runtime's thirty-second call limit
|
|
||||||
// so a hung command answers rather than times the caller out.
|
|
||||||
//
|
|
||||||
// **`execute` is optional** (novox/hq ADR 0268). It runs any command as the operator account, which can
|
|
||||||
// become root without a person, so a machine may withhold it: the control-node does, through the
|
|
||||||
// holder's own `execute` setting, until a call to it needs a person's approval (hq research 039). The mark
|
|
||||||
// is ADR 0246's: a holder that does not serve the verb on a machine still holds the seat there, and its
|
|
||||||
// silence on the bus is not judged — a holder withholding it serves nothing on the seat.
|
|
||||||
func loginShellVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "execute", Optional: true, Description: "Run one command on this machine as the operator account, in a " +
|
|
||||||
"non-interactive login shell in its home; answers with what it printed and how it exited.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"command": "the command line, as you would type it",
|
|
||||||
"timeout_seconds": "give up after this long, at most 25 (default 20)",
|
|
||||||
}, []string{"command"})},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAskKinds are a build seat's accepted kinds, in the order a holder pulls them (ADR 0536 §4; mesh-sdk
|
|
||||||
// delivery.AskKinds).
|
|
||||||
func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} }
|
|
||||||
|
|
||||||
// buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10).
|
|
||||||
func buildSeatVerbs(language string) []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "current", Description: "The " + language + " asks this node runs now (each ask's id, kind, module and when it " +
|
|
||||||
"started), its build capacity, and whether it is paused, taking no new ask.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "pause", Description: "Take no new " + language + " ask on this node until resumed; an ask running here " +
|
|
||||||
"ends in its place. Kept across a restart of the build module (its pause key). A capacity of 0 is the " +
|
|
||||||
"lasting way to keep a node from building.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
{Name: "resume", Description: "Take " + language + " asks again on this node.",
|
|
||||||
Input: schema(map[string]string{}, nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
// BrokerSeat is the mesh's bus. Its holder is the bus, and its account — when it declares one — may
|
||||||
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
// snapshot the bus's streams and do nothing else (novox/hq ADR 0235).
|
||||||
const BrokerSeat = "mesh-broker"
|
//
|
||||||
|
// Deprecated: a whole seat's name is mesh-contracts' (seats.MeshBroker, meshbroker.Seat); kept for its importers
|
||||||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
// until their next change takes it from there (testdata/seat-names.txt).
|
||||||
// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a
|
const BrokerSeat = contractseats.MeshBroker
|
||||||
// value for each, which the vault keeps as that holder's own and hands to it alone.
|
|
||||||
func withHolders(input map[string]any) map[string]any {
|
|
||||||
props, _ := input["properties"].(map[string]any)
|
|
||||||
props["holders"] = map[string]any{"type": "array",
|
|
||||||
"description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " +
|
|
||||||
"for each, kept as that holder's own and handed to it alone",
|
|
||||||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
|
||||||
"node": map[string]any{"type": "string", "description": "the holder's machine"},
|
|
||||||
"module": map[string]any{"type": "string", "description": "the consuming module"}}}}
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
func vaultIntake(first map[string]string) map[string]string {
|
|
||||||
out := map[string]string{"call": "the second step: the call the first step answered",
|
|
||||||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
|
||||||
for k, v := range first {
|
|
||||||
out[k] = v
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,4 +34,105 @@ func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
|
|||||||
if len(Seats()) != 1 {
|
if len(Seats()) != 1 {
|
||||||
t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats()))
|
t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **The compiled set is the generated one, and the move changed nothing a reader sees** (design 26, SD-M3, SDK-8):
|
||||||
|
// every seat the list mesh-sdk compiled before the move held (testdata, written from 5a38a12) is in the set
|
||||||
|
// generated from mesh-contracts' definitions, entry for entry, its verbs' optional marks and a kind's code
|
||||||
|
// included. A seat defined whole may add verbs, events and answers; the vault drops accept and move-asks (ADR 0620
|
||||||
|
// §3); three registry seats are new.
|
||||||
|
UseSeats(DefaultSeats())
|
||||||
|
type golden struct {
|
||||||
|
Seat
|
||||||
|
Optional []string
|
||||||
|
ShapeCode []string
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile("testdata/seats-before-move.json")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var compiled []golden
|
||||||
|
if err := json.Unmarshal(raw, &compiled); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
whole := []string{"npm-package-registry", "mesh-broker", "mesh-config", "mesh-vault", "mesh-delivery-checker", "node-engine"}
|
||||||
|
dropped := map[string]bool{"mesh-vault.accept": true, "mesh-vault.move-asks": true}
|
||||||
|
// Verbs a seat at the seat list's grain gained in the milestone, each optional until its holders serve it.
|
||||||
|
addedVerbs := map[string]bool{}
|
||||||
|
// A verb whose input the whole definition corrected: compose-candidate's input is the boolean the engine and the
|
||||||
|
// checker read, where the compiled list said a word (engine_compose_candidate_test.go reads it).
|
||||||
|
retyped := map[string]bool{"node-engine.compose-candidate": true}
|
||||||
|
norm := func(v any) any {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
var out any
|
||||||
|
_ = json.Unmarshal(b, &out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, want := range compiled {
|
||||||
|
names = append(names, want.Name)
|
||||||
|
got, ok := SeatNamed(want.Name)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s left the generated set", want.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g := golden{Seat: got}
|
||||||
|
for _, v := range got.Serves {
|
||||||
|
if v.Optional {
|
||||||
|
g.Optional = append(g.Optional, v.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range got.Receives {
|
||||||
|
if r.Shape != nil && (r.Shape.Check != nil || r.Shape.Reads != nil) {
|
||||||
|
g.ShapeCode = append(g.ShapeCode, r.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.Serves = slices.DeleteFunc(slices.Clone(g.Serves), func(v Verb) bool { return addedVerbs[want.Name+"."+v.Name] })
|
||||||
|
g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool { return addedVerbs[want.Name+"."+n] })
|
||||||
|
if slices.Contains(whole, want.Name) {
|
||||||
|
want := &want
|
||||||
|
byName := map[string]Verb{}
|
||||||
|
for _, v := range got.Serves {
|
||||||
|
v.Output = nil
|
||||||
|
byName[v.Name] = v
|
||||||
|
}
|
||||||
|
var kept []Verb
|
||||||
|
for _, v := range want.Serves {
|
||||||
|
if dropped[want.Name+"."+v.Name] {
|
||||||
|
if _, still := byName[v.Name]; still {
|
||||||
|
t.Errorf("%s still serves %s", want.Name, v.Name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kv := byName[v.Name]
|
||||||
|
if retyped[want.Name+"."+v.Name] {
|
||||||
|
kv.Input = v.Input
|
||||||
|
}
|
||||||
|
kept = append(kept, kv)
|
||||||
|
}
|
||||||
|
g.Serves, g.Emits, g.Accepts = kept, want.Emits, want.Accepts
|
||||||
|
// A verb the whole seat added (new, optional while its holders catch up) is no verb of the compiled list.
|
||||||
|
g.Optional = slices.DeleteFunc(g.Optional, func(n string) bool {
|
||||||
|
return !slices.ContainsFunc(want.Serves, func(v Verb) bool { return v.Name == n })
|
||||||
|
})
|
||||||
|
if len(g.Optional) == 0 {
|
||||||
|
g.Optional = nil
|
||||||
|
}
|
||||||
|
want.Optional = slices.DeleteFunc(want.Optional, func(n string) bool { return dropped[want.Name+"."+n] })
|
||||||
|
want.Serves = slices.DeleteFunc(slices.Clone(want.Serves), func(v Verb) bool { return dropped[want.Name+"."+v.Name] })
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(norm(g), norm(want)) {
|
||||||
|
gb, _ := json.Marshal(g)
|
||||||
|
wb, _ := json.Marshal(want)
|
||||||
|
t.Errorf("%s is not as the compiled list held it:\n got %s\nwant %s", want.Name, gb, wb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var added []string
|
||||||
|
for _, s := range DefaultSeats() {
|
||||||
|
if !slices.Contains(names, s.Name) {
|
||||||
|
added = append(added, s.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if want := []string{"go-package-registry", "container-image-registry", "oci-artifact-registry"}; !slices.Equal(added, want) {
|
||||||
|
t.Errorf("the generated set adds %v, want %v", added, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ type SettingDeclaration struct {
|
|||||||
Required bool `json:"required,omitempty"`
|
Required bool `json:"required,omitempty"`
|
||||||
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
|
// Runtime is a value the module's code reads at runtime rather than one placed in a file (ADR 0348 §5).
|
||||||
Runtime bool `json:"runtime,omitempty"`
|
Runtime bool `json:"runtime,omitempty"`
|
||||||
|
// RenamedFrom is the value's old name, a config value of this module's own (novox/hq ADR 0609 §4, design 32 *A
|
||||||
|
// config value renamed*): on the merge mesh-config moves the value held under it to this name, and answers both
|
||||||
|
// names until every node-engine reports a build reading the new one. The config form only: the judge refuses it
|
||||||
|
// under `settings`, on a secret, and where it changes the value's trust.
|
||||||
|
RenamedFrom string `json:"renamed-from,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// KindPreference is the kind of a setting that may have a default.
|
// KindPreference is the kind of a setting that may have a default.
|
||||||
|
|||||||
+4
-75
@@ -1,80 +1,9 @@
|
|||||||
package catalogue
|
package catalogue
|
||||||
|
|
||||||
|
import contractseats "git.novox.be/novox/mesh-contracts/go/seats"
|
||||||
|
|
||||||
// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263).
|
// NFSServerSeat is the role of the machine that shares directories over NFS (novox/hq ADR 0263).
|
||||||
const NFSServerSeat = "node-nfs-server"
|
const NFSServerSeat = contractseats.NodeNfsServer
|
||||||
|
|
||||||
// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263).
|
// MountsSeat is the role that mounts a machine's shares and occasional sources (novox/hq ADR 0263).
|
||||||
const MountsSeat = "node-mounts"
|
const MountsSeat = contractseats.NodeMounts
|
||||||
|
|
||||||
// nfsServerVerbs is the contract every holder of node-nfs-server serves (novox/hq ADR 0263).
|
|
||||||
func nfsServerVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "exports", Description: "Every share this machine exports: its name, its path, read-write or " +
|
|
||||||
"read-only, the owner every client is mapped to (uid and gid), each node it is exported to with that " +
|
|
||||||
"node's private address and access (only the nodes the server grants it to and that ask for it), " +
|
|
||||||
"what is granted and not asked for or asked for and not granted, and whether the kernel holds it " +
|
|
||||||
"now. Also the exports found that are not the mesh's: a dataset's sharenfs property, a line in " +
|
|
||||||
"/etc/exports.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"exportfs -v", "cat /etc/exports", "zfs get sharenfs"}},
|
|
||||||
{Name: "clients", Description: "Which machines have mounted which share now, as the NFS server " +
|
|
||||||
"knows its clients.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"ss -tn sport = :2049", "cat /proc/fs/nfsd/clients/*/info"}},
|
|
||||||
{Name: "test", Description: "Whether this machine exports one share for the mesh now, and whether its " +
|
|
||||||
"NFS service is up; with an address, also whether the share is exported to that address: a node's " +
|
|
||||||
"own private address, when the server grants it the share and the node asks for it. What a machine " +
|
|
||||||
"mounting the share asks before it mounts.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"share": "the share, by its name",
|
|
||||||
"address": "a node's private address, to ask whether the share is exported to it (optional)",
|
|
||||||
}, []string{"share"}),
|
|
||||||
Replaces: []string{"showmount -e"}},
|
|
||||||
{Name: "reload", Description: "Have the kernel read this machine's export files again now (exportfs " +
|
|
||||||
"-ra) and answer the shares as it then holds them. The module's own process writes its export " +
|
|
||||||
"file; this is for after a change made outside it. Changes no shared path.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"exportfs -ra"}},
|
|
||||||
{Name: "adopt", Description: "Take over an export a person made by hand: clear a dataset's sharenfs " +
|
|
||||||
"property for a path the mesh's own export now serves — only when that export is live. Without " +
|
|
||||||
"confirm, says what it would do and changes nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"path": "the shared path whose hand-made export is taken over",
|
|
||||||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
|
||||||
"why": "why, for the record",
|
|
||||||
}, []string{"path"}, "confirm"),
|
|
||||||
Replaces: []string{"zfs set sharenfs=off"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountsVerbs is the contract every holder of node-mounts serves (novox/hq ADR 0263).
|
|
||||||
func mountsVerbs() []Verb {
|
|
||||||
return []Verb{
|
|
||||||
{Name: "list", Description: "Every mount point on this machine: its fstab line, the mesh's mount and " +
|
|
||||||
"automount units for it, its state (armed, mounted, absent, unreachable, failed) and since when, " +
|
|
||||||
"and which settings asked for it. A password in a mount's options is never shown.",
|
|
||||||
Input: schema(map[string]string{}, nil),
|
|
||||||
Replaces: []string{"cat /etc/fstab", "findmnt", "systemctl list-units --type=mount"}},
|
|
||||||
{Name: "test", Description: "Whether one share's or occasional source's server answers from this " +
|
|
||||||
"machine now, without touching its mount point.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"showmount -e", "ping"}},
|
|
||||||
{Name: "mount", Description: "Mount one share or occasional source now, rather than at its first " +
|
|
||||||
"access.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"mount"}},
|
|
||||||
{Name: "unmount", Description: "Release one share or occasional source now; its automount stays " +
|
|
||||||
"armed, so the next access mounts it again.",
|
|
||||||
Input: schema(map[string]string{"name": "the share or source, by its name"}, []string{"name"}),
|
|
||||||
Replaces: []string{"umount"}},
|
|
||||||
{Name: "adopt", Description: "Take over a mount a person wrote by hand: comment out the /etc/fstab " +
|
|
||||||
"line for a mount point the mesh's own units now serve, keeping a copy of the file — only when " +
|
|
||||||
"the mesh's automount for it is armed. Without confirm, says what it would do and changes nothing.",
|
|
||||||
Input: schema(map[string]string{
|
|
||||||
"mountpoint": "the mount point whose fstab line is taken over",
|
|
||||||
"confirm": "\"true\": change it (needs why); anything else is a dry run",
|
|
||||||
"why": "why, for the record",
|
|
||||||
}, []string{"mountpoint"}, "confirm"),
|
|
||||||
Replaces: []string{"sed -i /etc/fstab"}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,9 +31,13 @@ type StateDeclaration struct {
|
|||||||
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
|
// KeyTTL says each key of the bucket lives the time its writer gives it, and the server writes a delete marker
|
||||||
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
|
// when it ends (novox/hq ADR 0433 §1): the bucket is made with the server's time to live per message.
|
||||||
KeyTTL bool `json:"key-ttl,omitempty"`
|
KeyTTL bool `json:"key-ttl,omitempty"`
|
||||||
|
// ReadOnly says only the module's processes whose own bus.state names the state write it; the module's tools,
|
||||||
|
// the tool runner's account, read it and never write it (the lead's ruling, 2026-11-04: nats' warrant spend record
|
||||||
|
// `spent`). The judge's rule and the grants follow from it.
|
||||||
|
ReadOnly bool `json:"read-only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}.
|
// UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds, per-machine, key-ttl, read-only}.
|
||||||
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
||||||
trimmed := bytes.TrimSpace(raw)
|
trimmed := bytes.TrimSpace(raw)
|
||||||
if len(trimmed) > 0 && trimmed[0] == '"' {
|
if len(trimmed) > 0 && trimmed[0] == '"' {
|
||||||
@@ -44,7 +48,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
|||||||
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
dec := json.NewDecoder(bytes.NewReader(trimmed))
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
if err := dec.Decode(&full); err != nil {
|
if err := dec.Decode(&full); err != nil {
|
||||||
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: %w", typedUnknown(err))
|
return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl, read-only}: %w", typedUnknown(err))
|
||||||
}
|
}
|
||||||
*s = StateDeclaration(full)
|
*s = StateDeclaration(full)
|
||||||
return nil
|
return nil
|
||||||
@@ -52,7 +56,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error {
|
|||||||
|
|
||||||
// MarshalJSON writes back the short form when there is nothing else to say.
|
// MarshalJSON writes back the short form when there is nothing else to say.
|
||||||
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
func (s StateDeclaration) MarshalJSON() ([]byte, error) {
|
||||||
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL {
|
if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine && !s.KeyTTL && !s.ReadOnly {
|
||||||
return json.Marshal(s.Name)
|
return json.Marshal(s.Name)
|
||||||
}
|
}
|
||||||
type plain StateDeclaration
|
type plain StateDeclaration
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package catalogue
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,17 +29,25 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before.
|
// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before.
|
||||||
|
// G-sdk-contracts 4: read-only likewise, and given twice it is refused.
|
||||||
func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
|
func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
|
||||||
m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` +
|
m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` +
|
||||||
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`))
|
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL {
|
if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL {
|
||||||
t.Fatalf("key-ttl not read as said: %+v", m.State)
|
t.Fatalf("key-ttl not read as said: %+v", m.State)
|
||||||
}
|
}
|
||||||
|
if !m.State[3].ReadOnly || m.State[0].ReadOnly || m.State[1].ReadOnly {
|
||||||
|
t.Fatalf("read-only not read as said: %+v", m.State)
|
||||||
|
}
|
||||||
out, _ := json.Marshal(m.State)
|
out, _ := json.Marshal(m.State)
|
||||||
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` {
|
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5},{"name":"spent","read-only":true}]` {
|
||||||
t.Fatalf("written back as %s", out)
|
t.Fatalf("written back as %s", out)
|
||||||
}
|
}
|
||||||
|
if _, err := decodeForTest([]byte(`{"module":"nats","version":"1","state":[{"name":"spent","read-only":true,"read-only":false}]}`)); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "the field state[0].read-only is given twice") {
|
||||||
|
t.Fatalf("read-only given twice: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) {
|
|||||||
s.Days() != 1 || s.Cap() != 64 {
|
s.Days() != 1 || s.Cap() != 64 {
|
||||||
t.Fatalf("the stream read as %+v", s)
|
t.Fatalf("the stream read as %+v", s)
|
||||||
}
|
}
|
||||||
|
// ADR 0626 §1: the bus module's keeper declares keeps beside the rest of its bus, and it reads back.
|
||||||
|
k, err := decodeForTest([]byte(`{"module":"nats","version":"1","resources":[{"id":"keeper","type":"process",` +
|
||||||
|
`"run":["./keeper"],"bus":{"keeps":true,"state":["requests"]}}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a keeper's bus was refused: %v", err)
|
||||||
|
}
|
||||||
|
if b, ok := k.ProcessBuses()["keeper"]; !ok || !b.Keeps {
|
||||||
|
t.Fatalf("the keeper's bus read as %+v", b)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven
|
// 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven
|
||||||
|
|||||||
+4810
File diff suppressed because it is too large
Load Diff
@@ -27,44 +27,17 @@ func TestTheVaultsSeatDeliversSecret(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The vault answers `get` on request and reply (novox/hq ADR 0349 §11, issue 674): the seat promises the
|
|
||||||
// verb, so a holder serving it is accepted — and, optional while the vault that runs is the TypeScript one,
|
|
||||||
// so is a holder that does not yet.
|
|
||||||
func TestTheVaultsSeatPromisesGet(t *testing.T) {
|
|
||||||
seat, _ := SeatNamed("mesh-vault")
|
|
||||||
var get *Verb
|
|
||||||
for i := range seat.Serves {
|
|
||||||
if seat.Serves[i].Name == "get" {
|
|
||||||
get = &seat.Serves[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if get == nil {
|
|
||||||
t.Fatal("the vault's seat does not promise get")
|
|
||||||
}
|
|
||||||
for _, arg := range []string{"name", "public_key"} {
|
|
||||||
if props, _ := get.Input["properties"].(map[string]any); props[arg] == nil {
|
|
||||||
t.Errorf("get takes no %s", arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, Serves: []string{"get"}}}}
|
|
||||||
if err := CanHold(serving, seat); err != nil {
|
|
||||||
t.Fatalf("the vault serving get was refused: %v", err)
|
|
||||||
}
|
|
||||||
notYet := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
|
|
||||||
if err := CanHold(notYet, seat); err != nil {
|
|
||||||
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
|
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
|
||||||
// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted,
|
// secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted, and,
|
||||||
// and, optional while the vault that runs does not, so is one that does not yet.
|
// optional while the vault that runs does not, so is one that does not yet. And the move of the controller's secrets
|
||||||
|
// (design 57's V3, issue 676): move-in, asked in two steps, never a value in the clear. accept and move-asks, which no
|
||||||
|
// holder served and nothing called, are not the seat's (ADR 0620 §3); retire is, optional (design 32).
|
||||||
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||||
seat, _ := SeatNamed("mesh-vault")
|
seat, _ := SeatNamed("mesh-vault")
|
||||||
want := map[string][]string{
|
want := map[string][]string{
|
||||||
"accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"},
|
// get, on request and reply (ADR 0349 §11, issue 674); a vault not serving it yet still holds the seat
|
||||||
|
// (TestTheVaultsSeatDeliversSecret).
|
||||||
|
"get": {"name", "public_key"},
|
||||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||||
@@ -76,6 +49,20 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|||||||
"rotate-node": {"node", "why", "act"},
|
"rotate-node": {"node", "why", "act"},
|
||||||
"node-account-confirm": {},
|
"node-account-confirm": {},
|
||||||
"abandon": {"node"},
|
"abandon": {"node"},
|
||||||
|
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
||||||
|
"generation", "made_at", "call", "sealed", "read_back_to"},
|
||||||
|
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
|
||||||
|
"own": {"credentials"},
|
||||||
|
"retire": {"node", "module", "name", "warrant"},
|
||||||
|
// Issue 786: node-key, optional while the vault that runs does not serve it.
|
||||||
|
"node-key": {"key"},
|
||||||
|
}
|
||||||
|
for _, gone := range []string{"accept", "move-asks"} {
|
||||||
|
for _, v := range seat.Serves {
|
||||||
|
if v.Name == gone {
|
||||||
|
t.Errorf("the vault's seat still promises %s, which no holder serves (ADR 0620 §3)", gone)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for name, args := range want {
|
for name, args := range want {
|
||||||
var verb *Verb
|
var verb *Verb
|
||||||
@@ -124,70 +111,9 @@ func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
Serves: []string{"get", "secret-ask", "issued", "rotate", "sign", "enrolment-token", "node-account",
|
||||||
"rotate-node", "node-account-confirm", "abandon"}}}}
|
"rotate-node", "node-account-confirm", "abandon", "move-in", "node-key", "own"}}}}
|
||||||
if err := CanHold(serving, seat); err != nil {
|
if err := CanHold(serving, seat); err != nil {
|
||||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The move of the controller's secrets into the vault (design 57's V3, issue 676): the seat promises move-in, which a
|
|
||||||
// machine's node-engine asks in two steps with the row, its origin and generation, then the value sealed to the
|
|
||||||
// call's key and a key to read back to; and move-asks, the controller's record of asks copied as it is. Optional
|
|
||||||
// while the vault that runs does not serve them; never a value in the clear.
|
|
||||||
func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
|
||||||
seat, _ := SeatNamed("mesh-vault")
|
|
||||||
want := map[string][]string{
|
|
||||||
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
|
||||||
"generation", "made_at", "call", "sealed", "read_back_to"},
|
|
||||||
"move-asks": {"asks"},
|
|
||||||
// And own (issue 1126): a machine's node-engine asks its own machine's secrets, sealed to its stated key.
|
|
||||||
"own": {"credentials"},
|
|
||||||
}
|
|
||||||
for name, args := range want {
|
|
||||||
var verb *Verb
|
|
||||||
for i := range seat.Serves {
|
|
||||||
if seat.Serves[i].Name == name {
|
|
||||||
verb = &seat.Serves[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if verb == nil {
|
|
||||||
t.Errorf("the vault's seat does not promise %s", name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !verb.Optional {
|
|
||||||
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
|
|
||||||
}
|
|
||||||
props, _ := verb.Input["properties"].(map[string]any)
|
|
||||||
for _, arg := range args {
|
|
||||||
if props[arg] == nil {
|
|
||||||
t.Errorf("%s takes no %s", name, arg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if props["value"] != nil {
|
|
||||||
t.Errorf("%s takes a value in the clear", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
|
||||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
|
||||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "node-key", "move-asks", "own"}}}}
|
|
||||||
if err := CanHold(serving, seat); err != nil {
|
|
||||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue 786: the vault's seat promises node-key, optional while the vault that runs does not serve it.
|
|
||||||
func TestTheVaultsSeatPromisesNodeKey(t *testing.T) {
|
|
||||||
seat, _ := SeatNamed("mesh-vault")
|
|
||||||
for _, v := range seat.Serves {
|
|
||||||
if v.Name != "node-key" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
props, _ := v.Input["properties"].(map[string]any)
|
|
||||||
if !v.Optional || props["key"] == nil {
|
|
||||||
t.Errorf("node-key: optional %v, key %v", v.Optional, props["key"])
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t.Error("the vault's seat does not promise node-key")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
|
||||||
@@ -76,21 +74,6 @@ func VerbNames(verbs []Verb) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
|
||||||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
|
|
||||||
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
|
|
||||||
// enum so a caller sees it and the verb can refuse any other word rather than read it as false.
|
|
||||||
//
|
|
||||||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
|
||||||
// name is refused when the verb is called, never passed over.
|
|
||||||
// withObject adds to a verb's input s the property name, an object of the string fields given, each required.
|
|
||||||
func withObject(s map[string]any, name, description string, fields map[string]string) map[string]any {
|
|
||||||
inner := schema(fields, slices.Sorted(maps.Keys(fields)))
|
|
||||||
inner["description"], inner["additionalProperties"] = description, false
|
|
||||||
s["properties"].(map[string]any)[name] = inner
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||||||
isSwitch := map[string]bool{}
|
isSwitch := map[string]bool{}
|
||||||
for _, s := range switches {
|
for _, s := range switches {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Package channel holds the shapes of the two kinded benches a channel holds, `channel` (to show and say) and `intake`
|
||||||
|
// (to say what was chosen, and to ask a proof), and the subjects they travel on (novox/hq ADR 0259 §3). The router
|
||||||
|
// sends a channel a Message, and a channel says a Choice, a LinkAsked, a Failed or its Standing, or asks a Code's proof.
|
||||||
|
// Those benches are not defined whole, so their value layouts stay in mesh-sdk (novox/hq ADR 0625 §9, ADR 0627 §4);
|
||||||
|
// the operator-channel seat's own ask and warrant are its contract's and its client's, in mesh-contracts.
|
||||||
|
package channel
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// The benches (novox/hq ADR 0259 §3).
|
||||||
|
const (
|
||||||
|
// ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind.
|
||||||
|
ChannelSeat = "channel"
|
||||||
|
// IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry
|
||||||
|
// the kind.
|
||||||
|
IntakeSeat = "intake"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The work a channel takes, on ChannelSubject.
|
||||||
|
const (
|
||||||
|
Show = "show" // a message offering answers
|
||||||
|
Edit = "edit" // a message shown before, replaced
|
||||||
|
Send = "send" // a message offering nothing
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChannelSubject is where the router sends a channel of a kind its work.
|
||||||
|
func ChannelSubject(verb, kind string) string {
|
||||||
|
return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a channel says, on IntakeSubject.
|
||||||
|
const (
|
||||||
|
Chosen = "choice" // a button tapped
|
||||||
|
Link = "link" // somebody asked to be linked as the operator
|
||||||
|
)
|
||||||
|
|
||||||
|
// IntakeSubject is where a channel of a kind says what arrived.
|
||||||
|
func IntakeSubject(what, kind string) string {
|
||||||
|
return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept.
|
||||||
|
const CodeProof = "code"
|
||||||
|
|
||||||
|
// ProofSubject is where a channel of a kind asks a proof.
|
||||||
|
func ProofSubject(verb, kind string) string {
|
||||||
|
return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button is one answer a channel offers: its label and the router's one-time ticket for it.
|
||||||
|
type Button struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said
|
||||||
|
// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps
|
||||||
|
// which of its own messages that is. The words are the router's, shown as given.
|
||||||
|
type Message struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Buttons []Button `json:"buttons,omitempty"`
|
||||||
|
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
|
||||||
|
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
|
||||||
|
// where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's
|
||||||
|
// length cuts the body first and the note only when the title and the note alone do not fit, saying each
|
||||||
|
// cut (novox/hq ADR 0347 §5).
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Urgent bool `json:"urgent,omitempty"`
|
||||||
|
Silent bool `json:"silent,omitempty"`
|
||||||
|
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
|
||||||
|
Reply string `json:"reply,omitempty"`
|
||||||
|
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
|
||||||
|
// the channel sends what is not a reply. The router's word, from its list; empty when none is linked.
|
||||||
|
To string `json:"to,omitempty"`
|
||||||
|
// Secret says the words carry something shown once (a link's code): the channel shows it and keeps no
|
||||||
|
// copy of it — no state, no history of its own.
|
||||||
|
Secret bool `json:"secret,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender is who a channel's service says sent something: the account, the name it shows, and whether the
|
||||||
|
// service authenticated it. The router alone judges whether that is the operator.
|
||||||
|
type Sender struct {
|
||||||
|
Identity string `json:"identity"`
|
||||||
|
Display string `json:"display,omitempty"`
|
||||||
|
Authenticated bool `json:"authenticated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help.
|
||||||
|
type Failed struct {
|
||||||
|
Handle string `json:"handle"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
Permanent bool `json:"permanent,omitempty"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standing is what a channel says of itself, at least every five minutes and whenever it changes:
|
||||||
|
// whether it can send now, why not, and whether its edits notify nobody.
|
||||||
|
type Standing struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
|
EditsSilently bool `json:"edits-silently,omitempty"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a channel says of its own delivery, on IntakeSubject.
|
||||||
|
const (
|
||||||
|
FailedWhat = "failed"
|
||||||
|
StandingWhat = "standing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Choice is a button chosen on a channel.
|
||||||
|
type Choice struct {
|
||||||
|
// ID is the channel's own for this arrival, unique, so the router acts on it once.
|
||||||
|
ID string `json:"id"`
|
||||||
|
Ticket string `json:"ticket"`
|
||||||
|
Handle string `json:"handle,omitempty"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LinkAsked is somebody on a channel asking to be linked as the operator.
|
||||||
|
type LinkAsked struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept.
|
||||||
|
type Code struct {
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person.
|
||||||
|
type ProofAnswer struct {
|
||||||
|
Accepted bool `json:"accepted"`
|
||||||
|
Words string `json:"words"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package channel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheSubjectsNameTheirPublisher(t *testing.T) {
|
||||||
|
for got, want := range map[string]string{
|
||||||
|
ChannelSubject(Show, "telegram"): "mesh.seat.channel.accept.show.telegram",
|
||||||
|
IntakeSubject(Chosen, "desktop"): "mesh.seat.intake.event.choice.desktop",
|
||||||
|
ProofSubject(CodeProof, "telegram"): "mesh.seat.intake.proof.code.telegram",
|
||||||
|
} {
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older
|
||||||
|
// than it reads every message it read before.
|
||||||
|
func TestAMessageCarriesItsNoteApart(t *testing.T) {
|
||||||
|
raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."})
|
||||||
|
if !strings.Contains(string(raw), `"note":"Asked by x."`) {
|
||||||
|
t.Fatalf("the note is not encoded: %s", raw)
|
||||||
|
}
|
||||||
|
raw, _ = json.Marshal(Message{Handle: "h", Title: "t"})
|
||||||
|
if strings.Contains(string(raw), "note") {
|
||||||
|
t.Fatalf("an empty note is encoded: %s", raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,11 @@ type Request struct {
|
|||||||
// longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts
|
// longer read: the composer judges no manifest (novox/hq ADR 0578 §5), the indexer's judge does, and its verdicts
|
||||||
// come in each case's Verdicts. Kept so an asker that still sends it is read.
|
// come in each case's Verdicts. Kept so an asker that still sends it is read.
|
||||||
Predefined catalogue.PredefinedList `json:"predefined,omitempty"`
|
Predefined catalogue.PredefinedList `json:"predefined,omitempty"`
|
||||||
|
// Packages are the known packages a manifest's `packages` may name, each by its name in its own ecosystem (design 32:
|
||||||
|
// a Go package's go.mod module path, a TypeScript package's package.json name), for the indexer's judge (novox/hq ADR
|
||||||
|
// 0578 §5, ADR 0606 §4); the composer does not read them. Nil is no list at hand, and the judge judges no name; an
|
||||||
|
// empty list is a list, and every name is unknown to it.
|
||||||
|
Packages []string `json:"packages,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
|
// Case is one catalogue composed: every module's manifest by name, and per node the manifests kept at the build it runs.
|
||||||
|
|||||||
@@ -39,6 +39,18 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
|
|||||||
if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo {
|
if asked != 2 || string(back.Cases[0].Manifests["demo"].Raw) != demo || string(back.Cases[0].Kept["laptop"]["demo"].Raw) != demo {
|
||||||
t.Fatalf("filled %d times: %+v", asked, back.Cases[0])
|
t.Fatalf("filled %d times: %+v", asked, back.Cases[0])
|
||||||
}
|
}
|
||||||
|
// The known packages: none is no list (not on the wire), an empty list is a list, and a list reads back whole.
|
||||||
|
for _, known := range [][]string{nil, {}, {"git.novox.be/novox/mesh-sdk", "@novox/mesh-sdk"}} {
|
||||||
|
wire, err := json.Marshal(Request{V: Version, Packages: known})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
back, err := Decode(wire)
|
||||||
|
if err != nil || (back.Packages == nil) != (known == nil) || strings.Join(back.Packages, ",") != strings.Join(known, ",") ||
|
||||||
|
known == nil && strings.Contains(string(wire), "packages") {
|
||||||
|
t.Errorf("the known packages %#v went as %s and read back %#v (%v)", known, wire, back.Packages, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}}
|
bad := Request{V: Version, Cases: []Case{{Name: "c", Manifests: map[string]Manifest{"x": {Digest: "nope"}}}}}
|
||||||
if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") {
|
if err := Fill(&bad, func(string) ([]byte, error) { return nil, errors.New("not there") }); err == nil || !strings.Contains(err.Error(), "nope") {
|
||||||
t.Fatalf("a digest the indexer does not hold: %v", err)
|
t.Fatalf("a digest the indexer does not hold: %v", err)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const adoptedResources = `"resources":[
|
|||||||
|
|
||||||
const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000"
|
const sixtyFour = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
|
||||||
func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
|
func testAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
|
d, err := Parse([]byte(`{"adoption":{"taken":["postgres"],"untaken":{"hello-web":["hello-web.page","hello-web.server"]}},
|
||||||
"declaration":1,` + adoptedResources + `}`))
|
"declaration":1,` + adoptedResources + `}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,7 +36,7 @@ func TestAnAdoptionIsReadWithTheDeclaration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) {
|
func testADeclarationWithNoAdoptionIsConverged(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`))
|
d, err := Parse([]byte(`{"declaration":1,` + adoptedResources + `}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -49,7 +49,7 @@ func TestADeclarationWithNoAdoptionIsConverged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
|
func testAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}},
|
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.missing"]}},
|
||||||
"declaration":1,`+adoptedResources+`}`)
|
"declaration":1,`+adoptedResources+`}`)
|
||||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") {
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "hello-web.missing") {
|
||||||
@@ -57,7 +57,7 @@ func TestAnAdoptionNamingAnUnknownIDIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
|
func testAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
|
||||||
// A directory, a service or an action can reach what was found as surely as a file can, so
|
// A directory, a service or an action can reach what was found as surely as a file can, so
|
||||||
// the controller lists every resource of an untaken module (novox/hq ADR 0103).
|
// the controller lists every resource of an untaken module (novox/hq ADR 0103).
|
||||||
d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}},
|
d, err := Parse([]byte(`{"adoption":{"taken":[],"untaken":{"hello-web":["hello-web.data"]}},
|
||||||
@@ -70,7 +70,7 @@ func TestAnAdoptionMayNameAResourceOfAnyKind(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnIDUnderTwoModulesIsRefused(t *testing.T) {
|
func testAnIDUnderTwoModulesIsRefused(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}},
|
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"a":["hello-web.page"],"b":["hello-web.page"]}},
|
||||||
"declaration":1,`+adoptedResources+`}`)
|
"declaration":1,`+adoptedResources+`}`)
|
||||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") {
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both") {
|
||||||
@@ -78,7 +78,7 @@ func TestAnIDUnderTwoModulesIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
|
func testAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}},
|
refusal := refusalFor(t, `{"adoption":{"taken":["hello-web"],"untaken":{"hello-web":["hello-web.page"]}},
|
||||||
"declaration":1,`+adoptedResources+`}`)
|
"declaration":1,`+adoptedResources+`}`)
|
||||||
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") {
|
if !strings.Contains(strings.Join(refusal.Problems, "\n"), "both taken and untaken") {
|
||||||
@@ -86,7 +86,7 @@ func TestAModuleBothTakenAndUntakenIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
|
func testTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}},
|
refusal := refusalFor(t, `{"adoption":{"taken":[],"untaken":{"x":["adoption.guard"]}},
|
||||||
"declaration":1,"resources":[
|
"declaration":1,"resources":[
|
||||||
{"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`)
|
{"id":"adoption.guard","type":"file","path":"/etc/mesh/guard.nft","content":"x"}]}`)
|
||||||
@@ -95,11 +95,11 @@ func TestTheMeshsOwnResourcesAreNeverUntaken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) {
|
func testAnAdoptionWithAnUnknownFieldIsRefused(t *testing.T) {
|
||||||
refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`)
|
refusalFor(t, `{"adoption":{"taken":[],"held":["x"]},"declaration":1,`+adoptedResources+`}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
|
func testACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
|
||||||
_, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`))
|
_, err := ParseTrusted([]byte(`{"adoption":{"taken":[]},"declaration":1,` + adoptedResources + `}`))
|
||||||
if err == nil || !strings.Contains(err.Error(), "only the mesh can say") {
|
if err == nil || !strings.Contains(err.Error(), "only the mesh can say") {
|
||||||
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
|
t.Fatalf("a bundle claiming adoption was not refused: %v", err)
|
||||||
@@ -107,7 +107,7 @@ func TestACarriedBundleCannotSayTheNodeIsAdopted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
|
// novox/hq ADR 0105: a service may take over a found tunnel, said whole and on an adopted node.
|
||||||
func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
|
func testTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
|
||||||
adoptedWith := func(service string) error {
|
adoptedWith := func(service string) error {
|
||||||
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
|
_, err := Parse([]byte(`{"declaration":1,"adoption":{"taken":[]},"resources":[` + service + `]}`))
|
||||||
return err
|
return err
|
||||||
@@ -130,3 +130,17 @@ func TestTakingOverATunnelIsSaidWholeAndForARunningService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAdoption holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestAdoption(t *testing.T) {
|
||||||
|
t.Run("AnAdoptionIsReadWithTheDeclaration", testAnAdoptionIsReadWithTheDeclaration)
|
||||||
|
t.Run("ADeclarationWithNoAdoptionIsConverged", testADeclarationWithNoAdoptionIsConverged)
|
||||||
|
t.Run("AnAdoptionNamingAnUnknownIDIsRefused", testAnAdoptionNamingAnUnknownIDIsRefused)
|
||||||
|
t.Run("AnAdoptionMayNameAResourceOfAnyKind", testAnAdoptionMayNameAResourceOfAnyKind)
|
||||||
|
t.Run("AnIDUnderTwoModulesIsRefused", testAnIDUnderTwoModulesIsRefused)
|
||||||
|
t.Run("AModuleBothTakenAndUntakenIsRefused", testAModuleBothTakenAndUntakenIsRefused)
|
||||||
|
t.Run("TheMeshsOwnResourcesAreNeverUntaken", testTheMeshsOwnResourcesAreNeverUntaken)
|
||||||
|
t.Run("AnAdoptionWithAnUnknownFieldIsRefused", testAnAdoptionWithAnUnknownFieldIsRefused)
|
||||||
|
t.Run("ACarriedBundleCannotSayTheNodeIsAdopted", testACarriedBundleCannotSayTheNodeIsAdopted)
|
||||||
|
t.Run("TakingOverATunnelIsSaidWholeAndForARunningService", testTakingOverATunnelIsSaidWholeAndForARunningService)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
|
// Defends novox/hq issue 128: a file written into a block carries only its lines, in content,
|
||||||
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
|
// never a line the host keeps as its own marker, and says where a new region goes only as a block.
|
||||||
|
|
||||||
func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
|
func testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
|
||||||
for name, c := range map[string]struct{ resource, refusal string }{
|
for name, c := range map[string]struct{ resource, refusal string }{
|
||||||
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
|
"a begin marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# BEGIN mesh f\nb\n"}`, "# BEGIN mesh f"},
|
||||||
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
|
"an end marker in content": {`{"id":"f","type":"file","path":"/etc/hosts","into":"block","content":"a\n# END mesh other\n"}`, "# END mesh other"},
|
||||||
@@ -30,7 +30,7 @@ func TestAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
|
func testAFileWrittenIntoABlockIsRead(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
|
{"id":"mesh-wireguard.fact-node-names","type":"file","path":"/etc/hosts","into":"block","content":"10.42.0.1 ace\n# BEGIN devtool x\n"},
|
||||||
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
|
{"id":"dhcpcd.options","type":"file","path":"/etc/dhcpcd.conf","into":"block","content":"nohook resolv.conf\n","at":"start"},
|
||||||
@@ -50,3 +50,9 @@ func TestAFileWrittenIntoABlockIsRead(t *testing.T) {
|
|||||||
t.Errorf("the markers are %q and %q", begin, end)
|
t.Errorf("the markers are %q and %q", begin, end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestBlock holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestBlock(t *testing.T) {
|
||||||
|
t.Run("AFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines", testAFileWrittenIntoABlockIsRefusedUnlessItIsOnlyItsLines)
|
||||||
|
t.Run("AFileWrittenIntoABlockIsRead", testAFileWrittenIntoABlockIsRead)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
|
// arrival and computes the next due minute the scheduler fires on (novox/hq ADR 0053). These test
|
||||||
// the parsing and the evaluation; the firing is tested against the scheduler.
|
// the parsing and the evaluation; the firing is tested against the scheduler.
|
||||||
|
|
||||||
func TestParseCronRefusesMalformed(t *testing.T) {
|
func testParseCronRefusesMalformed(t *testing.T) {
|
||||||
for _, expr := range []string{
|
for _, expr := range []string{
|
||||||
"", // nothing
|
"", // nothing
|
||||||
"* * * *", // four fields
|
"* * * *", // four fields
|
||||||
@@ -31,7 +31,7 @@ func TestParseCronRefusesMalformed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
|
func testParseCronAcceptsTheOrdinaryForms(t *testing.T) {
|
||||||
for _, expr := range []string{
|
for _, expr := range []string{
|
||||||
"* * * * *", // every minute
|
"* * * * *", // every minute
|
||||||
"0 3 * * *", // 03:00 daily
|
"0 3 * * *", // 03:00 daily
|
||||||
@@ -47,7 +47,7 @@ func TestParseCronAcceptsTheOrdinaryForms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
|
func testCronNextIsTheNextMatchingMinute(t *testing.T) {
|
||||||
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
|
// A Monday at 12:00:30, so "next" must round up to a whole minute and land on the first match
|
||||||
// strictly after now — never re-firing the minute we are already in.
|
// strictly after now — never re-firing the minute we are already in.
|
||||||
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
|
now := time.Date(2026, 9, 7, 12, 0, 30, 0, time.UTC) // 2026-09-07 is a Monday
|
||||||
@@ -77,7 +77,7 @@ func TestCronNextIsTheNextMatchingMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
|
func testCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
|
||||||
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
|
// The standard cron surprise: when day-of-month and day-of-week are both set, a day matches if
|
||||||
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
|
// EITHER does. "1 * 13 * 5" is due on the 13th OR on any Friday. 2026-09-07 is a Monday the 7th
|
||||||
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
|
// — neither — and 2026-09-11 is a Friday, and 2026-11-13 is the 13th.
|
||||||
@@ -98,3 +98,11 @@ func TestCronDayFieldsAreOredWhenBothRestricted(t *testing.T) {
|
|||||||
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
|
t.Error("a Monday the 7th matched a cron restricted to the 13th OR Friday")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCron holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestCron(t *testing.T) {
|
||||||
|
t.Run("ParseCronRefusesMalformed", testParseCronRefusesMalformed)
|
||||||
|
t.Run("ParseCronAcceptsTheOrdinaryForms", testParseCronAcceptsTheOrdinaryForms)
|
||||||
|
t.Run("CronNextIsTheNextMatchingMinute", testCronNextIsTheNextMatchingMinute)
|
||||||
|
t.Run("CronDayFieldsAreOredWhenBothRestricted", testCronDayFieldsAreOredWhenBothRestricted)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1368,8 +1368,14 @@ type Container struct {
|
|||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
|
// Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not.
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Env map[string]string `json:"env,omitempty"`
|
// ImageSeat and ImageHolder say, beside an image the composition routed through a store, the seat the reference came
|
||||||
|
// through and that seat's holder (novox/hq design 51, issue 1966 ENG-10): what the node-engine finds the holder to wait
|
||||||
|
// on when the pull fails. Not part of what a container is: they are no part of its spec or digest, so a declaration
|
||||||
|
// composed again for a new holder replaces no container.
|
||||||
|
ImageSeat string `json:"image-seat,omitempty"`
|
||||||
|
ImageHolder string `json:"image-holder,omitempty"`
|
||||||
|
Env map[string]string `json:"env,omitempty"`
|
||||||
// EnvFile names files the runtime reads environment from, in order.
|
// EnvFile names files the runtime reads environment from, in order.
|
||||||
//
|
//
|
||||||
// **Because a secret may not travel in Env.** A declaration reaches a node over the broker,
|
// **Because a secret may not travel in Env.** A declaration reaches a node over the broker,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func refusalFor(t *testing.T, raw string) *RefusalError {
|
|||||||
return refusal
|
return refusal
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
func testAValidDeclarationParsesInOrder(t *testing.T) {
|
||||||
d, err := Parse([]byte(valid()))
|
d, err := Parse([]byte(valid()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected refusal: %v", err)
|
t.Fatalf("unexpected refusal: %v", err)
|
||||||
@@ -45,7 +45,7 @@ func TestAValidDeclarationParsesInOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
func testAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
||||||
// The property everything else rests on. A host that skipped what it did not understand
|
// The property everything else rests on. A host that skipped what it did not understand
|
||||||
// would apply most of a declaration and report success — a node that looks configured and
|
// would apply most of a declaration and report success — a node that looks configured and
|
||||||
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
|
// is not, which is 04-ISSUES/003 with the declaration on the other side of the wire.
|
||||||
@@ -64,7 +64,7 @@ func TestAnUnknownTypeRefusesTheWholeDeclaration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnUnknownFieldIsRefused(t *testing.T) {
|
func testAnUnknownFieldIsRefused(t *testing.T) {
|
||||||
// A field the host does not know is a thing the control plane believes it asked for.
|
// A field the host does not know is a thing the control plane believes it asked for.
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
{"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true}
|
{"id":"conf","type":"file","path":"/etc/x","content":"a","immutable":true}
|
||||||
@@ -74,7 +74,7 @@ func TestAnUnknownFieldIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
func testAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
||||||
// The same fault in miniature: set and ignored means the control plane believes it asked
|
// The same fault in miniature: set and ignored means the control plane believes it asked
|
||||||
// for something the host will never do.
|
// for something the host will never do.
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
@@ -86,7 +86,7 @@ func TestAFieldTheTypeDoesNotUseIsRefusedNotIgnored(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
func testAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
||||||
// An older host handed a newer vocabulary must not quietly do half of it.
|
// An older host handed a newer vocabulary must not quietly do half of it.
|
||||||
refusal := refusalFor(t, `{"declaration":99,"resources":[
|
refusal := refusalFor(t, `{"declaration":99,"resources":[
|
||||||
{"id":"a","type":"directory","path":"/etc/mesh"}
|
{"id":"a","type":"directory","path":"/etc/mesh"}
|
||||||
@@ -102,7 +102,7 @@ func TestAnUnknownVersionIsRefusedWhole(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEveryProblemIsReportedAtOnce(t *testing.T) {
|
func testEveryProblemIsReportedAtOnce(t *testing.T) {
|
||||||
// A declaration is generated, so a person reading a refusal is debugging the generator.
|
// A declaration is generated, so a person reading a refusal is debugging the generator.
|
||||||
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
|
// Fixing one problem at a time and re-running to find the next wastes their afternoon.
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
@@ -115,7 +115,7 @@ func TestEveryProblemIsReportedAtOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIdentityIsRequiredAndUnique(t *testing.T) {
|
func testIdentityIsRequiredAndUnique(t *testing.T) {
|
||||||
// Identity is what lets the store know this is the same resource it applied last time,
|
// Identity is what lets the store know this is the same resource it applied last time,
|
||||||
// which is what makes removal possible at all.
|
// which is what makes removal possible at all.
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
@@ -127,7 +127,7 @@ func TestIdentityIsRequiredAndUnique(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
func testModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
||||||
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
|
// "644" and "0644" differ, and the one that looks right in a manifest is the four-digit
|
||||||
// form. Accepting both would make a mode mean two things.
|
// form. Accepting both would make a mode mean two things.
|
||||||
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
|
for _, mode := range []string{"644", "0999", "rwxr-xr-x", "07777777"} {
|
||||||
@@ -145,7 +145,7 @@ func TestModeIsRefusedUnlessItMeansWhatItLooksLike(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
func testARefusalSaysNothingWasApplied(t *testing.T) {
|
||||||
// The reader's first question is whether the machine was left half-changed.
|
// The reader's first question is whether the machine was left half-changed.
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
|
refusal := refusalFor(t, `{"declaration":1,"resources":[{"id":"x","type":"nope"}]}`)
|
||||||
if !strings.Contains(refusal.Error(), "none of it was applied") {
|
if !strings.Contains(refusal.Error(), "none of it was applied") {
|
||||||
@@ -153,13 +153,13 @@ func TestARefusalSaysNothingWasApplied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnEmptyDeclarationIsAMistake(t *testing.T) {
|
func testAnEmptyDeclarationIsAMistake(t *testing.T) {
|
||||||
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
refusalFor(t, `{"declaration":1,"resources":[]}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) ---
|
// --- the vocabulary the foundation bootstrap needs (novox/hq 07-the-foundation.md) ---
|
||||||
|
|
||||||
func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
func testAnActionOverTheLinkIsRefused(t *testing.T) {
|
||||||
// novox/hq ADR 0005. The link may push declarations of known shape and never a command to
|
// novox/hq ADR 0005. The link may push declarations of known shape and never a command to
|
||||||
// run. This is the boundary the whole security argument rests on, so it is asserted
|
// run. This is the boundary the whole security argument rests on, so it is asserted
|
||||||
// directly rather than inferred from the type list.
|
// directly rather than inferred from the type list.
|
||||||
@@ -179,7 +179,7 @@ func TestAnActionOverTheLinkIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
func testAnActionWithoutVerifyIsRefused(t *testing.T) {
|
||||||
// An action that runs and reports success without reading anything back is the fault this
|
// An action that runs and reports success without reading anything back is the fault this
|
||||||
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
// host exists to prevent. Verify is also the idempotency check, so an action without one
|
||||||
// cannot be applied twice safely either.
|
// cannot be applied twice safely either.
|
||||||
@@ -194,7 +194,7 @@ func TestAnActionWithoutVerifyIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
func testAnImageMustBePinnedByDigest(t *testing.T) {
|
||||||
// novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle
|
// novox/hq ADR 0006: reproducibility comes from pinning the identity of a thing. A bundle
|
||||||
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
// naming a tag pins nothing — it names whatever that tag points at on the day it runs.
|
||||||
for _, image := range []string{
|
for _, image := range []string{
|
||||||
@@ -226,7 +226,7 @@ func TestAnImageMustBePinnedByDigest(t *testing.T) {
|
|||||||
// can start. The mesh's own control plane is built from source and lives in no public registry; a
|
// can start. The mesh's own control plane is built from source and lives in no public registry; a
|
||||||
// machine that built it, or was handed it, names it by what it is — a content address, not a name,
|
// machine that built it, or was handed it, names it by what it is — a content address, not a name,
|
||||||
// and immutable in exactly the way the rule asks for.
|
// and immutable in exactly the way the rule asks for.
|
||||||
func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
|
func testAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
|
||||||
held := "sha256:" + strings.Repeat("b", 64)
|
held := "sha256:" + strings.Repeat("b", 64)
|
||||||
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
if _, err := ParseTrusted([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"}
|
{"id":"control","type":"container","name":"mesh-controller","image":"` + held + `"}
|
||||||
@@ -245,7 +245,7 @@ func TestAnImageTheMachineHoldsIsNamedByItsOwnDigest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
func testAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
||||||
// The field-set check must cover the types added last, not only the three it was written
|
// The field-set check must cover the types added last, not only the three it was written
|
||||||
// for. A package that carries a `content` is a control plane believing it asked for
|
// for. A package that carries a `content` is a control plane believing it asked for
|
||||||
// something that will never happen.
|
// something that will never happen.
|
||||||
@@ -266,7 +266,7 @@ func TestAFieldTheNewTypesDoNotUseIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
|
func testTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
|
||||||
// Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a
|
// Six of them the bootstrap uses (novox/hq 07-the-foundation.md), and removing one is a
|
||||||
// failing test rather than a discovery during a first-node install.
|
// failing test rather than a discovery during a first-node install.
|
||||||
//
|
//
|
||||||
@@ -320,7 +320,7 @@ func TestTheVocabularyIsTheTwelveShapesTheMeshNeeds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
|
func testADeclarationFromDiskMayBeAnnotated(t *testing.T) {
|
||||||
// The bundle in this repository is mostly explanation of why each digest is what it is, and
|
// The bundle in this repository is mostly explanation of why each digest is what it is, and
|
||||||
// it could be built into a binary and not applied from disk — two readers for one file. The
|
// it could be built into a binary and not applied from disk — two readers for one file. The
|
||||||
// failure was `invalid character '/'`, which names the symptom and not the cause.
|
// failure was `invalid character '/'`, which names the symptom and not the cause.
|
||||||
@@ -346,7 +346,7 @@ func TestADeclarationFromDiskMayBeAnnotated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
|
func testSomethingInsideAValueIsNotAComment(t *testing.T) {
|
||||||
// Every image reference has a `//` in it somewhere near. Only whole lines are dropped.
|
// Every image reference has a `//` in it somewhere near. Only whole lines are dropped.
|
||||||
d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[
|
d, err := ParseFileTrusted([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`))
|
{"id":"f","type":"file","path":"/etc/x","content":"see https://example.invalid/ for why"}]}`))
|
||||||
@@ -370,7 +370,7 @@ func TestSomethingInsideAValueIsNotAComment(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every
|
// Not hypothetical: a test harness appended a line to the foundation bundle by accident, every
|
||||||
// apply kept working, and nothing said so for the entire time it was wrong.
|
// apply kept working, and nothing said so for the entire time it was wrong.
|
||||||
func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) {
|
func testSomethingAfterTheDeclarationIsRefused(t *testing.T) {
|
||||||
good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` +
|
good := `{"declaration":1,"resources":[{"id":"a","type":"file","path":"/tmp/a",` +
|
||||||
`"content":"x","mode":"0644"}]}`
|
`"content":"x","mode":"0644"}]}`
|
||||||
if _, err := ParseFileTrusted([]byte(good)); err != nil {
|
if _, err := ParseFileTrusted([]byte(good)); err != nil {
|
||||||
@@ -394,7 +394,7 @@ func TestSomethingAfterTheDeclarationIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A name that would read as a reference to something else is refused before it reaches a runtime.
|
// A name that would read as a reference to something else is refused before it reaches a runtime.
|
||||||
func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
|
func testANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
|
||||||
for _, name := range []string{"", "mail/private", "host:mail", "a b"} {
|
for _, name := range []string{"", "mail/private", "host:mail", "a b"} {
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
{"id":"private","type":"network","name":"`+name+`"}
|
{"id":"private","type":"network","name":"`+name+`"}
|
||||||
@@ -409,7 +409,7 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// The alternative is a configuration file containing the literal `${secret:x}`, which the program
|
// The alternative is a configuration file containing the literal `${secret:x}`, which the program
|
||||||
// reads as a value and fails on somewhere with no connection to this.
|
// reads as a value and fails on somewhere with no connection to this.
|
||||||
func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
|
func testAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
{"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"}
|
{"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"}
|
||||||
]}`)
|
]}`)
|
||||||
@@ -419,7 +419,7 @@ func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And a secret nobody asked for, because somebody believes it is in a file where it is not.
|
// And a secret nobody asked for, because somebody believes it is in a file where it is not.
|
||||||
func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
func testASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
||||||
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
refusal := refusalFor(t, `{"declaration":1,"resources":[
|
||||||
{"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}}
|
{"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}}
|
||||||
]}`)
|
]}`)
|
||||||
@@ -431,7 +431,7 @@ func TestASecretTheContentNeverUsesIsRefused(t *testing.T) {
|
|||||||
// The runtime's resolver and address flags take only addresses; a name would be refused at
|
// The runtime's resolver and address flags take only addresses; a name would be refused at
|
||||||
// create, after the old container was already gone. Refused on arrival instead — and an address
|
// create, after the old container was already gone. Refused on arrival instead — and an address
|
||||||
// without a user-defined network is refused for the same reason.
|
// without a user-defined network is refused for the same reason.
|
||||||
func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
|
func testAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
|
||||||
refused := func(body string) []string {
|
refused := func(body string) []string {
|
||||||
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
_, err := Parse([]byte(`{"declaration":1,"resources":[` + body + `]}`))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -452,7 +452,7 @@ func TestAContainersResolverAndAddressAreAddressesOrRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
|
func testAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
|
||||||
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
|
// A deliberately-empty declaration (novox/hq issue 127) says owns_nothing, and is applied so
|
||||||
// the node drops what it last held — distinct from an accidental empty body, which is refused.
|
// the node drops what it last held — distinct from an accidental empty body, which is refused.
|
||||||
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
|
if _, err := Parse([]byte(`{"declaration":1,"owns_nothing":true,"resources":[]}`)); err != nil {
|
||||||
@@ -467,7 +467,7 @@ func TestAnExplicitlyEmptyDeclarationIsAccepted(t *testing.T) {
|
|||||||
|
|
||||||
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
|
// A container's kept networks are names, not its own network, and not for a step (novox/hq ADR
|
||||||
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
|
// 0163, rule 4); and the mesh may say which modules it left out, which a carried bundle may not.
|
||||||
func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
func testKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
||||||
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
|
pinnedImage := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||||
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"left_out":["web"],"resources":[
|
||||||
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
|
{"id":"app","type":"container","name":"app","image":"` + pinnedImage + `","networks":["predecessor_default"]}
|
||||||
@@ -506,7 +506,7 @@ func TestKeptNetworksAndLeftOutModulesAreReadStrictly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
|
// A container may ask for a capability by name, and nothing else (novox/hq ADR 0170).
|
||||||
func TestACapabilityIsNamedOrRefused(t *testing.T) {
|
func testACapabilityIsNamedOrRefused(t *testing.T) {
|
||||||
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
|
{"id":"fw","type":"container","name":"fw","image":"` + image + `","network":"host","capabilities":["NET_ADMIN","CAP_NET_RAW"]}
|
||||||
@@ -527,7 +527,7 @@ func TestACapabilityIsNamedOrRefused(t *testing.T) {
|
|||||||
|
|
||||||
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
|
// A container may send its output to the machine's journal, and nowhere else but the runtime's own
|
||||||
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
|
// file (novox/hq ADR 0179): what reads the machine's logs then reads the container's too.
|
||||||
func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
|
func testAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
|
||||||
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
image := "postgres@sha256:" + strings.Repeat("a", 64)
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
|
{"id":"front","type":"container","name":"front","image":"` + image + `","logging":"journald"}
|
||||||
@@ -548,7 +548,7 @@ func TestAContainerMayLogToTheJournalAndNowhereElse(t *testing.T) {
|
|||||||
|
|
||||||
// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write.
|
// Issue 1024, ADR 0422: a directory or archive says how it treats files it did not write.
|
||||||
|
|
||||||
func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
|
func testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]},
|
{"id":"d","type":"directory","path":"/home/a/x","unknown":"move-out","knows":["keep","sub/"]},
|
||||||
{"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"}
|
{"id":"r","type":"archive","source":"https://x/y","digest":"sha256:` + strings.Repeat("a", 64) + `","path":"/home/a/y","unknown":"module"}
|
||||||
@@ -570,7 +570,7 @@ func TestADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
|
func testBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
|
||||||
digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"`
|
digest := `"digest":"sha256:` + strings.Repeat("a", 64) + `"`
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
"bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`,
|
"bad value": `{"id":"d","type":"directory","path":"/x","unknown":"delete"}`,
|
||||||
@@ -593,3 +593,36 @@ func TestBadUnknownAndKnowsFormsAreRefusedNamingTheResource(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeclaration holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestDeclaration(t *testing.T) {
|
||||||
|
t.Run("AValidDeclarationParsesInOrder", testAValidDeclarationParsesInOrder)
|
||||||
|
t.Run("AnUnknownTypeRefusesTheWholeDeclaration", testAnUnknownTypeRefusesTheWholeDeclaration)
|
||||||
|
t.Run("AnUnknownFieldIsRefused", testAnUnknownFieldIsRefused)
|
||||||
|
t.Run("AFieldTheTypeDoesNotUseIsRefusedNotIgnored", testAFieldTheTypeDoesNotUseIsRefusedNotIgnored)
|
||||||
|
t.Run("AnUnknownVersionIsRefusedWhole", testAnUnknownVersionIsRefusedWhole)
|
||||||
|
t.Run("EveryProblemIsReportedAtOnce", testEveryProblemIsReportedAtOnce)
|
||||||
|
t.Run("IdentityIsRequiredAndUnique", testIdentityIsRequiredAndUnique)
|
||||||
|
t.Run("ModeIsRefusedUnlessItMeansWhatItLooksLike", testModeIsRefusedUnlessItMeansWhatItLooksLike)
|
||||||
|
t.Run("ARefusalSaysNothingWasApplied", testARefusalSaysNothingWasApplied)
|
||||||
|
t.Run("AnEmptyDeclarationIsAMistake", testAnEmptyDeclarationIsAMistake)
|
||||||
|
t.Run("AnActionOverTheLinkIsRefused", testAnActionOverTheLinkIsRefused)
|
||||||
|
t.Run("AnActionWithoutVerifyIsRefused", testAnActionWithoutVerifyIsRefused)
|
||||||
|
t.Run("AnImageMustBePinnedByDigest", testAnImageMustBePinnedByDigest)
|
||||||
|
t.Run("AnImageTheMachineHoldsIsNamedByItsOwnDigest", testAnImageTheMachineHoldsIsNamedByItsOwnDigest)
|
||||||
|
t.Run("AFieldTheNewTypesDoNotUseIsRefused", testAFieldTheNewTypesDoNotUseIsRefused)
|
||||||
|
t.Run("TheVocabularyIsTheTwelveShapesTheMeshNeeds", testTheVocabularyIsTheTwelveShapesTheMeshNeeds)
|
||||||
|
t.Run("ADeclarationFromDiskMayBeAnnotated", testADeclarationFromDiskMayBeAnnotated)
|
||||||
|
t.Run("SomethingInsideAValueIsNotAComment", testSomethingInsideAValueIsNotAComment)
|
||||||
|
t.Run("SomethingAfterTheDeclarationIsRefused", testSomethingAfterTheDeclarationIsRefused)
|
||||||
|
t.Run("ANetworkNameIsRefusedIfItIsNotOne", testANetworkNameIsRefusedIfItIsNotOne)
|
||||||
|
t.Run("AFileAskingForASecretItWasNotGivenIsRefused", testAFileAskingForASecretItWasNotGivenIsRefused)
|
||||||
|
t.Run("ASecretTheContentNeverUsesIsRefused", testASecretTheContentNeverUsesIsRefused)
|
||||||
|
t.Run("AContainersResolverAndAddressAreAddressesOrRefused", testAContainersResolverAndAddressAreAddressesOrRefused)
|
||||||
|
t.Run("AnExplicitlyEmptyDeclarationIsAccepted", testAnExplicitlyEmptyDeclarationIsAccepted)
|
||||||
|
t.Run("KeptNetworksAndLeftOutModulesAreReadStrictly", testKeptNetworksAndLeftOutModulesAreReadStrictly)
|
||||||
|
t.Run("ACapabilityIsNamedOrRefused", testACapabilityIsNamedOrRefused)
|
||||||
|
t.Run("AContainerMayLogToTheJournalAndNowhereElse", testAContainerMayLogToTheJournalAndNowhereElse)
|
||||||
|
t.Run("ADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles", testADirectoryAndAnArchiveSayWhatTheyDoWithUnknownFiles)
|
||||||
|
t.Run("BadUnknownAndKnowsFormsAreRefusedNamingTheResource", testBadUnknownAndKnowsFormsAreRefusedNamingTheResource)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func refFile(extra string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1694/AC1: a reference round trips, and an invalid one is refused naming the field.
|
// 1694/AC1: a reference round trips, and an invalid one is refused naming the field.
|
||||||
func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
|
func testAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
|
||||||
d, err := Parse([]byte(refFile("")))
|
d, err := Parse([]byte(refFile("")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -49,7 +49,7 @@ func TestAFileReferenceRoundTripsAndABadOneNamesItsField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1694/AC2: a declaration as today's SDK writes it reads unchanged and its bytes carry no new key.
|
// 1694/AC2: a declaration as today's SDK writes it reads unchanged and its bytes carry no new key.
|
||||||
func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
|
func testAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
|
||||||
const in = `{"id":"m.f","type":"file","path":"/e","content":"hello"}`
|
const in = `{"id":"m.f","type":"file","path":"/e","content":"hello"}`
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[` + in + `]}`))
|
d, err := Parse([]byte(`{"declaration":1,"resources":[` + in + `]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,7 +65,7 @@ func TestAnInlineFileIsReadAndWrittenAsBefore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1694/AC3: the controller's helper makes a reference of a big file and the engine's check refuses a changed byte.
|
// 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) {
|
func testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
|
||||||
big := strings.Repeat("x", FileReferenceThreshold+1)
|
big := strings.Repeat("x", FileReferenceThreshold+1)
|
||||||
f := &File{ID: "m.f", Type: TypeFile, Path: "/e", Content: big}
|
f := &File{ID: "m.f", Type: TypeFile, Path: "/e", Content: big}
|
||||||
if !f.TravelsByDigest() {
|
if !f.TravelsByDigest() {
|
||||||
@@ -95,7 +95,7 @@ func TestABigFileBecomesAReferenceAndAChangedByteFailsTheCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1694/AC4: what stays inline, and bytes files travel decoded.
|
// 1694/AC4: what stays inline, and bytes files travel decoded.
|
||||||
func TestWhichFilesTravelByDigest(t *testing.T) {
|
func testWhichFilesTravelByDigest(t *testing.T) {
|
||||||
small := strings.Repeat("x", FileReferenceThreshold)
|
small := strings.Repeat("x", FileReferenceThreshold)
|
||||||
big := strings.Repeat("x", FileReferenceThreshold+1)
|
big := strings.Repeat("x", FileReferenceThreshold+1)
|
||||||
for name, f := range map[string]File{
|
for name, f := range map[string]File{
|
||||||
@@ -126,7 +126,7 @@ func TestWhichFilesTravelByDigest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheSharedNames(t *testing.T) {
|
func testTheSharedNames(t *testing.T) {
|
||||||
if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 {
|
if FileBucketPrefix != "declaration-files-" || FileReferencesCapability != "file-references" || FileReferenceThreshold != 65536 {
|
||||||
t.Fatal("a name changed")
|
t.Fatal("a name changed")
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ func TestTheSharedNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1694/AC6: each node has a bucket of its own, so a grant names it and never needs a `*` (ADR 0486 §2).
|
// 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) {
|
func testEachNodeHasABucketOfItsOwn(t *testing.T) {
|
||||||
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
|
||||||
b, err := FileBucketFor(n)
|
b, err := FileBucketFor(n)
|
||||||
if err != nil || b != "declaration-files-"+n {
|
if err != nil || b != "declaration-files-"+n {
|
||||||
@@ -156,3 +156,13 @@ func TestEachNodeHasABucketOfItsOwn(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilereference holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestFilereference(t *testing.T) {
|
||||||
|
t.Run("AFileReferenceRoundTripsAndABadOneNamesItsField", testAFileReferenceRoundTripsAndABadOneNamesItsField)
|
||||||
|
t.Run("AnInlineFileIsReadAndWrittenAsBefore", testAnInlineFileIsReadAndWrittenAsBefore)
|
||||||
|
t.Run("ABigFileBecomesAReferenceAndAChangedByteFailsTheCheck", testABigFileBecomesAReferenceAndAChangedByteFailsTheCheck)
|
||||||
|
t.Run("WhichFilesTravelByDigest", testWhichFilesTravelByDigest)
|
||||||
|
t.Run("TheSharedNames", testTheSharedNames)
|
||||||
|
t.Run("EachNodeHasABucketOfItsOwn", testEachNodeHasABucketOfItsOwn)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// A container may be given supplementary groups (novox/hq ADR 0480 clause 7, issue 1636): numbers, each above zero.
|
// 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.
|
// 1636/AC1: a declaration with groups decodes, is written back with them, and a declaration without them has none.
|
||||||
func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
|
func testAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"n8n.server","type":"container","name":"n8n","image":"` + anImage + `","groups":[2000,2001]},
|
{"id":"n8n.server","type":"container","name":"n8n","image":"` + anImage + `","groups":[2000,2001]},
|
||||||
{"id":"n8n.plain","type":"container","name":"plain","image":"` + anImage + `"}]}`))
|
{"id":"n8n.plain","type":"container","name":"plain","image":"` + anImage + `"}]}`))
|
||||||
@@ -34,7 +34,7 @@ func TestAContainerWithGroupsDecodesAndIsWrittenBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1636/AC2: zero, a number below it, one the kernel keeps for "no group" and a number twice are refused, naming why.
|
// 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) {
|
func testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
|
||||||
for want, groups := range map[string]string{
|
for want, groups := range map[string]string{
|
||||||
"never root": `[0]`,
|
"never root": `[0]`,
|
||||||
"below zero": `[-5]`,
|
"below zero": `[-5]`,
|
||||||
@@ -53,3 +53,9 @@ func TestAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated(t *testing.T) {
|
|||||||
t.Error("a group named by text was accepted")
|
t.Error("a group named by text was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGroups holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestGroups(t *testing.T) {
|
||||||
|
t.Run("AContainerWithGroupsDecodesAndIsWrittenBack", testAContainerWithGroupsDecodesAndIsWrittenBack)
|
||||||
|
t.Run("AContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated", testAContainersGroupsAreWholeNumbersAboveZeroAndNotRepeated)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
const anImage = "docker.io/library/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
// The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567).
|
// The fields ADR 0325 adds, read as the controller sends them (novox/hq ADR 0325 §2, §3, §5, issue 567).
|
||||||
func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
|
func testIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,
|
d, err := Parse([]byte(`{"declaration":1,
|
||||||
"identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}],
|
"identities":[{"module":"radarr","name":"mesh-radarr","uid":61001,"gid":61001}],
|
||||||
"resources":[
|
"resources":[
|
||||||
@@ -35,7 +35,7 @@ func TestIdentitiesUsersAndARunAsAreReadAsSent(t *testing.T) {
|
|||||||
|
|
||||||
// What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not
|
// What the engine refuses on arrival: a root number, an identity not named for its module, a field it does not
|
||||||
// know, a user that writes a read access, a variable set twice.
|
// know, a user that writes a read access, a variable set twice.
|
||||||
func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
|
func testWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
|
||||||
for want, raw := range map[string]string{
|
for want, raw := range map[string]string{
|
||||||
"never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`,
|
"never root": `"identities":[{"module":"m","name":"mesh-m","uid":0,"gid":61001}],"resources":[]`,
|
||||||
"named mesh-<module>": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` +
|
"named mesh-<module>": `"identities":[{"module":"m","name":"m","uid":61001,"gid":61001}],` +
|
||||||
@@ -59,3 +59,9 @@ func TestWhatADeclarationMayNotSayOfAnIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIdentity holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestIdentity(t *testing.T) {
|
||||||
|
t.Run("IdentitiesUsersAndARunAsAreReadAsSent", testIdentitiesUsersAndARunAsAreReadAsSent)
|
||||||
|
t.Run("WhatADeclarationMayNotSayOfAnIdentity", testWhatADeclarationMayNotSayOfAnIdentity)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "testing"
|
|||||||
|
|
||||||
// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does
|
// 1132/AC1: a resource says its module, and the declaration says each module's build; a resource that does
|
||||||
// not say gets today's guess, so an older controller still works.
|
// not say gets today's guess, so an older controller still works.
|
||||||
func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
|
func testAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,
|
d, err := Parse([]byte(`{"declaration":1,
|
||||||
"builds":{"novox.be":"b-9","web":"b-4"},
|
"builds":{"novox.be":"b-9","web":"b-4"},
|
||||||
"resources":[
|
"resources":[
|
||||||
@@ -30,7 +30,7 @@ func TestAResourceSaysItsModuleAndTheDeclarationItsBuilds(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another.
|
// 1132/AC1: the module the declaration gives wins over the guess, even when the id would guess another.
|
||||||
func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
|
func testTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"},
|
{"id":"a.b.c","type":"directory","path":"/srv/a","module":"a.b"},
|
||||||
{"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`))
|
{"id":"x.y","type":"directory","path":"/srv/x","module":"other"}]}`))
|
||||||
@@ -43,7 +43,7 @@ func TestTheDeclaredModuleWinsOverTheGuess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1132/AC1: every kind accepts the field.
|
// 1132/AC1: every kind accepts the field.
|
||||||
func TestEveryKindAcceptsAModule(t *testing.T) {
|
func testEveryKindAcceptsAModule(t *testing.T) {
|
||||||
for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess,
|
for _, typ := range []Type{TypeDirectory, TypeFile, TypeUser, TypeNetwork, TypeAccess, TypeArchive, TypeProcess,
|
||||||
TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} {
|
TypeService, TypeOpening, TypePackage, TypeContainer, TypeAction} {
|
||||||
r := newOf(typ)
|
r := newOf(typ)
|
||||||
@@ -58,3 +58,10 @@ func TestEveryKindAcceptsAModule(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestModule holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestModule(t *testing.T) {
|
||||||
|
t.Run("AResourceSaysItsModuleAndTheDeclarationItsBuilds", testAResourceSaysItsModuleAndTheDeclarationItsBuilds)
|
||||||
|
t.Run("TheDeclaredModuleWinsOverTheGuess", testTheDeclaredModuleWinsOverTheGuess)
|
||||||
|
t.Run("EveryKindAcceptsAModule", testEveryKindAcceptsAModule)
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ func withOrder(order string) string {
|
|||||||
return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}`
|
return `{"declaration":1,` + order + `"resources":[{"id":"etc","type":"directory","path":"/etc/mesh","mode":"0755"}]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) {
|
func testADeclarationCarriesItsEpochAndSequence(t *testing.T) {
|
||||||
d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`)))
|
d, err := Parse([]byte(withOrder(`"epoch":57,"sequence":3,`)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a declaration carrying its order was refused: %v", err)
|
t.Fatalf("a declaration carrying its order was refused: %v", err)
|
||||||
@@ -27,7 +27,7 @@ func TestADeclarationCarriesItsEpochAndSequence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnOrderBelowZeroIsRefused(t *testing.T) {
|
func testAnOrderBelowZeroIsRefused(t *testing.T) {
|
||||||
for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} {
|
for _, order := range []string{`"epoch":-1,`, `"sequence":-4,`} {
|
||||||
refusal := refusalFor(t, withOrder(order))
|
refusal := refusalFor(t, withOrder(order))
|
||||||
if !strings.Contains(refusal.Error(), "below zero") {
|
if !strings.Contains(refusal.Error(), "below zero") {
|
||||||
@@ -38,7 +38,7 @@ func TestAnOrderBelowZeroIsRefused(t *testing.T) {
|
|||||||
|
|
||||||
// **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234):
|
// **The assignment generation it was composed from, and whether it is a put-back** (novox/hq issue 234):
|
||||||
// absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back.
|
// absent is no claim; below zero is none the mesh assigns; a carried bundle puts nothing back.
|
||||||
func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
|
func testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
|
||||||
d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`)))
|
d, err := Parse([]byte(withOrder(`"sequence":3,"generation":41,"put_back":true,`)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a declaration carrying its generation was refused: %v", err)
|
t.Fatalf("a declaration carrying its generation was refused: %v", err)
|
||||||
@@ -57,3 +57,10 @@ func TestADeclarationCarriesItsGenerationAndWhetherItIsAPutBack(t *testing.T) {
|
|||||||
t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err)
|
t.Errorf("a carried bundle said it was a put-back and was not refused for it: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOrder holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestOrder(t *testing.T) {
|
||||||
|
t.Run("ADeclarationCarriesItsEpochAndSequence", testADeclarationCarriesItsEpochAndSequence)
|
||||||
|
t.Run("AnOrderBelowZeroIsRefused", testAnOrderBelowZeroIsRefused)
|
||||||
|
t.Run("ADeclarationCarriesItsGenerationAndWhetherItIsAPutBack", testADeclarationCarriesItsGenerationAndWhetherItIsAPutBack)
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are
|
// — owned, seed or managed — and the declaration names the files the provisioner fills. The words are
|
||||||
// read and judged here before any controller sends them.
|
// read and judged here before any controller sends them.
|
||||||
|
|
||||||
func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
|
func testAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"},
|
{"id":"o","type":"file","path":"/etc/o","content":"a","owner-at-runtime":"owned"},
|
||||||
{"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"},
|
{"id":"s","type":"file","path":"/etc/s","content":"a","owner-at-runtime":"seed"},
|
||||||
@@ -31,7 +31,7 @@ func TestAFileSaysWhoOwnsItAtRuntime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A file that does not say is owned by its old form, so the word means exactly what that form does.
|
// A file that does not say is owned by its old form, so the word means exactly what that form does.
|
||||||
func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
|
func testAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"o","type":"file","path":"/etc/o","content":"a"},
|
{"id":"o","type":"file","path":"/etc/o","content":"a"},
|
||||||
{"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true},
|
{"id":"s","type":"file","path":"/etc/s","content":"a","create-once":true},
|
||||||
@@ -53,7 +53,7 @@ func TestAFileThatDoesNotSayIsOwnedByItsOldForm(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
|
func testAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
|
||||||
for name, c := range map[string]struct{ resource, refusal string }{
|
for name, c := range map[string]struct{ resource, refusal string }{
|
||||||
"another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`,
|
"another word": {`{"id":"f","type":"file","path":"/etc/x","content":"a","owner-at-runtime":"mine"}`,
|
||||||
`owner-at-runtime "mine"`},
|
`owner-at-runtime "mine"`},
|
||||||
@@ -78,7 +78,7 @@ func TestAnOwnershipThatContradictsTheFileIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
|
func testTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"filled_files":["/etc/a","/etc/b"],"resources":[
|
||||||
{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"},
|
{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"},
|
||||||
{"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"},
|
{"id":"b","type":"file","path":"/etc/b","into":"block","content":"b\n","owner-at-runtime":"managed"},
|
||||||
@@ -104,7 +104,7 @@ func TestTheDeclarationNamesTheFilesTheProvisionerFills(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
|
func testAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
|
||||||
for name, c := range map[string]struct{ filled, resources, refusal string }{
|
for name, c := range map[string]struct{ filled, resources, refusal string }{
|
||||||
"not declared": {`["/etc/none"]`,
|
"not declared": {`["/etc/none"]`,
|
||||||
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
|
`{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}`,
|
||||||
@@ -128,3 +128,12 @@ func TestAFilledFileMustBeADeclaredFileThatSaysItsOwner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOwnership holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestOwnership(t *testing.T) {
|
||||||
|
t.Run("AFileSaysWhoOwnsItAtRuntime", testAFileSaysWhoOwnsItAtRuntime)
|
||||||
|
t.Run("AFileThatDoesNotSayIsOwnedByItsOldForm", testAFileThatDoesNotSayIsOwnedByItsOldForm)
|
||||||
|
t.Run("AnOwnershipThatContradictsTheFileIsRefused", testAnOwnershipThatContradictsTheFileIsRefused)
|
||||||
|
t.Run("TheDeclarationNamesTheFilesTheProvisionerFills", testTheDeclarationNamesTheFilesTheProvisionerFills)
|
||||||
|
t.Run("AFilledFileMustBeADeclaredFileThatSaysItsOwner", testAFilledFileMustBeADeclaredFileThatSaysItsOwner)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func aProcess() *Process {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A process is part of the vocabulary, or a declaration carrying one is refused whole.
|
// A process is part of the vocabulary, or a declaration carrying one is refused whole.
|
||||||
func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
|
func testAProcessIsSomethingTheHostSpeaks(t *testing.T) {
|
||||||
var found bool
|
var found bool
|
||||||
for _, kind := range Vocabulary() {
|
for _, kind := range Vocabulary() {
|
||||||
if kind == TypeProcess {
|
if kind == TypeProcess {
|
||||||
@@ -33,7 +33,7 @@ func TestAProcessIsSomethingTheHostSpeaks(t *testing.T) {
|
|||||||
// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a
|
// **Pinned by digest, like everything else that crosses a network.** A bundle fetched by a
|
||||||
// reference somebody can repoint is not pinned, and it is the one thing on a machine that would
|
// reference somebody can repoint is not pinned, and it is the one thing on a machine that would
|
||||||
// then be running code nobody reviewed.
|
// then be running code nobody reviewed.
|
||||||
func TestAProcesssBundleMustBePinned(t *testing.T) {
|
func testAProcesssBundleMustBePinned(t *testing.T) {
|
||||||
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
|
for _, bad := range []string{"", "latest", "sha256:short", strings.Repeat("a", 64)} {
|
||||||
d := aProcess()
|
d := aProcess()
|
||||||
d.Digest = bad
|
d.Digest = bad
|
||||||
@@ -45,7 +45,7 @@ func TestAProcesssBundleMustBePinned(t *testing.T) {
|
|||||||
|
|
||||||
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
|
// What to run is named, never inferred. Guessing an entrypoint from which files are present makes
|
||||||
// a process change what it runs when somebody adds a file.
|
// a process change what it runs when somebody adds a file.
|
||||||
func TestAProcessMustSayWhatToRun(t *testing.T) {
|
func testAProcessMustSayWhatToRun(t *testing.T) {
|
||||||
d := aProcess()
|
d := aProcess()
|
||||||
d.Run = nil
|
d.Run = nil
|
||||||
if problems := d.validate("a process", false); len(problems) == 0 {
|
if problems := d.validate("a process", false); len(problems) == 0 {
|
||||||
@@ -54,7 +54,7 @@ func TestAProcessMustSayWhatToRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
|
// Its name becomes a unit name and a path, so a separator in it would write somewhere nobody meant.
|
||||||
func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
|
func testAProcesssNameCannotEscapeItsUnit(t *testing.T) {
|
||||||
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
|
// "." and ".." are one path element each, and the mesh's own bundle directory and its parent:
|
||||||
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
|
// removing a process named ".." would delete every bundle the mesh has, and more (novox/hq ADR
|
||||||
// 0118). A leading dash is an option to the service manager, not a unit.
|
// 0118). A leading dash is an option to the service manager, not a unit.
|
||||||
@@ -68,7 +68,7 @@ func TestAProcesssNameCannotEscapeItsUnit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And a well-formed one is accepted, or the tests above prove only that everything is refused.
|
// And a well-formed one is accepted, or the tests above prove only that everything is refused.
|
||||||
func TestAWellFormedDaemonIsAccepted(t *testing.T) {
|
func testAWellFormedDaemonIsAccepted(t *testing.T) {
|
||||||
if problems := aProcess().validate("a process", false); len(problems) != 0 {
|
if problems := aProcess().validate("a process", false); len(problems) != 0 {
|
||||||
t.Fatalf("a well-formed daemon was refused: %v", problems)
|
t.Fatalf("a well-formed daemon was refused: %v", problems)
|
||||||
}
|
}
|
||||||
@@ -78,7 +78,7 @@ func TestAWellFormedDaemonIsAccepted(t *testing.T) {
|
|||||||
// once does not run on a schedule; something not running between fires cannot be restarted when a
|
// once does not run on a schedule; something not running between fires cannot be restarted when a
|
||||||
// file changes. A container's modes carry the same rule, and this is the same rule because it is
|
// file changes. A container's modes carry the same rule, and this is the same rule because it is
|
||||||
// the same thing hosted differently.
|
// the same thing hosted differently.
|
||||||
func TestTheModesAreExclusive(t *testing.T) {
|
func testTheModesAreExclusive(t *testing.T) {
|
||||||
both := aProcess()
|
both := aProcess()
|
||||||
both.RunOnce = true
|
both.RunOnce = true
|
||||||
both.Schedule = "0 3 * * *"
|
both.Schedule = "0 3 * * *"
|
||||||
@@ -110,7 +110,7 @@ func TestTheModesAreExclusive(t *testing.T) {
|
|||||||
|
|
||||||
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
|
// A cadence that is not a cadence is refused near its author, rather than by a machine at the far
|
||||||
// end of a declaration.
|
// end of a declaration.
|
||||||
func TestAScheduleMustBeACadence(t *testing.T) {
|
func testAScheduleMustBeACadence(t *testing.T) {
|
||||||
for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} {
|
for _, bad := range []string{"often", "0 3 * *", "99 3 * * *"} {
|
||||||
p := aProcess()
|
p := aProcess()
|
||||||
p.Schedule = bad
|
p.Schedule = bad
|
||||||
@@ -121,7 +121,7 @@ func TestAScheduleMustBeACadence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// And each mode on its own is accepted, or the tests above prove only that everything is refused.
|
// And each mode on its own is accepted, or the tests above prove only that everything is refused.
|
||||||
func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
|
func testEachModeOnItsOwnIsAccepted(t *testing.T) {
|
||||||
once := aProcess()
|
once := aProcess()
|
||||||
once.RunOnce = true
|
once.RunOnce = true
|
||||||
if problems := once.validate("a process", false); len(problems) != 0 {
|
if problems := once.validate("a process", false); len(problems) != 0 {
|
||||||
@@ -148,7 +148,7 @@ func TestEachModeOnItsOwnIsAccepted(t *testing.T) {
|
|||||||
//
|
//
|
||||||
// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of
|
// So it is refused here, near whoever wrote it, rather than rendered into a unit at the far end of
|
||||||
// a declaration.
|
// a declaration.
|
||||||
func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
|
func testAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
|
||||||
for _, bad := range []string{
|
for _, bad := range []string{
|
||||||
"safe\nExecStart=/usr/bin/whatever",
|
"safe\nExecStart=/usr/bin/whatever",
|
||||||
"carriage\rreturn",
|
"carriage\rreturn",
|
||||||
@@ -173,10 +173,24 @@ func TestAnEnvironmentValueCannotCarryALineBreak(t *testing.T) {
|
|||||||
|
|
||||||
// And ordinary awkward values are accepted, because escaping is what handles those — refusing them
|
// And ordinary awkward values are accepted, because escaping is what handles those — refusing them
|
||||||
// too would make a module unable to hold a generated password.
|
// too would make a module unable to hold a generated password.
|
||||||
func TestOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
|
func testOrdinaryAwkwardValuesAreAccepted(t *testing.T) {
|
||||||
p := aProcess()
|
p := aProcess()
|
||||||
p.Env = map[string]string{"PASSWORD": `a%H b"c\d`}
|
p.Env = map[string]string{"PASSWORD": `a%H b"c\d`}
|
||||||
if problems := p.validate("a process", false); len(problems) != 0 {
|
if problems := p.validate("a process", false); len(problems) != 0 {
|
||||||
t.Fatalf("a password containing the characters passwords contain was refused: %v", problems)
|
t.Fatalf("a password containing the characters passwords contain was refused: %v", problems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProcess holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestProcess(t *testing.T) {
|
||||||
|
t.Run("AProcessIsSomethingTheHostSpeaks", testAProcessIsSomethingTheHostSpeaks)
|
||||||
|
t.Run("AProcesssBundleMustBePinned", testAProcesssBundleMustBePinned)
|
||||||
|
t.Run("AProcessMustSayWhatToRun", testAProcessMustSayWhatToRun)
|
||||||
|
t.Run("AProcesssNameCannotEscapeItsUnit", testAProcesssNameCannotEscapeItsUnit)
|
||||||
|
t.Run("AWellFormedDaemonIsAccepted", testAWellFormedDaemonIsAccepted)
|
||||||
|
t.Run("TheModesAreExclusive", testTheModesAreExclusive)
|
||||||
|
t.Run("AScheduleMustBeACadence", testAScheduleMustBeACadence)
|
||||||
|
t.Run("EachModeOnItsOwnIsAccepted", testEachModeOnItsOwnIsAccepted)
|
||||||
|
t.Run("AnEnvironmentValueCannotCarryALineBreak", testAnEnvironmentValueCannotCarryALineBreak)
|
||||||
|
t.Run("OrdinaryAwkwardValuesAreAccepted", testOrdinaryAwkwardValuesAreAccepted)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
// Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as,
|
// Defends novox/hq ADR 0349 §4 (issue 668): the declaration names the account the provisioner's client runs as,
|
||||||
// the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it.
|
// the only one whose write of a filled file the node-engine accepts. Read here before any controller sends it.
|
||||||
|
|
||||||
func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
|
func testTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"],
|
d, err := Parse([]byte(`{"declaration":1,"provisioner_account":"mesh-node-provisioner","filled_files":["/etc/a"],
|
||||||
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`))
|
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a","owner-at-runtime":"owned"}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -26,7 +26,7 @@ func TestTheDeclarationNamesTheProvisionersAccount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
|
func testAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
|
||||||
for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} {
|
for name, account := range map[string]string{"root": "root", "not a name": "a b", "a path": "../x"} {
|
||||||
_, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `",
|
_, err := Parse([]byte(`{"declaration":1,"provisioner_account":"` + account + `",
|
||||||
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`))
|
"resources":[{"id":"a","type":"file","path":"/etc/a","content":"a"}]}`))
|
||||||
@@ -35,3 +35,9 @@ func TestAProvisionersAccountThatIsNoAccountIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProvisioner holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestProvisioner(t *testing.T) {
|
||||||
|
t.Run("TheDeclarationNamesTheProvisionersAccount", testTheDeclarationNamesTheProvisionersAccount)
|
||||||
|
t.Run("AProvisionersAccountThatIsNoAccountIsRefused", testAProvisionersAccountThatIsNoAccountIsRefused)
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func replacing(extra string) string {
|
|||||||
return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}`
|
return `{"declaration":1,"resources":[` + strings.Replace(aReplacingProcess, "%s", extra, 1) + `]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAProcessMayNameWhatItReplaces(t *testing.T) {
|
func testAProcessMayNameWhatItReplaces(t *testing.T) {
|
||||||
d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`)))
|
d, err := Parse([]byte(replacing(`,"replaces":["m.server"]`)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("a process replacing an undeclared container was refused: %v", err)
|
t.Fatalf("a process replacing an undeclared container was refused: %v", err)
|
||||||
@@ -27,7 +27,7 @@ func TestAProcessMayNameWhatItReplaces(t *testing.T) {
|
|||||||
|
|
||||||
// What is replaced is what the declaration no longer says. A resource still declared is applied,
|
// What is replaced is what the declaration no longer says. A resource still declared is applied,
|
||||||
// and handing it over as well would remove something the same declaration asks to keep.
|
// and handing it over as well would remove something the same declaration asks to keep.
|
||||||
func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
|
func testAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
|
||||||
raw := `{"declaration":1,"resources":[
|
raw := `{"declaration":1,"resources":[
|
||||||
{"id":"m.server","type":"directory","path":"/tmp/x"},
|
{"id":"m.server","type":"directory","path":"/tmp/x"},
|
||||||
` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}`
|
` + strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1) + `]}`
|
||||||
@@ -36,7 +36,7 @@ func TestAProcessMayNotReplaceSomethingStillDeclared(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
|
func testOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
|
||||||
for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} {
|
for _, mode := range []string{`,"run-once":true`, `,"schedule":"0 3 * * *"`} {
|
||||||
if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil {
|
if _, err := Parse([]byte(replacing(mode + `,"replaces":["m.server"]`))); err == nil {
|
||||||
t.Errorf("a process with %s was allowed to replace something", mode)
|
t.Errorf("a process with %s was allowed to replace something", mode)
|
||||||
@@ -44,7 +44,7 @@ func TestOnlyAProcessThatStaysUpReplacesAnything(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
|
func testAProcessCannotReplaceItselfOrNothing(t *testing.T) {
|
||||||
for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} {
|
for _, bad := range []string{`,"replaces":["m.controller"]`, `,"replaces":[""]`} {
|
||||||
if _, err := Parse([]byte(replacing(bad))); err == nil {
|
if _, err := Parse([]byte(replacing(bad))); err == nil {
|
||||||
t.Errorf("replaces %s was accepted", bad)
|
t.Errorf("replaces %s was accepted", bad)
|
||||||
@@ -52,7 +52,7 @@ func TestAProcessCannotReplaceItselfOrNothing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOneThingHasOneReplacement(t *testing.T) {
|
func testOneThingHasOneReplacement(t *testing.T) {
|
||||||
second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`).
|
second := strings.NewReplacer(`"id":"m.controller"`, `"id":"m.other"`, `"name":"m"`, `"name":"other"`).
|
||||||
Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1))
|
Replace(strings.Replace(aReplacingProcess, "%s", `,"replaces":["m.server"]`, 1))
|
||||||
raw := `{"declaration":1,"resources":[` +
|
raw := `{"declaration":1,"resources":[` +
|
||||||
@@ -61,3 +61,12 @@ func TestOneThingHasOneReplacement(t *testing.T) {
|
|||||||
t.Fatal("two processes replacing one resource were accepted")
|
t.Fatal("two processes replacing one resource were accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReplaces holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestReplaces(t *testing.T) {
|
||||||
|
t.Run("AProcessMayNameWhatItReplaces", testAProcessMayNameWhatItReplaces)
|
||||||
|
t.Run("AProcessMayNotReplaceSomethingStillDeclared", testAProcessMayNotReplaceSomethingStillDeclared)
|
||||||
|
t.Run("OnlyAProcessThatStaysUpReplacesAnything", testOnlyAProcessThatStaysUpReplacesAnything)
|
||||||
|
t.Run("AProcessCannotReplaceItselfOrNothing", testAProcessCannotReplaceItselfOrNothing)
|
||||||
|
t.Run("OneThingHasOneReplacement", testOneThingHasOneReplacement)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) }
|
func schedulePinned() string { return "registry.example/runtime@sha256:" + strings.Repeat("a", 64) }
|
||||||
|
|
||||||
func TestAScheduledContainerCarriesTheCronField(t *testing.T) {
|
func testAScheduledContainerCarriesTheCronField(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
d, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"}
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"0 3 * * *"}
|
||||||
]}`))
|
]}`))
|
||||||
@@ -27,7 +27,7 @@ func TestAScheduledContainerCarriesTheCronField(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAMalformedScheduleIsRefused(t *testing.T) {
|
func testAMalformedScheduleIsRefused(t *testing.T) {
|
||||||
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"}
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","schedule":"every night"}
|
||||||
]}`))
|
]}`))
|
||||||
@@ -39,7 +39,7 @@ func TestAMalformedScheduleIsRefused(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
|
func testAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
|
||||||
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
|
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
|
||||||
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
_, err := Parse([]byte(`{"declaration":1,"resources":[
|
||||||
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"}
|
{"id":"sync","type":"container","name":"sync","image":"` + schedulePinned() + `","run-once":true,"schedule":"0 3 * * *"}
|
||||||
@@ -51,3 +51,10 @@ func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
|
|||||||
t.Errorf("refused for the wrong reason: %v", err)
|
t.Errorf("refused for the wrong reason: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScheduleDeclaration holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestScheduleDeclaration(t *testing.T) {
|
||||||
|
t.Run("AScheduledContainerCarriesTheCronField", testAScheduledContainerCarriesTheCronField)
|
||||||
|
t.Run("AMalformedScheduleIsRefused", testAMalformedScheduleIsRefused)
|
||||||
|
t.Run("AContainerCannotBeBothRunOnceAndScheduled", testAContainerCannotBeBothRunOnceAndScheduled)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names
|
// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names
|
||||||
// the account, a system one may not, and any other word is refused.
|
// the account, a system one may not, and any other word is refused.
|
||||||
func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
|
func testAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
|
||||||
cases := []struct{ scope, user, wants string }{
|
cases := []struct{ scope, user, wants string }{
|
||||||
{"user", "ops", ""},
|
{"user", "ops", ""},
|
||||||
{"", "", ""},
|
{"", "", ""},
|
||||||
@@ -30,7 +30,7 @@ func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted.
|
// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted.
|
||||||
func TestAnAccountMayBeDeclaredToLinger(t *testing.T) {
|
func testAnAccountMayBeDeclaredToLinger(t *testing.T) {
|
||||||
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`))
|
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -47,3 +47,9 @@ func TestAnAccountMayBeDeclaredToLinger(t *testing.T) {
|
|||||||
t.Fatal("an account that said nothing about lingering asserts it")
|
t.Fatal("an account that said nothing about lingering asserts it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUserscope holds this file's cases, each a subtest of its own name (mesh-sdk's test budget: at most 500 unit tests).
|
||||||
|
func TestUserscope(t *testing.T) {
|
||||||
|
t.Run("AUserScopedUnitNamesItsAccountAndASystemOneMayNot", testAUserScopedUnitNamesItsAccountAndASystemOneMayNot)
|
||||||
|
t.Run("AnAccountMayBeDeclaredToLinger", testAnAccountMayBeDeclaredToLinger)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
)
|
)
|
||||||
|
|
||||||
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
|
// The builder's keys (novox/hq ADR 0429 clauses 5 and 16, ADR 0437 clause 5, ADR 0405 clauses 2 and 7, ADR 0453
|
||||||
@@ -72,6 +75,12 @@ func ParseBuilderBuildKey(key string) (module, fingerprint string, err error) {
|
|||||||
// artifact-store://<module>/<artifact>@<digest> catalogue.Recorded keeps without its address
|
// artifact-store://<module>/<artifact>@<digest> catalogue.Recorded keeps without its address
|
||||||
// package <name>@<version> the version published from the trunk
|
// package <name>@<version> the version published from the trunk
|
||||||
//
|
//
|
||||||
|
// From the registry milestone a done key names the reference its seat answered (novox/hq ADR 0605 §9, design 51 *The
|
||||||
|
// done key names the reference the seat answered*): `<seat>/<module>/<artifact>@<digest>`, <seat> the registry seat it
|
||||||
|
// came through (oci-artifact-registry for a bundle or an archive, container-image-registry for an image or an upstream
|
||||||
|
// copy), and `<name>@<version>` for a package as before. A key written in the older form keeps it; this reads both, and
|
||||||
|
// no build is built again for its form (catalogue.InArtifactStore reads both the same way).
|
||||||
|
//
|
||||||
// <artifact> is Name and <digest> is Digest. A reference is empty for a candidate's image or package, which is never
|
// <artifact> is Name and <digest> is Digest. A reference is empty for a candidate's image or package, which is never
|
||||||
// pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose
|
// pushed or published (ADR 0498 clause 6), and in a key done before R4e; the controller delivers only from a key whose
|
||||||
// every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked.
|
// every artifact names one (Referenced, clause 8). A kind left empty (a key written before kinds) is not checked.
|
||||||
@@ -80,6 +89,10 @@ type BuiltArtifact struct {
|
|||||||
Kind string `json:"kind,omitempty"`
|
Kind string `json:"kind,omitempty"`
|
||||||
Digest string `json:"digest"`
|
Digest string `json:"digest"`
|
||||||
Reference string `json:"reference,omitempty"`
|
Reference string `json:"reference,omitempty"`
|
||||||
|
// References are every form this artifact is named in, Reference among them (novox/hq ADR 0618, ADR 0624): a build
|
||||||
|
// keeps each form that a counted node-engine states, away nodes included, and Reference stays the form every one
|
||||||
|
// of them reads. A reader that does not know the field reads Reference alone.
|
||||||
|
References []string `json:"references,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const artifactStore = "artifact-store://"
|
const artifactStore = "artifact-store://"
|
||||||
@@ -90,6 +103,27 @@ func (a BuiltArtifact) referenceProblem() string {
|
|||||||
if r == "" || a.Kind == "" {
|
if r == "" || a.Kind == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
// An upstream copy may be named by the holder's own naming, upstream/<host>/<path>@<digest>, in either form: the
|
||||||
|
// holder names it, not the module that asked for it (catalogue.InUpstream).
|
||||||
|
if path, kept := catalogue.InArtifactStore(r); kept && a.Kind == "upstream" {
|
||||||
|
if _, image, ok := catalogue.InUpstream(path); ok {
|
||||||
|
if _, digest, _ := strings.Cut(image, "@"); digest != a.Digest {
|
||||||
|
return "does not name its digest " + a.Digest
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if seat, path, ok := catalogue.InRegistrySeat(r); ok {
|
||||||
|
want := catalogue.ContainerImageRegistrySeat
|
||||||
|
if a.Kind == "bundle" || a.Kind == "archive" {
|
||||||
|
want = catalogue.OciArtifactRegistrySeat
|
||||||
|
}
|
||||||
|
module, rest, _ := strings.Cut(path, "/")
|
||||||
|
if a.Kind == "package" || seat != want || module == "" || rest != a.Name+"@"+a.Digest {
|
||||||
|
return "is not " + want + "/<module>/" + a.Name + "@" + a.Digest
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
switch a.Kind {
|
switch a.Kind {
|
||||||
case "bundle", "archive":
|
case "bundle", "archive":
|
||||||
path, ok := strings.CutPrefix(r, artifactStore)
|
path, ok := strings.CutPrefix(r, artifactStore)
|
||||||
@@ -191,6 +225,10 @@ type BuilderBuild struct {
|
|||||||
// Imports are, on a done key, the mesh packages the built programs import, as the build module answered them
|
// Imports are, on a done key, the mesh packages the built programs import, as the build module answered them
|
||||||
// (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports).
|
// (BuildAnswer.Imports; novox/hq ADR 0593 §2): the builder carries them onto the build's end (BuildLogEntry.Imports).
|
||||||
Imports []string `json:"imports,omitempty"`
|
Imports []string `json:"imports,omitempty"`
|
||||||
|
// Needs are, on a done key only, the reader capabilities (`form:<name>`) the build's reference forms need of a
|
||||||
|
// node-engine, beside the ones its manifest needs (novox/hq ADR 0618 §1). A reader that does not know the field
|
||||||
|
// reads the build as needing none.
|
||||||
|
Needs []string `json:"needs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b BuilderBuild) check() error {
|
func (b BuilderBuild) check() error {
|
||||||
@@ -225,6 +263,22 @@ func (b BuilderBuild) check() error {
|
|||||||
if p := a.referenceProblem(); p != "" {
|
if p := a.referenceProblem(); p != "" {
|
||||||
return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p)
|
return fmt.Errorf("delivery: the reference %q of the %s %q %s", a.Reference, a.Kind, a.Name, p)
|
||||||
}
|
}
|
||||||
|
if len(a.References) > 0 && !slices.Contains(a.References, a.Reference) {
|
||||||
|
return fmt.Errorf("delivery: the references of the %s %q do not name its reference %q", a.Kind, a.Name, a.Reference)
|
||||||
|
}
|
||||||
|
for _, r := range a.References {
|
||||||
|
if p := (BuiltArtifact{Name: a.Name, Kind: a.Kind, Digest: a.Digest, Reference: r}).referenceProblem(); p != "" {
|
||||||
|
return fmt.Errorf("delivery: the reference %q of the %s %q %s", r, a.Kind, a.Name, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, n := range b.Needs {
|
||||||
|
if !strings.HasPrefix(n, "form:") || len(n) == len("form:") {
|
||||||
|
return fmt.Errorf("delivery: the need %q of the build is not form:<name>", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(b.Needs) > 0 && b.State != BuildDone {
|
||||||
|
return fmt.Errorf("delivery: a %s build names needs; only a done key does", b.State)
|
||||||
}
|
}
|
||||||
for language, id := range b.Asks {
|
for language, id := range b.Asks {
|
||||||
if b.State != BuildBuilding {
|
if b.State != BuildBuilding {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) {
|
|||||||
artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"),
|
artifactOf("lib", "package", d, "@novox/mesh-sdk@0.1.15"),
|
||||||
artifactOf("lib", "package", d, "left-pad@1.3.0"),
|
artifactOf("lib", "package", d, "left-pad@1.3.0"),
|
||||||
artifactOf("old", "", d, "anything at all"),
|
artifactOf("old", "", d, "anything at all"),
|
||||||
|
// The form a registry seat answers (ADR 0605 §9), beside the older one.
|
||||||
|
artifactOf("code", "bundle", d, "oci-artifact-registry/mail/code@"+d),
|
||||||
|
artifactOf("files", "archive", d, "oci-artifact-registry/mail/files@"+d),
|
||||||
|
artifactOf("server", "image", d, "container-image-registry/mail/server@"+d),
|
||||||
|
artifactOf("nats", "upstream", d, "container-image-registry/nats/nats@"+d),
|
||||||
|
// An upstream copy under the holder's own naming, upstream/<host>/<path>, in either form (ADR 0624 §1).
|
||||||
|
artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+d),
|
||||||
|
artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+d),
|
||||||
}
|
}
|
||||||
for _, a := range good {
|
for _, a := range good {
|
||||||
b := doneBuild()
|
b := doneBuild()
|
||||||
@@ -43,6 +51,15 @@ func TestADoneKeyNamesEachArtifactsReferenceInTheFormOfItsKind(t *testing.T) {
|
|||||||
artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d),
|
artifactOf("server", "image", d, "artifact-store://mail/server/blobs/"+d),
|
||||||
artifactOf("lib", "package", d, "@novox/mesh-sdk"),
|
artifactOf("lib", "package", d, "@novox/mesh-sdk"),
|
||||||
artifactOf("lib", "package", d, "0.1.15"),
|
artifactOf("lib", "package", d, "0.1.15"),
|
||||||
|
artifactOf("code", "bundle", d, "container-image-registry/mail/code@"+d),
|
||||||
|
artifactOf("server", "image", d, "oci-artifact-registry/mail/server@"+d),
|
||||||
|
artifactOf("server", "image", d, "container-image-registry/mail/server@"+other),
|
||||||
|
artifactOf("server", "image", d, "container-image-registry/mail/other@"+d),
|
||||||
|
artifactOf("lib", "package", d, "container-image-registry/mail/lib@"+d),
|
||||||
|
artifactOf("app", "upstream", d, "container-image-registry/upstream/quay.io:443/org/app@"+other),
|
||||||
|
artifactOf("app", "upstream", d, "artifact-store://upstream/quay.io:443/org/app@"+other),
|
||||||
|
artifactOf("app", "upstream", d, "oci-artifact-registry/upstream/quay.io:443/org/app@"+d),
|
||||||
|
artifactOf("server", "image", d, "container-image-registry/upstream/quay.io:443/org/server@"+d),
|
||||||
}
|
}
|
||||||
for _, a := range bad {
|
for _, a := range bad {
|
||||||
b := doneBuild()
|
b := doneBuild()
|
||||||
@@ -94,6 +111,11 @@ func TestOnlyAPushFailureIsBuiltAgainBeforeTheFingerprintChanges(t *testing.T) {
|
|||||||
if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " {
|
if NotStored != "not stored: " || NotPinned != "not pinned: " || VersionTaken != "version taken: " || RefusedKind != "refused kind: " {
|
||||||
t.Fatal("a prefix changed its words")
|
t.Fatal("a prefix changed its words")
|
||||||
}
|
}
|
||||||
|
// A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the
|
||||||
|
// builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change.
|
||||||
|
if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") {
|
||||||
|
t.Fatalf("WaitsFor = %q", WaitsFor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which
|
// A key whose module's lock names a version of the group's package not yet published waits, queued, saying which
|
||||||
@@ -133,11 +155,3 @@ func TestADoneKeyWithNoArtifactIsReferenced(t *testing.T) {
|
|||||||
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
|
t.Fatalf("Referenced = %v, %v; want true", ok, missing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Node fetch that finds the lock naming a package version the registry does not hold fails with WaitsFor; the
|
|
||||||
// builder then writes the key queued with Waits, and the error is not one that waits for the fingerprint to change.
|
|
||||||
func TestWaitsForIsAPrefixThatIsBuiltAgain(t *testing.T) {
|
|
||||||
if WaitsFor != "waits for: " || FailedUntilChanged(WaitsFor+"@novox/mesh-sdk@0.1.15") {
|
|
||||||
t.Fatalf("WaitsFor = %q", WaitsFor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -26,11 +28,15 @@ func doneBuild() BuilderBuild {
|
|||||||
|
|
||||||
func builderSamples() map[string]BuilderBuild {
|
func builderSamples() map[string]BuilderBuild {
|
||||||
failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"}
|
failed := BuilderBuild{State: BuildFailed, By: "builder.novox.1", Step: "test", Error: "go test: 2 failed"}
|
||||||
|
both := doneBuild()
|
||||||
|
both.Needs = []string{"form:artifact-registry"}
|
||||||
|
both.Artifacts[1].References = []string{both.Artifacts[1].Reference, "artifact-store://mail/server@" + both.Artifacts[1].Digest}
|
||||||
return map[string]BuilderBuild{
|
return map[string]BuilderBuild{
|
||||||
"queued": {State: BuildQueued, Manifest: digestOf('4')},
|
"done with needs and both forms": both,
|
||||||
"building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')},
|
"queued": {State: BuildQueued, Manifest: digestOf('4')},
|
||||||
"done": doneBuild(),
|
"building": {State: BuildBuilding, By: "builder.novox.1", Step: "fetch", Manifest: digestOf('4')},
|
||||||
"failed": failed,
|
"done": doneBuild(),
|
||||||
|
"failed": failed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,10 +96,17 @@ func TestBuilderBuildChecked(t *testing.T) {
|
|||||||
edit func(*BuilderBuild)
|
edit func(*BuilderBuild)
|
||||||
words []string
|
words []string
|
||||||
}{
|
}{
|
||||||
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
|
"done without a manifest": {func(b *BuilderBuild) { b.Manifest = "" }, []string{"manifest"}},
|
||||||
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
|
"a malformed manifest": {func(b *BuilderBuild) { b.Manifest = "abc" }, []string{"abc"}},
|
||||||
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
|
"an artifact without a digest": {func(b *BuilderBuild) { b.Artifacts[1].Digest = "" }, []string{"digest", "server"}},
|
||||||
"an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}},
|
"an artifact without a name": {func(b *BuilderBuild) { b.Artifacts[0].Name = "" }, []string{"name"}},
|
||||||
|
"a need that is not a form": {func(b *BuilderBuild) { b.Needs = []string{"word:x"} }, []string{"word:x", "form:"}},
|
||||||
|
"a reference not among the references": {func(b *BuilderBuild) {
|
||||||
|
b.Artifacts[1].References = []string{"artifact-store://mail/server@" + b.Artifacts[1].Digest}
|
||||||
|
}, []string{"server", "references"}},
|
||||||
|
"a reference of another artifact": {func(b *BuilderBuild) {
|
||||||
|
b.Artifacts[1].References = []string{b.Artifacts[1].Reference, "registry.example/mail/other@" + b.Artifacts[1].Digest}
|
||||||
|
}, []string{"server", "other"}},
|
||||||
"an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}},
|
"an unknown state": {func(b *BuilderBuild) { b.State = "paused" }, []string{"paused"}},
|
||||||
"a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}},
|
"a toolchain without a version": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Recipe: "3"} }, []string{"go", "version"}},
|
||||||
"a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}},
|
"a toolchain without a recipe": {func(b *BuilderBuild) { b.Toolchains["go"] = ToolchainUsed{Version: "1.26.0"} }, []string{"go", "recipe"}},
|
||||||
@@ -119,6 +132,11 @@ func TestBuilderBuildChecked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// a queued or building value may not already carry an outcome
|
// a queued or building value may not already carry an outcome
|
||||||
q := builderSamples()["queued"]
|
q := builderSamples()["queued"]
|
||||||
|
q.Needs = []string{"form:x"}
|
||||||
|
if _, err := EncodeBuilderBuild(q); err == nil {
|
||||||
|
t.Error("a queued build with needs accepted")
|
||||||
|
}
|
||||||
|
q.Needs = nil
|
||||||
q.Artifacts = doneBuild().Artifacts
|
q.Artifacts = doneBuild().Artifacts
|
||||||
if _, err := EncodeBuilderBuild(q); err == nil {
|
if _, err := EncodeBuilderBuild(q); err == nil {
|
||||||
t.Error("a queued build with artifacts accepted")
|
t.Error("a queued build with artifacts accepted")
|
||||||
@@ -158,6 +176,21 @@ func TestBuilderBuildVersion(t *testing.T) {
|
|||||||
t.Errorf("DecodeBuilderBuild(%s) = %v", in, err)
|
t.Errorf("DecodeBuilderBuild(%s) = %v", in, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A holder's question before it stores (ADR 0605 §4): asked at the builder's address with name, version and digest;
|
||||||
|
// an answer without recorded is an error, never a yes.
|
||||||
|
for answer, want := range map[string]string{`{"recorded":true}`: "true ", `{"recorded":false,"why":"other digest"}`: "false other digest",
|
||||||
|
`{"name":"x"}`: "error", `not json`: "error"} {
|
||||||
|
b := BuilderVersions{Ask: func(addr string, body any) (json.RawMessage, error) {
|
||||||
|
if a := body.(map[string]any); addr != BuilderVersionsAddress || a["name"] != "n" || a["version"] != "0.1.2" || a["digest"] != "sha256:d" {
|
||||||
|
t.Errorf("asked %s %v", addr, body)
|
||||||
|
}
|
||||||
|
return json.RawMessage(answer), nil
|
||||||
|
}}
|
||||||
|
ok, why, err := b.Recorded("n", "0.1.2", "sha256:d")
|
||||||
|
if got := fmt.Sprintf("%v %s", ok, why); err != nil && want != "error" || err == nil && got != want {
|
||||||
|
t.Errorf("answer %s: %s, %v; want %s", answer, got, err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which
|
// 1358/AC6: the builder's bucket and module do not collide with the controller's build layout (buildrecord.go), which
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The holder's question before it stores a publish (novox/hq ADR 0605 §4, design 51 *Publishing*): did the builder
|
||||||
|
// number this version with this digest? Every registry seat's holder asks it the same way, so the asking lives here
|
||||||
|
// once (ADR 0627 §2), beside the builder's layouts; no holder writes it again.
|
||||||
|
//
|
||||||
|
// The builder answers it today only as its module's tool builder_versions: no seat in the seat set names the builder's
|
||||||
|
// role yet. Once one does, BuilderVersionsAddress becomes that seat's verb and nothing else changes for a holder.
|
||||||
|
const BuilderVersionsAddress = BuilderModule + ".builder_versions"
|
||||||
|
|
||||||
|
// AskFunc is the runtime's request and reply over the bus (mesh-sdk stdio.Ask), given by the holder so this package does
|
||||||
|
// not depend on the runtime.
|
||||||
|
type AskFunc func(address string, body any) (json.RawMessage, error)
|
||||||
|
|
||||||
|
// BuilderVersions asks the builder whether it numbered a version with a digest. It answers the holder's Check:
|
||||||
|
// recorded, and why not when it is not.
|
||||||
|
type BuilderVersions struct{ Ask AskFunc }
|
||||||
|
|
||||||
|
// Recorded asks the builder about name@version with digest. An answer without `recorded` is an error, never a yes.
|
||||||
|
func (b BuilderVersions) Recorded(name, version, digest string) (bool, string, error) {
|
||||||
|
raw, err := b.Ask(BuilderVersionsAddress, map[string]any{"name": name, "version": version, "digest": digest})
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
var a struct {
|
||||||
|
Recorded *bool `json:"recorded"`
|
||||||
|
Why string `json:"why"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &a); err != nil || a.Recorded == nil {
|
||||||
|
return false, "", fmt.Errorf("builder_versions answered no recorded: %s", raw)
|
||||||
|
}
|
||||||
|
return *a.Recorded, a.Why, nil
|
||||||
|
}
|
||||||
@@ -179,6 +179,20 @@ type Verdict struct {
|
|||||||
// carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages
|
// carried over, by repository (ADR 0548 §5): the verdict was rewritten for Trunk and Beside by running only the packages
|
||||||
// whose digest the moves changed. Absent from a verdict judged whole, and from one still running.
|
// whose digest the moves changed. Absent from a verdict judged whole, and from one still running.
|
||||||
CarriedFrom map[string]string `json:"carried-from,omitempty"`
|
CarriedFrom map[string]string `json:"carried-from,omitempty"`
|
||||||
|
// Series names each change of an artifact's series the checked heads make against the trunk's manifests (design 32;
|
||||||
|
// the checker's DLV-C4), each once, in the order the check found them; the pull request's mesh/check line says the
|
||||||
|
// same, and a reader that cuts that line still has every one here. Absent when the heads change no series, and in a
|
||||||
|
// value written before it.
|
||||||
|
Series []SeriesChange `json:"series,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesChange is one change of a series: the module, its artifact (or the package's ecosystem name, as the module's
|
||||||
|
// packages name it), the series on trunk and the series the heads give it.
|
||||||
|
type SeriesChange struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Artifact string `json:"artifact"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Composition) check() error {
|
func (c *Composition) check() error {
|
||||||
@@ -243,6 +257,19 @@ func (v Verdict) check() error {
|
|||||||
return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried")
|
return fmt.Errorf("delivery: the verdict's carried-from names the commits it is judged on: nothing moved, so nothing was carried")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
changed := map[string]bool{}
|
||||||
|
for _, c := range v.Series {
|
||||||
|
if c.Module == "" || c.Artifact == "" || c.From == "" || c.To == "" {
|
||||||
|
return fmt.Errorf("delivery: the verdict's series change %+v names no module, artifact, from or to", c)
|
||||||
|
}
|
||||||
|
if c.From == c.To {
|
||||||
|
return fmt.Errorf("delivery: the verdict's series change of %s/%s is from %s to the same series", c.Module, c.Artifact, c.From)
|
||||||
|
}
|
||||||
|
if changed[c.Module+"/"+c.Artifact] {
|
||||||
|
return fmt.Errorf("delivery: the verdict names the series change of %s/%s twice", c.Module, c.Artifact)
|
||||||
|
}
|
||||||
|
changed[c.Module+"/"+c.Artifact] = true
|
||||||
|
}
|
||||||
for level, r := range v.Levels {
|
for level, r := range v.Levels {
|
||||||
if err := checkLevel(level); err != nil {
|
if err := checkLevel(level); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ func verdictSamples() map[string]Verdict {
|
|||||||
carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
carried.Beside = map[string]string{"novox/mesh-catalog": "cccc"}
|
||||||
carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"}
|
carried.CarriedFrom = map[string]string{"novox/mesh-sdk": "9999", "novox/mesh-control": "bbbb", "novox/mesh-catalog": "cccc"}
|
||||||
carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"}
|
carried.Composition = &Composition{State: CompositionPass, SDK: carried.SDK, Machines: []string{"ace"}, FPSet: "fps1:00"}
|
||||||
|
series := passVerdict()
|
||||||
|
series.Series = []SeriesChange{{Module: "mesh-sdk", Artifact: "go", From: "0.1", To: "0.2"},
|
||||||
|
{Module: "mesh-contracts", Artifact: "@novox/mesh-contracts", From: "0.1", To: "1.0"}}
|
||||||
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine,
|
return map[string]Verdict{"running": running, "pass": passVerdict(), "fail by a test": failTest, "fail by a machine": failMachine,
|
||||||
"carried": carried}
|
"carried": carried, "naming its series changes": series}
|
||||||
}
|
}
|
||||||
|
|
||||||
func keptSample() Kept {
|
func keptSample() Kept {
|
||||||
@@ -93,6 +96,11 @@ func TestVerdictRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
data, _ := EncodeVerdict(passVerdict())
|
data, _ := EncodeVerdict(passVerdict())
|
||||||
|
data, _ = EncodeVerdict(verdictSamples()["naming its series changes"])
|
||||||
|
if !strings.Contains(string(data), `"series":[{"module":"mesh-sdk","artifact":"go","from":"0.1","to":"0.2"},`) {
|
||||||
|
t.Errorf("the series changes are not each {module, artifact, from, to}: %s", data)
|
||||||
|
}
|
||||||
|
data, _ = EncodeVerdict(passVerdict())
|
||||||
for _, field := range []string{`"queue":1200`, `"clone":800`, `"unit":950`, `"integration-setup":2100`, `"integration-run":7400`, `"composition":3300`, `"run":12`, `"kept":301`, `"machines":["ace","g14","novox","shanks"]`, `"sdk":"v0.1.14`} {
|
for _, field := range []string{`"queue":1200`, `"clone":800`, `"unit":950`, `"integration-setup":2100`, `"integration-run":7400`, `"composition":3300`, `"run":12`, `"kept":301`, `"machines":["ace","g14","novox","shanks"]`, `"sdk":"v0.1.14`} {
|
||||||
if !strings.Contains(string(data), field) {
|
if !strings.Contains(string(data), field) {
|
||||||
t.Errorf("the value lacks %s: %s", field, data)
|
t.Errorf("the value lacks %s: %s", field, data)
|
||||||
@@ -314,6 +322,12 @@ func TestVerdictNewFieldsChecked(t *testing.T) {
|
|||||||
"carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}},
|
"carried from an empty commit": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": ""} }, []string{"carried-from", "mesh-sdk"}},
|
||||||
"carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}},
|
"carried over no move": {func(v *Verdict) { v.CarriedFrom = map[string]string{"novox/mesh-sdk": "aaaa"} }, []string{"nothing moved"}},
|
||||||
"a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}},
|
"a running verdict carried": {func(v *Verdict) { v.State, v.CarriedFrom = VerdictRunning, map[string]string{"novox/mesh-sdk": "9999"} }, []string{"running", "carried"}},
|
||||||
|
// Design 32: each series change whole, once, and a change.
|
||||||
|
"a series change with no from": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", To: "0.2"}} }, []string{"series", "from"}},
|
||||||
|
"a series change to itself": {func(v *Verdict) { v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.2", To: "0.2"}} }, []string{"m/go", "same series"}},
|
||||||
|
"a series change named twice": {func(v *Verdict) {
|
||||||
|
v.Series = []SeriesChange{{Module: "m", Artifact: "go", From: "0.1", To: "0.2"}, {Module: "m", Artifact: "go", From: "0.2", To: "0.3"}}
|
||||||
|
}, []string{"m/go", "twice"}},
|
||||||
}
|
}
|
||||||
for name, c := range cases {
|
for name, c := range cases {
|
||||||
v := passVerdict()
|
v := passVerdict()
|
||||||
|
|||||||
@@ -1,203 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// A composition that does not exist yet is asked of the node-engine (novox/hq ADR 0593 §7): the seat node-engine's verb
|
|
||||||
// compose-candidate, served by each node's engine alone, composes a check's candidate in shadow with the node's own
|
|
||||||
// sealed values and the indexer's manifest verdicts, applies and writes nothing, and answers within the asker's bound.
|
|
||||||
// The checker asks every placed node in parallel and makes the check's composition verdict from the answers; a node
|
|
||||||
// that does not answer within the bound is named "not composed on that node: no answer" and the check passes with that
|
|
||||||
// warning (§9). This is the contract only: the composing is the node-engine's own.
|
|
||||||
|
|
||||||
const (
|
|
||||||
// EngineComposeCandidateVerb is the verb's name on the seat node-engine (catalogue.EngineSeat).
|
|
||||||
EngineComposeCandidateVerb = "compose-candidate"
|
|
||||||
// ComposeAnswerBound is the default of the checker's setting compose-answer-bound: how long it waits for a node's
|
|
||||||
// answer (ADR 0593 §7, §9).
|
|
||||||
ComposeAnswerBound = 60 * time.Second
|
|
||||||
// ComposeNoAnswer is the words a check's verdict names a node with that did not answer within the bound (ADR 0593 §9).
|
|
||||||
ComposeNoAnswer = "not composed on that node: no answer"
|
|
||||||
|
|
||||||
// ComposeCandidateComposed and ComposeCandidateRefused are an answer's verdict: no module and not the node's own composition is
|
|
||||||
// refused by the candidate, or at least one is (or the node could not compose with it at all, Refused).
|
|
||||||
ComposeCandidateComposed = "composed"
|
|
||||||
ComposeCandidateRefused = "refused"
|
|
||||||
// ComposeCandidateUnready is the verdict of a node that cannot compose yet (its shadow not ready, its agent
|
|
||||||
// starting): no module is named, Refused says why in words, and the checker takes it as a node that did not answer
|
|
||||||
// (ComposeNoAnswer, ADR 0593 §9), deciding by this verdict and never by the words.
|
|
||||||
ComposeCandidateUnready = "unready"
|
|
||||||
// ComposeCandidateNotJudged is the verdict for a node the checker's sandbox composed and could not judge (an older
|
|
||||||
// node-engine's input that does not say how it composes, novox/hq ADR 0593 §9): no module is named, Refused says why in
|
|
||||||
// words, and the checker names the node with them and passes the check with that warning, never silent and never a
|
|
||||||
// failure of the change. The checker decides by this verdict, never by the words (ADR 0601 §4's kind: a contract is a
|
|
||||||
// field, not a phrase).
|
|
||||||
ComposeCandidateNotJudged = "not-judged"
|
|
||||||
|
|
||||||
// The states of one module's composition with the candidate: composed (Digest set), refused by the candidate
|
|
||||||
// (Words set), or held, refused without the candidate too (Words set), which counts toward no verdict.
|
|
||||||
ModuleComposedState = "composed"
|
|
||||||
ModuleRefusedState = "refused"
|
|
||||||
ModuleHeldState = "held"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ComposeCandidateAsk is the verb's input: Candidate is the key of the indexer's candidate (CompositionKey,
|
|
||||||
// `<escaped group>.<heads hash>`); the engine reads that candidate itself and lays its modules over its placement.
|
|
||||||
// Input asks the engine to answer too the composition input it composed the candidate from (its own node's scope, the
|
|
||||||
// catalogue left out as composer.Request.Input says): the checker puts it into the request a build module's composer
|
|
||||||
// runs over, for a group that changes the node-engine's composition code (ADR 0593 §7).
|
|
||||||
type ComposeCandidateAsk struct {
|
|
||||||
Candidate string `json:"candidate"`
|
|
||||||
Input bool `json:"input,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ComposeCandidateAnswer is the verb's answer: the node, the candidate, the verdict, every module placed on the node
|
|
||||||
// sorted by name, the node's own composition (Own, Module "node"), why the node could not compose with the candidate at
|
|
||||||
// all (Refused: Modules is then empty and Verdict refused, or unready when it cannot compose yet), how long the engine took in milliseconds, and, when asked
|
|
||||||
// (ComposeCandidateAsk.Input), the composition input it composed from.
|
|
||||||
type ComposeCandidateAnswer struct {
|
|
||||||
V int `json:"v"`
|
|
||||||
Node string `json:"node"`
|
|
||||||
Candidate string `json:"candidate"`
|
|
||||||
Verdict string `json:"verdict"`
|
|
||||||
Modules []ModuleComposed `json:"modules"`
|
|
||||||
Own ModuleComposed `json:"own"`
|
|
||||||
Refused string `json:"refused,omitempty"`
|
|
||||||
Took int64 `json:"took"`
|
|
||||||
Input json.RawMessage `json:"input,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModuleComposed is one module's composition with the candidate: the fingerprint it was composed at (the candidate's
|
|
||||||
// FP2 for a module the candidate touches, else its newest done build as composed-<node> holds it), its state, the
|
|
||||||
// composition's digest when composed (ComposedDigestOf), whether the candidate changes it from the one composed without
|
|
||||||
// it (the checker's "what the change reaches on this node"), and why, for a refused or held one.
|
|
||||||
type ModuleComposed struct {
|
|
||||||
Module string `json:"module"`
|
|
||||||
FP string `json:"fp,omitempty"`
|
|
||||||
State string `json:"state"`
|
|
||||||
Digest string `json:"digest,omitempty"`
|
|
||||||
Changed bool `json:"changed,omitempty"`
|
|
||||||
Words string `json:"words,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ComposeCandidateAsk) check() error {
|
|
||||||
if _, _, err := ParseCompositionKey(a.Candidate); err != nil {
|
|
||||||
return fmt.Errorf("delivery: a compose-candidate ask names no candidate: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m ModuleComposed) check(node string) error {
|
|
||||||
if m.Module == "" {
|
|
||||||
return fmt.Errorf("delivery: a module's composition on %s names no module", node)
|
|
||||||
}
|
|
||||||
switch m.State {
|
|
||||||
case ModuleComposedState:
|
|
||||||
if m.Digest == "" {
|
|
||||||
return fmt.Errorf("delivery: %s is composed on %s and names no digest", m.Module, node)
|
|
||||||
}
|
|
||||||
case ModuleRefusedState, ModuleHeldState:
|
|
||||||
if m.Words == "" {
|
|
||||||
return fmt.Errorf("delivery: %s is %s on %s and says no why", m.Module, m.State, node)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("delivery: %s on %s is %q, neither %s, %s nor %s", m.Module, node, m.State,
|
|
||||||
ModuleComposedState, ModuleRefusedState, ModuleHeldState)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a ComposeCandidateAnswer) check() error {
|
|
||||||
if err := checkName("node", a.Node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, _, err := ParseCompositionKey(a.Candidate); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if a.Took < 0 {
|
|
||||||
return fmt.Errorf("delivery: the answer of %s took %d ms", a.Node, a.Took)
|
|
||||||
}
|
|
||||||
if (a.Verdict == ComposeCandidateUnready || a.Verdict == ComposeCandidateNotJudged) && a.Refused == "" {
|
|
||||||
return fmt.Errorf("delivery: %s's answer is %s and says no why", a.Node, a.Verdict)
|
|
||||||
}
|
|
||||||
if a.Refused != "" {
|
|
||||||
if len(a.Modules) > 0 || a.Verdict != ComposeCandidateRefused && a.Verdict != ComposeCandidateUnready && a.Verdict != ComposeCandidateNotJudged {
|
|
||||||
return fmt.Errorf("delivery: %s could not compose the candidate (%s), and its answer names modules or is neither %s, %s nor %s",
|
|
||||||
a.Node, a.Refused, ComposeCandidateRefused, ComposeCandidateUnready, ComposeCandidateNotJudged)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
refused := false
|
|
||||||
for i, m := range append([]ModuleComposed{a.Own}, a.Modules...) {
|
|
||||||
if err := m.check(a.Node); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if i > 1 && a.Modules[i-2].Module >= m.Module {
|
|
||||||
return fmt.Errorf("delivery: the modules of %s's answer are not sorted and each once (%s after %s)", a.Node, m.Module, a.Modules[i-2].Module)
|
|
||||||
}
|
|
||||||
refused = refused || m.State == ModuleRefusedState
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case a.Own.Module != nodeWord:
|
|
||||||
return fmt.Errorf("delivery: the node's own composition in %s's answer is named %q, not %q", a.Node, a.Own.Module, nodeWord)
|
|
||||||
case refused && a.Verdict != ComposeCandidateRefused:
|
|
||||||
return fmt.Errorf("delivery: %s's answer refuses a module and its verdict is %q", a.Node, a.Verdict)
|
|
||||||
case !refused && a.Verdict != ComposeCandidateComposed:
|
|
||||||
return fmt.Errorf("delivery: %s's answer refuses no module and its verdict is %q", a.Node, a.Verdict)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// strictDecode reads data into v, refusing an unknown key.
|
|
||||||
func strictDecode(data []byte, v any, what string) error {
|
|
||||||
d := json.NewDecoder(bytes.NewReader(data))
|
|
||||||
d.DisallowUnknownFields()
|
|
||||||
if err := d.Decode(v); err != nil {
|
|
||||||
return fmt.Errorf("delivery: %s does not fit its shape: %w", what, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncodeComposeCandidateAsk is a as JSON; it refuses an ask naming no candidate key.
|
|
||||||
func EncodeComposeCandidateAsk(a ComposeCandidateAsk) ([]byte, error) {
|
|
||||||
if err := a.check(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return json.Marshal(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecodeComposeCandidateAsk reads an ask strictly; it refuses an unknown key and what EncodeComposeCandidateAsk refuses.
|
|
||||||
func DecodeComposeCandidateAsk(data []byte) (ComposeCandidateAsk, error) {
|
|
||||||
var a ComposeCandidateAsk
|
|
||||||
if err := strictDecode(data, &a, "a compose-candidate ask"); err != nil {
|
|
||||||
return ComposeCandidateAsk{}, err
|
|
||||||
}
|
|
||||||
return a, a.check()
|
|
||||||
}
|
|
||||||
|
|
||||||
// EncodeComposeCandidateAnswer is a as JSON at Version; it refuses an unknown state, a composed module without a digest,
|
|
||||||
// a refused or held one without words, a refusal of the whole naming modules, and a verdict the modules do not give.
|
|
||||||
func EncodeComposeCandidateAnswer(a ComposeCandidateAnswer) ([]byte, error) {
|
|
||||||
if err := a.check(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
a.V = Version
|
|
||||||
return json.Marshal(a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecodeComposeCandidateAnswer reads an answer strictly; it refuses an unknown version (ErrUnknownVersion), an unknown
|
|
||||||
// key and what EncodeComposeCandidateAnswer refuses.
|
|
||||||
func DecodeComposeCandidateAnswer(data []byte) (ComposeCandidateAnswer, error) {
|
|
||||||
if err := checkVersion(data); err != nil {
|
|
||||||
return ComposeCandidateAnswer{}, err
|
|
||||||
}
|
|
||||||
var a ComposeCandidateAnswer
|
|
||||||
if err := strictDecode(data, &a, "a compose-candidate answer"); err != nil {
|
|
||||||
return ComposeCandidateAnswer{}, err
|
|
||||||
}
|
|
||||||
return a, a.check()
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
package delivery
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The verb compose-candidate's ask and answer, and a composition that carries its plan or its refusal (ADR 0593 §6, §7):
|
|
||||||
// each round-trips, and a reader refuses what it could not take, naming it.
|
|
||||||
func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) {
|
|
||||||
key, err := CompositionKey("mesh-sdk#7", "0123456789ab")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
raw, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: key, Input: true})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if a, err := DecodeComposeCandidateAsk(raw); err != nil || a.Candidate != key || !a.Input {
|
|
||||||
t.Fatalf("ask round trip: %v, %+v", err, a)
|
|
||||||
}
|
|
||||||
if _, err := DecodeComposeCandidateAsk([]byte(`{"candidate":"` + key + `","other":1}`)); err == nil {
|
|
||||||
t.Error("an ask with an unknown key was read")
|
|
||||||
}
|
|
||||||
if _, err := EncodeComposeCandidateAsk(ComposeCandidateAsk{Candidate: "nope"}); err == nil {
|
|
||||||
t.Error("an ask naming no candidate key was encoded")
|
|
||||||
}
|
|
||||||
|
|
||||||
good := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Took: 812,
|
|
||||||
Own: ModuleComposed{Module: "node", State: ModuleComposedState, Digest: "sha256:00"},
|
|
||||||
Modules: []ModuleComposed{
|
|
||||||
{Module: "netcheck", FP: "src2:x", State: ModuleComposedState, Digest: "sha256:01", Changed: true},
|
|
||||||
{Module: "route-proxy", State: ModuleRefusedState, Words: "the setting domain has no value"},
|
|
||||||
{Module: "zz", State: ModuleHeldState, Words: "held before"},
|
|
||||||
}, Input: json.RawMessage(`{"machines":["ace"]}`)}
|
|
||||||
raw, err = EncodeComposeCandidateAnswer(good)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
back, err := DecodeComposeCandidateAnswer(raw)
|
|
||||||
if err != nil || back.Verdict != ComposeCandidateRefused || len(back.Modules) != 3 || string(back.Input) != string(good.Input) {
|
|
||||||
t.Fatalf("answer round trip: %v, %+v", err, back)
|
|
||||||
}
|
|
||||||
for name, mutate := range map[string]func(a *ComposeCandidateAnswer){
|
|
||||||
"verdict not the modules'": func(a *ComposeCandidateAnswer) { a.Verdict = ComposeCandidateComposed },
|
|
||||||
"unknown state": func(a *ComposeCandidateAnswer) { a.Modules[0].State = "fine" },
|
|
||||||
"composed without digest": func(a *ComposeCandidateAnswer) { a.Modules[0].Digest = "" },
|
|
||||||
"refused without words": func(a *ComposeCandidateAnswer) { a.Modules[1].Words = "" },
|
|
||||||
"unsorted": func(a *ComposeCandidateAnswer) { a.Modules[0], a.Modules[1] = a.Modules[1], a.Modules[0] },
|
|
||||||
"own misnamed": func(a *ComposeCandidateAnswer) { a.Own.Module = "ace" },
|
|
||||||
"whole refusal w/ modules": func(a *ComposeCandidateAnswer) { a.Refused = "the bound passed" },
|
|
||||||
} {
|
|
||||||
a := good
|
|
||||||
a.Modules = append([]ModuleComposed(nil), good.Modules...)
|
|
||||||
mutate(&a)
|
|
||||||
if _, err := EncodeComposeCandidateAnswer(a); err == nil {
|
|
||||||
t.Errorf("%s: encoded", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
whole := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateRefused, Refused: "the bound passed"}
|
|
||||||
if _, err := EncodeComposeCandidateAnswer(whole); err != nil {
|
|
||||||
t.Errorf("a whole refusal was not encoded: %v", err)
|
|
||||||
}
|
|
||||||
unready := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateUnready, Refused: "the agent is starting"}
|
|
||||||
if raw, err := EncodeComposeCandidateAnswer(unready); err != nil {
|
|
||||||
t.Errorf("an unready answer was not encoded: %v", err)
|
|
||||||
} else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateUnready {
|
|
||||||
t.Errorf("an unready answer read back as %+v, %v", back, err)
|
|
||||||
}
|
|
||||||
unready.Refused = ""
|
|
||||||
if _, err := EncodeComposeCandidateAnswer(unready); err == nil {
|
|
||||||
t.Error("an unready answer saying no why was encoded")
|
|
||||||
}
|
|
||||||
unready.Refused, unready.Modules = "starting", good.Modules
|
|
||||||
if _, err := EncodeComposeCandidateAnswer(unready); err == nil {
|
|
||||||
t.Error("an unready answer naming modules was encoded")
|
|
||||||
}
|
|
||||||
// A node the sandbox did not judge: its own verdict, with words and no module (ADR 0593 §9, ADR 0601 §4).
|
|
||||||
notJudged := ComposeCandidateAnswer{Node: "ace", Candidate: key, Verdict: ComposeCandidateNotJudged, Refused: "its node-engine sends no running state"}
|
|
||||||
if raw, err := EncodeComposeCandidateAnswer(notJudged); err != nil {
|
|
||||||
t.Errorf("a not-judged answer was not encoded: %v", err)
|
|
||||||
} else if back, err := DecodeComposeCandidateAnswer(raw); err != nil || back.Verdict != ComposeCandidateNotJudged {
|
|
||||||
t.Errorf("a not-judged answer read back as %+v, %v", back, err)
|
|
||||||
}
|
|
||||||
for name, mutate := range map[string]func(*ComposeCandidateAnswer){
|
|
||||||
"no why": func(a *ComposeCandidateAnswer) { a.Refused = "" },
|
|
||||||
"modules too": func(a *ComposeCandidateAnswer) { a.Modules = good.Modules },
|
|
||||||
} {
|
|
||||||
a := notJudged
|
|
||||||
mutate(&a)
|
|
||||||
if _, err := EncodeComposeCandidateAnswer(a); err == nil {
|
|
||||||
t.Errorf("a not-judged answer with %s was encoded", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := DecodeComposeCandidateAnswer([]byte(strings.Replace(string(raw), `"took"`, `"other":1,"took"`, 1))); err == nil {
|
|
||||||
t.Error("an answer with an unknown key was read")
|
|
||||||
}
|
|
||||||
|
|
||||||
fp := "src2:" + strings.Repeat("a", 64)
|
|
||||||
planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{},
|
|
||||||
Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}}
|
|
||||||
raw, err = EncodeComposed(planned)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
c, err := DecodeComposed(raw)
|
|
||||||
if err != nil || c.Plan == nil {
|
|
||||||
t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c)
|
|
||||||
}
|
|
||||||
d1, _ := ComposedDigestOf(planned)
|
|
||||||
d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}})
|
|
||||||
if d1 == "" || d1 != d0 {
|
|
||||||
t.Error("the digest moves with the plan: it is the resources' alone")
|
|
||||||
}
|
|
||||||
if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) {
|
|
||||||
t.Error("a plan change is not a new composition value")
|
|
||||||
}
|
|
||||||
signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1)
|
|
||||||
if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" {
|
|
||||||
t.Fatalf("a composition the ended controller signed does not decode: %v", err)
|
|
||||||
}
|
|
||||||
if strings.Contains(string(raw), "signature") {
|
|
||||||
t.Error("a composition is written with a signature")
|
|
||||||
}
|
|
||||||
refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"}
|
|
||||||
if raw, err = EncodeComposed(refused); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused {
|
|
||||||
t.Fatalf("a refused composition did not round-trip: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := ComposedDigestOf(refused); err == nil {
|
|
||||||
t.Error("a refused composition has a digest")
|
|
||||||
}
|
|
||||||
refused.Data = true
|
|
||||||
if _, err := EncodeComposed(refused); err == nil {
|
|
||||||
t.Error("a refused composition holding data was encoded")
|
|
||||||
}
|
|
||||||
if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil {
|
|
||||||
t.Error("the node's own composition carried a refusal")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4).
|
|
||||||
func TestAKeptImportFormNeedsImports(t *testing.T) {
|
|
||||||
form := SourcePrefix2 + strings.Repeat("b", 64)
|
|
||||||
in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form}
|
|
||||||
m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"}
|
|
||||||
if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") {
|
|
||||||
t.Fatalf("a good import form refused: %v", err)
|
|
||||||
}
|
|
||||||
for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} {
|
|
||||||
m.Inputs2 = &bad
|
|
||||||
if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") {
|
|
||||||
t.Errorf("%+v: %v", bad, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A composition that carries its plan or its refusal (ADR 0593 §6): each round-trips, and a reader refuses what it could
|
||||||
|
// not take, naming it. (compose-candidate's ask and answer are node-engine's client's, mesh-contracts
|
||||||
|
// go/clients/nodeengineclient, ADR 0627 §1.)
|
||||||
|
func TestComposeCandidateAndComposedPlanRoundTripAndRefuse(t *testing.T) {
|
||||||
|
fp := "src2:" + strings.Repeat("a", 64)
|
||||||
|
planned := Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{},
|
||||||
|
Plan: &catalogue.Plan{Files: []catalogue.PlanFile{}}}
|
||||||
|
raw, err := EncodeComposed(planned)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := DecodeComposed(raw)
|
||||||
|
if err != nil || c.Plan == nil {
|
||||||
|
t.Fatalf("a composition's plan did not round-trip: %v, %+v", err, c)
|
||||||
|
}
|
||||||
|
d1, _ := ComposedDigestOf(planned)
|
||||||
|
d0, _ := ComposedDigestOf(Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}})
|
||||||
|
if d1 == "" || d1 != d0 {
|
||||||
|
t.Error("the digest moves with the plan: it is the resources' alone")
|
||||||
|
}
|
||||||
|
if SameComposed(planned, Composed{Node: "ace", Module: "netcheck", FP: fp, Resources: []json.RawMessage{}}) {
|
||||||
|
t.Error("a plan change is not a new composition value")
|
||||||
|
}
|
||||||
|
signed := strings.Replace(string(raw), `"resources"`, `"signature":{"signer":"x","sig":"y"},"resources"`, 1)
|
||||||
|
if c, err := DecodeComposed([]byte(signed)); err != nil || c.Node != "ace" {
|
||||||
|
t.Fatalf("a composition the ended controller signed does not decode: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(raw), "signature") {
|
||||||
|
t.Error("a composition is written with a signature")
|
||||||
|
}
|
||||||
|
refused := Composed{Node: "ace", Module: "netcheck", FP: fp, Refused: "the setting domain has no value"}
|
||||||
|
if raw, err = EncodeComposed(refused); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c, err := DecodeComposed(raw); err != nil || c.Refused != refused.Refused {
|
||||||
|
t.Fatalf("a refused composition did not round-trip: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ComposedDigestOf(refused); err == nil {
|
||||||
|
t.Error("a refused composition has a digest")
|
||||||
|
}
|
||||||
|
refused.Data = true
|
||||||
|
if _, err := EncodeComposed(refused); err == nil {
|
||||||
|
t.Error("a refused composition holding data was encoded")
|
||||||
|
}
|
||||||
|
if _, err := EncodeComposed(Composed{Node: "ace", Resources: []json.RawMessage{}, Refused: "x"}); err == nil {
|
||||||
|
t.Error("the node's own composition carried a refusal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A kept import form is set only with imports, and in the fingerprint's form (ADR 0593 §4).
|
||||||
|
func TestAKeptImportFormNeedsImports(t *testing.T) {
|
||||||
|
form := SourcePrefix2 + strings.Repeat("b", 64)
|
||||||
|
in := KeptInputs2{Tree: "t", Imports: []string{"git.novox.be/novox/mesh-sdk/go"}, ImportForm: form}
|
||||||
|
m := Module{FP2: form, Manifest: strings.Repeat("c", 64), Inputs2: &in, Repository: "novox/x", Commit: "c"}
|
||||||
|
if err := m.check(); err != nil && strings.Contains(err.Error(), "import form") {
|
||||||
|
t.Fatalf("a good import form refused: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []KeptInputs2{{Tree: "t", ImportForm: form}, {Tree: "t", Imports: in.Imports, ImportForm: "src2:short"}} {
|
||||||
|
m.Inputs2 = &bad
|
||||||
|
if err := m.check(); err == nil || !strings.Contains(err.Error(), "import form") {
|
||||||
|
t.Errorf("%+v: %v", bad, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -369,6 +369,31 @@ type Have struct {
|
|||||||
// slot that holds it, so a provider and a consumer of one value on one node each keep their own. Written in Sealed's
|
// slot that holds it, so a provider and a consumer of one value on one node each keep their own. Written in Sealed's
|
||||||
// place by a node-engine that knows it; Sealed is read only from a have written before.
|
// place by a node-engine that knows it; Sealed is read only from a have written before.
|
||||||
SealedAt Seals `json:"sealed-at,omitempty"`
|
SealedAt Seals `json:"sealed-at,omitempty"`
|
||||||
|
// PullWait is, on a failed have, the pull from a store its apply failed on and waits on that store's holder to settle
|
||||||
|
// for (design 51 *What a pull meets in the swap*, issue 1966 ENG-9): nil on every other have, and on a failure that
|
||||||
|
// waits on no holder.
|
||||||
|
PullWait *PullWait `json:"pull-wait,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullWait is the pull a failed apply met from a store: the image, the seat it came through and that seat's holder, whose
|
||||||
|
// settled the apply is tried again on, when the pull began, and when the apply was last tried again with what triggered
|
||||||
|
// it, in words (empty while it has not been).
|
||||||
|
type PullWait struct {
|
||||||
|
Image string `json:"image"`
|
||||||
|
Seat string `json:"seat,omitempty"`
|
||||||
|
Holder string `json:"holder"`
|
||||||
|
Began time.Time `json:"began"`
|
||||||
|
Retried string `json:"retried,omitempty"`
|
||||||
|
RetriedAt time.Time `json:"retried-at,omitzero"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// samePullWait says whether two pull waits are the same, their times compared as instants.
|
||||||
|
func samePullWait(a, b *PullWait) bool {
|
||||||
|
if a == nil || b == nil {
|
||||||
|
return a == b
|
||||||
|
}
|
||||||
|
return a.Image == b.Image && a.Seat == b.Seat && a.Holder == b.Holder && a.Began.Equal(b.Began) &&
|
||||||
|
a.Retried == b.Retried && a.RetriedAt.Equal(b.RetriedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied
|
// UserListWritten is the bus's user list as the node-engine of the bus node wrote it to the bus module's file, applied
|
||||||
@@ -488,6 +513,9 @@ func (h Have) check() error {
|
|||||||
if h.Judgement != "" || h.Waits != "" || h.BuildAlone {
|
if h.Judgement != "" || h.Waits != "" || h.BuildAlone {
|
||||||
return fmt.Errorf("delivery: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone")
|
return fmt.Errorf("delivery: only a have waiting for a yes names a judgement, the composition waiting and whether it changes the build alone")
|
||||||
}
|
}
|
||||||
|
if h.PullWait != nil && h.Phase != PhaseFailed {
|
||||||
|
return fmt.Errorf("delivery: only a failed have names the pull it waits on, not one %s", h.Phase)
|
||||||
|
}
|
||||||
case PhaseWaitingYes:
|
case PhaseWaitingYes:
|
||||||
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
|
if h.Judgement != JudgedRecreates && h.Judgement != JudgedMovesData && h.Judgement != JudgedCannot {
|
||||||
return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
|
return fmt.Errorf("delivery: a have waiting for a yes names the judgement %q, not recreates, moves-data or cannot-judge", h.Judgement)
|
||||||
@@ -498,6 +526,9 @@ func (h Have) check() error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
|
return fmt.Errorf("delivery: phase %q is not backing-up, applying, checking, running, failed, removed, held or waiting-yes", h.Phase)
|
||||||
}
|
}
|
||||||
|
if w := h.PullWait; w != nil && (w.Image == "" || w.Holder == "") {
|
||||||
|
return fmt.Errorf("delivery: a have's pull wait names no image or no holder")
|
||||||
|
}
|
||||||
if h.Composed != "" {
|
if h.Composed != "" {
|
||||||
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
if err := checkDigest(h.Composed, "the have's composition digest"); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -601,6 +632,10 @@ func DecodePulse(data []byte) (Pulse, error) {
|
|||||||
func SameHave(a, b Have) bool {
|
func SameHave(a, b Have) bool {
|
||||||
ua, ub := a.UserList, b.UserList
|
ua, ub := a.UserList, b.UserList
|
||||||
a.UserList, b.UserList = nil, nil
|
a.UserList, b.UserList = nil, nil
|
||||||
|
if !samePullWait(a.PullWait, b.PullWait) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.PullWait, b.PullWait = nil, nil
|
||||||
if !slices.Equal(a.Sealed, b.Sealed) || !maps.Equal(a.SealedAt, b.SealedAt) {
|
if !slices.Equal(a.Sealed, b.Sealed) || !maps.Equal(a.SealedAt, b.SealedAt) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,6 +452,35 @@ func TestSame(t *testing.T) {
|
|||||||
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) {
|
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) {
|
||||||
t.Errorf("sealed values do not travel: %+v, %v", back, err)
|
t.Errorf("sealed values do not travel: %+v, %v", back, err)
|
||||||
}
|
}
|
||||||
|
// A failed have carries the pull it waits on, compared by what it says (issue 1966 ENG-9); only a failed one does.
|
||||||
|
began := time.Date(2026, 11, 4, 14, 0, 0, 0, time.UTC)
|
||||||
|
pa, pb := a, a
|
||||||
|
pa.Phase, pb.Phase = PhaseFailed, PhaseFailed
|
||||||
|
pa.PullWait = &PullWait{Image: "reg/x@sha256:1", Seat: "container-image-registry", Holder: "distribution", Began: began,
|
||||||
|
Retried: "distribution settled", RetriedAt: began.Add(time.Minute)}
|
||||||
|
w := *pa.PullWait
|
||||||
|
w.Began, w.RetriedAt = began.In(time.Local), w.RetriedAt.In(time.Local)
|
||||||
|
pb.PullWait = &w
|
||||||
|
if !SameHave(pa, pb) || SameHave(pa, Have{V: 1, Build: "b", Phase: PhaseFailed, Health: HealthOK}) {
|
||||||
|
t.Error("equal pull waits differ, or one said and one not are the same")
|
||||||
|
}
|
||||||
|
if raw, err := EncodeHave(pa); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, pa) {
|
||||||
|
t.Errorf("a pull wait does not travel: %+v, %v", back, err)
|
||||||
|
}
|
||||||
|
pb.PullWait = &PullWait{Image: "reg/x@sha256:1", Holder: "distribution", Began: began}
|
||||||
|
if SameHave(pa, pb) {
|
||||||
|
t.Error("pull waits retried and not are the same")
|
||||||
|
}
|
||||||
|
for name, h := range map[string]Have{
|
||||||
|
"on a running have": {Phase: PhaseRunning, Health: HealthOK, PullWait: pa.PullWait},
|
||||||
|
"naming no holder": {Phase: PhaseFailed, Health: HealthOK, PullWait: &PullWait{Image: "reg/x@sha256:1"}},
|
||||||
|
} {
|
||||||
|
if _, err := EncodeHave(h); err == nil {
|
||||||
|
t.Errorf("a pull wait %s was encoded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The user list is compared by what it says, never by where it is held (ADR 0547 §8).
|
// The user list is compared by what it says, never by where it is held (ADR 0547 §8).
|
||||||
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
|
||||||
ua, ub := a, a
|
ua, ub := a, a
|
||||||
|
|||||||
@@ -303,30 +303,6 @@ func (a RedeemAnswer) SignedBytes() []byte {
|
|||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
||||||
// **The vault's enrolment verbs** (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone.
|
// The vault's enrolment verbs (ADR 0561 §5, ADR 0587 §3, §5), asked by mesh-enrolment alone, and their answers are the
|
||||||
const (
|
// vault's contract's (mesh-contracts meshvault: VerbEnrolmentToken, EnrolmentTokenAnswer, VerbNodeAccount,
|
||||||
EnrolmentTokenVerb = "enrolment-token"
|
// NodeAccountAnswer; novox/hq ADR 0627 §1).
|
||||||
NodeAccountVerb = "node-account"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EnrolmentTokenAnswer is what enrolment-token answers: what the joining machine must trust (the bus's address, its
|
|
||||||
// certificate's fingerprint, the vault's public signing key in base64), never the token; Sealed holds the token sealed
|
|
||||||
// to the operator key when asked with sealed, relayed by tokens as it is; Dropped says a drop left neither the token
|
|
||||||
// nor its hash.
|
|
||||||
type EnrolmentTokenAnswer struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Broker string `json:"broker,omitempty"`
|
|
||||||
Fingerprint string `json:"fingerprint,omitempty"`
|
|
||||||
Signer string `json:"signer,omitempty"`
|
|
||||||
Sealed string `json:"sealed,omitempty"`
|
|
||||||
Dropped bool `json:"dropped,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NodeAccountAnswer is what node-account answers: the login minted, the account sealed to the sealing key the node
|
|
||||||
// presented (base64), never in the clear; Retired says a retire left no account.
|
|
||||||
type NodeAccountAnswer struct {
|
|
||||||
Node string `json:"node"`
|
|
||||||
Login string `json:"login,omitempty"`
|
|
||||||
Account string `json:"account,omitempty"`
|
|
||||||
Retired bool `json:"retired,omitempty"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ func TestForgeMergedRefusesBadInputs(t *testing.T) {
|
|||||||
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
|
// ADR 0565 §2 (issue 1953 D1): the predefined list is one JSON object, said present or absent, never both.
|
||||||
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
|
"predefined not an object": {func(f *Forge) { f.Predefined = []byte(`["domain"]`) }, "one JSON object"},
|
||||||
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
|
"predefined both": {func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true }, "says the merge holds none"},
|
||||||
|
// A deleted branch names its branch and carries nothing else (design 51 *Tagged versions*).
|
||||||
|
"branch-deleted with no branch": {func(f *Forge) { *f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk"} }, "needs the branch"},
|
||||||
|
"branch-deleted with a head": {func(f *Forge) {
|
||||||
|
*f = Forge{Kind: ForgeBranchDeleted, Repository: "novox/mesh-sdk", Branch: "feat/x", Head: "aaaa"}
|
||||||
|
}, "carries nothing else"},
|
||||||
} {
|
} {
|
||||||
f := mergedWithInputs()
|
f := mergedWithInputs()
|
||||||
tc.mut(&f)
|
tc.mut(&f)
|
||||||
|
|||||||
+20
-15
@@ -1,6 +1,7 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -10,8 +11,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected
|
||||||
@@ -131,7 +130,7 @@ func ParseAskKey(key string) (kind, group, hash string, err error) {
|
|||||||
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string.
|
||||||
func AnswerKey(askKey string) string { return askKey }
|
func AnswerKey(askKey string) string { return askKey }
|
||||||
|
|
||||||
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID),
|
// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (its usable id),
|
||||||
// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and
|
// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and
|
||||||
// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back:
|
// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back:
|
||||||
// the teller keeps the key beside it.
|
// the teller keeps the key beside it.
|
||||||
@@ -329,12 +328,15 @@ func (m Merge) check() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it
|
||||||
// unchanged, and the group and heads it was composed for.
|
// unchanged, and the group and heads it was composed for. The ask is the operator-channel seat's (mesh-contracts
|
||||||
|
// operatorchannel.Ask), carried here as its JSON: mesh-sdk names no seat defined whole (novox/hq ADR 0627 §4), so its
|
||||||
|
// id (AskingID of its key and asking), its options and its digest are checked by the gate and the teller through
|
||||||
|
// operator-channel's client.
|
||||||
type Ask struct {
|
type Ask struct {
|
||||||
V int `json:"v"`
|
V int `json:"v"`
|
||||||
Group string `json:"group"`
|
Group string `json:"group"`
|
||||||
Heads []Head `json:"heads"`
|
Heads []Head `json:"heads"`
|
||||||
Ask asks.Ask `json:"ask"`
|
Ask json.RawMessage `json:"ask"`
|
||||||
// Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th.
|
// Asking counts the times the gate wrote this group's ask at these heads: 0 or 1 for the first, n for the n-th.
|
||||||
// The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id
|
// The router drops an ask whose id it has already kept as a redelivery, so each asking has its own id
|
||||||
// (AskingID); the first asking's id is unchanged and the field is omitted for it.
|
// (AskingID); the first asking's id is unchanged and the field is omitted for it.
|
||||||
@@ -345,15 +347,18 @@ func (a Ask) check() error {
|
|||||||
if a.Asking < 0 {
|
if a.Asking < 0 {
|
||||||
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
|
return fmt.Errorf("delivery: the ask's asking %d is negative", a.Asking)
|
||||||
}
|
}
|
||||||
key, err := MergeAskKey(a.Group, a.Heads)
|
if _, err := MergeAskKey(a.Group, a.Heads); err != nil {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err)
|
||||||
}
|
}
|
||||||
if want := AskingID(key, a.Asking); a.Ask.ID != want {
|
return routerAsk(a.Ask)
|
||||||
return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group, heads and asking %d give", a.Ask.ID, want, a.Asking)
|
}
|
||||||
}
|
|
||||||
if len(a.Ask.Options) == 0 {
|
// routerAsk refuses a value that carries no router's ask: none, null, or JSON that is no object. What the ask says
|
||||||
return fmt.Errorf("delivery: the ask has no option")
|
// is operator-channel's to check, through its client (novox/hq ADR 0627 §4).
|
||||||
|
func routerAsk(raw json.RawMessage) error {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if len(bytes.TrimSpace(raw)) == 0 || json.Unmarshal(raw, &fields) != nil || fields == nil {
|
||||||
|
return fmt.Errorf("delivery: the value carries no router's ask")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-29
@@ -3,12 +3,11 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func gateHeads() []Head {
|
func gateHeads() []Head {
|
||||||
@@ -66,7 +65,7 @@ func TestAskID(t *testing.T) { // 1201/AC2
|
|||||||
if strings.Contains(id, ".") {
|
if strings.Contains(id, ".") {
|
||||||
t.Errorf("id %q holds a dot", id)
|
t.Errorf("id %q holds a dot", id)
|
||||||
}
|
}
|
||||||
if !asks.UsableID(id) {
|
if !routerUsableID(id) {
|
||||||
t.Errorf("id %q is not usable by the router", id)
|
t.Errorf("id %q is not usable by the router", id)
|
||||||
}
|
}
|
||||||
if other, dup := seen[id]; dup {
|
if other, dup := seen[id]; dup {
|
||||||
@@ -83,7 +82,7 @@ func TestAskID(t *testing.T) { // 1201/AC2
|
|||||||
if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 {
|
if g != SettleGroup([]string{"novox/a@111111111111", "novox/b@222222222222"}) || !strings.HasPrefix(g, SettleGroupPrefix) || len(g) != len(SettleGroupPrefix)+12 {
|
||||||
t.Errorf("SettleGroup = %q", g)
|
t.Errorf("SettleGroup = %q", g)
|
||||||
}
|
}
|
||||||
if k, err := MergeAskKey(g, gateHeads()); err != nil || !asks.UsableID(AskID(k)) {
|
if k, err := MergeAskKey(g, gateHeads()); err != nil || !routerUsableID(AskID(k)) {
|
||||||
t.Errorf("the settle ask's key %q: %v", k, err)
|
t.Errorf("the settle ask's key %q: %v", k, err)
|
||||||
}
|
}
|
||||||
for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge",
|
for group, want := range map[string]string{g: "mesh-delivery.settle", "feat/x": "mesh-delivery.merge",
|
||||||
@@ -149,13 +148,22 @@ func sampleTurn() Turn {
|
|||||||
|
|
||||||
func sampleAsk() Ask {
|
func sampleAsk() Ask {
|
||||||
key, _ := MergeAskKey("feat/x", gateHeads())
|
key, _ := MergeAskKey("feat/x", gateHeads())
|
||||||
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: asks.Ask{
|
return Ask{Group: "feat/x", Heads: gateHeads(), Ask: routerAskJSON(AskID(key), "Merge into main: group feat/x")}
|
||||||
ID: AskID(key), Headline: "Merge into main: group feat/x", Explanation: "The group passed its checks.",
|
|
||||||
Who: asks.Operator, Expires: time.Date(2026, 10, 20, 8, 0, 0, 0, time.UTC), OnExpiry: "nothing is merged",
|
|
||||||
Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}},
|
|
||||||
}}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// routerAskJSON is a router's ask as the gate carries it: the operator-channel seat's JSON, which mesh-sdk does not
|
||||||
|
// type (novox/hq ADR 0627 §4).
|
||||||
|
func routerAskJSON(id, headline string) json.RawMessage {
|
||||||
|
raw, _ := json.Marshal(map[string]any{"id": id, "headline": headline, "explanation": "The group passed its checks.",
|
||||||
|
"who": "operator", "expires": "2026-10-20T08:00:00Z", "on-expiry": "nothing is merged",
|
||||||
|
"options": []map[string]string{{"id": "approve", "label": "Approve", "does": "merges", "level": "approve", "binds": "sha256:00"}}})
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
// routerUsableID is the router's rule for an ask's id (operator-channel's client, UsableID), held here to check that
|
||||||
|
// AskID and AskingID give ids the router takes.
|
||||||
|
var routerUsableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`).MatchString
|
||||||
|
|
||||||
func sampleOwed() []Owed {
|
func sampleOwed() []Owed {
|
||||||
return []Owed{
|
return []Owed{
|
||||||
{Act: OwedStatus, Context: "mesh/merge-gate", State: "success", Target: "https://mesh.example/delivery/36"},
|
{Act: OwedStatus, Context: "mesh/merge-gate", State: "success", Target: "https://mesh.example/delivery/36"},
|
||||||
@@ -271,13 +279,11 @@ func TestGateValuesRefused(t *testing.T) { // 1201/AC5
|
|||||||
wantErr("merge without its ask", err, "ask")
|
wantErr("merge without its ask", err, "ask")
|
||||||
|
|
||||||
a := sampleAsk()
|
a := sampleAsk()
|
||||||
a.Ask.ID = "something-else"
|
for _, raw := range []string{"", "null", "[]", `"ask"`} {
|
||||||
_, err = EncodeAsk(a)
|
a.Ask = json.RawMessage(raw)
|
||||||
wantErr("ask with another id", err, "something-else", AskID("merge.feat/x."+mustHash(gateHeads())))
|
_, err = EncodeAsk(a)
|
||||||
a = sampleAsk()
|
wantErr("a value with no router's ask "+raw, err, "no router's ask")
|
||||||
a.Ask.Options = nil
|
}
|
||||||
_, err = EncodeAsk(a)
|
|
||||||
wantErr("ask without options", err, "option")
|
|
||||||
a = sampleAsk()
|
a = sampleAsk()
|
||||||
a.Heads = nil
|
a.Heads = nil
|
||||||
_, err = EncodeAsk(a)
|
_, err = EncodeAsk(a)
|
||||||
@@ -343,7 +349,7 @@ func TestGateSame(t *testing.T) { // 1201/AC6
|
|||||||
t.Error("different merges are the same")
|
t.Error("different merges are the same")
|
||||||
}
|
}
|
||||||
a := sampleAsk()
|
a := sampleAsk()
|
||||||
a.Ask.Headline = "other"
|
a.Ask = routerAskJSON(AskID("merge.feat/x."+mustHash(gateHeads())), "other")
|
||||||
if SameAsk(a, sampleAsk()) {
|
if SameAsk(a, sampleAsk()) {
|
||||||
t.Error("different asks are the same")
|
t.Error("different asks are the same")
|
||||||
}
|
}
|
||||||
@@ -414,7 +420,7 @@ func TestKeysEscapeAndRoundTrip(t *testing.T) { // 1323/AC1
|
|||||||
t.Error("answer key differs from the ask key")
|
t.Error("answer key differs from the ask key")
|
||||||
}
|
}
|
||||||
// the ask and the answer of such a group encode and decode
|
// the ask and the answer of such a group encode and decode
|
||||||
ask := Ask{Group: name, Heads: gateHeads(), Ask: asks.Ask{ID: AskID(ak), Options: []asks.Option{{ID: "ok"}}}}
|
ask := Ask{Group: name, Heads: gateHeads(), Ask: routerAskJSON(AskID(ak), "ok")}
|
||||||
if data, err := EncodeAsk(ask); err != nil {
|
if data, err := EncodeAsk(ask); err != nil {
|
||||||
t.Errorf("encode ask for %q: %v", name, err)
|
t.Errorf("encode ask for %q: %v", name, err)
|
||||||
} else if _, err := DecodeAsk(data); err != nil {
|
} else if _, err := DecodeAsk(data); err != nil {
|
||||||
@@ -600,7 +606,7 @@ func TestAskingID(t *testing.T) { // 1370/AC1
|
|||||||
t.Errorf("askings %d and %d give the id %q", n, other, id)
|
t.Errorf("askings %d and %d give the id %q", n, other, id)
|
||||||
}
|
}
|
||||||
seen[id] = n
|
seen[id] = n
|
||||||
if len(id) > 64 || !asks.UsableID(id) {
|
if len(id) > 64 || !routerUsableID(id) {
|
||||||
t.Errorf("id %q of asking %d is not usable by the router", id, n)
|
t.Errorf("id %q of asking %d is not usable by the router", id, n)
|
||||||
}
|
}
|
||||||
if id != AskID(key+"."+strconv.Itoa(n)) {
|
if id != AskID(key+"."+strconv.Itoa(n)) {
|
||||||
@@ -621,7 +627,7 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2
|
|||||||
t.Fatalf("asking 1 under the first id: %v", err)
|
t.Fatalf("asking 1 under the first id: %v", err)
|
||||||
}
|
}
|
||||||
a.Asking = 3
|
a.Asking = 3
|
||||||
a.Ask.ID = AskingID(key, 3)
|
a.Ask = routerAskJSON(AskingID(key, 3), "Merge into main: group feat/x")
|
||||||
data, err := EncodeAsk(a)
|
data, err := EncodeAsk(a)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("asking 3 under its id: %v", err)
|
t.Fatalf("asking 3 under its id: %v", err)
|
||||||
@@ -630,15 +636,6 @@ func TestAskChecksItsAsking(t *testing.T) { // 1370/AC2
|
|||||||
if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 {
|
if got, err := DecodeAsk(data); err != nil || !SameAsk(got, a) || got.Asking != 3 {
|
||||||
t.Errorf("asking round trip: %+v %v", got, err)
|
t.Errorf("asking round trip: %+v %v", got, err)
|
||||||
}
|
}
|
||||||
a.Ask.ID = AskingID(key, 2)
|
|
||||||
if _, err := EncodeAsk(a); err == nil {
|
|
||||||
t.Error("asking 3 under asking 2's id was accepted")
|
|
||||||
}
|
|
||||||
a.Asking = 2
|
|
||||||
a.Ask.ID = AskID(key)
|
|
||||||
if _, err := EncodeAsk(a); err == nil {
|
|
||||||
t.Error("asking 2 under the first asking's id was accepted")
|
|
||||||
}
|
|
||||||
a.Asking = -1
|
a.Asking = -1
|
||||||
if _, err := EncodeAsk(a); err == nil {
|
if _, err := EncodeAsk(a); err == nil {
|
||||||
t.Error("a negative asking was accepted")
|
t.Error("a negative asking was accepted")
|
||||||
|
|||||||
@@ -132,6 +132,10 @@ const (
|
|||||||
BuildLogRead = BuilderModule + "." + BuilderLog
|
BuildLogRead = BuilderModule + "." + BuilderLog
|
||||||
BuildStartedEvent = "build.started"
|
BuildStartedEvent = "build.started"
|
||||||
BuildEndedEvent = "build.ended"
|
BuildEndedEvent = "build.ended"
|
||||||
|
// BuildSaidEvent is a build's narration between its start and its end (design 51: a build narrates its publish,
|
||||||
|
// asked, waiting on a seat, stored, refused, at least every five minutes while it waits): its module, fingerprint,
|
||||||
|
// line and why.
|
||||||
|
BuildSaidEvent = "build.said"
|
||||||
// BuilderLogDays is how long the log keeps its entries.
|
// BuilderLogDays is how long the log keeps its entries.
|
||||||
BuilderLogDays = 30
|
BuilderLogDays = 30
|
||||||
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
|
// BuildsSinceTool is the builder's tool answering every ended build since a time, in order (clause 9): its
|
||||||
@@ -175,6 +179,13 @@ type BuildLogEntry struct {
|
|||||||
// sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer
|
// sorted and each once, as the build module answered them (BuildAnswer.Imports; novox/hq ADR 0593 §2): the indexer
|
||||||
// keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's.
|
// keeps them on the module (KeptInputs2.Imports) when the build's fingerprint is the module's.
|
||||||
Imports []string `json:"imports,omitempty"`
|
Imports []string `json:"imports,omitempty"`
|
||||||
|
// OutOfStep are, at a build's end, the generated files its test step found out of step with what their generator
|
||||||
|
// writes, as the build module answered them (its test step's `out-of-step`): the indexer raises a finding naming them
|
||||||
|
// for a trunk build, and only a clean build of the same module clears it. A start or a narration names none.
|
||||||
|
OutOfStep []string `json:"out-of-step,omitempty"`
|
||||||
|
// Line is a narration's words, Why what it narrates (asked, waiting, stored, refused): a build.said entry's alone.
|
||||||
|
Line string `json:"line,omitempty"`
|
||||||
|
Why string `json:"why,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
|
// BuildsSince is the answer of BuildsSinceTool: every ended build since a time, oldest first, and whether the log held
|
||||||
@@ -214,14 +225,25 @@ func EndedSince(entries []BuildLogEntry, since time.Time) []BuildLogEntry {
|
|||||||
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
|
func EncodeBuildLogEntry(e BuildLogEntry) ([]byte, error) {
|
||||||
e.V = Version
|
e.V = Version
|
||||||
switch {
|
switch {
|
||||||
|
case e.Event == BuildSaidEvent:
|
||||||
|
switch {
|
||||||
|
case e.Module == "" || e.FP == "" || e.At.IsZero() || e.Line == "":
|
||||||
|
return nil, fmt.Errorf("delivery: a build's narration names its module, fingerprint, line and time")
|
||||||
|
case e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 || len(e.OutOfStep) > 0:
|
||||||
|
return nil, fmt.Errorf("delivery: a build's narration names no outcome, manifest, base, artifact, import or file out of step")
|
||||||
|
}
|
||||||
|
return json.Marshal(e)
|
||||||
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
|
case e.Event != BuildStartedEvent && e.Event != BuildEndedEvent:
|
||||||
return nil, fmt.Errorf("delivery: a build log entry is %s or %s, not %q", BuildStartedEvent, BuildEndedEvent, e.Event)
|
return nil, fmt.Errorf("delivery: a build log entry is %s, %s or %s, not %q", BuildStartedEvent, BuildSaidEvent, BuildEndedEvent, e.Event)
|
||||||
|
case e.Line != "" || e.Why != "":
|
||||||
|
return nil, fmt.Errorf("delivery: a build's start or end carries no narration's line")
|
||||||
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
|
case e.Module == "" || e.FP == "" || e.Node == "" || e.At.IsZero() || e.Repository == "" || e.Commit == "":
|
||||||
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
|
return nil, fmt.Errorf("delivery: a build log entry names its module, fingerprint, repository, commit, node and time")
|
||||||
case e.Event == BuildEndedEvent && e.Outcome == "":
|
case e.Event == BuildEndedEvent && e.Outcome == "":
|
||||||
return nil, fmt.Errorf("delivery: a build's end names its outcome")
|
return nil, fmt.Errorf("delivery: a build's end names its outcome")
|
||||||
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0):
|
case e.Event == BuildStartedEvent && (e.Outcome != "" || len(e.Made) > 0 || e.Manifest != "" || len(e.Against) > 0 || len(e.Imports) > 0 ||
|
||||||
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact or import")
|
len(e.OutOfStep) > 0):
|
||||||
|
return nil, fmt.Errorf("delivery: a build's start names no outcome, manifest, base, artifact, import or file out of step")
|
||||||
}
|
}
|
||||||
if err := CheckImports(e.Imports); err != nil {
|
if err := CheckImports(e.Imports); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -56,18 +56,52 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The indexer's series value (design 51 *The versions*), as the indexer writes it: read back whole, and refused
|
||||||
|
// where a reader could not take it.
|
||||||
|
written := `{"v":1,"name":"` + sdk + `","package":"mesh-sdk/go","series":"0.1","seat":"go-package-registry","go-module":"` + sdk +
|
||||||
|
`","newest":{"version":"0.1.15","commit":"` + commit + `","digest":"sha256:0f","fingerprint":"fp1","contents":` + string(raw) +
|
||||||
|
`},"numbered":{"version":"0.1.16","commit":"` + commit + `"},"at":"2026-11-04T14:00:00Z"}`
|
||||||
|
series, err := DecodeSeriesPackage([]byte(written))
|
||||||
|
if err != nil || series.Name != sdk || series.Package != "mesh-sdk/go" || series.Newest.Contents == nil || series.Numbered.Version != "0.1.16" {
|
||||||
|
t.Fatalf("a series value as the indexer writes it reads %+v, %v", series, err)
|
||||||
|
}
|
||||||
|
if again, err := EncodeSeriesPackage(series); err != nil || string(again) != written {
|
||||||
|
t.Fatalf("a series value writes back as\n%s, %v, not\n%s", again, err, written)
|
||||||
|
}
|
||||||
|
if key := SeriesPackageKey(sdk, "0.1"); key != "git=2enovox=2ebe/novox/mesh-sdk/go.0.1" || !IsSeriesPackageKey(key) || IsSeriesPackageKey(EscapeName(sdk)) {
|
||||||
|
t.Fatalf("the series key %q is not told apart from a per-module key", key)
|
||||||
|
}
|
||||||
|
for name, mutate := range map[string]func(s *SeriesPackage){
|
||||||
|
"a series with no name": func(s *SeriesPackage) { s.Name = "" },
|
||||||
|
"a series that is no series": func(s *SeriesPackage) { s.Series = "0.1.15" },
|
||||||
|
"built from no artifact": func(s *SeriesPackage) { s.Package = "mesh-sdk" },
|
||||||
|
"a numbered version of nothing": func(s *SeriesPackage) { s.Numbered = &SeriesVersion{} },
|
||||||
|
} {
|
||||||
|
s := series
|
||||||
|
mutate(&s)
|
||||||
|
if _, err := EncodeSeriesPackage(s); err == nil {
|
||||||
|
t.Errorf("%s: encoded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
imports := []string{sdk, sdk + "/announce"}
|
imports := []string{sdk, sdk + "/announce"}
|
||||||
|
outOfStep := []string{"go/seats/meshbroker/contract.go", "ts/seats/meshbroker/src/index.ts"}
|
||||||
end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit,
|
end := BuildLogEntry{Event: BuildEndedEvent, Module: "netcheck", FP: "src2:x", Repository: "novox/mesh-catalog", Commit: commit,
|
||||||
Node: "ace", At: good.At, Outcome: "done", Imports: imports}
|
Node: "ace", At: good.At, Outcome: "done", Imports: imports, OutOfStep: outOfStep}
|
||||||
raw, err = EncodeBuildLogEntry(end)
|
raw, err = EncodeBuildLogEntry(end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) {
|
if e, err := DecodeBuildLogEntry(raw); err != nil || !reflect.DeepEqual(e.Imports, imports) || !reflect.DeepEqual(e.OutOfStep, outOfStep) ||
|
||||||
t.Fatalf("build end round trip: %v, %v", err, e.Imports)
|
!strings.Contains(string(raw), `"out-of-step":["go/seats/meshbroker/contract.go",`) {
|
||||||
|
t.Fatalf("build end round trip: %v, %v, %v in %s", err, e.Imports, e.OutOfStep, raw)
|
||||||
}
|
}
|
||||||
start := end
|
start := end
|
||||||
start.Event, start.Outcome = BuildStartedEvent, ""
|
start.Event, start.Outcome, start.Imports = BuildStartedEvent, "", nil
|
||||||
|
if _, err := EncodeBuildLogEntry(start); err == nil {
|
||||||
|
t.Error("a build's start with files out of step encoded")
|
||||||
|
}
|
||||||
|
start.Imports, start.OutOfStep = imports, nil
|
||||||
if _, err := EncodeBuildLogEntry(start); err == nil {
|
if _, err := EncodeBuildLogEntry(start); err == nil {
|
||||||
t.Error("a build's start with imports encoded")
|
t.Error("a build's start with imports encoded")
|
||||||
}
|
}
|
||||||
@@ -75,9 +109,45 @@ func TestPublishedPackagesAndImportsRoundTripAndRefuse(t *testing.T) {
|
|||||||
if _, err := EncodeBuildLogEntry(end); err == nil {
|
if _, err := EncodeBuildLogEntry(end); err == nil {
|
||||||
t.Error("unsorted imports encoded")
|
t.Error("unsorted imports encoded")
|
||||||
}
|
}
|
||||||
|
// A build narrates between its start and its end (build.said): its module, fingerprint, line and why, nothing of an
|
||||||
|
// end; a start or end carries no line.
|
||||||
|
said := BuildLogEntry{Event: BuildSaidEvent, Module: "netcheck", FP: "src2:x", At: good.At,
|
||||||
|
Line: "waiting on container-image-registry", Why: "waiting"}
|
||||||
|
if raw, err := EncodeBuildLogEntry(said); err != nil {
|
||||||
|
t.Errorf("a narration refused: %v", err)
|
||||||
|
} else if e, err := DecodeBuildLogEntry(raw); err != nil || e.Line != said.Line || e.Why != "waiting" {
|
||||||
|
t.Errorf("a narration's round trip: %+v, %v", e, err)
|
||||||
|
}
|
||||||
|
for name, mutate := range map[string]func(e *BuildLogEntry){
|
||||||
|
"a narration with no line": func(e *BuildLogEntry) { e.Line = "" },
|
||||||
|
"a narration with an outcome": func(e *BuildLogEntry) { e.Outcome = "done" },
|
||||||
|
"a narration out of step": func(e *BuildLogEntry) { e.OutOfStep = outOfStep },
|
||||||
|
"an end with a line": func(e *BuildLogEntry) {
|
||||||
|
e.Event, e.Outcome, e.Node, e.Repository, e.Commit = BuildEndedEvent, "done", "ace", "novox/mesh-catalog", commit
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
e := said
|
||||||
|
mutate(&e)
|
||||||
|
if _, err := EncodeBuildLogEntry(e); err == nil {
|
||||||
|
t.Errorf("%s encoded", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
var answer BuildAnswer
|
var answer BuildAnswer
|
||||||
if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"]}`), &answer); err != nil || len(answer.Imports) != 1 {
|
if err := json.Unmarshal([]byte(`{"artifacts":[],"imports":["`+sdk+`"],"out-of-step":["go/seats/x/contract.go"]}`), &answer); err != nil ||
|
||||||
t.Fatalf("build answer imports: %v, %v", err, answer.Imports)
|
len(answer.Imports) != 1 || !reflect.DeepEqual(answer.OutOfStep, []string{"go/seats/x/contract.go"}) {
|
||||||
|
t.Fatalf("build answer imports and files out of step: %v, %v, %v", err, answer.Imports, answer.OutOfStep)
|
||||||
|
}
|
||||||
|
// The builder's version.numbered, as a build module consumes it: the builder's own body whole, and its subject.
|
||||||
|
numbered := VersionNumbered{Name: "mesh-sdk/go", Kind: "package", Version: "0.1.7", Module: "mesh-sdk", Fingerprint: "src2:x",
|
||||||
|
Commit: commit, Digest: "sha256:d", Seat: "go-package-registry", Head: commit, Again: "2026-11-04T15:00:00Z"}
|
||||||
|
if raw, err := EncodeVersionNumbered(numbered); err != nil {
|
||||||
|
t.Errorf("numbered refused: %v", err)
|
||||||
|
} else if back, err := DecodeVersionNumbered(raw); err != nil || back != numbered ||
|
||||||
|
!strings.Contains(string(raw), `"fingerprint":"src2:x"`) || VersionNumberedSubject != "mesh.mod.mesh-delivery-builder.event.version.numbered" {
|
||||||
|
t.Errorf("numbered round trip: %+v, %v, %s", back, err, raw)
|
||||||
|
}
|
||||||
|
if _, err := DecodeVersionNumbered([]byte(`{"name":"mesh-sdk/go","version":"0.1.7"}`)); err == nil {
|
||||||
|
t.Error("a numbered version naming no build is read")
|
||||||
}
|
}
|
||||||
if err := CheckImports([]string{"fmt"}); err == nil {
|
if err := CheckImports([]string{"fmt"}); err == nil {
|
||||||
t.Error("CheckImports takes a standard package")
|
t.Error("CheckImports takes a standard package")
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The packages state per series (design 51 *The versions*, design 32; ADR 0606 §1, ADR 0609 §1): beside the per-module
|
||||||
|
// values (PublishedPackages), the indexer keeps one key per package and series, `<name>.<series>` (SeriesPackageKey),
|
||||||
|
// holding the newest trunk version of that series the builder numbered and the newest a registry seat published. The
|
||||||
|
// indexer alone writes it; its readers (the builder, the checker, the build modules) take this value's form from here.
|
||||||
|
// A series key always holds a dot, which EscapeName never leaves in a per-module key (IsSeriesPackageKey).
|
||||||
|
|
||||||
|
// SeriesPackage is the value of a series key. Name is the package's name in its own ecosystem (a Go package's module
|
||||||
|
// path in its go.mod at the commit, an npm package's package.json name): the key is named by it, and a user's
|
||||||
|
// `packages` names it. Package is the module and artifact it is built from, `<module>/<artifact>`, by which the builder
|
||||||
|
// finds the name to number under; Series its `<major>.<minor>`. Seat is the registry seat it is published through;
|
||||||
|
// GoModule the Go module path a Go package is fetched under, empty for any other. Newest is the newest version of the
|
||||||
|
// series a seat published (what a user accepting the series builds against), Numbered the newest the builder numbered,
|
||||||
|
// published or not yet.
|
||||||
|
type SeriesPackage struct {
|
||||||
|
V int `json:"v"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Package string `json:"package,omitempty"`
|
||||||
|
Series string `json:"series"`
|
||||||
|
Seat string `json:"seat,omitempty"`
|
||||||
|
GoModule string `json:"go-module,omitempty"`
|
||||||
|
Newest *SeriesVersion `json:"newest,omitempty"`
|
||||||
|
Numbered *SeriesVersion `json:"numbered,omitempty"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesVersion is one version of a series: its number, the source commit it was built from, the digest and
|
||||||
|
// fingerprint. Contents are, for a series' newest of a Go package, its Go module's requirements' and packages' digests
|
||||||
|
// at Commit: what a user accepting the series is fingerprinted against; nil when the forge could not list it, and on a
|
||||||
|
// numbered version.
|
||||||
|
type SeriesVersion struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Commit string `json:"commit,omitempty"`
|
||||||
|
Digest string `json:"digest,omitempty"`
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
Contents *PublishedPackages `json:"contents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeriesPackageKey is the key of a package's series in the packages state: its ecosystem name as a key part and the
|
||||||
|
// series.
|
||||||
|
func SeriesPackageKey(name, series string) string { return EscapeName(name) + "." + series }
|
||||||
|
|
||||||
|
// IsSeriesPackageKey says whether a key of the packages state is a series key, not a per-module one.
|
||||||
|
func IsSeriesPackageKey(key string) bool { return strings.Contains(key, ".") }
|
||||||
|
|
||||||
|
// builtFrom says whether pkg is `<module>/<artifact>`: a module name (no slash) and an artifact named after it.
|
||||||
|
func builtFrom(pkg string) bool {
|
||||||
|
module, artifact, ok := strings.Cut(pkg, "/")
|
||||||
|
return ok && module != "" && artifact != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var seriesForm = regexp.MustCompile(`^[0-9]+\.[0-9]+$`)
|
||||||
|
|
||||||
|
func (s SeriesPackage) check() error {
|
||||||
|
switch {
|
||||||
|
case s.Name == "":
|
||||||
|
return fmt.Errorf("delivery: a series of %q names no package in its ecosystem", s.Package)
|
||||||
|
case !seriesForm.MatchString(s.Series):
|
||||||
|
return fmt.Errorf("delivery: the series %q of %s is no <major>.<minor>", s.Series, s.Name)
|
||||||
|
case s.Package != "" && !builtFrom(s.Package):
|
||||||
|
return fmt.Errorf("delivery: the series %s of %s is built from %q, which is no <module>/<artifact>", s.Series, s.Name, s.Package)
|
||||||
|
}
|
||||||
|
for which, v := range map[string]*SeriesVersion{"newest": s.Newest, "numbered": s.Numbered} {
|
||||||
|
if v != nil && v.Version == "" {
|
||||||
|
return fmt.Errorf("delivery: the %s version of %s %s names no version", which, s.Name, s.Series)
|
||||||
|
}
|
||||||
|
if v != nil && v.Contents != nil {
|
||||||
|
if err := v.Contents.check(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeSeriesPackage is s as JSON at Version; it refuses what a reader could not take.
|
||||||
|
func EncodeSeriesPackage(s SeriesPackage) ([]byte, error) {
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.V = Version
|
||||||
|
return json.Marshal(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeSeriesPackage reads a series key's value; it refuses an unknown version (ErrUnknownVersion) and what
|
||||||
|
// EncodeSeriesPackage refuses.
|
||||||
|
func DecodeSeriesPackage(data []byte) (SeriesPackage, error) {
|
||||||
|
var s SeriesPackage
|
||||||
|
if err := decode(data, &s); err != nil {
|
||||||
|
return SeriesPackage{}, err
|
||||||
|
}
|
||||||
|
if err := s.check(); err != nil {
|
||||||
|
return SeriesPackage{}, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
+2
-27
@@ -39,11 +39,8 @@ const (
|
|||||||
SignersKeyKey = "key"
|
SignersKeyKey = "key"
|
||||||
SignersAuthorityKey = "authority"
|
SignersAuthorityKey = "authority"
|
||||||
|
|
||||||
// SignVerb is the vault's verb that signs.
|
// The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign,
|
||||||
SignVerb = "sign"
|
// SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1).
|
||||||
// SignedEvent is what the vault says when it signed (Signed), on SignedSubject.
|
|
||||||
SignedEvent = "signed"
|
|
||||||
SignedSubject = "mesh.mod." + VaultModule + ".event." + SignedEvent
|
|
||||||
|
|
||||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||||
KindNodeCertificate = "node-certificate"
|
KindNodeCertificate = "node-certificate"
|
||||||
@@ -200,28 +197,6 @@ func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte {
|
|||||||
return ed25519.Sign(key, append([]byte(context), msg...))
|
return ed25519.Sign(key, append([]byte(context), msg...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signed is the vault's `signed`: who asked, the kind, the node, and the sha256 digest of what was signed. Never a key.
|
|
||||||
type Signed struct {
|
|
||||||
Caller string `json:"caller"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Node string `json:"node,omitempty"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
Signer string `json:"signer"`
|
|
||||||
At time.Time `json:"at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SignAnswer is what `sign` answers: for a node certificate the certificate as PEM and the authority that signed it,
|
|
||||||
// for an enrolment the signature in base64; always the signer's fingerprint and the digest. Never a key's private half.
|
|
||||||
type SignAnswer struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Node string `json:"node,omitempty"`
|
|
||||||
Certificate string `json:"certificate,omitempty"`
|
|
||||||
Authority string `json:"authority,omitempty"`
|
|
||||||
Signature string `json:"signature,omitempty"`
|
|
||||||
Signer string `json:"signer"`
|
|
||||||
Digest string `json:"digest"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DigestOf is the digest a `signed` names: `sha256:<hex>` of the bytes signed.
|
// DigestOf is the digest a `signed` names: `sha256:<hex>` of the bytes signed.
|
||||||
func DigestOf(b []byte) string {
|
func DigestOf(b []byte) string {
|
||||||
sum := sha256.Sum256(b)
|
sum := sha256.Sum256(b)
|
||||||
|
|||||||
+14
-2
@@ -47,6 +47,10 @@ const (
|
|||||||
ForgeMerged = "merged"
|
ForgeMerged = "merged"
|
||||||
ForgeRefused = "refused"
|
ForgeRefused = "refused"
|
||||||
ForgeOpen = "open"
|
ForgeOpen = "open"
|
||||||
|
// ForgeBranchDeleted says a branch of a repository was deleted on the forge, from the forge's own delete event
|
||||||
|
// (design 51 *Tagged versions*): the builder removes the tagged versions it asked for that branch. It names the
|
||||||
|
// repository and the branch and carries nothing else. A reader that does not know the kind ignores it.
|
||||||
|
ForgeBranchDeleted = "branch-deleted"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenPull names an open pull request into the trunk: the repository as `<owner>/<name>` and the number.
|
// OpenPull names an open pull request into the trunk: the repository as `<owner>/<name>` and the number.
|
||||||
@@ -166,10 +170,10 @@ func checkRepository(repository string) error {
|
|||||||
|
|
||||||
func checkForgeKind(kind string) error {
|
func checkForgeKind(kind string) error {
|
||||||
switch kind {
|
switch kind {
|
||||||
case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen:
|
case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen, ForgeBranchDeleted:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused or open", kind)
|
return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused, open or branch-deleted", kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
||||||
@@ -704,6 +708,14 @@ func (f Forge) check() error {
|
|||||||
if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil {
|
if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
case ForgeBranchDeleted:
|
||||||
|
if f.Branch == "" {
|
||||||
|
return fmt.Errorf("delivery: a branch-deleted message needs the branch")
|
||||||
|
}
|
||||||
|
if f.Number != 0 || f.Head != "" || f.Merge != "" || len(f.Dirs) > 0 || len(f.Trees) > 0 || len(f.Manifests) > 0 ||
|
||||||
|
len(f.Locks) > 0 || len(f.Paths) > 0 || len(f.Packages) > 0 || f.Act != "" {
|
||||||
|
return fmt.Errorf("delivery: a branch-deleted message names its repository and branch and carries nothing else")
|
||||||
|
}
|
||||||
case ForgeRefused:
|
case ForgeRefused:
|
||||||
if len(f.Dirs) > 0 || f.DirsKnown {
|
if len(f.Dirs) > 0 || f.DirsKnown {
|
||||||
return fmt.Errorf("delivery: a refused message carries no module directories")
|
return fmt.Errorf("delivery: a refused message carries no module directories")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user