Compare commits
95
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a397492503 | ||
|
|
dae2aa0be1 | ||
|
|
d064c41057 | ||
|
|
393f6902b3 | ||
|
|
015304f4a3 | ||
|
|
c55672ce5e | ||
|
|
7add29cffe | ||
|
|
c1b8b9d115 | ||
|
|
8717ec76d4 | ||
|
|
e1b9f3e095 | ||
|
|
c40a79e601 | ||
|
|
b75495594a | ||
|
|
24b7740b80 | ||
|
|
aa40b107c9 | ||
|
|
d69fd304cc | ||
|
|
4a57d8c90c | ||
|
|
b8dbee28cc | ||
|
|
2bcc34a59b | ||
|
|
e282e08917 | ||
|
|
b0baa17836 | ||
|
|
ae26376a90 | ||
|
|
58f45a1b12 | ||
|
|
c1a1efa62a | ||
|
|
5b9e9d872d | ||
|
|
13e2fe14aa | ||
|
|
4bfa94dfef | ||
|
|
10852161c9 | ||
|
|
669589b76b | ||
|
|
c35cc1d9e4 | ||
|
|
3cf4fbaf3d | ||
|
|
7eb7b9fa62 | ||
|
|
ec3a65fa8d | ||
|
|
5906e18028 | ||
|
|
0871fa7cc4 | ||
|
|
cdd428a7d8 | ||
|
|
514ffc30da | ||
|
|
df44d21c82 | ||
|
|
dcbdc70919 | ||
|
|
84e50beb65 | ||
|
|
308dbb94b8 | ||
|
|
c642471c16 | ||
|
|
24cfc18383 | ||
|
|
f29b19aff2 | ||
|
|
855f4471e2 | ||
|
|
6ebad21cbe | ||
|
|
a222db8c69 | ||
|
|
66fe7c8a0d | ||
|
|
7623279efe | ||
|
|
570b66230c | ||
|
|
e5d9753368 | ||
|
|
6adaf44f65 | ||
|
|
66e89b71a2 | ||
|
|
32c5df952c | ||
|
|
4363c371c5 | ||
|
|
1b01d065ac | ||
|
|
d3ee49d6f3 | ||
|
|
ded92a8137 | ||
|
|
20614822df | ||
|
|
9740238ec9 | ||
|
|
a2313057c0 | ||
|
|
2b14f166de | ||
|
|
d8207358b0 | ||
|
|
c6ae0fc405 | ||
|
|
0128b962ab | ||
|
|
271de0108d | ||
|
|
c93feeb30f | ||
|
|
7f33c8b499 | ||
|
|
18c1735f7c | ||
|
|
2a71a8ff76 | ||
|
|
e73aae3686 | ||
|
|
3eef84d979 | ||
|
|
60358d01e9 | ||
|
|
05418b4fe5 | ||
|
|
2779f5312b | ||
|
|
e2380404a1 | ||
|
|
998d8f7370 | ||
|
|
5dc3a8e17f | ||
|
|
768f637bd0 | ||
|
|
ee0f46b78f | ||
|
|
84a14b08d5 | ||
|
|
fabfdf04f7 | ||
|
|
966093d8d0 | ||
|
|
4d47ff5ce7 | ||
|
|
8e32583089 | ||
|
|
6019c6d514 | ||
|
|
054754482c | ||
|
|
f7129fdb41 | ||
|
|
f7abd05981 | ||
|
|
514cf23aef | ||
|
|
95b509e0d2 | ||
|
|
83e135f101 | ||
|
|
30b86ea602 | ||
|
|
32a99144f6 | ||
|
|
3544d644ba | ||
|
|
a0ce37fb2c |
@@ -4,3 +4,5 @@
|
||||
# The placeholder `make bootstrap` moves aside while a saved image is embedded. Ignored so an
|
||||
# interrupted release build cannot commit a twenty-megabyte tar by accident.
|
||||
/internal/image/builder.tar.placeholder
|
||||
/cmd/mesh-host/mesh-host
|
||||
/cmd/mesh-bootstrap/mesh-bootstrap
|
||||
|
||||
@@ -3,6 +3,9 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"github.com/novox/mesh-host/internal/buckets/bucketstest"
|
||||
"go/build"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -14,7 +17,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/beside"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/composer"
|
||||
@@ -94,7 +96,7 @@ func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
|
||||
r := request(t, composer.DepthResolve)
|
||||
r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
|
||||
r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
|
||||
r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}}
|
||||
r.Cases[0].Verdicts = bucketstest.Raw(map[string]meshcatalog.VerdictsByDigestValue{"refused": {V: 1, Module: "refused", Judge: "src2:j", State: meshcatalogclient.VerdictRefused, Problems: []string{"it is wrong"}}})
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
@@ -280,7 +282,8 @@ func ownPackages(t *testing.T) map[string][]string {
|
||||
}
|
||||
|
||||
// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard
|
||||
// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or
|
||||
// library, mesh-sdk, the contracts modules' pure packages (seats' types, clients' codecs), the delivery SDK's composer (the command's own request and verdict types), the builder's builds package (a build's value and its keys), the private network's overlay package (mesh-wireguard's own pure package: names, graph and
|
||||
// generator, no bus) and this repository's own packages, and none that reaches a bus, a store, a network service or
|
||||
// another process.
|
||||
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
|
||||
@@ -291,7 +294,7 @@ func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
|
||||
for path, imports := range own {
|
||||
for _, imp := range imports {
|
||||
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
|
||||
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
|
||||
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/contracts/") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/sdk/composer") && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
|
||||
t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestAnApplyOutlivesTheLinksContext(t *testing.T) {
|
||||
// An apply is bounded all the same: a step that hangs (a docker call that never returns) cannot hold the node agent for
|
||||
// ever (the review of issue 1945).
|
||||
func TestAnApplyIsBounded(t *testing.T) {
|
||||
t.Run("the marks of the haves never hold the pass", marksOfTheHavesNeverHoldThePass)
|
||||
ctx, done := applyContext(context.Background())
|
||||
defer done()
|
||||
at, ok := ctx.Deadline()
|
||||
|
||||
@@ -2,12 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// authorityPath is where this engine keeps the mesh's own certificate as it last applied it (public): beside the
|
||||
@@ -17,7 +17,7 @@ func authorityPath(state string) string { return filepath.Join(filepath.Dir(stat
|
||||
// keepAuthority keeps the mesh's own certificate a composition carries (the resource a module's certificate.authority
|
||||
// asks for, catalogue.AuthorityID), so the record says the authority this machine holds. A composition carrying none
|
||||
// changes nothing.
|
||||
func keepAuthority(state string, c delivery.Composed) error {
|
||||
func keepAuthority(state string, c engcomp.Composed) error {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -7,14 +7,16 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
)
|
||||
@@ -28,13 +30,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
raw, _ := json.Marshal(map[string]any{"id": id, "type": "file", "path": "/etc/x", "content": content})
|
||||
return raw
|
||||
}
|
||||
if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
|
||||
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("web.conf", "x")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(authorityPath(state)); !os.IsNotExist(err) {
|
||||
t.Fatalf("a composition with no authority kept one: %v", err)
|
||||
}
|
||||
if err := keepAuthority(state, delivery.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
|
||||
if err := keepAuthority(state, engcomp.Composed{Resources: []json.RawMessage{res("ca-trust.certificate-authority", "CA-1")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recordKeys(state, nil)(); got.Authority != "CA-1" {
|
||||
@@ -50,38 +52,38 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
told := 0
|
||||
trust.onChange(func() { told++ })
|
||||
keys := recordKeys(state, trust.Trusts)
|
||||
ctrlFP, vaultFP := delivery.SignerFingerprint(controllerPub), delivery.SignerFingerprint(vaultPub)
|
||||
enrol, _ := delivery.SignContext(delivery.KindEnrolment)
|
||||
ctrlFP, vaultFP := meshvaultclient.SignerFingerprint(controllerPub), meshvaultclient.SignerFingerprint(vaultPub)
|
||||
enrol, _ := meshvaultclient.SignContext(meshvaultclient.KindEnrolment)
|
||||
msg := []byte("a reply")
|
||||
if got := keys().Trusts; len(got) != 0 {
|
||||
t.Errorf("before the signers are read the record trusts %v", got)
|
||||
}
|
||||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
|
||||
t.Error("a controller-signed message is accepted")
|
||||
}
|
||||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err == nil {
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err == nil {
|
||||
t.Error("a vault-signed message is accepted before the vault's key is read")
|
||||
}
|
||||
vaultCA := testAuthority(t, vaultPub, vault)
|
||||
keyValue, _ := json.Marshal(delivery.NewSignerKey(vaultPub, time.Now()))
|
||||
fp, _ := delivery.AuthorityFingerprint(vaultCA)
|
||||
authValue, _ := json.Marshal(delivery.SignerAuthority{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
|
||||
trust.heardSigner(delivery.SignersKeyKey, keyValue, false, func(string) {})
|
||||
trust.heardSigner(delivery.SignersAuthorityKey, authValue, false, func(string) {})
|
||||
if s, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(vault, enrol, msg)); err != nil || s.Name != delivery.SignerVault {
|
||||
keyValue, _ := json.Marshal(meshvaultclient.NewSignerKey(vaultPub, time.Now()))
|
||||
fp, _ := meshvaultclient.AuthorityFingerprint(vaultCA)
|
||||
authValue, _ := json.Marshal(meshvault.SignersByAuthorityValue{Certificate: vaultCA, Fingerprint: fp, Made: time.Now()})
|
||||
trust.heardSigner(meshvault.SignersByKeyKey(), keyValue, false, func(string) {})
|
||||
trust.heardSigner(meshvault.SignersByAuthorityKey(), authValue, false, func(string) {})
|
||||
if s, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(vault, enrol, msg)); err != nil || s.Name != meshvaultclient.SignerVault {
|
||||
t.Errorf("a vault-signed message on a node that read signers: %+v, %v", s, err)
|
||||
}
|
||||
if _, err := trust.Verify(delivery.KindEnrolment, msg, delivery.SignMessage(controller, enrol, msg)); err == nil {
|
||||
if _, err := trust.Verify(meshvaultclient.KindEnrolment, msg, meshvaultclient.SignMessage(controller, enrol, msg)); err == nil {
|
||||
t.Error("a controller-signed message is accepted once the vault's key is read")
|
||||
}
|
||||
if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !delivery.TrustsKey(got, delivery.SignerVault, vaultFP) ||
|
||||
if got := keys().Trusts; strings.Contains(strings.Join(got, " "), ctrlFP) || !meshvaultclient.TrustsKey(got, meshvaultclient.SignerVault, vaultFP) ||
|
||||
trust.VaultAuthority() != vaultCA || told != 2 {
|
||||
t.Errorf("after the signers are read the record trusts %v (told %d)", got, told)
|
||||
}
|
||||
// A signers' key naming another fingerprint is not trusted.
|
||||
bad, _ := json.Marshal(delivery.SignerKey{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
|
||||
trust.heardSigner(delivery.SignersKeyKey, bad, false, func(string) {})
|
||||
if !delivery.TrustsKey(keys().Trusts, delivery.SignerVault, vaultFP) {
|
||||
bad, _ := json.Marshal(meshvault.SignersByKeyValue{Key: base64.StdEncoding.EncodeToString(controllerPub), Fingerprint: vaultFP})
|
||||
trust.heardSigner(meshvault.SignersByKeyKey(), bad, false, func(string) {})
|
||||
if !meshvaultclient.TrustsKey(keys().Trusts, meshvaultclient.SignerVault, vaultFP) {
|
||||
t.Error("a signers' key that does not check replaced the vault's")
|
||||
}
|
||||
|
||||
@@ -96,8 +98,8 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
}
|
||||
servingPub, _ := base64.StdEncoding.DecodeString(serving.Public)
|
||||
signedBy := func(ca string, key ed25519.PrivateKey) string {
|
||||
parent, _ := delivery.ParseCertificate(ca)
|
||||
der, err := x509.CreateCertificate(nil, delivery.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
|
||||
parent, _ := meshvaultclient.ParseCertificate(ca)
|
||||
der, err := x509.CreateCertificate(nil, meshvaultclient.NodeCertificateTemplate(overlay.InternalName("n1"), servingPub, time.Now().Add(-time.Hour)),
|
||||
parent, ed25519.PublicKey(servingPub), key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -108,13 +110,13 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
answer := signedBy(vaultCA, vault)
|
||||
ask := func(_ context.Context, verb string, args any) (json.RawMessage, error) {
|
||||
asked = append(asked, verb, args)
|
||||
return json.Marshal(delivery.SignAnswer{Kind: delivery.KindNodeCertificate, Node: "n1", Certificate: answer})
|
||||
return json.Marshal(meshvault.SignAnswer{Kind: meshvaultclient.KindNodeCertificate, Node: "n1", Certificate: answer})
|
||||
}
|
||||
written := 0
|
||||
if err := certifyThisNode(context.Background(), state, "n1", trust, ask, func() { written++ }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != delivery.SignVerb {
|
||||
if got := recordKeys(state, nil)(); got.Certificate != answer || written != 1 || len(asked) != 2 || asked[0] != meshvault.VerbSign {
|
||||
t.Errorf("the vault's certificate: kept %v, written %d, asked %v", got.Certificate == answer, written, asked)
|
||||
}
|
||||
answer = signedBy(testAuthority(t, controllerPub, controller), controller)
|
||||
@@ -158,7 +160,7 @@ func TestTheAppliedAuthorityIsKeptForTheRecord(t *testing.T) {
|
||||
// testAuthority is a self-signed authority certificate for key, as the vault makes its own.
|
||||
func testAuthority(t *testing.T, pub ed25519.PublicKey, key ed25519.PrivateKey) string {
|
||||
t.Helper()
|
||||
tpl := delivery.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
tpl := meshvaultclient.AuthorityTemplate(pub, time.Now().Add(-time.Hour))
|
||||
der, err := x509.CreateCertificate(nil, tpl, tpl, pub, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -6,10 +6,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
@@ -85,4 +88,62 @@ func TestABackupEndsWhenItsEventSaysSo(t *testing.T) {
|
||||
t.Errorf("%s: asked %d times, %v", row.name, asked, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The events of the same kind (ENG-11): the status view's settled is told to the agent by the module that settled, on
|
||||
// whichever node; a body with no module, or no JSON, tells nothing; the end of the link ends the hearing.
|
||||
told := &settledRecorder{}
|
||||
events := make(chan []byte, 4)
|
||||
for _, body := range []string{`{"node":"n2","module":"distribution"}`, `{"node":"n1"}`, `not json`, `{"module":"gitea"}`} {
|
||||
events <- []byte(body)
|
||||
}
|
||||
linked, end := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); listen(linked, events, func() settledTold { return told }, func(string) {}) }()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for len(told.heard()) < 2 && time.Now().Before(deadline) {
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
end()
|
||||
<-done
|
||||
if got := strings.Join(told.heard(), ","); got != "distribution,gitea" {
|
||||
t.Errorf("the agent was told %q settled, want distribution,gitea", got)
|
||||
}
|
||||
// And the images an apply could not pull are said with the holder the declaration names beside each, none for a public
|
||||
// one (ENG-8, ENG-10).
|
||||
d, err := declaration.ParseFileTrusted([]byte(`{"declaration":1,"resources":[` +
|
||||
`{"id":"a.first","type":"container","name":"first","image":"reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","image-seat":"mesh-artifact-store","image-holder":"distribution"},` +
|
||||
`{"id":"a.second","type":"container","name":"second","image":"public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pull := func(image string) error {
|
||||
return &apply.Error{Resource: "x", Err: &apply.PullError{Image: image, Err: errors.New("manifest unknown")}}
|
||||
}
|
||||
if got := pullsOf(d, pull("reg/x@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")); len(got) != 1 || got[0].Seat != "mesh-artifact-store" || got[0].Holder != "distribution" {
|
||||
t.Errorf("a store's image is said as %+v", got)
|
||||
}
|
||||
if got := pullsOf(d, pull("public/y@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")); len(got) != 1 || got[0].Holder != "" || got[0].Seat != "" {
|
||||
t.Errorf("a public image is said as %+v", got)
|
||||
}
|
||||
if got := pullsOf(d, errors.New("another failure")); len(got) != 0 {
|
||||
t.Errorf("a failure that is no pull is said as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// settledRecorder is the agent a hearing tells.
|
||||
type settledRecorder struct {
|
||||
mu sync.Mutex
|
||||
list []string
|
||||
}
|
||||
|
||||
func (r *settledRecorder) Settled(holder string) {
|
||||
r.mu.Lock()
|
||||
r.list = append(r.list, holder)
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *settledRecorder) heard() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.list...)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// A composition that names a file the provisioner fills without saying it is filled would have this node-engine write
|
||||
@@ -13,7 +12,7 @@ import (
|
||||
// when ace, g14 and shanks wrote claude-code's, node-env's and power's files empty).
|
||||
func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
|
||||
unit := json.RawMessage(`{"id":"power.after-boot-unit","type":"file","path":"/etc/systemd/system/mesh-power-after-boot.service","mode":"0644","owner-at-runtime":"owned"}`)
|
||||
c := delivery.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
|
||||
c := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{unit}}
|
||||
d, err := declarationOf(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -25,7 +24,7 @@ func TestAFileItCannotProduceIsNeverWrittenEmpty(t *testing.T) {
|
||||
if d, _ = declarationOf(c); refuseEmptyWrites(d) != nil {
|
||||
t.Fatalf("a filled file was refused: %v", refuseEmptyWrites(d))
|
||||
}
|
||||
plain := delivery.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
plain := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"x.f","type":"file","path":"/etc/x","mode":"0644","content":"x"}`)}}
|
||||
if d, _ = declarationOf(plain); refuseEmptyWrites(d) != nil {
|
||||
t.Fatalf("a file with its content was refused: %v", refuseEmptyWrites(d))
|
||||
|
||||
+14
-14
@@ -13,10 +13,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/accounts"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
@@ -255,9 +255,9 @@ func engineActs(opts options, node string, queue *link.Session, search *accounts
|
||||
})
|
||||
return judgeRootFree(node, lastAccounts.Load(), time.Now(), served, why)
|
||||
},
|
||||
Say: queue.SayEngine}
|
||||
asker := asks.EngineAsker(node)
|
||||
h.Asker = &meshasker.Client{Module: asker, State: state, Publish: queue.PublishAsk,
|
||||
ManifestReads: queue.StatedReads, Say: queue.SayEngine}
|
||||
asker := occ.EngineAsker(node)
|
||||
h.Asker = &occ.Client{Module: asker, State: state, Publish: queue.PublishAsk,
|
||||
Record: func(id string) (*stdio.StateEntry, error) { return queue.RouterRecord(asker + "." + id) }}
|
||||
return h
|
||||
}
|
||||
@@ -364,26 +364,26 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s
|
||||
|
||||
// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
|
||||
// node in shadow by the run's shadow, applying and writing nothing, within the shadow's own bound (shorter than
|
||||
// delivery.ComposeAnswerBound, selfcompose candidateWithin). The answer is
|
||||
// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
|
||||
// nec.AnswerBound, selfcompose candidateWithin). The answer is
|
||||
// the seat's shape (nodeengine.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
|
||||
// answers unready, which the checker takes as no answer.
|
||||
func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
|
||||
return func(candidate string, input bool) (any, error) {
|
||||
ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input}
|
||||
if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil {
|
||||
ask := nodeengine.ComposeCandidateInput{Candidate: candidate, Input: input}
|
||||
if _, err := nec.EncodeAsk(ask); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a delivery.ComposeCandidateAnswer
|
||||
var a nodeengine.ComposeCandidateAnswer
|
||||
if s := shadow.Load(); s == nil {
|
||||
// Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9).
|
||||
a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready,
|
||||
a = nodeengine.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: nec.Unready,
|
||||
Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"}
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nec.AnswerBound)
|
||||
defer cancel()
|
||||
a = s.ComposeCandidate(ctx, ask)
|
||||
}
|
||||
raw, err := delivery.EncodeComposeCandidateAnswer(a)
|
||||
raw, err := nec.EncodeAnswer(a)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
|
||||
}
|
||||
|
||||
@@ -6,22 +6,21 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// enrolThroughSeat joins this machine through the seat mesh-enrolment (novox/hq ADR 0587 §6, issue 774): it redeems the token
|
||||
// enrolThroughSeat joins this machine through the seat mesh-node-enroller (novox/hq ADR 0587 §6, issue 774): it redeems the token
|
||||
// with the keys it made, verifies the vault's signed answer against the key the token carries before it keeps anything, keeps
|
||||
// its membership and its login, dials as the login node-logins names and confirms. A bus that does not serve the seat to this
|
||||
// token (link.ErrNoSeat) is said as the error: nothing was sent or kept.
|
||||
func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, node string, mine identity.Identity,
|
||||
sealing identity.SealingKey, serving identity.ServingKey, reported map[string]any, found *link.Tunnel, identityPath string) error {
|
||||
|
||||
req := delivery.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public,
|
||||
req := meshnodeenrollerclient.Redeem{Node: node, PublicKey: base64.StdEncoding.EncodeToString(mine.Public), OverlayKey: mine.Overlay.Public,
|
||||
SealingKey: sealing.Public, ServingKey: serving.Public, Profile: reported}
|
||||
if found != nil {
|
||||
var err error
|
||||
@@ -29,18 +28,18 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n
|
||||
return err
|
||||
}
|
||||
}
|
||||
req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(delivery.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey)))
|
||||
req.Proof = base64.StdEncoding.EncodeToString(mine.Sign(meshnodeenrollerclient.RedeemProof(node, req.PublicKey, req.OverlayKey, req.SealingKey, req.ServingKey)))
|
||||
to := link.Approach{Address: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
|
||||
membership := link.Membership{Node: node, Broker: token.Broker, Fingerprint: token.Fingerprint, Transport: link.OnNATS}
|
||||
j := link.Joining{Node: node, Signer: token.SignerKey(),
|
||||
Redeem: func(ctx context.Context) (delivery.RedeemAnswer, error) {
|
||||
Redeem: func(ctx context.Context) (meshnodeenrollerclient.RedeemAnswer, error) {
|
||||
return link.Redeem(ctx, to, node, token.Secret, req, opts.timeout)
|
||||
},
|
||||
Open: func(a delivery.RedeemAnswer) (string, error) {
|
||||
Open: func(a meshnodeenrollerclient.RedeemAnswer) (string, error) {
|
||||
value, err := sealing.Open(a.Account)
|
||||
return string(value), err
|
||||
},
|
||||
Keep: func(a delivery.RedeemAnswer, password string) error {
|
||||
Keep: func(a meshnodeenrollerclient.RedeemAnswer, password string) error {
|
||||
// The mesh's name for this node wins over what the machine called itself, as on the controller's path.
|
||||
mine.Node = a.Node
|
||||
mine.Membership = identity.Membership{Broker: token.Broker, Fingerprint: token.Fingerprint, Signer: token.Signer,
|
||||
@@ -64,9 +63,9 @@ func enrolThroughSeat(ctx context.Context, opts options, token identity.Token, n
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("\nenrolled as %s through the seat mesh-enrolment, dialled as %s and confirmed\n", answer.Node, answer.Login)
|
||||
fmt.Printf("\nenrolled as %s through the seat mesh-node-enroller, dialled as %s and confirmed\n", answer.Node, answer.Login)
|
||||
fmt.Printf(" identity %s\n", identityPath)
|
||||
fmt.Println(" nothing is applied until the placement of this node names a module")
|
||||
fmt.Println(" nothing is applied until the assignment of this node names a module")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -11,7 +12,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
@@ -140,7 +140,7 @@ func TestADeclarationWithAFileByDigestAppliesFromTheObject(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runs := runsAsWritten([]delivery.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state)
|
||||
runs := runsAsWritten([]engcomp.Composed{{Node: "n", Module: "m", Resources: []json.RawMessage{stale}}}, opts.state)
|
||||
var got struct {
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -130,7 +130,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe
|
||||
p.list = func(ctx context.Context, prefix string) (map[string][]byte, error) {
|
||||
wctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
updates, err := queue.KVWatch(wctx, provisions.ProvisionsBucket, prefix+">")
|
||||
updates, err := queue.KVWatch(wctx, buckets.Provisions, prefix+">")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func provisionSecretsOn(queue *link.Session, node, statePath string) provisionSe
|
||||
return nil, ctx.Err()
|
||||
case u, ok := <-updates:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the watch of %s ended before it caught up", provisions.ProvisionsBucket)
|
||||
return nil, fmt.Errorf("the watch of %s ended before it caught up", buckets.Provisions)
|
||||
}
|
||||
switch {
|
||||
case u.Caught:
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
|
||||
@@ -3,15 +3,15 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
// owedBus is a link that takes reports and statements and keeps a have bucket in memory.
|
||||
@@ -44,10 +44,10 @@ func (b *owedBus) Set(_ context.Context, key string, value []byte, revision uint
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *owedBus) have(key string) (delivery.Have, bool) {
|
||||
func (b *owedBus) have(key string) (nodeengine.HaveByNodeModuleValue, bool) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
h, err := delivery.DecodeHave(b.kv[key])
|
||||
h, err := nodeengineclient.DecodeHave(b.kv[key])
|
||||
return h, err == nil
|
||||
}
|
||||
|
||||
@@ -66,19 +66,19 @@ func TestAModuleWithNothingLongRunningIsOkWithinALookOfItsApply(t *testing.T) {
|
||||
}
|
||||
fact := link.ModuleFact{Module: "xorg", FP: "src2:" + strings.Repeat("a", 64), Composed: "sha256:" + strings.Repeat("b", 64)}
|
||||
applying := fact
|
||||
applying.Phase = string(delivery.PhaseApplying)
|
||||
applying.Phase = string(nodeengineclient.PhaseApplying)
|
||||
queue.NoteModule(ctx, applying)
|
||||
go judgeWhatRuns(ctx, j, queue, func(string) {}, nil, nil)
|
||||
// The facts look inside the apply, while the module is still applying.
|
||||
queue.SayFacts(ctx, link.Report{Health: &link.Health{Contract: link.LivenessContract, At: time.Now().UTC()}})
|
||||
if h, _ := bus.have("n1.xorg"); h.Health != delivery.HealthUnknown {
|
||||
if h, _ := bus.have("n1.xorg"); h.Health != nodeengineclient.HealthUnknown {
|
||||
t.Fatalf("judged while applying: %+v", h)
|
||||
}
|
||||
queue.NoteModule(ctx, fact)
|
||||
deadline := time.Now().Add(2 * time.Second) // well within one look (liveness.LookEvery, 15 s)
|
||||
for {
|
||||
h, _ := bus.have("n1.xorg")
|
||||
if h.Phase == delivery.PhaseRunning && h.Health == delivery.HealthOK {
|
||||
if h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
|
||||
@@ -2,9 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
@@ -26,8 +26,8 @@ func heldBy(windows *apply.Windows) func(time.Time) map[string]bool {
|
||||
// long-running resource the composition declares running not active, read from the service manager and the container
|
||||
// runtime and from no record. A resource held by an open maintenance window, or waiting for a library, is not counted;
|
||||
// one that cannot be read is not counted either (unknown is not inactive), and neither is one the manager is restarting.
|
||||
func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, delivery.Composed) bool {
|
||||
return func(ctx context.Context, c delivery.Composed) bool {
|
||||
func inactiveIn(rt liveness.Runtime, held func(time.Time) map[string]bool) func(context.Context, engcomp.Composed) bool {
|
||||
return func(ctx context.Context, c engcomp.Composed) bool {
|
||||
d, err := declarationOf(c)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -4,9 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ func testTheMachineShowsAResourceNotActive(t *testing.T) {
|
||||
{"unread", fakeRuntime{err: errors.New("the service manager could not be read")}, nil, false},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
d, err := declarationOf(delivery.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
|
||||
d, err := declarationOf(engcomp.Composed{Node: "n1", Module: "dnsmasq", Resources: []json.RawMessage{svc, ctr}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
|
||||
"git.novox.be/novox/mesh-sdk/go/asks"
|
||||
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
||||
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
@@ -58,16 +58,16 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
|
||||
// 774 (ADR 0563 §4): the library's home engine asks its take of the operator itself, once: no second ask while one is
|
||||
// open, nor after it ended; and a library waiting for its disk is not asked.
|
||||
t.Run("the home engine asks the take once", func(t *testing.T) {
|
||||
var asked []asks.Ask
|
||||
var asked []oc.Ask
|
||||
state := &link.MemState{}
|
||||
h := &link.HandActs{Node: "n1", State: state,
|
||||
JudgeAccess: func(act, access string) (link.Judged, error) {
|
||||
return link.Judged{Subject: access, Path: root, Sets: "the group mesh-media", Disk: "0:0 755"}, nil
|
||||
},
|
||||
AccessAct: func(link.AccessAct) (string, error) { return "taken", nil }}
|
||||
h.Asker = &meshasker.Client{Module: asks.EngineAsker("n1"), State: state,
|
||||
h.Asker = &occ.Client{Module: occ.EngineAsker("n1"), State: state,
|
||||
Publish: func(subject string, body any, id string) error {
|
||||
if a, ok := body.(asks.Ask); ok {
|
||||
if a, ok := body.(oc.Ask); ok {
|
||||
asked = append(asked, a)
|
||||
}
|
||||
return nil
|
||||
@@ -78,7 +78,7 @@ func TestALibrarySaysItsWaitWithoutWordsToParse(t *testing.T) {
|
||||
if len(waiting) != 1 || waiting[0] != "library:media" || len(asked) != 1 {
|
||||
t.Fatalf("waiting %v asked %d times", waiting, len(asked))
|
||||
}
|
||||
h.Heard(asks.Warrant{Ask: asked[0].ID, Asker: asks.EngineAsker("n1"), Outcome: asks.OutcomeExpired, At: time.Now()})
|
||||
h.Heard(oc.Warrant{Ask: asked[0].ID, Asker: occ.EngineAsker("n1"), Outcome: occ.OutcomeExpired, At: occ.FormatTime(time.Now())})
|
||||
askTakes(h, waiting, nil)
|
||||
if len(asked) != 1 {
|
||||
t.Fatalf("the take was asked again after its ask ended: %d", len(asked))
|
||||
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
@@ -48,7 +49,7 @@ var loginsFollowAgain = 2 * time.Minute
|
||||
|
||||
// followLogins follows this node's key of the vault's node-logins on every link, as a watch (never a poll), for as long as ctx
|
||||
// lasts, and hands each value to hear. A watch that cannot be made is said once and tried again.
|
||||
func followLogins(ctx context.Context, session *link.Session, node string, hear func(delivery.NodeLogin), say link.Announce) {
|
||||
func followLogins(ctx context.Context, session *link.Session, node string, hear func(meshvault.NodeLoginsByNodeValue), say link.Announce) {
|
||||
said := ""
|
||||
for ctx.Err() == nil {
|
||||
linked, cancel, err := session.WhenLinked(ctx)
|
||||
@@ -56,7 +57,7 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear
|
||||
return
|
||||
}
|
||||
watching, stop := context.WithCancel(linked)
|
||||
updates, werr := session.KVWatch(watching, delivery.NodeLoginsBucket, node)
|
||||
updates, werr := session.KVWatch(watching, buckets.NodeLogins, node)
|
||||
if werr != nil {
|
||||
stop()
|
||||
if line := "this node's login is not followed yet (" + werr.Error() + "): it dials as it last did, and looks again in " + loginsFollowAgain.String(); line != said {
|
||||
@@ -69,9 +70,9 @@ func followLogins(ctx context.Context, session *link.Session, node string, hear
|
||||
if u.Caught || u.Deleted || len(u.Value) == 0 {
|
||||
continue
|
||||
}
|
||||
nl, err := delivery.DecodeNodeLogin(u.Value)
|
||||
nl, err := meshvaultclient.DecodeNodeLogin(u.Value)
|
||||
if err != nil {
|
||||
say("the key " + node + " of " + delivery.NodeLoginsRead + " is not read: " + err.Error())
|
||||
say("the key " + node + " of " + buckets.NodeLoginsRead + " is not read: " + err.Error())
|
||||
continue
|
||||
}
|
||||
hear(nl)
|
||||
|
||||
+19
-5
@@ -16,6 +16,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
@@ -30,7 +31,6 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/accounts"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
@@ -819,7 +819,7 @@ func enrol(ctx context.Context, opts options) error {
|
||||
detected := profile.Detect(ctx, profile.Default(nil), opts.timeout)
|
||||
reported := profileAsReported(detected)
|
||||
|
||||
// Through the seat mesh-enrolment (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended
|
||||
// Through the seat mesh-node-enroller (novox/hq ADR 0587 §6), the one way in: the controller's enrolment subject ended
|
||||
// with the controller (issue 1126).
|
||||
return enrolThroughSeat(ctx, opts, token, *name, mine, sealing, serving, reported, found, identityPath)
|
||||
}
|
||||
@@ -936,6 +936,11 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// which the launcher already reads as "run whatever is on disk now".
|
||||
aside, standAside := context.WithCancel(ctx)
|
||||
defer standAside()
|
||||
// **And it ends between applies too** (novox/hq issue 1585): whichever way the link ends, the apply in flight ends and
|
||||
// saves before the process does. Deferred after standAside, so it runs before it: nothing is cancelled under it.
|
||||
defer func() { _ = stopBetweenApplies(say, stopBound) }()
|
||||
// What an earlier stop cut off before its rename is removed, and said, before anything saves.
|
||||
removeLeftoverSaves(opts.state, say)
|
||||
stoodAside := false
|
||||
membership := link.Membership{
|
||||
Node: mine.Node,
|
||||
@@ -1012,6 +1017,7 @@ func runLink(ctx context.Context, opts options) error {
|
||||
queue.Acts.Composition = compositionFrom(current)
|
||||
queue.Acts.Compose = composeFrom(current, &composerNow)
|
||||
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
|
||||
queue.Acts.SeatList, queue.Acts.SeatListAgain = seatListRead, seatListAgain
|
||||
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
|
||||
setRunsHere(runsFromAgent(current))
|
||||
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
|
||||
@@ -1059,9 +1065,17 @@ func runLink(ctx context.Context, opts options) error {
|
||||
// And what it trusts, followed on the bus, said in its record whenever it changes (ADR 0550 §2).
|
||||
trust.onChange(func() { factsOfNow(aside, current.get()) })
|
||||
go followTrust(aside, queue, trust, say)
|
||||
// And the status view's `settled`, for a failed pull from a store to be tried again once the store's holder has settled
|
||||
// (design 51, ENG-11).
|
||||
go followSettled(aside, queue, func() settledTold {
|
||||
if ag := current.get(); ag != nil {
|
||||
return ag
|
||||
}
|
||||
return nil
|
||||
}, say)
|
||||
// And the login it dials as, which the vault's node-logins names for this node (novox/hq ADR 0583 §1).
|
||||
rotator := newRotator(queue, opts.state, opts.timeout, say)
|
||||
go followLogins(aside, queue, mine.Node, func(nl delivery.NodeLogin) {
|
||||
go followLogins(aside, queue, mine.Node, func(nl meshvault.NodeLoginsByNodeValue) {
|
||||
if line := membership.Logins.Heard(nl); line != "" {
|
||||
say(line)
|
||||
}
|
||||
@@ -1399,7 +1413,7 @@ func healthAsReported(st liveness.Statement, ns *network.Statement) *link.Health
|
||||
h.Network = &link.NetworkHealth{State: ns.State, Since: ns.Since.UTC(), Parts: []link.NetworkPart{}}
|
||||
for _, p := range ns.Parts {
|
||||
h.Network.Parts = append(h.Network.Parts, link.NetworkPart{Part: p.Part, State: p.State, Reason: p.Reason,
|
||||
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: p.Streak})
|
||||
Said: p.Said, Writer: p.Writer, Owner: p.Owner, Toward: p.Toward, Since: p.Since.UTC(), Streak: int64(p.Streak)})
|
||||
}
|
||||
}
|
||||
return h
|
||||
@@ -2019,7 +2033,7 @@ func adoptDeliveredMembership(identityPath string, mine *identity.Identity, say
|
||||
return
|
||||
}
|
||||
say(fmt.Sprintf("moving to the %s bus at %s — restarting to dial it", next.Transport, next.Broker))
|
||||
os.Exit(0)
|
||||
exitBetweenApplies(say)
|
||||
}
|
||||
|
||||
// profileAsReported is the profile as the mesh reads it — the same bytes enrolment sends, so a
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -132,6 +133,57 @@ func TestAFlagAfterAPositionalIsRead(t *testing.T) {
|
||||
// state, acts, and writes it back — so they must not run at the same time, or the last save loses
|
||||
// what the other recorded.
|
||||
func TestOnlyOneApplyRunsAtATime(t *testing.T) {
|
||||
// Issue 1585: the engine's stop waits for the apply in flight to end and save, and then lets no save start; before,
|
||||
// the process ended under it and a save cut before its rename left its temporary file and the state before it.
|
||||
t.Run("the stop waits for the apply in flight and its save", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state.json")
|
||||
applying.Lock()
|
||||
var said []string
|
||||
var mu sync.Mutex
|
||||
waiting := make(chan struct{})
|
||||
say := func(line string) {
|
||||
mu.Lock()
|
||||
said = append(said, line)
|
||||
mu.Unlock()
|
||||
if strings.Contains(line, "waiting up to") {
|
||||
close(waiting)
|
||||
}
|
||||
}
|
||||
stopped := make(chan func(), 1)
|
||||
go func() { stopped <- stopBetweenApplies(say, time.Hour) }()
|
||||
// Said before it blocks, and it cannot end before the apply lets go: so it has not ended now.
|
||||
<-waiting
|
||||
select {
|
||||
case release := <-stopped:
|
||||
release()
|
||||
applying.Unlock()
|
||||
t.Fatal("the stop did not wait for the apply in flight")
|
||||
default:
|
||||
}
|
||||
if err := store.Save(path, store.State{Resources: []store.Applied{{ID: "nats.program-snapshot", Type: "archive"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
applying.Unlock()
|
||||
release := <-stopped
|
||||
saved := make(chan error, 1)
|
||||
go func() { saved <- store.Save(path, store.State{}) }()
|
||||
// A save after the stop blocks; nothing says it blocks, so this one wait is a bounded look.
|
||||
select {
|
||||
case <-saved:
|
||||
release()
|
||||
t.Fatal("a save started after the stop")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := <-saved; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(said) != 2 || !strings.Contains(said[0], "waiting up to 1h0m0s") || !strings.Contains(said[1], "its state is saved") {
|
||||
t.Errorf("said %q", said)
|
||||
}
|
||||
})
|
||||
// A host is built for one system at link time, and a test binary has no link time: this asks
|
||||
// the machine it runs on, and stands aside where the answer is no.
|
||||
built, err := system.For("arch")
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// markWithin bounds one marking of the haves (what the composer tells its agent: a refusal, the words it keeps). The marks
|
||||
// read and write each module's have over the bus one after the other; a bus that answers slowly or not at all must hold
|
||||
// the marking, never the composition pass (issue 1965, shanks 2026-11-05 22:22: a pass waited minutes on them).
|
||||
var markWithin = 45 * time.Second
|
||||
|
||||
// marker runs the composer's marks of the haves off the composition pass: set returns at once, one worker runs what was
|
||||
// set last under each name, in the order the names were first set, each within markWithin. A mark set while one runs
|
||||
// replaces the pending mark of its name, so the haves are marked once for the newest word, not once for every pass.
|
||||
type marker struct {
|
||||
run context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
pending map[string]func(context.Context)
|
||||
order []string
|
||||
working bool
|
||||
idle *sync.Cond
|
||||
}
|
||||
|
||||
func newMarker(run context.Context) *marker {
|
||||
m := &marker{run: run, pending: map[string]func(context.Context){}}
|
||||
m.idle = sync.NewCond(&m.mu)
|
||||
return m
|
||||
}
|
||||
|
||||
// set takes the mark name should carry; it never waits on the bus.
|
||||
func (m *marker) set(name string, mark func(context.Context)) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, queued := m.pending[name]; !queued {
|
||||
m.order = append(m.order, name)
|
||||
}
|
||||
m.pending[name] = mark
|
||||
if m.working {
|
||||
return
|
||||
}
|
||||
m.working = true
|
||||
go m.work()
|
||||
}
|
||||
|
||||
func (m *marker) work() {
|
||||
for {
|
||||
m.mu.Lock()
|
||||
if len(m.order) == 0 || m.run.Err() != nil {
|
||||
m.order, m.pending = nil, map[string]func(context.Context){}
|
||||
m.working = false
|
||||
m.idle.Broadcast()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
name := m.order[0]
|
||||
m.order = m.order[1:]
|
||||
mark := m.pending[name]
|
||||
delete(m.pending, name)
|
||||
m.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(m.run, markWithin)
|
||||
mark(ctx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// wait returns once no mark is pending or running (for tests).
|
||||
func (m *marker) wait() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for m.working {
|
||||
m.idle.Wait()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue 1965 (shanks 2026-11-05 22:22): the marks of the haves never hold the composition pass. A mark that stalls on the
|
||||
// bus is cut at markWithin, a mark set while one runs replaces the pending one of its name, and set itself never waits.
|
||||
func marksOfTheHavesNeverHoldThePass(t *testing.T) {
|
||||
// The bound is the test's own: the stalled mark is released by a channel, never by a real wait.
|
||||
setMarkWithin(t, time.Hour)
|
||||
m := newMarker(context.Background())
|
||||
release := make(chan struct{})
|
||||
var ran []string
|
||||
stalled := make(chan struct{})
|
||||
m.set("refused", func(ctx context.Context) { // a have write that stalls until the test lets it go
|
||||
close(stalled)
|
||||
<-release
|
||||
ran = append(ran, "refused-stalled")
|
||||
})
|
||||
<-stalled
|
||||
// Set while the first runs: only the newest of a name is kept.
|
||||
m.set("kept", func(context.Context) { ran = append(ran, "kept-old") })
|
||||
m.set("kept", func(context.Context) { ran = append(ran, "kept-new") })
|
||||
m.set("refused", func(context.Context) { ran = append(ran, "refused-new") })
|
||||
// All four sets above returned while the first mark still stalled: set never waits on it.
|
||||
close(release)
|
||||
m.wait()
|
||||
want := []string{"refused-stalled", "kept-new", "refused-new"}
|
||||
if len(ran) != len(want) {
|
||||
t.Fatalf("ran %v; want %v", ran, want)
|
||||
}
|
||||
for i := range want {
|
||||
if ran[i] != want[i] {
|
||||
t.Fatalf("ran %v; want %v", ran, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setMarkWithin(t *testing.T, d time.Duration) {
|
||||
t.Helper()
|
||||
old := markWithin
|
||||
markWithin = d
|
||||
t.Cleanup(func() { markWithin = old })
|
||||
}
|
||||
+151
-54
@@ -6,6 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -14,9 +18,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
@@ -43,7 +46,7 @@ func (w bucketWatch) Watch(ctx context.Context, pattern string) (<-chan agent.Up
|
||||
return w.updates(ctx, in), nil
|
||||
}
|
||||
|
||||
// WatchAwait is Watch for a state that its holder makes when it starts (the placement seat's): a bucket not on the bus
|
||||
// WatchAwait is Watch for a state that its holder makes when it starts (the assignment seat's): a bucket not on the bus
|
||||
// yet is waited for on the bus's word that it was created (link.Session.KVWatchAwait), and waiting is told why.
|
||||
func (w bucketWatch) WatchAwait(ctx context.Context, pattern string, waiting func(error)) (<-chan agent.Update, error) {
|
||||
in, err := w.session.KVWatchAwait(ctx, w.bucket, pattern, waiting)
|
||||
@@ -84,7 +87,7 @@ func (h sessionHaves) Read(ctx context.Context, module string) (agent.Have, bool
|
||||
if !ok {
|
||||
return agent.Have{}, false
|
||||
}
|
||||
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
Sealed: got.SealedAt}, true
|
||||
}
|
||||
|
||||
@@ -102,25 +105,25 @@ func (h sessionHaves) ReadErr(ctx context.Context, module string) (agent.Have, b
|
||||
if !ok || err != nil {
|
||||
return agent.Have{}, false, err
|
||||
}
|
||||
return agent.Have{Module: module, FP: got.FP, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
return agent.Have{Module: module, FP: got.Fp, Composed: got.Composed, Phase: string(got.Phase), Detail: got.Detail,
|
||||
Sealed: got.SealedAt}, true, nil
|
||||
}
|
||||
|
||||
func (h sessionHaves) Write(ctx context.Context, x agent.Have) {
|
||||
if x.Module == agent.NodeModule {
|
||||
// The node's own key holds its facts and the digest of the composition it runs.
|
||||
if x.Phase == "" || x.Phase == string(delivery.PhaseRunning) {
|
||||
if x.Phase == "" || x.Phase == string(nodeengineclient.PhaseRunning) {
|
||||
h.session.NoteNode(ctx, x.Composed, x.Sealed)
|
||||
}
|
||||
return
|
||||
}
|
||||
phase := x.Phase
|
||||
if phase == string(delivery.PhaseRunning) {
|
||||
if phase == string(nodeengineclient.PhaseRunning) {
|
||||
phase = ""
|
||||
}
|
||||
h.session.NoteModule(ctx, link.ModuleFact{Module: x.Module, FP: x.FP, Composed: x.Composed, Phase: phase,
|
||||
Failed: phase == string(delivery.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
|
||||
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed})
|
||||
Failed: phase == string(nodeengineclient.PhaseFailed), Detail: shorten(x.Detail), Judgement: x.Judgement, Waits: x.Waits,
|
||||
BuildAlone: x.BuildAlone, Kept: x.Kept, Sealed: x.Sealed, PullWait: x.PullWait})
|
||||
}
|
||||
|
||||
func (h sessionHaves) Removed(ctx context.Context, module string) {
|
||||
@@ -255,16 +258,16 @@ func awaitBackup(ctx context.Context, events <-chan []byte, node, module string,
|
||||
// the controller put modules it knew no fingerprint of the new form for into the node's own composition while their
|
||||
// wants named their own, and the declaration joined of both declared each such file twice, which the filling refuses
|
||||
// ("2 resources declare X") on every node. The node's own copy is left out, and which were left out is said once.
|
||||
func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
|
||||
func declarationOf(cs ...compositions.Composed) (*declaration.Declaration, error) {
|
||||
d := &declaration.Declaration{Version: declaration.Version}
|
||||
var modules []delivery.Composed
|
||||
var modules []compositions.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
modules = append(modules, c)
|
||||
}
|
||||
}
|
||||
if len(modules) > 0 {
|
||||
joined := make([]delivery.Composed, 0, len(cs))
|
||||
joined := make([]compositions.Composed, 0, len(cs))
|
||||
for _, c := range cs {
|
||||
if c.Module == "" {
|
||||
var dropped []string
|
||||
@@ -299,7 +302,7 @@ func declarationOf(cs ...delivery.Composed) (*declaration.Declaration, error) {
|
||||
// the same kind and id. A resource that shares only its target (a unit or container of the same name, another id) is
|
||||
// not the module's, and stays: dropped, its record would be an orphan the apply removes, stopping the module's unit. It
|
||||
// answers what it left out, as "<id> (<module>)".
|
||||
func ownWithout(own delivery.Composed, modules []delivery.Composed) (delivery.Composed, []string) {
|
||||
func ownWithout(own compositions.Composed, modules []compositions.Composed) (compositions.Composed, []string) {
|
||||
byID := map[string]string{}
|
||||
for _, c := range modules {
|
||||
if c.Module == "" {
|
||||
@@ -355,14 +358,14 @@ func sayDroppedOnce(dropped []string) {
|
||||
"declares, and they are left out of it: %s (issue 1945)\n", len(dropped), key)
|
||||
}
|
||||
|
||||
func scopeName(c delivery.Composed) string {
|
||||
func scopeName(c compositions.Composed) string {
|
||||
if c.Module == "" {
|
||||
return agent.NodeModule
|
||||
}
|
||||
return c.Module
|
||||
}
|
||||
|
||||
func resourcesOf(cs []delivery.Composed) []declaration.Resource {
|
||||
func resourcesOf(cs []compositions.Composed) []declaration.Resource {
|
||||
var out []declaration.Resource
|
||||
for _, c := range cs {
|
||||
for _, raw := range c.Resources {
|
||||
@@ -444,14 +447,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}
|
||||
began := time.Now()
|
||||
node := session.Membership.Node
|
||||
composedBucket, err := delivery.ComposedBucketFor(node)
|
||||
composedBucket, err := compositions.BucketFor(node)
|
||||
if err != nil {
|
||||
cancel()
|
||||
say("the node agent cannot name this node's composition bucket: " + err.Error())
|
||||
return
|
||||
}
|
||||
var ag *agent.Agent
|
||||
// The kept release-5 declaration is no longer read once every module the seat's placement puts here has a have of its
|
||||
// The kept release-5 declaration is no longer read once every module the seat's assignment puts here has a have of its
|
||||
// own (ADR 0550 §2: its signature is the controller's, trusted no more after the lease write).
|
||||
// Nor before this engine keeps the node's own as it runs (applied/node.json): until then the kept declaration is the one
|
||||
// record of what the node's own composition carries, its folded modules among them (issue 774, review of the
|
||||
@@ -468,7 +471,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// composes its own (ADR 0555 §3): without it a module that asks for a certificate would be stood in for.
|
||||
if byModule, _ := kept(); byModule != nil {
|
||||
for module, resources := range byModule {
|
||||
if err := keepAuthority(opts.state, delivery.Composed{Module: module, Resources: resources}); err != nil {
|
||||
if err := keepAuthority(opts.state, compositions.Composed{Module: module, Resources: resources}); err != nil {
|
||||
say("the mesh's certificate the kept declaration carries could not be kept for this machine's record: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -477,7 +480,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// bus module this node runs declares, as its agent holds what runs (its own composition, or the declaration kept
|
||||
// before the switch): never read from the node's composition bucket, which the controller wrote. On every node;
|
||||
// a node that runs no list writes nothing. Looked at again whenever what runs changed (RunsChanged, Settled).
|
||||
users := userListFollower(opts, session, func() []delivery.Composed {
|
||||
users := userListFollower(opts, session, func() []compositions.Composed {
|
||||
if now := current.get(); now != nil {
|
||||
return now.Everything()
|
||||
}
|
||||
@@ -498,7 +501,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
}()
|
||||
env := agent.Env{
|
||||
Node: node,
|
||||
Wants: bucketWatch{session, delivery.AssignerWantsBucket},
|
||||
Wants: bucketWatch{session, buckets.Wants},
|
||||
Composed: bucketWatch{session, composedBucket},
|
||||
Haves: sessionHaves{session},
|
||||
Kept: func() map[string][]json.RawMessage { by, _ := kept(); return by },
|
||||
@@ -509,6 +512,12 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// composes its own (issue 774).
|
||||
Applied: appliedStoreFor(opts.state),
|
||||
Inactive: inactiveIn(&liveness.Exec{Run: liveness.Runner(opts.run()), System: builtFor}, heldBy(apply.WindowsIn(filepath.Dir(opts.state)))),
|
||||
Held: func() map[string]string {
|
||||
if c := composerNow.Load(); c != nil {
|
||||
return c.LastHeld()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Revisions: func(ctx context.Context, key string) ([]agent.Update, error) {
|
||||
got, err := session.KVHistory(ctx, composedBucket, key)
|
||||
if err != nil {
|
||||
@@ -538,7 +547,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
Say: say,
|
||||
// A failure of the bus not answering for a moment is tried again: 2 s, 5 s, 15 s, 60 s, then every 5 min.
|
||||
Backoff: link.Backoff,
|
||||
Apply: func(ctx context.Context, c delivery.Composed, others []delivery.Composed) agent.Result {
|
||||
Apply: func(ctx context.Context, c compositions.Composed, others []compositions.Composed) agent.Result {
|
||||
actx, done := applyContext(session.WithLink(ctx))
|
||||
// While the grants part's list is followed, the controller's list in the bus module's composition is not
|
||||
// written: the apply keeps the list on the machine (ADR 0547 clause 8, never both).
|
||||
@@ -564,7 +573,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
sayFacts(ctx, ag)
|
||||
return res
|
||||
},
|
||||
Remove: func(ctx context.Context, module string, others []delivery.Composed) agent.Result {
|
||||
Remove: func(ctx context.Context, module string, others []compositions.Composed) agent.Result {
|
||||
actx, done := applyContext(session.WithLink(ctx))
|
||||
res := removeComposition(actx, opts, module, others, ag.Placed(module), sched, say)
|
||||
done()
|
||||
@@ -580,8 +589,19 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
// The agent of this run alone: its composer tells it, never the agent of a run begun since (issue 1126: a pass in
|
||||
// flight at the switch marked the next agent's modules failed).
|
||||
own := &agentNow{}
|
||||
// And the seat list it reads through the seat mesh-catalog, from its bootstrapped definitions until it has: the
|
||||
// composer hands nothing new meanwhile, and the node says it has waited five minutes (novox/hq ADR 0636 §1).
|
||||
seats, err := startSeatList(run, session, node, say)
|
||||
if err != nil {
|
||||
// No reader is no gate that lets everything through: the node acts on nothing new, as one that waits does.
|
||||
say(err.Error() + "; the node composes nothing new until it is started again and reads the list (novox/hq ADR 0636 §1)")
|
||||
}
|
||||
// The marks of the haves run off the composition pass, each bounded (see marker).
|
||||
marks := newMarker(run)
|
||||
hooks := composerHooks{
|
||||
running: func() []delivery.Composed {
|
||||
seatList: seats,
|
||||
seatListFailed: err != nil,
|
||||
running: func() []compositions.Composed {
|
||||
if now := own.get(); now != nil {
|
||||
return runsAsWritten(now.Everything(), opts.state)
|
||||
}
|
||||
@@ -590,7 +610,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
unseal: sealOpener(opts.state),
|
||||
// And what each module's have says it runs sealed: after a restart the agent has applied nothing yet.
|
||||
// A have the bus did not answer for is said as such, never as one holding no seals (issue 1126, 2026-10-28).
|
||||
held: func(module string) (delivery.Seals, []string, error) {
|
||||
held: func(module string) (compositions.Seals, []string, error) {
|
||||
if module == agent.NodeModule {
|
||||
got, _, err := session.ReadNodeHaveErr(linked)
|
||||
return got.SealedAt, got.Sealed, err
|
||||
@@ -603,13 +623,14 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
},
|
||||
refused: func(why string, held map[string]string) {
|
||||
if now := own.get(); now != nil && run.Err() == nil {
|
||||
now.NotComposedEach(run, why, held) // a module held alone marks that module, never the others (issue 1126)
|
||||
// A module held alone marks that module, never the others (issue 1126); the marking never holds the pass.
|
||||
marks.set("refused", func(ctx context.Context) { now.NotComposedEach(ctx, why, held) })
|
||||
}
|
||||
},
|
||||
// And what it keeps of a module as it runs, for a manifest that does not say it yet: on the module's have (ADR 0601 §4).
|
||||
kept: func(kept map[string][]string) {
|
||||
if now := own.get(); now != nil && run.Err() == nil {
|
||||
now.KeptNow(run, kept)
|
||||
marks.set("kept", func(ctx context.Context) { now.KeptNow(ctx, kept) })
|
||||
}
|
||||
},
|
||||
// And the vault's authority beside the controller's, and this node's certificate asked of the vault when a
|
||||
@@ -619,26 +640,27 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
|
||||
return certifyThisNode(ctx, opts.state, node, trust, session.AskVault, func() { sayFacts(ctx, ag) })
|
||||
},
|
||||
}
|
||||
// And its answer to every placement candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
|
||||
// And its answer to every assignment candidate, placed or not, applying nothing (novox/hq ADR 0559 §4): written into
|
||||
// its own key, `<node>.node`, under candidates.
|
||||
shadowHooks := hooks
|
||||
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify = nil, nil, nil
|
||||
shadowHooks.refused, shadowHooks.kept, shadowHooks.certify, shadowHooks.seatList = nil, nil, nil, nil
|
||||
shadowHooks.seatListFailed = false
|
||||
shadowEnv := composerEnv(session, node, say, shadowHooks)
|
||||
shadow := selfcompose.NewShadow(shadowEnv,
|
||||
func(ctx context.Context, answers map[string]delivery.CandidateAnswer) {
|
||||
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
|
||||
func(ctx context.Context, answers map[string]nodeengine.CandidateAnswer) {
|
||||
session.NoteCandidates(ctx, compositions.WithValuesDiffer(answers))
|
||||
})
|
||||
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
|
||||
shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
|
||||
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
|
||||
// composition bucket) is never applied. While the seat's placement holds no key for this node it composes
|
||||
// nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again.
|
||||
// composition bucket) is never applied. While the seat's assignment holds no key for this node it composes
|
||||
// nothing and keeps running what it runs; a assignment it cannot watch stops the agent, said, and it begins again.
|
||||
composer := selfComposer(session, node, hooks, say)
|
||||
machinesNow.Store(composer) // the machines a failing network part points at, by the placements (issue 774)
|
||||
machinesNow.Store(composer) // the machines a failing network part points at, by the assignments (issue 774)
|
||||
composerNow.Store(composer) // what the verb compose asks a pass of, this run's alone
|
||||
env.Composed, env.SelfComposed, env.Policy, env.ComposedToday = composer, true, composer.Policy, composer.ComposedToday
|
||||
env.HoldsData = composer.HoldsData // what runs from the kept declaration says Data as its manifest does
|
||||
session.NoteComposes(linked, delivery.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
|
||||
session.NoteComposes(linked, nodeengineclient.ComposesOwn, "it composes its own modules, and applies no composition made elsewhere")
|
||||
ag = agent.New(env)
|
||||
own.set(ag)
|
||||
current.set(ag)
|
||||
@@ -677,14 +699,60 @@ var reloadSettles = 10 * time.Second
|
||||
type agentNow struct {
|
||||
mu sync.Mutex
|
||||
ag *agent.Agent
|
||||
// last is the view of what runs the previous agent held when it stopped, kept in memory: while the next agent has
|
||||
// read no haves yet, what runs is this and never the retired kept declaration (a bus link that drops restarts the
|
||||
// agent, and every fill in that window was refused; issue 924).
|
||||
last *runsView
|
||||
// view reads an agent's view; viewOf unless a test stands in for an agent that holds the node.
|
||||
view func(*agent.Agent) *runsView
|
||||
}
|
||||
|
||||
func viewOf(a *agent.Agent) *runsView { return &runsView{a.Everything(), a.HoldsNode()} }
|
||||
|
||||
// runsView is what an agent said runs, to be read after the agent stopped.
|
||||
type runsView struct {
|
||||
everything []compositions.Composed
|
||||
holdsNode bool
|
||||
}
|
||||
|
||||
func (c *agentNow) set(a *agent.Agent) {
|
||||
c.mu.Lock()
|
||||
if a == nil && c.ag != nil {
|
||||
read := c.view
|
||||
if read == nil {
|
||||
read = viewOf
|
||||
}
|
||||
if v := read(c.ag); len(v.everything) > 0 || v.holdsNode {
|
||||
c.last = v
|
||||
}
|
||||
}
|
||||
c.ag = a
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *agentNow) viewFn() func(*agent.Agent) *runsView {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.view == nil {
|
||||
return viewOf
|
||||
}
|
||||
return c.view
|
||||
}
|
||||
|
||||
// holdsNode drops the kept view once the agent in hand holds the node: its own view is complete from then on.
|
||||
func (c *agentNow) holdsNode() {
|
||||
c.mu.Lock()
|
||||
c.last = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// lastView is the view the last agent held when it stopped, nil when none held any.
|
||||
func (c *agentNow) lastView() *runsView {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.last
|
||||
}
|
||||
|
||||
func (c *agentNow) get() *agent.Agent {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -698,7 +766,7 @@ func (c *agentNow) get() *agent.Agent {
|
||||
//
|
||||
// removed is the modules whose want says removed: in the node's own apply, a resource of any other module goes only when
|
||||
// its module is among them (issue 774).
|
||||
func applyComposition(ctx context.Context, opts options, c delivery.Composed, others []delivery.Composed, placed, removed map[string]bool,
|
||||
func applyComposition(ctx context.Context, opts options, c compositions.Composed, others []compositions.Composed, placed, removed map[string]bool,
|
||||
sched *apply.Scheduler, say link.Announce) agent.Result {
|
||||
name := scopeName(c)
|
||||
if c.Module == "" {
|
||||
@@ -725,12 +793,19 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
|
||||
if got.Refused != "" {
|
||||
return failedWith(got.Refused)
|
||||
}
|
||||
// An image that could not be pulled from a store is said with its holder, for the engine to try the apply again once the
|
||||
// holder has settled (design 51, ENG-8).
|
||||
failedOn := func(why string) agent.Result {
|
||||
res := failedWith(why)
|
||||
res.Pulls = pullsOf(d, got.Err)
|
||||
return res
|
||||
}
|
||||
results := apply.ModuleResults(d, got.Outcome, got.Err)
|
||||
if r, ok := results[name]; ok && r.Failed {
|
||||
return failedWith(r.Detail)
|
||||
return failedOn(r.Detail)
|
||||
}
|
||||
if got.Err != nil {
|
||||
return failedWith(got.Err.Error())
|
||||
return failedOn(got.Err.Error())
|
||||
}
|
||||
if got.Probe != nil {
|
||||
return failedWith(got.Probe.partial().Error())
|
||||
@@ -739,7 +814,7 @@ func applyComposition(ctx context.Context, opts options, c delivery.Composed, ot
|
||||
}
|
||||
|
||||
// removeComposition removes one module's own resources and nothing of another's.
|
||||
func removeComposition(ctx context.Context, opts options, module string, others []delivery.Composed, placed map[string]bool,
|
||||
func removeComposition(ctx context.Context, opts options, module string, others []compositions.Composed, placed map[string]bool,
|
||||
sched *apply.Scheduler, say link.Announce) agent.Result {
|
||||
scope := apply.Scope{Module: module, Others: resourcesOf(others), Placed: placed}
|
||||
got := applyModuleHeld(ctx, opts, &declaration.Declaration{Version: declaration.Version}, &scope, sched, say)
|
||||
@@ -766,7 +841,7 @@ func busyModules() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// machinesNow is the composer whose placements name the mesh's machines by address, for the health report's targets
|
||||
// machinesNow is the composer whose assignments name the mesh's machines by address, for the health report's targets
|
||||
// (writeHealthReport); nil on a node that does not compose its own, whose parts then name no target.
|
||||
var machinesNow atomic.Pointer[selfcompose.Composer]
|
||||
|
||||
@@ -774,11 +849,11 @@ var machinesNow atomic.Pointer[selfcompose.Composer]
|
||||
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
|
||||
var composerNow atomic.Pointer[selfcompose.Composer]
|
||||
|
||||
// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil
|
||||
// shadowNow is the shadow of the run now, which answers assignment candidates and the verb compose-candidate; nil
|
||||
// between runs.
|
||||
var shadowNow atomic.Pointer[selfcompose.Shadow]
|
||||
|
||||
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
|
||||
// keptRetired says the kept declaration is read no more: every module the seat's assignment puts on this node has a have of
|
||||
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
|
||||
var keptRetired atomic.Bool
|
||||
|
||||
@@ -812,7 +887,7 @@ func keptDeclaration(statePath string, signer ed25519.PublicKey) (*declaration.D
|
||||
// (adoption, identities, groups, libraries) from the node's composition once the agent holds it and from the kept
|
||||
// declaration until then. What the node judges its health by, and what the filling, the take act and the judgement of
|
||||
// directories read.
|
||||
func whatRuns(everything []delivery.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
|
||||
func whatRuns(everything []compositions.Composed, holdsNode bool, kept func() (*declaration.Declaration, error)) (*declaration.Declaration, error) {
|
||||
d, err := declarationOf(everything...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -861,11 +936,23 @@ func declaredNow(statePath string, signer ed25519.PublicKey) (*declaration.Decla
|
||||
func runsFromAgent(current *agentNow) func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
return func(statePath string, signer ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
kept := func() (*declaration.Declaration, error) { return keptDeclaration(statePath, signer) }
|
||||
ag := current.get()
|
||||
if ag == nil {
|
||||
var view *runsView
|
||||
if ag := current.get(); ag != nil {
|
||||
view = current.viewFn()(ag)
|
||||
if view.holdsNode {
|
||||
current.holdsNode()
|
||||
}
|
||||
}
|
||||
// An agent that has read no haves yet, or none: the last view held, never the retired kept declaration.
|
||||
if view == nil || (len(view.everything) == 0 && !view.holdsNode) {
|
||||
if last := current.lastView(); last != nil {
|
||||
view = last
|
||||
}
|
||||
}
|
||||
if view == nil {
|
||||
return kept()
|
||||
}
|
||||
return whatRuns(ag.Everything(), ag.HoldsNode(), kept)
|
||||
return whatRuns(view.everything, view.holdsNode, kept)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,8 +985,8 @@ func refuseEmptyWrites(d *declaration.Declaration) error {
|
||||
// recordedIn says the engine's applied state holds a record of every resource of a composition: what the agent believes
|
||||
// of a have or the kept declaration only when it does, so a resource removed since is made again (issue 1945). A state
|
||||
// that cannot be read says yes: the agent then believes what it was told, as before.
|
||||
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c delivery.Composed) bool {
|
||||
return func(_ context.Context, c delivery.Composed) bool {
|
||||
func recordedIn(statePath string, say func(string)) func(ctx context.Context, c compositions.Composed) bool {
|
||||
return func(_ context.Context, c compositions.Composed) bool {
|
||||
known, err := store.Load(statePath)
|
||||
if err != nil {
|
||||
if say != nil {
|
||||
@@ -961,18 +1048,22 @@ func applyContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
|
||||
// composerHooks are what the composer reads of the agent and the machine (selfcompose.Env Running, Unseal, Refused).
|
||||
type composerHooks struct {
|
||||
running func() []delivery.Composed
|
||||
running func() []compositions.Composed
|
||||
unseal func(string) ([]byte, error)
|
||||
refused func(why string, held map[string]string)
|
||||
kept func(kept map[string][]string)
|
||||
held func(module string) (delivery.Seals, []string, error)
|
||||
held func(module string) (compositions.Seals, []string, error)
|
||||
// vaultAuthority and certify are selfcompose.Env's VaultAuthority and Certify.
|
||||
vaultAuthority func() string
|
||||
certify func(ctx context.Context) error
|
||||
// seatList is this run's reader of the seat list: the composer hands nothing new until it was read (ADR 0636 §1).
|
||||
seatList *seatListState
|
||||
// seatListFailed says the reader could not start: the composer then holds like a reader that waits (selfcompose.Unread).
|
||||
seatListFailed bool
|
||||
}
|
||||
|
||||
// selfComposer is the node's own composer: it composes what the seat's placement places on the node, and nothing while
|
||||
// the placement holds no key for it (issue 1126).
|
||||
// selfComposer is the node's own composer: it composes what the seat's assignment places on the node, and nothing while
|
||||
// the assignment holds no key for it (issue 1126).
|
||||
func selfComposer(session *link.Session, node string, hooks composerHooks, say func(string)) *selfcompose.Composer {
|
||||
env := composerEnv(session, node, say, hooks)
|
||||
// And it keeps each placed module's data items in its own state data, which the node's disk-load watches (novox/hq
|
||||
@@ -988,11 +1079,11 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f
|
||||
}
|
||||
|
||||
// composerEnv is what the node's composers read of the bus, the vault and the agent: its own composer and its answer
|
||||
// to placement candidates (selfcompose.Shadow) alike.
|
||||
// to assignment candidates (selfcompose.Shadow) alike.
|
||||
func composerEnv(session *link.Session, node string, say func(string), hooks composerHooks) selfcompose.Env {
|
||||
return selfcompose.Env{Node: node,
|
||||
env := selfcompose.Env{Node: node,
|
||||
Watch: func(ctx context.Context, bucket, pattern string) (<-chan agent.Update, error) {
|
||||
if bucket == delivery.PlacementBucket || bucket == delivery.PlacementCandidatesBucket {
|
||||
if bucket == buckets.Assignments || bucket == buckets.ProposalCandidates {
|
||||
// The seat's states are made when their holder starts: a node-engine that starts first waits for the
|
||||
// bus's word that the bucket was created, and the shadow and the composer say they wait.
|
||||
return bucketWatch{session, bucket}.WatchAwait(ctx, pattern, func(err error) {
|
||||
@@ -1020,4 +1111,10 @@ func composerEnv(session *link.Session, node string, say func(string), hooks com
|
||||
Fetch: apply.Fetch,
|
||||
Running: hooks.running, Unseal: hooks.unseal, Refused: hooks.refused, Held: hooks.held, Kept: hooks.kept,
|
||||
VaultAuthority: hooks.vaultAuthority, Certify: hooks.certify}
|
||||
if hooks.seatList != nil {
|
||||
env.SeatList, env.SeatListChanged = hooks.seatList.reader, hooks.seatList.changed
|
||||
} else if hooks.seatListFailed {
|
||||
env.SeatList = selfcompose.Unread{}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
@@ -7,22 +7,36 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
builderbuilds "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/buckets/bucketstest"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/nodegrant"
|
||||
"github.com/novox/mesh-host/internal/seatwait"
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
"github.com/novox/mesh-host/internal/system"
|
||||
@@ -54,6 +68,11 @@ type itBed struct {
|
||||
// refusals are the bus's asynchronous errors on the node's connection: each permissions violation it met.
|
||||
refusals []string
|
||||
mu sync.Mutex
|
||||
// seatSilent makes the stand-in of the seat mesh-catalog refuse the seat list (it is served from the start otherwise).
|
||||
seatSilent atomic.Bool
|
||||
// seatAsked counts the node's asks of the stand-in seat, answered or refused: the node reached the bus and asked.
|
||||
seatAsked atomic.Int32
|
||||
findings nats.KeyValue
|
||||
}
|
||||
|
||||
// aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what
|
||||
@@ -119,19 +138,23 @@ func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
|
||||
if b.nodeJS, err = b.nodeConn.JetStream(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
composedBucket, _ := delivery.ComposedBucketFor(itNode)
|
||||
composedBucket, _ := engcomp.BucketFor(itNode)
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
into *nats.KeyValue
|
||||
history uint8
|
||||
}{{delivery.AssignerWantsBucket, &b.wants, 8}, {composedBucket, &b.composed, delivery.ComposedHistory},
|
||||
{delivery.HaveBucket, &b.haves, delivery.HaveHistory}} {
|
||||
}{{buckets.Wants, &b.wants, 8}, {composedBucket, &b.composed, engcomp.History},
|
||||
{buckets.Have, &b.haves, buckets.HaveHistory}} {
|
||||
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: c.name, History: c.history})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
*c.into = kv
|
||||
}
|
||||
if b.findings, err = b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: link.FindingsBucket, History: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.standInSeat()
|
||||
b.opts = options{state: filepath.Join(b.dir, "state", "state.json"), timeout: 2 * time.Second}
|
||||
if err := os.MkdirAll(filepath.Dir(b.opts.state), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -164,17 +187,17 @@ func (b *itBed) say(line string) {
|
||||
b.t.Log(line)
|
||||
}
|
||||
|
||||
func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
func fpIt(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
|
||||
func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
|
||||
func (b *itBed) want(module, fp, digest, backup string) {
|
||||
b.t.Helper()
|
||||
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
|
||||
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup})
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.WantKey(itNode, module)
|
||||
key := meshwants.WantsByWantNodeModuleKey(itNode, module)
|
||||
if _, err := b.wants.Put(key, raw); err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
@@ -185,13 +208,13 @@ func (b *itBed) file(module string) string {
|
||||
return string(got)
|
||||
}
|
||||
|
||||
func (b *itBed) have(module string) delivery.Have {
|
||||
key, _ := delivery.HaveKey(itNode, module)
|
||||
func (b *itBed) have(module string) nodeengine.HaveByNodeModuleValue {
|
||||
key, _ := nec.HaveKey(itNode, module)
|
||||
e, err := b.haves.Get(key)
|
||||
if err != nil {
|
||||
return delivery.Have{}
|
||||
return nodeengine.HaveByNodeModuleValue{}
|
||||
}
|
||||
h, _ := delivery.DecodeHave(e.Value())
|
||||
h, _ := nec.DecodeHave(e.Value())
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -207,16 +230,16 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) {
|
||||
}
|
||||
|
||||
// Issue 1126 (ADR 0547 §2, the milestone's H8): the node agent started whole against a real bus, once the catalogue
|
||||
// places its node (the placement written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it,
|
||||
// places its node (the assignment written after it started), composes a module from its want, the indexer's manifest, its record and its mesh-config value, and applies it,
|
||||
// with no key in the controller's compositions; a changed config value is composed again on its event and applied as a
|
||||
// revision of what runs. And a catalogue pull request's placement candidate equal to its placement is answered "same"
|
||||
// revision of what runs. And a catalogue pull request's assignment candidate equal to its assignment is answered "same"
|
||||
// in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone).
|
||||
//
|
||||
// And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it
|
||||
// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value
|
||||
// a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the
|
||||
// read and the node composes nothing.
|
||||
func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
|
||||
func TestNatsANodeTheAssignmentsNameComposesAndAppliesItsOwn(t *testing.T) {
|
||||
t.Run("without the served key the bus refuses the read", func(t *testing.T) {
|
||||
b := newItBed(t)
|
||||
stop, _ := h8(t, b, false)
|
||||
@@ -237,12 +260,12 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
|
||||
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
|
||||
stop, st := h8(t, b, true)
|
||||
defer stop()
|
||||
placement, resolved, candidates := st.placement, st.resolved, st.candidates
|
||||
assignment, resolved, candidates := st.assignment, st.resolved, st.candidates
|
||||
hkey, place, fp := st.hkey, st.place, st.fp
|
||||
within(t, 5*time.Second, "the node did not compose and apply its own module once placed", func() bool { return b.file("web") == "greeting=bonjour\n" })
|
||||
// ADR 0565 §6 (1953): the node's engine writes its own key of the state packages, under the grant the grants part
|
||||
// composes for it, with each package its compositions declare and the module declaring it; once, while nothing changes.
|
||||
wantPkgs := `{"node":"` + itNode + `","packages":[{"package":"web-tools","modules":["web"],"absent":false}]}`
|
||||
wantPkgs := `{"node":"` + itNode + `","packages":[{"absent":false,"modules":["web"],"package":"web-tools"}]}`
|
||||
within(t, 5*time.Second, "the node did not write its key of the state packages", func() bool {
|
||||
e, err := st.packages.Get(itNode)
|
||||
return err == nil && string(e.Value()) == wantPkgs
|
||||
@@ -254,7 +277,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
|
||||
t.Errorf("the node's packages key was written again with no package changed: %v, was %v", e, written)
|
||||
}
|
||||
t.Run("one answer per candidate revision", func(t *testing.T) { h8once(t, b, resolved, candidates, place) })
|
||||
h8back(t, b, placement, resolved, candidates, hkey)
|
||||
h8back(t, b, assignment, resolved, candidates, hkey)
|
||||
}
|
||||
|
||||
// h8once is issue 1126, 2026-10-28 (shanks answered probe/1126-seed-7 at revision 84 every 13-17 s, its bus flooded):
|
||||
@@ -267,7 +290,7 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
|
||||
defer b.mu.Unlock()
|
||||
n := 0
|
||||
for _, l := range b.said {
|
||||
if strings.HasPrefix(l, "answering placement candidate feat-x ") {
|
||||
if strings.HasPrefix(l, "answering assignment candidate feat-x ") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
@@ -278,11 +301,11 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
|
||||
if settled == 0 {
|
||||
t.Fatal("feat-x was never answered")
|
||||
}
|
||||
hkey, _ := delivery.HaveKey(itNode, "web")
|
||||
hkey, _ := nec.HaveKey(itNode, "web")
|
||||
for _, detail := range []string{"its tool web_health: no link to the bus is open", "its tool web_health: no answer within 10s"} {
|
||||
h := b.have("web")
|
||||
h.Health, h.Detail = delivery.HealthFailed, detail
|
||||
raw, err := delivery.EncodeHave(h)
|
||||
h.Health, h.Detail = nec.HealthFailed, detail
|
||||
raw, err := nec.EncodeHave(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -297,12 +320,12 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
|
||||
if got := answering(); got != settled {
|
||||
t.Errorf("feat-x answered %d more time(s) at the same revision, with nothing it is composed from changed", got-settled)
|
||||
}
|
||||
same, _ := delivery.DecodePlacement(place)
|
||||
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same})
|
||||
same, _ := meshmoduleassignerclient.DecodeAssignments(place)
|
||||
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode)
|
||||
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode)
|
||||
if _, err := candidates.Put(ckey, craw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -313,66 +336,66 @@ func h8once(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, place []
|
||||
}
|
||||
}
|
||||
|
||||
// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's placement is
|
||||
// refused, never answered as a return to the controller's compositions; the placement key deleted leaves the agent on
|
||||
// h8back is A7 of issue 1126: no composition made elsewhere is applied. A candidate deleting the node's assignment is
|
||||
// refused, never answered as a return to the controller's compositions; the assignment key deleted leaves the agent on
|
||||
// its own composer, composing nothing and keeping what runs, said in the journal, the node's have still saying it
|
||||
// composes its own.
|
||||
func h8back(t *testing.T, b *itBed, placement, resolved, candidates nats.KeyValue, hkey string) {
|
||||
func h8back(t *testing.T, b *itBed, assignment, resolved, candidates nats.KeyValue, hkey string) {
|
||||
t.Helper()
|
||||
node := func() delivery.HaveNode {
|
||||
node := func() nodeengine.HaveByNodeNodeValue {
|
||||
e, err := b.haves.Get(hkey)
|
||||
if err != nil {
|
||||
return delivery.HaveNode{}
|
||||
return nodeengine.HaveByNodeNodeValue{}
|
||||
}
|
||||
n, _ := delivery.DecodeHaveNode(e.Value())
|
||||
n, _ := nec.DecodeHaveNode(e.Value())
|
||||
return n
|
||||
}
|
||||
if n := node(); n.Composes != delivery.ComposesOwn {
|
||||
if n := node(); n.Composes != nec.ComposesOwn {
|
||||
t.Errorf("a node composing its own says it composes %q", n.Composes)
|
||||
}
|
||||
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "revert-x"})
|
||||
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "revert-x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.PlacementCandidateKey("revert-x", itNode)
|
||||
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("revert-x", itNode)
|
||||
if _, err := candidates.Put(ckey, craw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
within(t, 10*time.Second, "the node did not refuse a candidate taking its placement away", func() bool {
|
||||
within(t, 10*time.Second, "the node did not refuse a candidate taking its assignment away", func() bool {
|
||||
a := node().Candidates["revert-x"]
|
||||
return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == delivery.CandidateRefused &&
|
||||
return a.Proposal == "revert-x" && a.Returns == "" && a.Verdict == nodeengine.CandidateAnswerVerdictRefused &&
|
||||
strings.Contains(a.Refused, "composes nothing")
|
||||
})
|
||||
ran := b.have("web").Composed
|
||||
pkey, _ := delivery.PlacementNodeKey(itNode)
|
||||
pkey, _ := meshmoduleassignerclient.NodeKey(itNode)
|
||||
// A pass in flight when the key goes (issue 1126, 2026-10-28): a config value changed starts one, and the key goes at once.
|
||||
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := placement.Delete(pkey); err != nil {
|
||||
if err := assignment.Delete(pkey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
within(t, 15*time.Second, "the node did not say it composes nothing once its placement went", func() bool {
|
||||
within(t, 15*time.Second, "the node did not say it composes nothing once its assignment went", func() bool {
|
||||
b.mu.Lock()
|
||||
said := strings.Join(b.said, "\n")
|
||||
b.mu.Unlock()
|
||||
return strings.Contains(said, "key in the seat's placement state is gone: it composes nothing of its own")
|
||||
return strings.Contains(said, "key in the seat's assignment state is gone: it composes nothing of its own")
|
||||
})
|
||||
time.Sleep(3 * time.Second) // what a pass in flight would mark lands within it
|
||||
b.mu.Lock()
|
||||
said := strings.Join(b.said, "\n")
|
||||
b.mu.Unlock()
|
||||
if n := node(); n.Composes != delivery.ComposesOwn || strings.Contains(said, "applying the controller's compositions") {
|
||||
t.Errorf("without its placement the node turned to the controller's compositions: %q", n.Composes)
|
||||
if n := node(); n.Composes != nec.ComposesOwn || strings.Contains(said, "applying the controller's compositions") {
|
||||
t.Errorf("without its assignment the node turned to the controller's compositions: %q", n.Composes)
|
||||
}
|
||||
if h := b.have("web"); h.Composed != ran || h.Phase == delivery.PhaseFailed || b.file("web") != "greeting=salut\n" {
|
||||
t.Errorf("without its placement what runs moved: %+v, %q", h, b.file("web"))
|
||||
if h := b.have("web"); h.Composed != ran || h.Phase == nec.PhaseFailed || b.file("web") != "greeting=salut\n" {
|
||||
t.Errorf("without its assignment what runs moved: %+v, %q", h, b.file("web"))
|
||||
}
|
||||
}
|
||||
|
||||
// h8state is what h8 set up that the rest of H8 writes to.
|
||||
type h8state struct {
|
||||
placement, resolved, candidates nats.KeyValue
|
||||
assignment, resolved, candidates nats.KeyValue
|
||||
// packages is the engine's state packages (ADR 0565 §6), as the grants part creates it from the engine's manifest.
|
||||
packages nats.KeyValue
|
||||
hkey string
|
||||
@@ -391,42 +414,42 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
|
||||
}
|
||||
return kv
|
||||
}
|
||||
placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket)
|
||||
assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests)
|
||||
resolved := kvOf(selfcompose.ResolvedBucket)
|
||||
builds := kvOf(delivery.BuilderBuildsBucket)
|
||||
candidates := kvOf(delivery.PlacementCandidatesBucket)
|
||||
builds := kvOf(builderbuilds.BuilderBuildsBucket)
|
||||
candidates := kvOf(buckets.ProposalCandidates)
|
||||
packages := kvOf(link.PackagesBucket)
|
||||
path := filepath.Join(b.dir, "web.conf")
|
||||
man := `{"module":"web","version":"1","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
|
||||
`"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0644","content":"greeting=${config:greeting}\n"},` +
|
||||
`{"id":"tools","type":"package","package":"web-tools"}],` +
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
digest := delivery.ManifestDigest([]byte(man))
|
||||
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
|
||||
mkey, _ := delivery.ManifestsKey(digest)
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(man))
|
||||
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man})
|
||||
mkey, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
fp := fpIt("a")
|
||||
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)})
|
||||
place, _ := delivery.EncodePlacement(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
|
||||
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}})
|
||||
modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40)})
|
||||
place, _ := meshmoduleassignerclient.EncodeAssignments(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}})
|
||||
rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode, Capabilities: []string{"service-manager", "package-manager"}}})
|
||||
// it1 in one key per module and node (research 072 row 37), it2 in the form before: a node reads both.
|
||||
pkey, _ := delivery.PlacementNodeKey(itNode)
|
||||
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "web"}}})
|
||||
pkey, _ := meshmoduleassignerclient.NodeKey(itNode)
|
||||
places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "web"}}})
|
||||
place = places[pkey]
|
||||
hkey, _ := delivery.HaveNodeKey(itNode)
|
||||
hkey, _ := nec.HaveNodeKey(itNode)
|
||||
// A provider on it2 serving from its own config value: a mail domain, which it1's composition settles.
|
||||
mailer := `{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
|
||||
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`
|
||||
mdigest := delivery.ManifestDigest([]byte(mailer))
|
||||
mlraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "mailer", JSON: mailer})
|
||||
mlkey, _ := delivery.ManifestsKey(mdigest)
|
||||
mlmod, _ := delivery.EncodeModule(delivery.Module{FP2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)})
|
||||
place2, _ := delivery.EncodePlacement(delivery.Placement{Node: "it2", Modules: []delivery.PlacedModule{{Module: "mailer"}}})
|
||||
rec2, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}})
|
||||
pkey2, _ := delivery.LegacyPlacementKey("it2")
|
||||
hkey2, _ := delivery.HaveNodeKey("it2")
|
||||
buildKey, _ := delivery.BuilderBuildKey("web", fp)
|
||||
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}})
|
||||
mdigest := meshcatalogclient.ManifestDigest([]byte(mailer))
|
||||
mlraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "mailer", Manifest: mailer})
|
||||
mlkey, _ := meshcatalogclient.ManifestsKey(mdigest)
|
||||
mlmod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpIt("b"), Manifest: mdigest, Repository: "novox/mailer", Commit: strings.Repeat("2", 40)})
|
||||
places2, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: "it2", Modules: []meshmoduleassigner.PlacedModule{{Module: "mailer"}}})
|
||||
rec2, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: "it2", Capabilities: []string{"service-manager"}}})
|
||||
pkey2, _ := meshmoduleassignerclient.NodeKey("it2")
|
||||
hkey2, _ := nec.HaveNodeKey("it2")
|
||||
buildKey, _ := builderbuilds.BuilderBuildKey("web", fp)
|
||||
done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest,
|
||||
Artifacts: []builderbuilds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://web/files/blobs/" + fpDigest("f"), Digest: fpDigest("f")}}})
|
||||
firstBuild := done
|
||||
if checkBuild {
|
||||
firstBuild = []byte(`{"v":1,"state":"no such state"}`)
|
||||
@@ -437,14 +460,14 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
|
||||
value []byte
|
||||
}{{manifests, mkey, mraw}, {modules, "web", modv}, {b.haves, hkey, rec},
|
||||
{resolved, itNode + ".web.greeting", []byte(`{"value":"bonjour","source":"module"}`)},
|
||||
{manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {placement, pkey2, place2},
|
||||
{manifests, mlkey, mlraw}, {modules, "mailer", mlmod}, {b.haves, hkey2, rec2}, {assignment, pkey2, places2[pkey2]}, {assignment, "it2.mailer", places2["it2.mailer"]},
|
||||
{resolved, "it2.mailer.domain", []byte(`{"value":"example.org","source":"module"}`)}, {builds, buildKey, firstBuild}} {
|
||||
if _, err := w.kv.Put(w.key, w.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want, _ := delivery.EncodeWant(delivery.Want{FP: fp, Self: true})
|
||||
wkey, _ := delivery.WantKey(itNode, "web")
|
||||
want, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Self: true})
|
||||
wkey := meshwants.WantsByWantNodeModuleKey(itNode, "web")
|
||||
if _, err := b.wants.Put(wkey, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -456,10 +479,10 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
|
||||
if b.file("web") != "" {
|
||||
t.Fatal("a node the catalogue does not place composed its own")
|
||||
}
|
||||
if _, err := placement.Put(itNode+".web", places[itNode+".web"]); err != nil {
|
||||
if _, err := assignment.Put(itNode+".web", places[itNode+".web"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := placement.Put(pkey, place); err != nil {
|
||||
if _, err := assignment.Put(pkey, place); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if checkBuild {
|
||||
@@ -480,7 +503,7 @@ func h8(t *testing.T, b *itBed, checkBuild bool) (func(), h8state) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return stop, h8state{placement: placement, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp}
|
||||
return stop, h8state{assignment: assignment, resolved: resolved, candidates: candidates, packages: packages, hkey: hkey, place: place, fp: fp}
|
||||
}
|
||||
|
||||
// h8rest is H8 once the node composes its own: the have, a config revision, and the candidate answered "same".
|
||||
@@ -488,22 +511,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
|
||||
t.Helper()
|
||||
within(t, 2*time.Second, "the have does not name the build", func() bool {
|
||||
h := b.have("web")
|
||||
return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning
|
||||
return h.Fp == fp && h.Composed != "" && h.Phase == nec.PhaseRunning
|
||||
})
|
||||
// ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build
|
||||
// its want names, with the digest its have names (the plan left out of it), and the node's own.
|
||||
first := b.have("web").Composed
|
||||
webKey, _ := delivery.ComposedKey(itNode, "web", fp)
|
||||
webKey, _ := engcomp.Key(itNode, "web", fp)
|
||||
within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool {
|
||||
e, err := b.composed.Get(webKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
c, err := delivery.DecodeComposed(e.Value())
|
||||
c, err := engcomp.Decode(e.Value())
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
d, err := delivery.ComposedDigestOf(c)
|
||||
d, err := engcomp.DigestOf(c)
|
||||
return err == nil && d == first
|
||||
})
|
||||
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
|
||||
@@ -512,36 +535,37 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
|
||||
within(t, 3*time.Second, "a changed config value was not composed and applied again", func() bool { return b.file("web") == "greeting=salut\n" })
|
||||
within(t, 2*time.Second, "the have does not name the revision", func() bool {
|
||||
h := b.have("web")
|
||||
return h.FP == fp && h.Composed != first && h.Phase == delivery.PhaseRunning
|
||||
return h.Fp == fp && h.Composed != first && h.Phase == nec.PhaseRunning
|
||||
})
|
||||
|
||||
// The node says its facts (its record, as the test wrote it), then a candidate equal to its placement is written: the
|
||||
// The node says its facts (its record, as the test wrote it), then a candidate equal to its assignment is written: the
|
||||
// node answers "same" at the candidate's head and revision in its own key, and applies nothing.
|
||||
b.session.SayFacts(context.Background(), link.Report{Profile: map[string]any{"capabilities": []any{
|
||||
map[string]any{"name": "service-manager", "present": true}}}})
|
||||
second := b.have("web").Composed
|
||||
same, _ := delivery.DecodePlacement(place)
|
||||
craw, err := delivery.EncodePlacementCandidate(delivery.PlacementCandidate{Proposal: "feat-x", Placement: &same})
|
||||
same, _ := meshmoduleassignerclient.DecodeAssignments(place)
|
||||
craw, err := bucketstest.Candidate(meshmoduleassigner.CandidatesByProposalNodeValue{Proposal: "feat-x", Assignments: &same})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.PlacementCandidateKey("feat-x", itNode)
|
||||
ckey := meshmoduleassigner.CandidatesByProposalNodeKey("feat-x", itNode)
|
||||
crev, err := candidates.Put(ckey, craw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var answer delivery.CandidateAnswer
|
||||
var answer nodeengine.CandidateAnswer
|
||||
within(t, 10*time.Second, "the node did not answer its candidate", func() bool {
|
||||
e, err := b.haves.Get(hkey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, err := delivery.DecodeHaveNode(e.Value())
|
||||
answer = n.Candidates["feat-x"]
|
||||
n, err := nec.DecodeHaveNode(e.Value())
|
||||
wire, _ := json.Marshal(n.Candidates["feat-x"])
|
||||
_ = json.Unmarshal(wire, &answer)
|
||||
return err == nil && answer.Proposal == "feat-x" && n.Record != nil
|
||||
})
|
||||
if answer.Verdict != delivery.CandidateSame || answer.Revision != crev || len(answer.Same) != 1 || answer.Same[0] != "web" {
|
||||
t.Errorf("the answer to a candidate equal to the placement: %+v", answer)
|
||||
if answer.Verdict != nodeengine.CandidateAnswerVerdictSame || answer.Revision != int64(crev) || len(answer.Same) != 1 || answer.Same[0] != "web" {
|
||||
t.Errorf("the answer to a candidate equal to the assignment: %+v", answer)
|
||||
}
|
||||
if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" {
|
||||
t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web"))
|
||||
@@ -561,8 +585,8 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
|
||||
}
|
||||
return kv
|
||||
}
|
||||
cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket)
|
||||
manifests, err := b.js.KeyValue(delivery.ManifestsBucket)
|
||||
cands, _ := kvOf(buckets.Candidates), kvOf(buckets.Verdicts)
|
||||
manifests, err := b.js.KeyValue(buckets.Manifests)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -570,25 +594,25 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
|
||||
man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
|
||||
`"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` +
|
||||
`{"id":"tools","type":"package","package":"web-tools"}]}`
|
||||
digest := delivery.ManifestDigest([]byte(man))
|
||||
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
|
||||
mkey, _ := delivery.ManifestsKey(digest)
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(man))
|
||||
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "web", Manifest: man})
|
||||
mkey, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
if _, err := manifests.Put(mkey, mraw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
|
||||
heads, _ := delivery.HeadsHash([]delivery.Head{head})
|
||||
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"},
|
||||
Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}}
|
||||
head := meshdeliveryclient.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
|
||||
heads, _ := meshdeliveryclient.HeadsHash([]meshdeliveryclient.Head{head})
|
||||
pulls := []meshcatalog.CandidatesByGroupValuePullsItem{bucketstest.Pull(head, []string{"web"},
|
||||
map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: fpIt("c"), Manifest: digest}})}
|
||||
// The indexer keys the candidate by its group (a branch name, slash allowed); the checker asks by the composition key.
|
||||
const group = "fix/3-web"
|
||||
craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: heads, Pulls: pulls,
|
||||
FPSet: delivery.FingerprintSet(pulls)})
|
||||
craw, err := bucketstest.GroupCandidate(meshcatalog.CandidatesByGroupValue{Group: group, Heads: heads, Pulls: pulls,
|
||||
Fpset: meshcatalogclient.FingerprintSet(pulls)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.CandidatesKey(group)
|
||||
key, _ := delivery.CompositionKey(group, heads)
|
||||
ckey, _ := buckets.CandidateKey(group)
|
||||
key, _ := nec.CompositionKey(group, heads)
|
||||
if _, err := cands.Put(ckey, craw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -598,12 +622,12 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := got.(json.RawMessage)
|
||||
a, err := delivery.DecodeComposeCandidateAnswer(raw)
|
||||
a, err := nec.DecodeAnswer(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the answer %s: %v", raw, err)
|
||||
}
|
||||
if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
|
||||
a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
|
||||
if a.Verdict != nec.Composed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
|
||||
a.Modules[0].Fp != fpIt("c") || !a.Modules[0].Changed || a.Took > nec.AnswerBound.Milliseconds() {
|
||||
t.Errorf("the node's answer to a candidate changing web: %+v", a)
|
||||
}
|
||||
after, _ := b.composed.Status()
|
||||
@@ -618,7 +642,7 @@ func h8candidate(t *testing.T, b *itBed, fp, running string) {
|
||||
|
||||
func mustComposedKey(t *testing.T, module, fp string) string {
|
||||
t.Helper()
|
||||
key, err := delivery.ComposedKey(itNode, module, fp)
|
||||
key, err := engcomp.Key(itNode, module, fp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -744,10 +768,10 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
}
|
||||
return kv
|
||||
}
|
||||
placement, modules, manifests := kvOf(delivery.PlacementBucket), kvOf(delivery.ModulesBucket), kvOf(delivery.ManifestsBucket)
|
||||
assignment, modules, manifests := kvOf(buckets.Assignments), kvOf(buckets.Modules), kvOf(buckets.Manifests)
|
||||
kvOf(selfcompose.ResolvedBucket)
|
||||
builds := kvOf(delivery.BuilderBuildsBucket)
|
||||
kvOf(delivery.PlacementCandidatesBucket)
|
||||
builds := kvOf(builderbuilds.BuilderBuildsBucket)
|
||||
kvOf(buckets.ProposalCandidates)
|
||||
kvOf(link.PackagesBucket)
|
||||
// The bus module at a build, done: its manifest written and the indexer's module naming it at fp.
|
||||
publish := func(fp, env string) {
|
||||
@@ -755,15 +779,15 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
`"data":{"own":[{"id":"jetstream","path":"` + b.dir + `","class":"valuable","why":"the bus's streams"}]},"resources":[` +
|
||||
`{"id":"server-conf","type":"file","path":"` + conf + `","mode":"0644","content":"listen: new\n"},` +
|
||||
`{"id":"server","type":"container","name":"` + server + `","image":"` + image + `"` + env + `}]}`
|
||||
digest := delivery.ManifestDigest([]byte(man))
|
||||
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "nats", JSON: man})
|
||||
mkey, _ := delivery.ManifestsKey(digest)
|
||||
modv, _ := delivery.EncodeModule(delivery.Module{FP2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)})
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(man))
|
||||
mraw, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "nats", Manifest: man})
|
||||
mkey, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
modv, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("3", 40)})
|
||||
if _, err := manifests.Put(mkey, mraw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bkey, _ := delivery.BuilderBuildKey("nats", fp)
|
||||
done, _ := delivery.EncodeBuilderBuild(delivery.BuilderBuild{State: delivery.BuildDone, Manifest: digest})
|
||||
bkey, _ := builderbuilds.BuilderBuildKey("nats", fp)
|
||||
done, _ := builderbuilds.EncodeBuilderBuild(builderbuilds.BuilderBuild{State: builderbuilds.BuildDone, Manifest: digest})
|
||||
if _, err := builds.Put(bkey, done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -771,24 +795,24 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rec, _ := delivery.EncodeHaveNode(delivery.HaveNode{Record: &delivery.NodeRecord{Node: itNode,
|
||||
rec, _ := nec.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: itNode,
|
||||
Capabilities: []string{"service-manager", "package-manager", "container-runtime"}}})
|
||||
hkey, _ := delivery.HaveNodeKey(itNode)
|
||||
places, _ := delivery.PlacementValues(delivery.Placement{Node: itNode, Modules: []delivery.PlacedModule{{Module: "nats"}}})
|
||||
hkey, _ := nec.HaveNodeKey(itNode)
|
||||
places, _ := meshmoduleassignerclient.AssignmentsValues(meshmoduleassigner.Assignments{Node: itNode, Modules: []meshmoduleassigner.PlacedModule{{Module: "nats"}}})
|
||||
// The have the engine wrote of the folded bus: the old-form build, no fingerprint.
|
||||
natsHave, _ := delivery.HaveKey(itNode, "nats")
|
||||
oldHave, _ := delivery.EncodeHave(delivery.Have{Build: "build-1792674595610176744", Phase: delivery.PhaseRunning, Health: delivery.HealthOK})
|
||||
natsHave, _ := nec.HaveKey(itNode, "nats")
|
||||
oldHave, _ := nec.EncodeHave(nodeengine.HaveByNodeModuleValue{Build: "build-1792674595610176744", Phase: nec.PhaseRunning, Health: nec.HealthOK})
|
||||
for key, value := range map[string][]byte{hkey: rec, natsHave: oldHave} {
|
||||
if _, err := b.haves.Put(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
busWant := func(w delivery.Want) {
|
||||
raw, err := delivery.EncodeWant(w)
|
||||
busWant := func(w meshwants.WantsByWantNodeModuleValue) {
|
||||
raw, err := bucketstest.Want(w)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _ := delivery.WantKey(itNode, "nats")
|
||||
key := meshwants.WantsByWantNodeModuleKey(itNode, "nats")
|
||||
if _, err := b.wants.Put(key, raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -820,11 +844,11 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
}
|
||||
first := fpIt("e")
|
||||
publish(first, "")
|
||||
busWant(delivery.Want{FP: first, Self: true})
|
||||
busWant(meshwants.WantsByWantNodeModuleValue{Fp: first, Self: true})
|
||||
stop := b.start()
|
||||
defer stop()
|
||||
for key, value := range places {
|
||||
if _, err := placement.Put(key, value); err != nil {
|
||||
if _, err := assignment.Put(key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -832,7 +856,7 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
within(t, 10*time.Second, "the bus module's first want was not applied", func() bool {
|
||||
h := b.have("nats")
|
||||
got, _ := os.ReadFile(conf)
|
||||
return h.FP == first && h.Phase == delivery.PhaseRunning && string(got) == "listen: new\n"
|
||||
return h.Fp == first && h.Phase == nec.PhaseRunning && string(got) == "listen: new\n"
|
||||
})
|
||||
if moved := runtime.moved(server); len(moved) != 0 || runtime.containers[server] != made {
|
||||
t.Fatalf("the first want moved the bus server its build does not change: %v", moved)
|
||||
@@ -852,13 +876,13 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
// Approve for a move the node judged to recreate it (ADR 0599 §2, §4).
|
||||
next := fpIt("f")
|
||||
publish(next, `,"env":{"CHANGED":"1"}`)
|
||||
busWant(delivery.Want{FP: next, Self: true})
|
||||
busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true})
|
||||
within(t, 10*time.Second, "the build that changes the server was not applied", func() bool {
|
||||
h := b.have("nats")
|
||||
if h.Phase == delivery.PhaseWaitingYes && h.Waits != "" {
|
||||
busWant(delivery.Want{FP: next, Self: true, Yes: h.Waits})
|
||||
if h.Phase == nec.PhaseWaitingYes && h.Waits != "" {
|
||||
busWant(meshwants.WantsByWantNodeModuleValue{Fp: next, Self: true, Yes: h.Waits})
|
||||
}
|
||||
return h.FP == next && h.Phase == delivery.PhaseRunning
|
||||
return h.Fp == next && h.Phase == nec.PhaseRunning
|
||||
})
|
||||
var runs int
|
||||
for _, a := range runtime.moved(server) {
|
||||
@@ -874,3 +898,171 @@ func TestNatsTheBusServerIsTakenOverAndReplacedOnlyWhenItsBuildChangesIt(t *test
|
||||
runtime.containers[server] == made, runtime.moved(server))
|
||||
}
|
||||
}
|
||||
|
||||
// standInSeat is the seat mesh-catalog as the node reads the seat list from it (novox/hq ADR 0636 §4): with no seat, every
|
||||
// bootstrapped seat with a digest; with one, its definition. While seatSilent it refuses.
|
||||
func (b *itBed) standInSeat() {
|
||||
defs := map[string]json.RawMessage{}
|
||||
var entries []seatlist.Entry
|
||||
for _, d := range seatlist.Bootstrapped() {
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
defs[d.Seat] = raw
|
||||
entries = append(entries, seatlist.Entry{Seat: d.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + d.Seat})
|
||||
}
|
||||
sub, err := b.conn.Subscribe("mesh.seat."+seatlist.Seat+".call."+seatlist.Verb+".*", func(m *nats.Msg) {
|
||||
var q struct {
|
||||
Seat string `json:"seat"`
|
||||
}
|
||||
_ = json.Unmarshal(m.Data, &q)
|
||||
b.seatAsked.Add(1)
|
||||
var result any
|
||||
switch {
|
||||
case b.seatSilent.Load():
|
||||
_ = m.Respond([]byte(`{"error":"the indexer has not started"}`))
|
||||
return
|
||||
case q.Seat == "":
|
||||
result = seatlist.Listing{Revision: 1, Seats: entries}
|
||||
default:
|
||||
result = seatlist.Answer{Seat: q.Seat, Found: true, Revision: 1,
|
||||
Entry: &seatlist.Entry{Seat: q.Seat, Source: seatlist.SourceTrunk, Digest: "d-" + q.Seat, Definition: defs[q.Seat]}}
|
||||
}
|
||||
out, _ := json.Marshal(map[string]any{"result": result})
|
||||
_ = m.Respond(out)
|
||||
})
|
||||
if err != nil {
|
||||
b.t.Fatal(err)
|
||||
}
|
||||
b.t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
|
||||
// fiveMinutes is a clock of the test: the bound of the node's wait fires when the test says so, nothing sleeps.
|
||||
type fiveMinutes struct {
|
||||
mu sync.Mutex
|
||||
timers []*time.Timer
|
||||
fire []func()
|
||||
}
|
||||
|
||||
func (c *fiveMinutes) Now() time.Time { return time.Now() }
|
||||
func (c *fiveMinutes) AfterFunc(_ time.Duration, f func()) seatwait.Timer {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
t := &stoppable{}
|
||||
c.fire = append(c.fire, func() {
|
||||
if !t.stopped.Load() {
|
||||
f()
|
||||
}
|
||||
})
|
||||
return t
|
||||
}
|
||||
|
||||
type stoppable struct{ stopped atomic.Bool }
|
||||
|
||||
func (s *stoppable) Stop() bool { return !s.stopped.Swap(true) }
|
||||
|
||||
func (c *fiveMinutes) pass() {
|
||||
c.mu.Lock()
|
||||
fire := c.fire
|
||||
c.fire = nil
|
||||
c.mu.Unlock()
|
||||
for _, f := range fire {
|
||||
f()
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0636 §1, §8, whole against a real bus and the stand-in of the seat: while the seat refuses, the node composes and
|
||||
// applies nothing new, and keeps running what it ran; after the bound it writes one finding in its state findings; when
|
||||
// the seat names its list the module is applied and the finding is cleared; a node started again with the seat gone
|
||||
// applies the composition it applied last.
|
||||
func TestNatsANodeThatCannotReadTheSeatListActsOnNothingNew(t *testing.T) {
|
||||
clock := &fiveMinutes{}
|
||||
was := seatWaitClock
|
||||
seatWaitClock = clock
|
||||
t.Cleanup(func() { seatWaitClock = was })
|
||||
|
||||
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
|
||||
b.seatSilent.Store(true)
|
||||
stop, st := h8(t, b, false)
|
||||
defer stop()
|
||||
_ = st
|
||||
finding := "machine." + itNode + ".seat-list-unread"
|
||||
within(t, 5*time.Second, "the node did not start its bound while the seat refused", func() bool {
|
||||
clock.mu.Lock()
|
||||
defer clock.mu.Unlock()
|
||||
return len(clock.fire) > 0
|
||||
})
|
||||
// A fresh node, nothing applied, reaches the bus and asks the seat while its own composition is held: the read needs
|
||||
// the bus the node composes from and the grant it has from its start, nothing of what the hold keeps back.
|
||||
if b.seatAsked.Load() == 0 {
|
||||
t.Fatal("the fresh node did not ask the seat for its list while its own composition was held")
|
||||
}
|
||||
within(t, 5*time.Second, "the fresh node did not say it holds web and its own composition for the unread list", func() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
said := strings.Join(b.said, "\n")
|
||||
return strings.Contains(said, selfcompose.WaitsForTheSeatListOwn) && strings.Contains(said, selfcompose.WaitsForTheSeatList)
|
||||
})
|
||||
if b.file("web") != "" {
|
||||
t.Fatalf("the node applied web before it read the seat list: %q", b.file("web"))
|
||||
}
|
||||
if _, err := b.findings.Get(finding); err == nil {
|
||||
t.Fatal("the node wrote its finding before the bound")
|
||||
}
|
||||
clock.pass()
|
||||
within(t, 5*time.Second, "the node did not write its finding after the bound", func() bool {
|
||||
_, err := b.findings.Get(finding)
|
||||
return err == nil
|
||||
})
|
||||
if b.file("web") != "" {
|
||||
t.Fatalf("the node applied web with its finding written: %q", b.file("web"))
|
||||
}
|
||||
|
||||
b.seatSilent.Store(false)
|
||||
// The hand act of the operator and the healers (the verb seat-list-again): a read now, as the seat's event would make.
|
||||
if out, err := seatListAgain(); err != nil || out.(map[string]any)["error"] != nil {
|
||||
t.Fatalf("seat-list-again: %v, %v", out, err)
|
||||
}
|
||||
within(t, 50*time.Second, "the node did not apply web once the seat named its list", func() bool { return b.file("web") == "greeting=bonjour\n" })
|
||||
within(t, 5*time.Second, "the node did not clear its finding at the first full read", func() bool {
|
||||
_, err := b.findings.Get(finding)
|
||||
return errors.Is(err, nats.ErrKeyNotFound)
|
||||
})
|
||||
|
||||
// Started again with the seat refusing: nothing new is composed, and what ran is neither removed nor changed.
|
||||
stop()
|
||||
b.seatSilent.Store(true)
|
||||
stopAgain := b.start()
|
||||
defer stopAgain()
|
||||
within(t, 30*time.Second, "the node did not hold web as it was after a start with the seat gone", func() bool {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return strings.Contains(strings.Join(b.said, "\n"), "it1 has read no complete seat list") || b.file("web") != ""
|
||||
})
|
||||
if b.file("web") != "greeting=bonjour\n" {
|
||||
t.Errorf("web does not stay as it ran after a start with the seat gone: %q", b.file("web"))
|
||||
}
|
||||
}
|
||||
|
||||
// The first read of the seat list ends the wait whether or not it changed what the reader held: Start returns at once, and
|
||||
// the node waits on the reader's Ready before it says the read is over (ADR 0636 §1).
|
||||
func TestNatsTheFirstReadOfTheSeatListEndsTheNodesWait(t *testing.T) {
|
||||
clock := &fiveMinutes{}
|
||||
was := seatWaitClock
|
||||
seatWaitClock = clock
|
||||
t.Cleanup(func() { seatWaitClock = was })
|
||||
|
||||
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
|
||||
stop, _ := h8(t, b, false)
|
||||
defer stop()
|
||||
within(t, 30*time.Second, "the node did not end its wait at the first read of the seat list", func() bool {
|
||||
st := seatListNow.Load()
|
||||
if st == nil {
|
||||
return false
|
||||
}
|
||||
<-st.reader.Ready()
|
||||
waiting, _ := st.waiter.Waiting()
|
||||
return st.reader.Status().Heard && !waiting
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
provisions "git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/provisions"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
)
|
||||
|
||||
// provisionSecrets fills a provision's value into a file the provisioner's client hands the node-engine to write
|
||||
@@ -92,7 +93,7 @@ func byName(keys map[string][]byte, name string) (provisions.Provision, string,
|
||||
}
|
||||
switch len(hit) {
|
||||
case 0:
|
||||
return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", provisions.ProvisionsRead)
|
||||
return provisions.Provision{}, "", fmt.Errorf("no provision of %s goes by that name: nothing asks for it", buckets.ProvisionsRead)
|
||||
case 1:
|
||||
return got, hit[0], nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
)
|
||||
|
||||
// pullsOf is the images an apply could not find or pull, each with the seat it came through and that seat's holder as the
|
||||
// declaration names them beside the container (design 51 *distribution's first delivery*, ENG-8, ENG-10). An image the
|
||||
// declaration names no holder for (a public registry's) carries none: its pull is not tried again on a store settling.
|
||||
func pullsOf(d *declaration.Declaration, err error) []agent.PullFailure {
|
||||
var out []agent.PullFailure
|
||||
for _, p := range apply.PullErrors(err) {
|
||||
f := agent.PullFailure{Image: p.Image, Started: p.Started}
|
||||
for _, r := range d.Resources {
|
||||
if c, ok := r.(*declaration.Container); ok && c.Image == p.Image && c.ImageHolder != "" {
|
||||
f.Seat, f.Holder = c.ImageSeat, c.ImageHolder
|
||||
break
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,11 +3,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
@@ -21,7 +20,7 @@ func TestACompositionIsRecordedOnlyWhenEveryResourceIs(t *testing.T) {
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := delivery.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
|
||||
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`)}}
|
||||
recorded := recordedIn(path, func(string) {})
|
||||
if !recorded(context.Background(), c) {
|
||||
@@ -87,7 +86,7 @@ func TestAFilledFileDoesNotMakeItsCompositionUnrecorded(t *testing.T) {
|
||||
if err := store.Save(path, s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := delivery.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
|
||||
c := engcomp.Composed{Node: "ace", Module: "ca-trust", Filled: []string{"/etc/ca-filled"}, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"ca-trust.trust","type":"file","module":"ca-trust","path":"/etc/ca","mode":"0644","content":"x"}`),
|
||||
json.RawMessage(`{"id":"ca-trust.filled","type":"file","module":"ca-trust","path":"/etc/ca-filled","mode":"0644","owner-at-runtime":"owned"}`)}}
|
||||
if !recordedIn(path, func(string) {})(context.Background(), c) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
// names what is on disk: **only when the node's record says it wrote exactly that content there** (store Applied.Wrote,
|
||||
// Target), never a file someone else changed. A file that cannot be read, or that the record does not vouch for, is
|
||||
// left as the composition names it.
|
||||
func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Composed {
|
||||
func runsAsWritten(running []engcomp.Composed, statePath string) []engcomp.Composed {
|
||||
if len(running) == 0 || statePath == "" {
|
||||
return running
|
||||
}
|
||||
@@ -28,7 +28,7 @@ func runsAsWritten(running []delivery.Composed, statePath string) []delivery.Com
|
||||
if err != nil {
|
||||
return running
|
||||
}
|
||||
out := make([]delivery.Composed, len(running))
|
||||
out := make([]engcomp.Composed, len(running))
|
||||
for i, c := range running {
|
||||
out[i] = c
|
||||
var changed []json.RawMessage
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
conditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
|
||||
condseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatdef"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
"github.com/novox/mesh-host/internal/seatwait"
|
||||
)
|
||||
|
||||
// The node-engine reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636): through the seat mesh-catalog,
|
||||
// from the bootstrapped definitions until it has been read, acting on nothing new meanwhile (the composer's gate), and
|
||||
// following the seat's events. What it holds is given to catalogue at every change, which compiles no seat list.
|
||||
|
||||
// seatListNow is this run's reader, what the node-engine's verbs seat-list and seat-list-again answer from.
|
||||
var seatListNow atomic.Pointer[seatListState]
|
||||
|
||||
// seatListRead answers the verb seat-list: what the engine holds of the list and what it waits for (ADR 0636 §7).
|
||||
func seatListRead() (any, error) {
|
||||
st := seatListNow.Load()
|
||||
if st == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
|
||||
}
|
||||
status := st.reader.Status()
|
||||
return map[string]any{"status": status, "health": status.Health()}, nil
|
||||
}
|
||||
|
||||
// seatListAgain answers the verb seat-list-again: the list read now from the seat, and what is held afterwards.
|
||||
func seatListAgain() (any, error) {
|
||||
st := seatListNow.Load()
|
||||
if st == nil {
|
||||
return nil, errors.New("this engine's node agent has not started, so it holds no seat list yet")
|
||||
}
|
||||
err := st.reader.Reload()
|
||||
st.waiter.Changed() // a read that changed nothing held still ends the wait
|
||||
out, _ := seatListRead()
|
||||
if err != nil {
|
||||
out.(map[string]any)["error"] = err.Error()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seatListState is one run's reader, the finding that it waits, and the channel on which the composer hears a change.
|
||||
type seatListState struct {
|
||||
reader *seatlist.Reader
|
||||
waiter *seatwait.Waiter
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
// sessionBus is the reader's bus over the link: a verb of the seat is asked in the engine's own name, an event heard on
|
||||
// the seat's event subject for as long as ctx lives.
|
||||
type sessionBus struct {
|
||||
ctx context.Context
|
||||
session *link.Session
|
||||
}
|
||||
|
||||
func (b sessionBus) Ask(key string, body any) (json.RawMessage, error) {
|
||||
seat, verb, _ := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
|
||||
return b.session.AskMeshSeat(b.ctx, seat, verb, body)
|
||||
}
|
||||
|
||||
func (b sessionBus) Subscribe(pattern string, handle func(string, json.RawMessage) error) error {
|
||||
seat, event, _ := strings.Cut(pattern, ".")
|
||||
heard, stop, err := b.session.Hear(seatdef.EventSubject(seat, event))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
defer stop()
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
return
|
||||
case body, ok := <-heard:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = handle(pattern, body)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// seatWaitClock is the clock of the node's bound; the tests give their own.
|
||||
var seatWaitClock seatwait.Clock = realClock{}
|
||||
|
||||
type realClock struct{}
|
||||
|
||||
func (realClock) Now() time.Time { return time.Now() }
|
||||
func (realClock) AfterFunc(d time.Duration, f func()) seatwait.Timer {
|
||||
return time.AfterFunc(d, f)
|
||||
}
|
||||
|
||||
// findingsOfTheNode writes the node's findings in its state `findings`, in the layout of the seat mesh-conditions.
|
||||
type findingsOfTheNode struct{ session *link.Session }
|
||||
|
||||
func (w findingsOfTheNode) encode(f views.Finding) (string, []byte, error) {
|
||||
key := conditions.Key(conditions.ScopeMachine, f.ID, f.Kind)
|
||||
v := condseat.FindingsByKeyValue{V: 1, Key: key, Kind: f.Kind, Observer: f.Observer, Severity: conditions.Warning,
|
||||
Subject: condseat.Subject{Scope: conditions.ScopeMachine, ID: f.ID, Machine: f.ID}, Summary: f.Summary,
|
||||
Headline: f.Headline, Explanation: f.Explanation, Source: "seat-list-unread", At: f.At.UTC()}
|
||||
if err := conditions.CheckFinding(v); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
raw, err := json.Marshal(v)
|
||||
return key, raw, err
|
||||
}
|
||||
|
||||
func (w findingsOfTheNode) Put(f views.Finding) error {
|
||||
key, raw, err := w.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.session.PutFinding(key, raw)
|
||||
}
|
||||
|
||||
func (w findingsOfTheNode) Delete(f views.Finding) error {
|
||||
key, _, err := w.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.session.DeleteFinding(key)
|
||||
}
|
||||
|
||||
// startSeatList starts this run's reader: its bootstrapped seats are given to catalogue at once, and every change of what
|
||||
// it holds again, saying so to the composer and to the waiter that writes the five-minute finding.
|
||||
func startSeatList(ctx context.Context, session *link.Session, node string, say func(string)) (*seatListState, error) {
|
||||
st := &seatListState{changed: make(chan struct{}, 1)}
|
||||
// Start returns at once and reads the seat on a goroutine, which may announce a change before the waiter exists.
|
||||
var announce atomic.Pointer[func()]
|
||||
reader, err := seatlist.Start(seatlist.Config{Bus: sessionBus{ctx, session}, Bootstrap: seatlist.Bootstrapped(),
|
||||
OnChange: func(seats []catalogue.Seat) {
|
||||
catalogue.UseSeats(seats)
|
||||
if f := announce.Load(); f != nil { // the first read may end before the waiter exists
|
||||
(*f)()
|
||||
}
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the node-engine cannot follow the seat list: %w", err)
|
||||
}
|
||||
st.reader = reader
|
||||
st.waiter = &seatwait.Waiter{Node: node, Observer: "node-engine", Reader: reader, Findings: findingsOfTheNode{session},
|
||||
Clock: seatWaitClock, Say: say}
|
||||
told := func() {
|
||||
st.waiter.Changed()
|
||||
select {
|
||||
case st.changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
announce.Store(&told)
|
||||
catalogue.UseSeats(reader.Seats())
|
||||
told()
|
||||
// The first read ends whether the seat answered or not, and a read that held what the reader held already says no
|
||||
// change: its end is told to the waiter and the composer, so a status that moved from reading to heard starts or ends
|
||||
// the bound (ADR 0636 §1). Start's return held nobody's start.
|
||||
go func() {
|
||||
select {
|
||||
case <-reader.Ready():
|
||||
catalogue.UseSeats(reader.Seats())
|
||||
told()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
seatListNow.Store(st)
|
||||
return st, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/internal/selfcompose"
|
||||
)
|
||||
|
||||
// ADR 0636 §1: when the node's reader of the seat list could not start, the composer is gated by a reader that never
|
||||
// allows, where a missing reader would let the node act on everything; the shadow, which applies nothing, stays ungated.
|
||||
func TestAReaderThatCouldNotStartHoldsTheComposer(t *testing.T) {
|
||||
failed := composerEnv(nil, "n1", func(string) {}, composerHooks{seatListFailed: true})
|
||||
if failed.SeatList == nil || failed.SeatList.MayAct() || failed.SeatList.MayAct("node-engine") {
|
||||
t.Fatalf("the composer of a node whose reader did not start is gated by %v, want a gate that allows nothing", failed.SeatList)
|
||||
}
|
||||
if _, ok := failed.SeatList.(selfcompose.Unread); !ok {
|
||||
t.Errorf("gate is %T", failed.SeatList)
|
||||
}
|
||||
if got := composerEnv(nil, "n1", func(string) {}, composerHooks{}).SeatList; got != nil {
|
||||
t.Errorf("a composer with no reader asked for gates by %v, want none (the shadow)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
)
|
||||
|
||||
// settledEvent is where the status view says a module has settled on a node: its have reached its want, running and healthy
|
||||
// (design 47; the view's `settled`, said once for a module on a node at one composition). The engine hears it for every module
|
||||
// and tells its agent, which tries again, once per settled, a failed apply that waits on that module as the holder of the store
|
||||
// its image comes from (design 51 *distribution's first delivery*, issue 1966 ENG-11). The engine's manifest consumes it (`mesh-delivery-view.settled`),
|
||||
// and that line alone grants the engine's account the subject (TestTheSettledEventIsTheManifestsConsume); a refused subscription is said, and made again when the next link opens.
|
||||
const settledEvent = "mesh.mod.mesh-delivery-view.event.settled"
|
||||
|
||||
// settledBody is what of the event the engine reads: the module that settled, on whichever node.
|
||||
type settledBody struct {
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// settledTold is who is told a holder has settled.
|
||||
type settledTold interface{ Settled(holder string) }
|
||||
|
||||
// followSettled hears the status view's `settled` on every link, never on a timer, until ctx ends. now names the agent running
|
||||
// now (nil while the node agent begins again): a settled heard while there is none is not lost for it, since a new agent applies
|
||||
// again every have that failed at its start.
|
||||
func followSettled(ctx context.Context, session *link.Session, now func() settledTold, say link.Announce) {
|
||||
said := ""
|
||||
for ctx.Err() == nil {
|
||||
linked, cancel, err := session.WhenLinked(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
events, stop, herr := session.Hear(settledEvent)
|
||||
if herr != nil {
|
||||
if line := fmt.Sprintf("a store settling is not heard yet (%s: %v): a failed pull waits for the next link, or for compose", settledEvent, herr); line != said {
|
||||
said = line
|
||||
say(line)
|
||||
}
|
||||
} else {
|
||||
said = ""
|
||||
listen(linked, events, now, say)
|
||||
stop()
|
||||
}
|
||||
<-linked.Done()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// listen tells the agent each settled it hears, until the link ends.
|
||||
func listen(linked context.Context, events <-chan []byte, now func() settledTold, say link.Announce) {
|
||||
for {
|
||||
select {
|
||||
case <-linked.Done():
|
||||
return
|
||||
case raw, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b settledBody
|
||||
if json.Unmarshal(raw, &b) != nil || b.Module == "" {
|
||||
continue
|
||||
}
|
||||
if told := now(); told != nil {
|
||||
told.Settled(b.Module)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The subject the engine hears for a store settling is the event its manifest consumes: the grants part grants an account
|
||||
// what its manifest consumes and nothing named in code (novox/hq ADR 0625 §4).
|
||||
func TestTheSettledEventIsTheManifestsConsume(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Consumes []string `json:"consumes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
emitter, event, _ := strings.Cut(strings.TrimPrefix(settledEvent, "mesh.mod."), ".event.")
|
||||
if !slices.Contains(m.Consumes, emitter+"."+event) {
|
||||
t.Errorf("the engine hears %s, its manifest consumes %v", settledEvent, m.Consumes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// stopBound is how long a stopping engine waits for the apply in flight to end and save what it did: under the service
|
||||
// manager's own stop bound (90 s by default, then every process of the unit is killed), so the wait ends with a line in
|
||||
// the journal rather than with a kill.
|
||||
const stopBound = 60 * time.Second
|
||||
|
||||
// stopBetweenApplies is how this engine ends, whichever way (a stop of the service, standing aside for a successor, a
|
||||
// move to another bus): it waits, up to bound, for the apply in flight to end, then lets no save start and waits for
|
||||
// the one writing (novox/hq issue 1585). An apply runs detached from the engine's own stop (applyContext), so without
|
||||
// this the process ended under it: what it unpacked was never recorded, and a save cut between its write and its
|
||||
// rename left a temporary file and the state before it. A stopping engine never gives back what it takes (the process
|
||||
// ends); the release it returns is for a test, and gives back the apply only when this took it.
|
||||
func stopBetweenApplies(say func(string), bound time.Duration) (release func()) {
|
||||
took := true
|
||||
if !applying.TryLock() {
|
||||
say(fmt.Sprintf("stopping: waiting up to %s for the apply in flight to end and save what it did", bound))
|
||||
got := make(chan struct{})
|
||||
go func() {
|
||||
applying.Lock()
|
||||
close(got)
|
||||
}()
|
||||
select {
|
||||
case <-got:
|
||||
say("stopping: the apply in flight ended and its state is saved")
|
||||
case <-time.After(bound):
|
||||
took = false
|
||||
say(fmt.Sprintf("stopping while an apply still runs after %s: what it did since its last save is not "+
|
||||
"recorded, and the next start applies its module again", bound))
|
||||
}
|
||||
}
|
||||
saves := store.Quiesce()
|
||||
return func() {
|
||||
saves()
|
||||
if took {
|
||||
applying.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// removeLeftoverSaves removes, at the engine's start, the temporary files of saves of the node's state that never
|
||||
// reached their rename, and says how many (issue 1585: about 260 on novox, ~80 MB).
|
||||
func removeLeftoverSaves(statePath string, say func(string)) {
|
||||
removed, err := store.RemoveLeftovers(statePath)
|
||||
if len(removed) > 0 {
|
||||
shown := removed
|
||||
if len(shown) > 5 {
|
||||
shown = append(append([]string(nil), shown[:5]...), "…")
|
||||
}
|
||||
say(fmt.Sprintf("removed %d leftover save(s) of this node's state, cut off before their rename by an earlier "+
|
||||
"stop: %s", len(removed), strings.Join(shown, ", ")))
|
||||
}
|
||||
if err != nil {
|
||||
say(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// exitBetweenApplies ends the process cleanly once no apply runs and no save writes: an exit that is not runLink's
|
||||
// return (a move to another bus). A variable, so a test sees it called rather than the test process ending.
|
||||
var exitBetweenApplies = func(say func(string)) {
|
||||
_ = stopBetweenApplies(say, stopBound)
|
||||
os.Exit(0)
|
||||
}
|
||||
+56
-56
@@ -7,14 +7,21 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
"github.com/novox/mesh-host/internal/link"
|
||||
@@ -22,7 +29,7 @@ import (
|
||||
|
||||
// **What this engine trusts a mesh signature from** (novox/hq ADR 0550 §2; design 47's L3 item d; issue 1126): the
|
||||
// vault's key alone, read from the vault's state `signers`, which only the vault may write: the bus grants are its
|
||||
// anchor, as the wants' and the placement's. The controller's key, which the node was enrolled with (its membership's
|
||||
// anchor, as the wants' and the assignment's. The controller's key, which the node was enrolled with (its membership's
|
||||
// signer), is trusted never: the controller ended by its unassignment (the operator, 2026-11-02) and its lease is never
|
||||
// handed, so no gate waits on it. What it trusts now the node states in its record (NodeRecord.Trusts).
|
||||
type meshTrust struct {
|
||||
@@ -44,23 +51,23 @@ func (t *meshTrust) onChange(f func()) {
|
||||
}
|
||||
|
||||
// Signers are the keys a mesh signature is verified against now: the vault's once read, and none before.
|
||||
func (t *meshTrust) Signers() []delivery.Signer {
|
||||
func (t *meshTrust) Signers() []meshvaultclient.Signer {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
var out []delivery.Signer
|
||||
var out []meshvaultclient.Signer
|
||||
if t.vault != nil {
|
||||
out = append(out, delivery.Signer{Name: delivery.SignerVault, Key: t.vault})
|
||||
out = append(out, meshvaultclient.Signer{Name: meshvaultclient.SignerVault, Key: t.vault})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Verify is the signer that signed msg as kind, or why none it trusts did.
|
||||
func (t *meshTrust) Verify(kind string, msg, sig []byte) (delivery.Signer, error) {
|
||||
context, err := delivery.SignContext(kind)
|
||||
func (t *meshTrust) Verify(kind string, msg, sig []byte) (meshvaultclient.Signer, error) {
|
||||
context, err := meshvaultclient.SignContext(kind)
|
||||
if err != nil {
|
||||
return delivery.Signer{}, err
|
||||
return meshvaultclient.Signer{}, err
|
||||
}
|
||||
return delivery.VerifyBy(t.Signers(), context, msg, sig)
|
||||
return meshvaultclient.VerifyBy(t.Signers(), context, msg, sig)
|
||||
}
|
||||
|
||||
// VaultAuthority is the vault's authority certificate as read from `signers`, or "".
|
||||
@@ -80,9 +87,9 @@ func (t *meshTrust) Trusts() []string {
|
||||
}
|
||||
authorities := map[string]string{}
|
||||
if a := t.VaultAuthority(); a != "" {
|
||||
authorities[delivery.SignerVault] = a
|
||||
authorities[meshvaultclient.SignerVault] = a
|
||||
}
|
||||
return delivery.Trusts(t.Signers(), authorities)
|
||||
return meshvaultclient.Trusts(t.Signers(), authorities)
|
||||
}
|
||||
|
||||
// change applies f under the lock and tells who follows when Trusts differs after it.
|
||||
@@ -101,25 +108,25 @@ func (t *meshTrust) change(f func()) {
|
||||
// check; a deleted key is no longer trusted.
|
||||
func (t *meshTrust) heardSigner(key string, value []byte, deleted bool, say link.Announce) {
|
||||
switch key {
|
||||
case delivery.SignersKeyKey:
|
||||
case meshvault.SignersByKeyKey():
|
||||
var pub ed25519.PublicKey
|
||||
if !deleted && len(value) > 0 {
|
||||
k, err := delivery.DecodeSignerKey(value)
|
||||
k, err := meshvaultclient.DecodeSignerKey(value)
|
||||
if err == nil {
|
||||
pub, err = k.PublicKey()
|
||||
pub, err = meshvaultclient.SignerPublicKey(k)
|
||||
}
|
||||
if err != nil {
|
||||
say("the vault's signing key in " + delivery.SignersRead + " is not trusted: " + err.Error())
|
||||
say("the vault's signing key in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t.change(func() { t.vault = pub })
|
||||
case delivery.SignersAuthorityKey:
|
||||
case meshvault.SignersByAuthorityKey():
|
||||
pem := ""
|
||||
if !deleted && len(value) > 0 {
|
||||
a, err := delivery.DecodeSignerAuthority(value)
|
||||
a, err := meshvaultclient.DecodeSignerAuthority(value)
|
||||
if err != nil {
|
||||
say("the vault's authority in " + delivery.SignersRead + " is not trusted: " + err.Error())
|
||||
say("the vault's authority in " + buckets.SignersRead + " is not trusted: " + err.Error())
|
||||
return
|
||||
}
|
||||
pem = a.Certificate
|
||||
@@ -150,7 +157,7 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
|
||||
return
|
||||
}
|
||||
watching, stop := context.WithCancel(linked)
|
||||
signers, serr := session.KVWatch(watching, delivery.SignersBucket, ">")
|
||||
signers, serr := session.KVWatch(watching, buckets.Signers, ">")
|
||||
if serr != nil {
|
||||
stop()
|
||||
sayOnce(fmt.Sprintf("what this node trusts a mesh signature from is not followed yet (the vault's signers: %v): "+
|
||||
@@ -172,52 +179,45 @@ func followTrust(ctx context.Context, session *link.Session, trust *meshTrust, s
|
||||
}
|
||||
}
|
||||
|
||||
// placedAllHave says whether every module the seat mesh-placement places on node has a have of its own, read from the
|
||||
// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose placement or haves
|
||||
// placedAllHave says whether every module the seat mesh-module-assigner places on node has a have of its own, read from the
|
||||
// bus: then the kept declaration is read no more (keptRetired). A node the seat does not place, or whose assignment or haves
|
||||
// cannot be read, keeps reading it.
|
||||
func placedAllHave(ctx context.Context, session *link.Session, node string) (bool, string) {
|
||||
key, err := delivery.PlacementNodeKey(node)
|
||||
key, err := meshmoduleassignerclient.NodeKey(node)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
legacy, _ := delivery.LegacyPlacementKey(node)
|
||||
// Its node key names every module placed (research 072 row 37); before the holder converted it, its legacy key does.
|
||||
// Its node key names every module placed (research 072 row 37).
|
||||
values := map[string][]byte{}
|
||||
for _, k := range []string{key, legacy} {
|
||||
if h, err := session.KVHistory(ctx, delivery.PlacementBucket, k); err == nil && len(h) > 0 && !h[len(h)-1].Deleted {
|
||||
values[k] = h[len(h)-1].Value
|
||||
}
|
||||
if h, err := session.KVHistory(ctx, buckets.Assignments, key); err == nil && len(h) > 0 && !h[len(h)-1].Deleted {
|
||||
values[key] = h[len(h)-1].Value
|
||||
}
|
||||
p, err := placedModules(node, values)
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
return allHave(p, func(module string) (delivery.Have, bool, error) {
|
||||
return allHave(p, func(module string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
||||
return session.ReadHaveErr(ctx, module)
|
||||
})
|
||||
}
|
||||
|
||||
// placedModules is the modules node's node key names (a placement of their names alone), or its legacy key's placement
|
||||
// where it has no node key; an error when neither reads.
|
||||
func placedModules(node string, values map[string][]byte) (delivery.Placement, error) {
|
||||
key, _ := delivery.PlacementNodeKey(node)
|
||||
if raw, ok := values[key]; ok {
|
||||
n, err := delivery.DecodePlacementNode(raw)
|
||||
if err != nil {
|
||||
return delivery.Placement{}, err
|
||||
}
|
||||
p := delivery.Placement{Node: node}
|
||||
for _, r := range n.Modules {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: r.Module})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
legacy, _ := delivery.LegacyPlacementKey(node)
|
||||
raw, ok := values[legacy]
|
||||
// placedModules is the modules node's node key names (an assignment of their names alone); an error when it does not
|
||||
// read.
|
||||
func placedModules(node string, values map[string][]byte) (meshmoduleassigner.Assignments, error) {
|
||||
key, _ := meshmoduleassignerclient.NodeKey(node)
|
||||
raw, ok := values[key]
|
||||
if !ok {
|
||||
return delivery.Placement{}, errors.New("its placement is not read")
|
||||
return meshmoduleassigner.Assignments{}, errors.New("its assignment is not read")
|
||||
}
|
||||
return delivery.DecodePlacement(raw)
|
||||
n, err := meshmoduleassignerclient.DecodeNode(raw)
|
||||
if err != nil {
|
||||
return meshmoduleassigner.Assignments{}, err
|
||||
}
|
||||
p := meshmoduleassigner.Assignments{Node: node}
|
||||
for _, r := range n.Modules {
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: r.Module})
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// allHave says whether has finds, for every module p places, a have that names a fingerprint of the new form, and why
|
||||
@@ -225,9 +225,9 @@ func placedModules(node string, values map[string][]byte) (delivery.Placement, e
|
||||
// declaration is the one record of what runs of it: retired then, the node's own composed it as nothing and refused to
|
||||
// remove what runs (issue 774, 2026-10-30 19:43: systemd-resolved on shanks, nats and restic on novox, each node's own
|
||||
// apply refused from then on).
|
||||
func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool, error)) (bool, string) {
|
||||
func allHave(p meshmoduleassigner.Assignments, has func(module string) (nodeengine.HaveByNodeModuleValue, bool, error)) (bool, string) {
|
||||
if len(p.Modules) == 0 {
|
||||
return false, "the seat mesh-placement places no module here"
|
||||
return false, "the seat mesh-module-assigner places no module here"
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
h, ok, err := has(m.Module)
|
||||
@@ -238,8 +238,8 @@ func allHave(p delivery.Placement, has func(module string) (delivery.Have, bool,
|
||||
return false, m.Module + " has no have yet"
|
||||
}
|
||||
// A module the mesh provides (mesh-wireguard) never has a fingerprint: the node's own composes it from the manifest
|
||||
// mesh-sdk provides (delivery.ProvidedManifest), never from the kept declaration, so it keeps no read.
|
||||
if h.Phase != delivery.PhaseRemoved && !strings.HasPrefix(h.FP, delivery.SourcePrefix2) && !delivery.ProvidedByMesh(m.Module) {
|
||||
// mesh-sdk provides (overlay.ProvidedManifest), never from the kept declaration, so it keeps no read.
|
||||
if h.Phase != nodeengineclient.PhaseRemoved && !strings.HasPrefix(h.Fp, meshcatalogclient.SourcePrefix2) && !overlay.ProvidedByMesh(m.Module) {
|
||||
return false, m.Module + " runs at a build of the old form (" + h.Build + "), which only the kept declaration records"
|
||||
}
|
||||
}
|
||||
@@ -259,11 +259,11 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
|
||||
if err != nil || len(key) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("this node's serving key is not an ed25519 key")
|
||||
}
|
||||
raw, err := ask(ctx, delivery.SignVerb, map[string]any{"kind": delivery.KindNodeCertificate, "node": node})
|
||||
raw, err := ask(ctx, meshvault.VerbSign, map[string]any{"kind": meshvaultclient.KindNodeCertificate, "node": node})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var a delivery.SignAnswer
|
||||
var a meshvault.SignAnswer
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return fmt.Errorf("the vault's answer is not a signing: %w", err)
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func certifyThisNode(ctx context.Context, state, node string, trust *meshTrust,
|
||||
if trusted == "" {
|
||||
return fmt.Errorf("this node has read no authority of the vault's to check the certificate against")
|
||||
}
|
||||
if err := delivery.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
|
||||
if err := meshvaultclient.CheckNodeCertificate(a.Certificate, []string{trusted}, overlay.InternalName(node),
|
||||
ed25519.PublicKey(key), time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
"github.com/novox/mesh-host/internal/apply"
|
||||
"github.com/novox/mesh-host/internal/identity"
|
||||
@@ -25,7 +26,7 @@ import (
|
||||
|
||||
// userListFollower is the follower of the bus's user list for one link: the node's composition bucket, the keys the
|
||||
// agent trusts, the write alone, and the have.
|
||||
func userListFollower(opts options, session *link.Session, running func() []delivery.Composed,
|
||||
func userListFollower(opts options, session *link.Session, running func() []engcomp.Composed,
|
||||
say link.Announce, wrote func()) *userlist.Follower {
|
||||
return userlist.New(userlist.Env{
|
||||
Node: session.Membership.Node,
|
||||
@@ -43,7 +44,7 @@ func userListFollower(opts options, session *link.Session, running func() []deli
|
||||
},
|
||||
// From take-over, the grants part's list (ADR 0547 clauses 7, 8): its one key watched, its object read by name and
|
||||
// opened with this machine's sealing key, the key's public half said in the have so the grants part seals to it.
|
||||
Grants: bucketWatch{session, delivery.GrantsUserListBucket},
|
||||
Grants: bucketWatch{session, grantsstate.GrantsUserListBucket},
|
||||
Fetch: func(ctx context.Context, name string) ([]byte, error) {
|
||||
return session.GrantsObject(ctx, name, grantsListLimit)
|
||||
},
|
||||
|
||||
@@ -5,16 +5,22 @@ import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
|
||||
"github.com/novox/mesh-host/internal/agent"
|
||||
"github.com/novox/mesh-host/internal/filling"
|
||||
"github.com/novox/mesh-host/internal/liveness"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
@@ -31,7 +37,7 @@ func aFileOf(module, id string) json.RawMessage {
|
||||
func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
kept := &declaration.Declaration{Identities: []declaration.Identity{{Module: "db", Name: "mesh-db"}},
|
||||
Libraries: []declaration.Library{{Library: "media"}}}
|
||||
everything := []delivery.Composed{
|
||||
everything := []engcomp.Composed{
|
||||
{Node: "n1", Module: "web", Resources: []json.RawMessage{aFileOf("web", "a")}},
|
||||
{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}}, // still the kept declaration's
|
||||
}
|
||||
@@ -45,7 +51,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
}
|
||||
|
||||
// Once the agent holds the node's composition, its parts are the node's.
|
||||
node := delivery.Composed{Node: "n1", Own: delivery.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
|
||||
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Identities: []declaration.Identity{{Module: "web", Name: "mesh-web"}}}}
|
||||
d, err = whatRuns(append(everything, node), true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -57,6 +63,7 @@ func TestWhatRunsIsTheAgentsCompositionsAndTheKeptRest(t *testing.T) {
|
||||
|
||||
// What the take act, the judgement of directories and the filling read is what runs, not the frozen kept declaration.
|
||||
func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
t.Run("a fill while the agent restarts", aFillWhileTheAgentRestartsIsWrittenAfterwards)
|
||||
setRunsHere(func(string, ed25519.PublicKey) (*declaration.Declaration, error) {
|
||||
return &declaration.Declaration{Version: 1, Resources: nil, Provisioner: "from-agent"}, nil
|
||||
})
|
||||
@@ -88,7 +95,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
for _, name := range row.runs {
|
||||
raws = append(raws, process(name))
|
||||
}
|
||||
runs, err := whatRuns([]delivery.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
|
||||
runs, err := whatRuns([]engcomp.Composed{{Node: "n1", Module: "restic", Resources: raws}}, false,
|
||||
func() (*declaration.Declaration, error) { return nil, errors.New("none kept") })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -116,27 +123,27 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
if d, err := keptDeclaration(state, nil); err != nil || len(d.Resources) != 1 {
|
||||
t.Fatalf("before the read is retired: %+v, %v", d, err)
|
||||
}
|
||||
placed := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
|
||||
placed := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: "lemurs"}}}
|
||||
// Issue 774, 2026-10-30 19:43: a have naming only a build of the old form (systemd-resolved on shanks, folded in the
|
||||
// node's own) keeps the kept declaration read, since it is the one record of what runs of it.
|
||||
newForm := delivery.Have{FP: delivery.SourcePrefix2 + strings.Repeat("a", 64), Phase: delivery.PhaseRunning}
|
||||
oldForm := delivery.Have{Build: "build-1791404191702719760", Phase: delivery.PhaseRunning}
|
||||
newForm := nodeengine.HaveByNodeModuleValue{Fp: meshcatalogclient.SourcePrefix2 + strings.Repeat("a", 64), Phase: nodeengineclient.PhaseRunning}
|
||||
oldForm := nodeengine.HaveByNodeModuleValue{Build: "build-1791404191702719760", Phase: nodeengineclient.PhaseRunning}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
has map[string]delivery.Have
|
||||
has map[string]nodeengine.HaveByNodeModuleValue
|
||||
want bool
|
||||
}{
|
||||
{"every placed module has a have of the new form", map[string]delivery.Have{"zsh": newForm, "lemurs": newForm}, true},
|
||||
{"one has none yet", map[string]delivery.Have{"zsh": newForm}, false},
|
||||
{"one runs at a build of the old form", map[string]delivery.Have{"zsh": newForm, "lemurs": oldForm}, false},
|
||||
{"every placed module has a have of the new form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": newForm}, true},
|
||||
{"one has none yet", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm}, false},
|
||||
{"one runs at a build of the old form", map[string]nodeengine.HaveByNodeModuleValue{"zsh": newForm, "lemurs": oldForm}, false},
|
||||
} {
|
||||
if got, why := allHave(placed, func(m string) (delivery.Have, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
|
||||
if got, why := allHave(placed, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) { h, ok := row.has[m]; return h, ok, nil }); got != row.want {
|
||||
t.Errorf("%s: %v (%s)", row.name, got, why)
|
||||
}
|
||||
}
|
||||
// The module the mesh provides never has a fingerprint, and keeps no read (review of issue 774).
|
||||
withWireguard := delivery.Placement{Node: "n1", Modules: []delivery.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
|
||||
if got, why := allHave(withWireguard, func(m string) (delivery.Have, bool, error) {
|
||||
withWireguard := meshmoduleassigner.Assignments{Node: "n1", Modules: []meshmoduleassigner.PlacedModule{{Module: "zsh"}, {Module: overlay.Name}}}
|
||||
if got, why := allHave(withWireguard, func(m string) (nodeengine.HaveByNodeModuleValue, bool, error) {
|
||||
if m == overlay.Name {
|
||||
return oldForm, true, nil
|
||||
}
|
||||
@@ -144,7 +151,7 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
}); !got {
|
||||
t.Errorf("%s without a fingerprint beside zsh at the new form keeps the read: %s", overlay.Name, why)
|
||||
}
|
||||
if got, _ := allHave(delivery.Placement{Node: "n1"}, nil); got {
|
||||
if got, _ := allHave(meshmoduleassigner.Assignments{Node: "n1"}, nil); got {
|
||||
t.Error("a node with nothing placed retires the read")
|
||||
}
|
||||
keptRetired.Store(true)
|
||||
@@ -161,9 +168,9 @@ func TestDeclaredNowReadsWhatRunsWhenTheAgentIsRunning(t *testing.T) {
|
||||
// the module's: the declaration joined of both names each file once, so the filling writes it; the node's own resources
|
||||
// no module declares stay. (Live from 2026-10-25 12:43: "2 resources declare X" on every node.)
|
||||
func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
|
||||
own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
||||
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{aFileOf("zsh", "rc"), aFileOf("mesh-wireguard", "conf")},
|
||||
Filled: []string{"/tmp/r6/zsh/rc"}}
|
||||
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
|
||||
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")},
|
||||
Filled: []string{"/tmp/r6/zsh/rc"}}
|
||||
d, err := declarationOf(own, zsh)
|
||||
if err != nil {
|
||||
@@ -181,7 +188,7 @@ func TestTheNodesOwnCopyOfAModulesResourceIsLeftOut(t *testing.T) {
|
||||
}
|
||||
|
||||
// The same at the node's own apply: what the others declare is theirs.
|
||||
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
|
||||
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
||||
if len(trimmed.Resources) != 1 || len(dropped) != 1 || dropped[0] != "zsh.rc (zsh)" {
|
||||
t.Errorf("the node's own without zsh's: %d resources, dropped %v", len(trimmed.Resources), dropped)
|
||||
}
|
||||
@@ -211,9 +218,9 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
||||
strings.Join(filled[agent.NodeModule], ",") != "/etc/motd" || len(filled["zsh"]) != 0 {
|
||||
t.Fatalf("filled by module: %v", filled)
|
||||
}
|
||||
cc := delivery.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
|
||||
node := delivery.Composed{Node: "n1"}
|
||||
d, err := whatRuns([]delivery.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
cc := engcomp.Composed{Node: "n1", Module: "claude-code", Resources: by["claude-code"], Filled: filled["claude-code"]}
|
||||
node := engcomp.Composed{Node: "n1"}
|
||||
d, err := whatRuns([]engcomp.Composed{cc, node}, true, func() (*declaration.Declaration, error) { return nil, errors.New("not read") })
|
||||
if err != nil || !d.Fills("/etc/cc.json") {
|
||||
t.Fatalf("what runs does not name claude-code's file filled: %v (%v)", d, err)
|
||||
}
|
||||
@@ -226,10 +233,73 @@ func TestTheKeptDeclarationsFilledPathsGoToTheirModules(t *testing.T) {
|
||||
// unit or a container of the same name) is not the module's, and is neither dropped nor left to be removed.
|
||||
func TestASharedTargetWithAnotherIDIsNotDropped(t *testing.T) {
|
||||
ownFile, _ := json.Marshal(map[string]any{"id": "host.zshrc", "type": "file", "path": "/tmp/r6/zsh/rc", "content": "x"})
|
||||
own := delivery.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
|
||||
zsh := delivery.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
|
||||
trimmed, dropped := ownWithout(own, []delivery.Composed{zsh})
|
||||
own := engcomp.Composed{Node: "n1", Resources: []json.RawMessage{ownFile}}
|
||||
zsh := engcomp.Composed{Node: "n1", Module: "zsh", FP: "src2:aa", Resources: []json.RawMessage{aFileOf("zsh", "rc")}}
|
||||
trimmed, dropped := ownWithout(own, []engcomp.Composed{zsh})
|
||||
if len(dropped) != 0 || len(trimmed.Resources) != 1 {
|
||||
t.Errorf("a resource sharing only its target was dropped: %v", dropped)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 924: the bus link drops, the agent restarts, and the filling is asked while it holds nothing yet. What runs is
|
||||
// then the last view the node held, never the retired kept declaration; and a fill refused for want of any is owed and
|
||||
// told once the agent holds the node again, so every file is written afterwards.
|
||||
func aFillWhileTheAgentRestartsIsWrittenAfterwards(t *testing.T) {
|
||||
keptRetired.Store(true)
|
||||
defer keptRetired.Store(false)
|
||||
node := engcomp.Composed{Node: "n1", Own: engcomp.NodeOwn{Provisioner: "mesh-node-provisioner"}}
|
||||
files := []engcomp.Composed{{Node: "n1", Module: "db", Resources: []json.RawMessage{aFileOf("db", "a")}, Filled: []string{"/tmp/r6/db/a"}}}
|
||||
view := &runsView{everything: append(files, node), holdsNode: true}
|
||||
|
||||
var placed []string
|
||||
// The agent holds the node, then the link drops and it stops: set(nil) keeps its view.
|
||||
holding := true
|
||||
current := &agentNow{view: func(*agent.Agent) *runsView {
|
||||
if holding {
|
||||
return view
|
||||
}
|
||||
return &runsView{}
|
||||
}}
|
||||
current.set(agent.New(agent.Env{}))
|
||||
e := &filling.Engine{
|
||||
Declared: func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) },
|
||||
UIDOf: func(string) (uint32, error) { return 4242, nil },
|
||||
Lock: func() (func(), error) { return func() {}, nil },
|
||||
Place: func(_ context.Context, _ *declaration.Declaration, path string, _ []byte) (string, string, error) {
|
||||
placed = append(placed, path)
|
||||
return "updated", "d", nil
|
||||
},
|
||||
}
|
||||
fill := func() filling.Answer {
|
||||
return e.Write(context.Background(), 4242, filling.Request{Path: "/tmp/r6/db/a", Content: []byte("x")})
|
||||
}
|
||||
socket := filepath.Join(t.TempDir(), "sock")
|
||||
if err := os.WriteFile(socket, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The next agent has read no haves and the previous one's view is gone (a cold start): refused, and owed.
|
||||
cold := &agentNow{}
|
||||
cold.set(agent.New(agent.Env{}))
|
||||
cold.set(nil)
|
||||
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(cold)("/nowhere", nil) }
|
||||
if a := fill(); a.Failed == "" && a.Refused == "" {
|
||||
t.Fatalf("a fill with nothing read was accepted: %+v", a)
|
||||
}
|
||||
// The link drops: set(nil) keeps the view the agent held, and the fill during the restart is written.
|
||||
e.Declared = func() (*declaration.Declaration, error) { return runsFromAgent(current)("/nowhere", nil) }
|
||||
current.set(nil)
|
||||
if told, err := e.Knock(socket); err != nil || told == "" {
|
||||
t.Fatalf("the refused fill was not owed and told: %q, %v", told, err)
|
||||
}
|
||||
if a := fill(); a.Failed != "" || a.Refused != "" || len(placed) != 1 {
|
||||
t.Fatalf("the fill during the restart was not written: %+v, placed %v", a, placed)
|
||||
}
|
||||
// The new agent holds the node: its own view answers and the kept one is dropped.
|
||||
next := agent.New(agent.Env{})
|
||||
current.set(next)
|
||||
holding = true
|
||||
if _, err := runsFromAgent(current)("/nowhere", nil); err != nil || current.lastView() != nil {
|
||||
t.Fatalf("the kept view stays after the new agent holds the node: %v, %v", err, current.lastView())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,23 @@ module github.com/novox/mesh-host
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b
|
||||
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
@@ -11,12 +27,24 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel v0.0.0-00010101000000-000000000000
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
github.com/klauspost/compress v1.20.1 // indirect
|
||||
github.com/minio/highwayhash v1.0.4 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/time v0.16.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
|
||||
git.novox.be/novox/mesh-networking/modules/mesh-connectivity-contracts/go/seats/theprivatenetwork v0.0.0-00010101000000-000000000000 // indirect
|
||||
)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b h1:UPkJqQj+1y6WraOxDidkrokNjA25jxmCk+zIRkujjMc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103155154-cf5812101b0b/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op h1:1BOWQJweNyvZMlpAHXGLiZQn9S+QXGcz3xh94lC0w6E=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4=
|
||||
github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17 h1:GKEghcFK6A+aFx11Yf1LjgLC3txAwvyhnYzhBIQZA8I=
|
||||
github.com/nats-io/nats-server/v2 v2.11.17/go.mod h1:B1sFVz4StNosQ903ak4N1G01Fl/9f8e06mXpFIE2K24=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
@@ -23,5 +21,5 @@ golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZ
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
||||
|
||||
+359
-122
File diff suppressed because it is too large
Load Diff
+349
-134
@@ -4,20 +4,28 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"github.com/novox/mesh-host/internal/buckets/bucketstest"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"github.com/novox/mesh-host/internal/judge"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
const node = "n1"
|
||||
|
||||
func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
func fpOf(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
|
||||
|
||||
// A watcher the test writes to; it says Caught once the keys written before the watch began are out.
|
||||
type feed struct {
|
||||
@@ -114,10 +122,14 @@ type bed struct {
|
||||
// transient marks failWith as the bus not answering; failTimes > 0 fails only the first that many applies.
|
||||
transient bool
|
||||
failTimes int
|
||||
backup func(module string) Backup
|
||||
kept map[string][]json.RawMessage
|
||||
others [][]delivery.Composed
|
||||
said []string
|
||||
// pullsFn, when set, is the images a failed apply could not pull (called as the apply fails); during runs at the start of
|
||||
// each apply, before it answers.
|
||||
pullsFn func() []PullFailure
|
||||
during func()
|
||||
backup func(module string) Backup
|
||||
kept map[string][]json.RawMessage
|
||||
others [][]compositions.Composed
|
||||
said []string
|
||||
// settled is each module Settled was told, and settledRan whether what runs held it then.
|
||||
settled []string
|
||||
settledRan []bool
|
||||
@@ -128,7 +140,10 @@ type bed struct {
|
||||
func newBed(t *testing.T) *bed {
|
||||
b := &bed{t: t, wants: newFeed(), comps: newFeed(), haves: &haves{}}
|
||||
b.env = Env{Node: node, Wants: b.wants, Composed: b.comps, Haves: b.haves,
|
||||
Apply: func(_ context.Context, c delivery.Composed, others []delivery.Composed) Result {
|
||||
Apply: func(_ context.Context, c compositions.Composed, others []compositions.Composed) Result {
|
||||
if b.during != nil {
|
||||
b.during()
|
||||
}
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.others = append(b.others, others)
|
||||
@@ -138,11 +153,15 @@ func newBed(t *testing.T) *bed {
|
||||
}
|
||||
b.applied = append(b.applied, name)
|
||||
if b.failWith != "" && (b.failTimes == 0 || len(b.applied) <= b.failTimes) {
|
||||
return Result{Failed: true, Transient: b.transient, Detail: b.failWith}
|
||||
res := Result{Failed: true, Transient: b.transient, Detail: b.failWith}
|
||||
if b.pullsFn != nil {
|
||||
res.Pulls = b.pullsFn()
|
||||
}
|
||||
return res
|
||||
}
|
||||
return Result{}
|
||||
},
|
||||
Remove: func(_ context.Context, m string, _ []delivery.Composed) Result {
|
||||
Remove: func(_ context.Context, m string, _ []compositions.Composed) Result {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.removed = append(b.removed, m)
|
||||
@@ -180,6 +199,17 @@ func newBed(t *testing.T) *bed {
|
||||
return b
|
||||
}
|
||||
|
||||
// idle waits until the agent has taken in and applied everything sent to it so far: what is counted after it is all there
|
||||
// will be, with no clock.
|
||||
func (b *bed) idle() {
|
||||
b.t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if !b.ag.waitIdle(ctx) {
|
||||
b.t.Fatalf("the agent's loop did not come to rest")
|
||||
}
|
||||
}
|
||||
|
||||
func (b *bed) run() (stop func()) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
@@ -197,13 +227,13 @@ func file(module, id, content string) json.RawMessage {
|
||||
// composition is a module's composition at a fingerprint: its key, its bytes and its digest, from the writer's code.
|
||||
func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessage) (key string, raw []byte, digest string) {
|
||||
t.Helper()
|
||||
c := delivery.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs}
|
||||
c := compositions.Composed{Node: node, Module: module, FP: fp, Data: data, Resources: rs}
|
||||
var err error
|
||||
if module == NodeModule {
|
||||
c.Module, c.FP = "", ""
|
||||
key, err = delivery.ComposedNodeKey(node)
|
||||
key, err = compositions.NodeKey(node)
|
||||
} else {
|
||||
key, err = delivery.ComposedKey(node, module, fp)
|
||||
key, err = compositions.Key(node, module, fp)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -212,13 +242,13 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa
|
||||
}
|
||||
|
||||
// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest.
|
||||
func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) {
|
||||
func encodedAs(t *testing.T, c compositions.Composed, key string) (string, []byte, string) {
|
||||
t.Helper()
|
||||
raw, err := delivery.EncodeComposed(c)
|
||||
raw, err := compositions.Encode(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest, err := delivery.ComposedDigestOf(c)
|
||||
digest, err := compositions.DigestOf(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -227,11 +257,8 @@ func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, s
|
||||
|
||||
func wantFor(t *testing.T, module, fp, digest, backup string) (string, []byte) {
|
||||
t.Helper()
|
||||
key, err := delivery.WantKey(node, module)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
|
||||
key := meshwants.WantsByWantNodeModuleKey(node, module)
|
||||
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Composed: digest, Backup: backup})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -275,7 +302,7 @@ func TestAWantAndItsCompositionApplyTheModuleAndTheHaveNamesBoth(t *testing.T) {
|
||||
eventually(t, "the module was not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
eventually(t, "the have does not name both", func() bool {
|
||||
h := b.haves.last("web")
|
||||
return h.FP == fp && h.Composed == dig && h.Phase == string(delivery.PhaseRunning)
|
||||
return h.FP == fp && h.Composed == dig && h.Phase == string(nodeengineclient.PhaseRunning)
|
||||
})
|
||||
// Settled is told once what runs holds the module, so a declaration read then names its files (the filling's Knock;
|
||||
// ace, 2026-10-25 21:17).
|
||||
@@ -312,7 +339,7 @@ func TestStartingOnWhatTheKeptDeclarationHoldsAppliesNothing(t *testing.T) {
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
eventually(t, "the have was not written", func() bool { return b.haves.last("web").Composed == dig })
|
||||
if h := b.haves.last("web"); h.FP != fp || h.Phase != string(delivery.PhaseRunning) {
|
||||
if h := b.haves.last("web"); h.FP != fp || h.Phase != string(nodeengineclient.PhaseRunning) {
|
||||
t.Fatalf("the have: %+v", h)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
@@ -332,7 +359,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
|
||||
fp := fpOf("a")
|
||||
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
|
||||
wk, wraw := wantFor(t, "avahi", fp, dig, "")
|
||||
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed),
|
||||
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
|
||||
Detail: "apply: unit failed to start"}}
|
||||
b.comps.before(ck, craw)
|
||||
b.wants.before(wk, wraw)
|
||||
@@ -347,7 +374,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
|
||||
fp := fpOf("a")
|
||||
ck, craw, dig := composition(t, "avahi", fp, false, file("avahi", "x", "1"))
|
||||
wk, wraw := wantFor(t, "avahi", fp, dig, "")
|
||||
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed),
|
||||
b.haves.stored = map[string]Have{"avahi": {Module: "avahi", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed),
|
||||
Detail: NotComposedDetail + "frombus: the credentials of ace: context canceled"}}
|
||||
b.comps.before(ck, craw)
|
||||
b.wants.before(wk, wraw)
|
||||
@@ -355,7 +382,7 @@ func TestStartingOnItsOwnHaveAppliesNothing(t *testing.T) {
|
||||
defer stop()
|
||||
eventually(t, "the mark was not cleared", func() bool {
|
||||
h := b.haves.last("avahi")
|
||||
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == ""
|
||||
return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp && h.Composed == dig && h.Detail == ""
|
||||
})
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("a module whose mark names what the want names was applied: %v", got)
|
||||
@@ -402,7 +429,7 @@ func testStartingOnItsOwnHaveSaysTheHaveAgainSoItsHealthIsThisEngines(t *testing
|
||||
if len(log) != 1 {
|
||||
t.Fatalf("the have was said %d times, want once: %+v", len(log), log)
|
||||
}
|
||||
if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(delivery.PhaseRunning) || h.Detail != "kept" {
|
||||
if h := log[0]; h.Module != "xorg" || h.FP != fp || h.Composed != dig || h.Phase != string(nodeengineclient.PhaseRunning) || h.Detail != "kept" {
|
||||
t.Fatalf("the have said again: %+v", h)
|
||||
}
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
@@ -429,7 +456,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
|
||||
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
eventually(t, "the have does not say failed", func() bool {
|
||||
h := b.haves.last("web")
|
||||
return h.Phase == string(delivery.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp
|
||||
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "no space") && h.FP == fp
|
||||
})
|
||||
// An unrelated key changes: not a retry.
|
||||
ck9, craw9, _ := composition(t, "web", fpOf("9"), false, file("web", "x", "9"))
|
||||
@@ -447,7 +474,7 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
|
||||
t.Fatalf("a failed apply was refused a retry: %v", err)
|
||||
}
|
||||
eventually(t, "the asked retry did not apply", func() bool { return len(b.appliedList()) == 2 })
|
||||
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) })
|
||||
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
|
||||
for module, words := range map[string]string{"web": "runs what its want names", "ghost": "has no want"} {
|
||||
if err := b.ag.RetryFailed(module); err == nil || !strings.Contains(err.Error(), words) {
|
||||
t.Errorf("a retry of %s: %v, want refused saying %q", module, err, words)
|
||||
@@ -460,6 +487,115 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
|
||||
t.Errorf("a module running its want was applied again (%d applies)", n)
|
||||
}
|
||||
})
|
||||
// design 51 (ENG-8, ENG-11): a pull from a store that failed is tried again when the store's holder settles, once per
|
||||
// settled, never on a clock; the have says whom it waits on and what triggered the last retry.
|
||||
t.Run("a pull from a store waits for its holder to settle", func(t *testing.T) {
|
||||
b := newBed(t)
|
||||
var clock atomic.Int64
|
||||
now := func() time.Time { return time.Unix(1_000_000, clock.Load()) }
|
||||
tick := func() time.Time { clock.Add(int64(time.Second)); return now() }
|
||||
b.env.Now = now
|
||||
b.env.Backoff = func(int) time.Duration { return time.Millisecond }
|
||||
b.failWith = "pulling image reg/x@sha256:1: manifest unknown"
|
||||
b.pullsFn = func() []PullFailure {
|
||||
return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: tick()},
|
||||
{Image: "public/y@sha256:2"}}
|
||||
}
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
fp := fpOf("a")
|
||||
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
|
||||
wk, wraw := wantFor(t, "web", fp, dig, "")
|
||||
b.comps.put(ck, craw)
|
||||
b.wants.put(wk, wraw)
|
||||
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
eventually(t, "the have does not say whom it waits on", func() bool {
|
||||
h := b.haves.last("web")
|
||||
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "tried again once distribution has settled")
|
||||
})
|
||||
b.idle()
|
||||
if n := len(b.appliedList()); n != 1 {
|
||||
t.Fatalf("a failed pull was tried again by the clock (%d applies)", n)
|
||||
}
|
||||
// A settled of another module starts nothing.
|
||||
b.ag.Settled("gitea")
|
||||
b.idle()
|
||||
if n := len(b.appliedList()); n != 1 {
|
||||
t.Fatalf("a settled of another module applied the module (%d applies)", n)
|
||||
}
|
||||
// The holder's settled: tried again once, whose pull fails again after it (started later): failed again, no more.
|
||||
tick()
|
||||
b.ag.Settled("distribution")
|
||||
eventually(t, "not tried again on the holder's settled", func() bool { return len(b.appliedList()) == 2 })
|
||||
eventually(t, "the have does not say what triggered the retry", func() bool {
|
||||
h := b.haves.last("web")
|
||||
return h.Phase == string(nodeengineclient.PhaseFailed) && strings.Contains(h.Detail, "last tried again at") && strings.Contains(h.Detail, "distribution settled")
|
||||
})
|
||||
b.idle()
|
||||
if n := len(b.appliedList()); n != 2 {
|
||||
t.Fatalf("a second failure after the same settled started a further apply (%d applies)", n)
|
||||
}
|
||||
// The failed have carries the pull it waits on (ENG-9, read through node_haves): the image, whose settled it waits
|
||||
// on, and what triggered the last retry.
|
||||
if w := b.haves.last("web").PullWait; w == nil || w.Holder != "distribution" || w.Image != "reg/x@sha256:1" ||
|
||||
w.Began.IsZero() || !strings.Contains(w.Retried, "distribution settled") || w.RetriedAt == nil {
|
||||
t.Fatalf("the failed have says it waits on %+v", w)
|
||||
}
|
||||
// The next settled answers once more, and the pull works: the module runs, and waits on nothing.
|
||||
b.mu.Lock()
|
||||
b.failWith = ""
|
||||
b.mu.Unlock()
|
||||
tick()
|
||||
b.ag.Settled("distribution")
|
||||
eventually(t, "not tried again on the next settled", func() bool { return len(b.appliedList()) == 3 })
|
||||
eventually(t, "not running", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
|
||||
if w := b.haves.last("web").PullWait; w != nil {
|
||||
t.Fatalf("a running module's have still waits on %+v", w)
|
||||
}
|
||||
b.ag.Settled("distribution")
|
||||
b.idle()
|
||||
if n := len(b.appliedList()); n != 3 {
|
||||
t.Fatalf("a running module was applied again by a settled (%d applies)", n)
|
||||
}
|
||||
})
|
||||
// The race just before `settled`: the holder's settled is heard while the pull is in flight, and the pull fails after it;
|
||||
// applied again at once, once, and a settled heard before the pull began applies nothing at once.
|
||||
t.Run("a settled heard while the pull ran is not missed", func(t *testing.T) {
|
||||
b := newBed(t)
|
||||
var clock atomic.Int64
|
||||
now := func() time.Time { return time.Unix(1_000_000, clock.Load()) }
|
||||
tick := func() time.Time { clock.Add(int64(time.Second)); return now() }
|
||||
b.env.Now = now
|
||||
b.failWith = "pulling image reg/x@sha256:1: connection refused"
|
||||
began := tick()
|
||||
first := true
|
||||
b.during = func() {
|
||||
if first {
|
||||
first = false
|
||||
tick()
|
||||
b.ag.Settled("distribution") // the holder settles while this apply's pull is in flight
|
||||
}
|
||||
}
|
||||
b.pullsFn = func() []PullFailure {
|
||||
started := began
|
||||
if len(b.applied) > 1 {
|
||||
started = tick() // the retry's pull begins after the settled
|
||||
}
|
||||
return []PullFailure{{Image: "reg/x@sha256:1", Seat: "mesh-artifact-store", Holder: "distribution", Started: started}}
|
||||
}
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
fp := fpOf("a")
|
||||
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
|
||||
wk, wraw := wantFor(t, "web", fp, dig, "")
|
||||
b.comps.put(ck, craw)
|
||||
b.wants.put(wk, wraw)
|
||||
eventually(t, "the settled heard during the pull was missed", func() bool { return len(b.appliedList()) == 2 })
|
||||
b.idle()
|
||||
if n := len(b.appliedList()); n != 2 {
|
||||
t.Fatalf("the race retry failing again started a further apply (%d applies)", n)
|
||||
}
|
||||
})
|
||||
// The bus did not answer for a moment (mesh-vault on the bus node, 2026-10-25 23:04:24): tried again with no new
|
||||
// revision of either key, by the backoff or by RetryTransient once the engine knows the bus is back; the have says
|
||||
// retrying meanwhile, never failed.
|
||||
@@ -486,11 +622,11 @@ func TestAFailedApplyIsRetriedOnlyByAChangeOfEitherKey(t *testing.T) {
|
||||
b.ag.RetryTransient()
|
||||
}
|
||||
eventually(t, "not applied again without a new revision", func() bool { return len(b.appliedList()) == 2 })
|
||||
eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(delivery.PhaseRunning) })
|
||||
eventually(t, "not running", func() bool { return b.haves.last("vault").Phase == string(nodeengineclient.PhaseRunning) })
|
||||
b.haves.mu.Lock()
|
||||
defer b.haves.mu.Unlock()
|
||||
for _, h := range b.haves.log {
|
||||
if h.Phase == string(delivery.PhaseFailed) {
|
||||
if h.Phase == string(nodeengineclient.PhaseFailed) {
|
||||
t.Errorf("a transient failure was written failed: %+v", h)
|
||||
}
|
||||
}
|
||||
@@ -530,7 +666,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
})
|
||||
eventually(t, "the have does not say held", func() bool {
|
||||
h := b.haves.last("db")
|
||||
return h.Phase == string(delivery.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1
|
||||
return h.Phase == string(nodeengineclient.PhaseHeld) && strings.Contains(h.Detail, "repository is locked") && h.FP == fp1
|
||||
})
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("something was applied behind a failed backup: %v", got)
|
||||
@@ -548,7 +684,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
}
|
||||
|
||||
// The answer arrives in the module's own want: back up again (it fails again), then skip.
|
||||
wk, again := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
|
||||
wk, again := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain)
|
||||
b.wants.put(wk, again)
|
||||
eventually(t, "back up again did not ask the backup", func() bool {
|
||||
b.mu.Lock()
|
||||
@@ -558,7 +694,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("applied behind a second failed backup: %v", got)
|
||||
}
|
||||
_, skip := wantFor(t, "db", fp2, dig2, delivery.BackupSkip)
|
||||
_, skip := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupSkip)
|
||||
b.wants.put(wk, skip)
|
||||
eventually(t, "skip did not apply the module", func() bool { return len(b.appliedList()) == 1 })
|
||||
b.mu.Lock()
|
||||
@@ -569,7 +705,7 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
}
|
||||
eventually(t, "not running at the new fingerprint", func() bool {
|
||||
h := b.haves.last("db")
|
||||
return h.Phase == string(delivery.PhaseRunning) && h.FP == fp2 && h.Composed == dig2
|
||||
return h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fp2 && h.Composed == dig2
|
||||
})
|
||||
|
||||
// The node agent stopped while the backup ran, the module known only from its have: the held have names what runs
|
||||
@@ -577,9 +713,9 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
ib := newBed(t)
|
||||
ib.comps.before(ck1, craw1)
|
||||
ib.comps.before(ck2, craw2)
|
||||
iwk, iwraw := wantFor(t, "db", fp2, dig2, delivery.BackupAgain)
|
||||
iwk, iwraw := wantFor(t, "db", fp2, dig2, meshwants.WantsByWantNodeModuleValueBackupAgain)
|
||||
ib.wants.before(iwk, iwraw)
|
||||
ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseRunning)}}
|
||||
ib.haves.stored = map[string]Have{"db": {Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseRunning)}}
|
||||
began := make(chan struct{})
|
||||
ib.env.Backup = func(ctx context.Context, _ string) Backup {
|
||||
close(began)
|
||||
@@ -589,29 +725,34 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
stopI := ib.run()
|
||||
<-began
|
||||
stopI()
|
||||
if h := ib.haves.last("db"); h.Phase != string(delivery.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted {
|
||||
if h := ib.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) || h.FP != fp1 || h.Composed != dig1 || h.Detail != BackupInterrupted {
|
||||
t.Errorf("held after an interrupted backup: %+v", h)
|
||||
}
|
||||
|
||||
// At a start: a backup the node agent's own stop interrupted (postgres on novox, 2026-10-31) is no failure, so it is
|
||||
// backed up again and applied while the want asks a backup; a backup that failed stays held for the operator.
|
||||
// backed up again and applied while the want asks a backup; a backup that failed stays held for the operator's
|
||||
// answer, and an answer the want already carries is acted on.
|
||||
for _, row := range []struct {
|
||||
detail, backup string
|
||||
backups, applied int
|
||||
phase delivery.Phase
|
||||
phase string
|
||||
namesNothing bool // the have names no fp and no composition
|
||||
}{
|
||||
{BackupInterrupted, delivery.BackupAgain, 1, 1, delivery.PhaseRunning, false},
|
||||
{BackupInterrupted, meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
|
||||
// novox's postgres: its held have, written before mesh-host #189, names nothing it runs.
|
||||
{BackupInterrupted, "", 1, 1, delivery.PhaseRunning, true},
|
||||
{"restic: the repository is locked", delivery.BackupAgain, 0, 0, delivery.PhaseHeld, false},
|
||||
{BackupInterrupted, "", 1, 1, nodeengineclient.PhaseRunning, true},
|
||||
// A backup that failed, and the want carries the operator's answer, written before this start (nats on novox,
|
||||
// 2026-11-04, issue 1585): again backs it up and applies, skip applies without one; no answer keeps the hold.
|
||||
{"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, 1, nodeengineclient.PhaseRunning, false},
|
||||
{"restic: the repository is locked", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, 1, nodeengineclient.PhaseRunning, false},
|
||||
{"restic: the repository is locked", "", 0, 0, nodeengineclient.PhaseHeld, false},
|
||||
} {
|
||||
r := newBed(t)
|
||||
r.comps.before(ck1, craw1)
|
||||
r.comps.before(ck2, craw2)
|
||||
wk, wraw := wantFor(t, "db", fp2, dig2, row.backup)
|
||||
r.wants.before(wk, wraw)
|
||||
held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(delivery.PhaseHeld), Detail: row.detail}
|
||||
held := Have{Module: "db", FP: fp1, Composed: dig1, Phase: string(nodeengineclient.PhaseHeld), Detail: row.detail}
|
||||
if row.namesNothing {
|
||||
held.FP, held.Composed = "", ""
|
||||
}
|
||||
@@ -630,6 +771,78 @@ func TestADataModuleIsBackedUpFirstAndHeldWhenItFails(t *testing.T) {
|
||||
}
|
||||
stopR()
|
||||
}
|
||||
|
||||
// The live shape (nats on novox, 2026-11-04, issue 1585, the second failure): a module the node composes itself, of
|
||||
// policy record, its want Self with the yes for its composition, and its held have naming that very composition (what
|
||||
// runs is what the want names). The operator's answer acted on at the start, and in a revision of the want that
|
||||
// arrives while the module is held; the engine's read tool says the want's answer and the hold.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
atStart string // the answer the want carries when the engine starts
|
||||
later string // the answer a later revision carries; "" writes none
|
||||
backups int
|
||||
holdsTill bool // it is held, and said held, before the later revision
|
||||
}{
|
||||
{"answered before the start", meshwants.WantsByWantNodeModuleValueBackupAgain, "", 1, false},
|
||||
{"answered while held", "", meshwants.WantsByWantNodeModuleValueBackupAgain, 1, true},
|
||||
{"skipped while held", "", meshwants.WantsByWantNodeModuleValueBackupSkip, 0, true},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
r := newBed(t)
|
||||
r.env.SelfComposed, r.kept = true, nil
|
||||
r.env.Policy = func(context.Context, string) string { return catalogue.PolicyRecord }
|
||||
ck, craw, dig := composition(t, "nats", fp2, true, file("nats", "x", "2"))
|
||||
r.comps.before(ck, craw)
|
||||
wk := meshwants.WantsByWantNodeModuleKey(node, "nats")
|
||||
selfWant := func(backup string) []byte {
|
||||
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp2, Self: true, Yes: dig, Backup: backup})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
r.wants.before(wk, selfWant(row.atStart))
|
||||
r.haves.stored = map[string]Have{"nats": {Module: "nats", FP: fp2, Composed: dig, Phase: string(nodeengineclient.PhaseHeld),
|
||||
Detail: "the backup failed: sh: exit status 1"}}
|
||||
stopR := r.run()
|
||||
defer stopR()
|
||||
if row.holdsTill {
|
||||
eventually(t, "the hold at the start is not said", func() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, s := range r.said {
|
||||
if strings.Contains(s, "nats is held for a failed backup") && strings.Contains(s, "carries none") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
view, err := r.ag.Composition(context.Background(), "nats", "", false)
|
||||
if err != nil || view.Attempt == nil || !view.Attempt.Held || view.Want == nil || view.Want.Backup != "" || !view.Started {
|
||||
t.Fatalf("the read tool does not say the hold: %+v %+v %v", view.Attempt, view.Want, err)
|
||||
}
|
||||
if got := r.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("applied behind a hold with no answer: %v", got)
|
||||
}
|
||||
r.wants.put(wk, selfWant(row.later))
|
||||
eventually(t, "the read tool does not say the answer", func() bool {
|
||||
v, err := r.ag.Composition(context.Background(), "nats", "", false)
|
||||
return err == nil && v.Want != nil && v.Want.Backup == row.later && v.Want.Revision > view.Want.Revision
|
||||
})
|
||||
}
|
||||
eventually(t, "the answer was not acted on", func() bool { return len(r.appliedList()) == 1 })
|
||||
eventually(t, "not running after the answer", func() bool {
|
||||
h := r.haves.last("nats")
|
||||
return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == dig
|
||||
})
|
||||
r.mu.Lock()
|
||||
n := len(r.backups)
|
||||
r.mu.Unlock()
|
||||
if n != row.backups {
|
||||
t.Errorf("%d backups for the answer, want %d", n, row.backups)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A data module with a good backup is applied after it; one that never ran has nothing to back up.
|
||||
@@ -675,7 +888,7 @@ func TestARemovedWantRemovesThatModuleOnly(t *testing.T) {
|
||||
wk, wraw := wantFor(t, "web", fp, dig, "")
|
||||
b.wants.put(wk, wraw)
|
||||
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
gone, _ := delivery.EncodeWant(delivery.Want{Removed: true})
|
||||
gone, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
|
||||
b.wants.put(wk, gone)
|
||||
eventually(t, "not removed", func() bool {
|
||||
b.mu.Lock()
|
||||
@@ -696,11 +909,8 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) {
|
||||
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
|
||||
wk, wraw := wantFor(t, "web", fp, dig, "")
|
||||
nk, nraw, ndig := composition(t, NodeModule, "", false)
|
||||
nwk, err := delivery.WantNodeKey(node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -748,9 +958,9 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
if got := a.Everything(); len(got) != 2 {
|
||||
t.Fatalf("before the agent holds anything: %d compositions, want the 2 kept", len(got))
|
||||
}
|
||||
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: delivery.PhaseRunning}
|
||||
a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: "sha256:" + strings.Repeat("1", 64), Phase: nodeengineclient.PhaseRunning}
|
||||
ck, craw, dig := composition(t, "web", fpOf("a"), false, file("web", "x", "new"))
|
||||
a.running["web"] = delivery.Have{FP: fpOf("a"), Composed: dig, Phase: delivery.PhaseRunning}
|
||||
a.running["web"] = nodeengine.HaveByNodeModuleValue{Fp: fpOf("a"), Composed: dig, Phase: nodeengineclient.PhaseRunning}
|
||||
a.takeComposition(Update{Key: ck, Value: craw, Revision: 1})
|
||||
got := a.Everything()
|
||||
if len(got) != 2 {
|
||||
@@ -776,20 +986,20 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
b.env.Applied = store
|
||||
// A folded module's resource in the node's own composition: its id names the module, as the controller folds it.
|
||||
natsConf, _ := json.Marshal(map[string]any{"id": "nats.conf", "type": "file", "path": "/tmp/r6/nats/conf", "content": "listen 4222"})
|
||||
own := delivery.Composed{Node: node, Resources: []json.RawMessage{natsConf}}
|
||||
ownRaw, err := delivery.EncodeComposed(own)
|
||||
own := compositions.Composed{Node: node, Resources: []json.RawMessage{natsConf}}
|
||||
ownRaw, err := compositions.Encode(own)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownDig, _ := delivery.ComposedDigestOf(own)
|
||||
web := delivery.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}}
|
||||
webRaw, _ := delivery.EncodeComposed(web)
|
||||
webDig, _ := delivery.ComposedDigestOf(web)
|
||||
ownDig, _ := compositions.DigestOf(own)
|
||||
web := compositions.Composed{Node: node, Module: "web", FP: fpOf("a"), Resources: []json.RawMessage{file("web", "x", "1")}}
|
||||
webRaw, _ := compositions.Encode(web)
|
||||
webDig, _ := compositions.DigestOf(web)
|
||||
store.kept[NodeModule], store.kept["web"] = ownRaw, webRaw
|
||||
b.haves.Write(context.Background(), Have{Module: NodeModule, Composed: ownDig})
|
||||
b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(delivery.PhaseRunning)})
|
||||
b.haves.Write(context.Background(), Have{Module: "web", FP: fpOf("a"), Composed: webDig, Phase: string(nodeengineclient.PhaseRunning)})
|
||||
// The composer's first pass reads what runs when its watch begins, before the agent reads any have.
|
||||
firstCh := make(chan []delivery.Composed, 1)
|
||||
firstCh := make(chan []compositions.Composed, 1)
|
||||
comps := b.comps
|
||||
var ag *Agent
|
||||
b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) {
|
||||
@@ -802,7 +1012,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
go func() { defer close(done); _ = ag.Run(ctx) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
var first []delivery.Composed
|
||||
var first []compositions.Composed
|
||||
select {
|
||||
case first = <-firstCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
@@ -818,15 +1028,15 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
ranOwn, ranWeb, len(first))
|
||||
}
|
||||
// An apply keeps the composition it applied; a removal forgets it.
|
||||
web2 := delivery.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}}
|
||||
ck, _ := delivery.ComposedKey(node, "web", fpOf("b"))
|
||||
raw2, _ := delivery.EncodeComposed(web2)
|
||||
web2 := compositions.Composed{Node: node, Module: "web", FP: fpOf("b"), Resources: []json.RawMessage{file("web", "x", "2")}}
|
||||
ck, _ := compositions.Key(node, "web", fpOf("b"))
|
||||
raw2, _ := compositions.Encode(web2)
|
||||
comps.put(ck, raw2)
|
||||
wk, _ := delivery.WantKey(node, "web")
|
||||
wraw, _ := delivery.EncodeWant(delivery.Want{FP: fpOf("b"), Self: true})
|
||||
wk := meshwants.WantsByWantNodeModuleKey(node, "web")
|
||||
wraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpOf("b"), Self: true})
|
||||
b.wants.put(wk, wraw)
|
||||
eventually(t, "the applied composition was not kept", func() bool { return string(store.get("web")) == string(raw2) })
|
||||
rraw, _ := delivery.EncodeWant(delivery.Want{Removed: true})
|
||||
rraw, _ := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
|
||||
b.wants.put(wk, rraw)
|
||||
eventually(t, "the removed module's composition was not forgotten", func() bool { return store.get("web") == nil })
|
||||
})
|
||||
@@ -839,10 +1049,10 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
store := &memApplied{kept: map[string][]byte{}}
|
||||
h := &haves{}
|
||||
h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)})
|
||||
firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed {
|
||||
firstPass := func(kept map[string][]json.RawMessage) []compositions.Composed {
|
||||
b := newBed(t)
|
||||
b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h
|
||||
firstCh := make(chan []delivery.Composed, 1)
|
||||
firstCh := make(chan []compositions.Composed, 1)
|
||||
comps := b.comps
|
||||
var ag *Agent
|
||||
b.env.Composed = watchFunc(func(ctx context.Context, p string) (<-chan Update, error) {
|
||||
@@ -862,7 +1072,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
runsNats := func(cs []delivery.Composed) bool {
|
||||
runsNats := func(cs []compositions.Composed) bool {
|
||||
for _, c := range cs {
|
||||
if c.Module == "" && len(c.Resources) == 1 && strings.Contains(string(c.Resources[0]), "4222") {
|
||||
return true
|
||||
@@ -932,7 +1142,7 @@ func TestAFailedApplyIsAppliedAgainAfterARestartWhateverIsKept(t *testing.T) {
|
||||
b.comps.before(ck, craw)
|
||||
b.wants.before(wk, wraw)
|
||||
b.kept = map[string][]json.RawMessage{"web": {file("web", "x", "1")}}
|
||||
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(delivery.PhaseFailed)}}
|
||||
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fp, Composed: dig, Phase: string(nodeengineclient.PhaseFailed)}}
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
eventually(t, "a failed apply was marked running from the kept declaration", func() bool { return len(b.appliedList()) == 1 })
|
||||
@@ -969,11 +1179,8 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.
|
||||
rk, rraw, _ := composition(t, "systemd-resolved", fp, false, file("systemd-resolved", "guard", "g"))
|
||||
b.comps.before(rk, rraw)
|
||||
nk, nraw, ndig := composition(t, NodeModule, "", false)
|
||||
nwk, err := delivery.WantNodeKey(node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nwraw, err := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, err := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1003,7 +1210,7 @@ func TestTheNodesOwnApplyHoldsOutAModuleThatMovedToItsOwnComposition(t *testing.
|
||||
spiegel, _ = json.Marshal(bare)
|
||||
b2.env.Kept = func() map[string][]json.RawMessage { return map[string][]json.RawMessage{NodeModule: {spiegel}} }
|
||||
nk2, nraw2, ndig2 := composition(t, NodeModule, "", false)
|
||||
nwraw2, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig2})
|
||||
nwraw2, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig2})
|
||||
b2.comps.before(nk2, nraw2)
|
||||
b2.wants.before(nwk, nwraw2)
|
||||
ctx2, cancel2 := context.WithCancel(context.Background())
|
||||
@@ -1031,18 +1238,15 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
|
||||
fp := fpOf("b")
|
||||
rk, rraw, _ := composition(t, "gone", fp, false, file("gone", "x", "1"))
|
||||
b.comps.before(rk, rraw)
|
||||
gk, err := delivery.WantKey(node, "gone")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
graw, err := delivery.EncodeWant(delivery.Want{Removed: true})
|
||||
gk := meshwants.WantsByWantNodeModuleKey(node, "gone")
|
||||
graw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Removed: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.wants.before(gk, graw)
|
||||
nk, nraw, ndig := composition(t, NodeModule, "", false)
|
||||
nwk, _ := delivery.WantNodeKey(node)
|
||||
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
|
||||
b.comps.before(nk, nraw)
|
||||
b.wants.before(nwk, nwraw)
|
||||
stop := b.run()
|
||||
@@ -1070,14 +1274,14 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
|
||||
// it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads.
|
||||
func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
|
||||
b := newBed(t)
|
||||
bad, err := delivery.ComposedKey(node, "broken", fpOf("c"))
|
||||
bad, err := compositions.Key(node, "broken", fpOf("c"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.comps.before(bad, []byte(`{"v":1,"not":"a composition"}`))
|
||||
nk, nraw, ndig := composition(t, NodeModule, "", false)
|
||||
nwk, _ := delivery.WantNodeKey(node)
|
||||
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
|
||||
b.comps.before(nk, nraw)
|
||||
b.wants.before(nwk, nwraw)
|
||||
stop := b.run()
|
||||
@@ -1106,7 +1310,7 @@ func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
|
||||
// compositions written before the filled paths, which the SDK now refuses, and nobody applies them).
|
||||
func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) {
|
||||
b := newBed(t)
|
||||
stale, err := delivery.ComposedKey(node, "power", fpOf("a"))
|
||||
stale, err := compositions.Key(node, "power", fpOf("a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1114,8 +1318,8 @@ func TestAStaleRefusedCompositionDoesNotHoldTheNodesOwnBack(t *testing.T) {
|
||||
gk, graw, _ := composition(t, "power", fpOf("b"), false, file("power", "x", "1"))
|
||||
b.comps.before(gk, graw)
|
||||
nk, nraw, ndig := composition(t, NodeModule, "", false)
|
||||
nwk, _ := delivery.WantNodeKey(node)
|
||||
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Composed: ndig})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Composed: ndig})
|
||||
b.comps.before(nk, nraw)
|
||||
b.wants.before(nwk, nwraw)
|
||||
stop := b.run()
|
||||
@@ -1142,7 +1346,7 @@ func TestACompositionWhoseResourcesWereRemovedIsAppliedAgain(t *testing.T) {
|
||||
} else {
|
||||
b.haves.stored = map[string]Have{"ca-trust": {Module: "ca-trust", FP: fp, Composed: dig, Phase: "running"}}
|
||||
}
|
||||
b.env.Recorded = func(context.Context, delivery.Composed) bool { return false }
|
||||
b.env.Recorded = func(context.Context, compositions.Composed) bool { return false }
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
eventually(t, "not applied", func() bool { return len(b.appliedList()) == 1 })
|
||||
@@ -1191,8 +1395,8 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
|
||||
`","mode":"0644","owner-at-runtime":"owned"}`)
|
||||
}
|
||||
fp := fpOf("a")
|
||||
ck, _ := delivery.ComposedKey(node, "power", fp)
|
||||
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
|
||||
ck, _ := compositions.Key(node, "power", fp)
|
||||
applied := compositions.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
|
||||
Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}}
|
||||
_, appliedRaw, appliedDig := encodedAs(t, applied, ck)
|
||||
newer := applied
|
||||
@@ -1214,7 +1418,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
|
||||
{name: "a have naming the bucket's value", have: &Have{Module: "power", FP: fp, Composed: appliedDig},
|
||||
bucket: appliedRaw, filled: old + "," + added, told: "the haves were read at the start", data: true},
|
||||
{name: "a have applying names nothing that runs yet", have: &Have{Module: "power", FP: fp, Composed: appliedDig,
|
||||
Phase: string(delivery.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
|
||||
Phase: string(nodeengineclient.PhaseApplying)}, bucket: appliedRaw, filled: old, told: "the haves were read at the start"},
|
||||
{name: "a data module from the kept declaration says Data as its manifest does", filled: old,
|
||||
told: "the haves were read at the start", data: true,
|
||||
holdsData: func(context.Context, string) (bool, bool) { return true, true }},
|
||||
@@ -1312,21 +1516,22 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
defer historyMu.Unlock()
|
||||
history[key] = append(history[key], Update{Key: key, Value: raw, Revision: uint64(len(history[key]) + 1)})
|
||||
}
|
||||
b.env.Held = func() map[string]string { return map[string]string{"mesh-build-go": "its binding is unset"} }
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
unsigned := func(module, fp string, rs ...json.RawMessage) (string, []byte, string) {
|
||||
c := delivery.Composed{Node: node, Module: module, FP: fp, Resources: rs}
|
||||
key, _ := delivery.ComposedKey(node, module, fp)
|
||||
raw, err := delivery.EncodeComposed(c)
|
||||
c := compositions.Composed{Node: node, Module: module, FP: fp, Resources: rs}
|
||||
key, _ := compositions.Key(node, module, fp)
|
||||
raw, err := compositions.Encode(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dig, _ := delivery.ComposedDigestOf(c)
|
||||
dig, _ := compositions.DigestOf(c)
|
||||
return key, raw, dig
|
||||
}
|
||||
want := func(module, fp, yes string) {
|
||||
key, _ := delivery.WantKey(node, module)
|
||||
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Yes: yes, Self: true})
|
||||
key := meshwants.WantsByWantNodeModuleKey(node, module)
|
||||
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fp, Yes: yes, Self: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1355,13 +1560,13 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
// The node's own composition carries its sealed values in its key, as a module's have does, so a restart reuses its
|
||||
// core boxes and applies nothing again (review of L2, round 3, finding 1).
|
||||
box, _ := json.Marshal(map[string]any{"id": "core-box", "type": "file", "path": "/tmp/r6/core-box", "sealed": "core"})
|
||||
own := delivery.Composed{Node: node, Resources: []json.RawMessage{box}}
|
||||
nk, _ := delivery.ComposedNodeKey(node)
|
||||
nraw, _ := delivery.EncodeComposed(own)
|
||||
ndig, _ := delivery.ComposedDigestOf(own)
|
||||
own := compositions.Composed{Node: node, Resources: []json.RawMessage{box}}
|
||||
nk, _ := compositions.NodeKey(node)
|
||||
nraw, _ := compositions.Encode(own)
|
||||
ndig, _ := compositions.DigestOf(own)
|
||||
b.comps.put(nk, nraw)
|
||||
nwk, _ := delivery.WantNodeKey(node)
|
||||
nwraw, _ := delivery.EncodeNodeWant(delivery.NodeWant{Self: true})
|
||||
nwk := meshwants.WantsByWantNodeNodeKey(node)
|
||||
nwraw, _ := bucketstest.NodeWant(meshwants.WantsByWantNodeNodeValue{Self: true})
|
||||
b.wants.put(nwk, nwraw)
|
||||
eventually(t, "the node's own key does not carry its sealed values", func() bool {
|
||||
h := b.haves.last(NodeModule)
|
||||
@@ -1371,8 +1576,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
// names what runs; composed again, the have is what it runs. web was applied by this agent; idle was not since the
|
||||
// agent's start (the switch and every delivery of the node-engine restart it), so what runs is what its have said.
|
||||
idleFP, idleDig := fpOf("c"), "sha256:"+strings.Repeat("9", 64)
|
||||
b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(delivery.PhaseRunning),
|
||||
Sealed: delivery.Seals{"idle.key#sealed": `{"box":"idle"}`}})
|
||||
b.haves.Write(context.Background(), Have{Module: "idle", FP: idleFP, Composed: idleDig, Phase: string(nodeengineclient.PhaseRunning),
|
||||
Sealed: compositions.Seals{"idle.key#sealed": `{"box":"idle"}`}})
|
||||
want("idle", idleFP, "")
|
||||
eventually(t, "idle's want was not taken", func() bool {
|
||||
b.ag.mu.Lock()
|
||||
@@ -1387,7 +1592,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
module, fp, composed, slot, sealed string
|
||||
}{{"web", fp, dig, "web.pw#sealed", "1"}, {"idle", idleFP, idleDig, "idle.key#sealed", `{"box":"idle"}`}} {
|
||||
h := b.haves.last(row.module)
|
||||
if h.Phase != string(delivery.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed ||
|
||||
if h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != row.fp || h.Composed != row.composed ||
|
||||
len(h.Sealed) != 1 || h.Sealed[row.slot] != row.sealed || !strings.Contains(h.Detail, "web's password") {
|
||||
t.Errorf("%s's have while the node cannot compose: %+v", row.module, h)
|
||||
}
|
||||
@@ -1402,7 +1607,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
want("fresh", fpOf("e"), "") // never ran, its have absent: marked, naming nothing it runs
|
||||
// A have that already says another cause keeps it (review of the switch, round 1): an apply that failed.
|
||||
brokenFP, brokenDig := fpOf("f"), "sha256:"+strings.Repeat("8", 64)
|
||||
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(delivery.PhaseFailed),
|
||||
b.haves.Write(context.Background(), Have{Module: "broken", FP: brokenFP, Composed: brokenDig, Phase: string(nodeengineclient.PhaseFailed),
|
||||
Detail: "apply: unit failed to start"})
|
||||
want("broken", brokenFP, "")
|
||||
eventually(t, "unread's want was not taken", func() bool {
|
||||
@@ -1414,7 +1619,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
if h := b.haves.last("unread"); h.Module != "" {
|
||||
t.Errorf("a module whose have could not be read was marked: %+v", h)
|
||||
}
|
||||
if h := b.haves.last("fresh"); h.Phase != string(delivery.PhaseFailed) || h.FP != "" {
|
||||
if h := b.haves.last("fresh"); h.Phase != string(nodeengineclient.PhaseFailed) || h.FP != "" {
|
||||
t.Errorf("a module that never ran, its have absent, is not marked: %+v", h)
|
||||
}
|
||||
if h := b.haves.last("broken"); h.Detail != "apply: unit failed to start" || h.Composed != brokenDig {
|
||||
@@ -1430,7 +1635,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
}
|
||||
b.ag.NotComposed(context.Background(), "")
|
||||
for _, row := range []struct{ module, composed string }{{"web", dig}, {"idle", idleDig}} {
|
||||
if h := b.haves.last(row.module); h.Phase != string(delivery.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {
|
||||
if h := b.haves.last(row.module); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != row.composed || len(h.Sealed) != 1 {
|
||||
t.Errorf("%s's have once the node composes again: %+v", row.module, h)
|
||||
}
|
||||
}
|
||||
@@ -1438,14 +1643,14 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
// novox) is marked alone, naming its own words; web, which composes, keeps running, unmarked. Composed again, idle's
|
||||
// have is what it runs.
|
||||
b.ag.NotComposedEach(context.Background(), "", map[string]string{"idle": "idle says ${config:name}, and nothing sets it"})
|
||||
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
|
||||
if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseFailed) || h.Composed != idleDig || !strings.Contains(h.Detail, "${config:name}") {
|
||||
t.Errorf("idle's have while it alone is held: %+v", h)
|
||||
}
|
||||
if h := b.haves.last("web"); h.Phase != string(delivery.PhaseRunning) || h.Composed != dig {
|
||||
if h := b.haves.last("web"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != dig {
|
||||
t.Errorf("web, which composes, is marked beside idle: %+v", h)
|
||||
}
|
||||
b.ag.NotComposedEach(context.Background(), "", nil)
|
||||
if h := b.haves.last("idle"); h.Phase != string(delivery.PhaseRunning) || h.Composed != idleDig {
|
||||
if h := b.haves.last("idle"); h.Phase != string(nodeengineclient.PhaseRunning) || h.Composed != idleDig {
|
||||
t.Errorf("idle's have once it composes again: %+v", h)
|
||||
}
|
||||
// A revision at the same build (a config value changed) is applied.
|
||||
@@ -1470,7 +1675,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
b.comps.put(dk, drawMoved)
|
||||
eventually(t, "db's revision did not wait for a yes", func() bool {
|
||||
h := b.haves.last("db")
|
||||
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == ddigMoved
|
||||
return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == ddigMoved
|
||||
})
|
||||
if h := b.haves.last("db"); h.BuildAlone {
|
||||
t.Errorf("a revision at the build it runs is said to change the build alone: %+v", h)
|
||||
@@ -1480,24 +1685,34 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
keep(dk, draw)
|
||||
keep(dk, drawMoved)
|
||||
view, err := b.ag.Composition(context.Background(), "db", "", true)
|
||||
if err != nil || view.Have == nil || view.Have.Phase != string(delivery.PhaseWaitingYes) || view.Composition.Digest != ddigMoved ||
|
||||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(delivery.JudgedRecreates) ||
|
||||
if err != nil || view.Have == nil || view.Have.Phase != string(nodeengineclient.PhaseWaitingYes) || view.Composition.Digest != ddigMoved ||
|
||||
view.Runs == nil || view.Runs.Composed != ddig || view.Judged.Judgement != string(nodeengineclient.JudgedRecreates) ||
|
||||
!view.Judged.WaitsForYes || len(view.Diff) != 1 || view.Diff[0].ID != "db.server" || view.Diff[0].Change != "changed" ||
|
||||
len(view.Revisions) != 2 {
|
||||
t.Errorf("the composition of db waiting for a yes reads %+v (%v)", view, err)
|
||||
}
|
||||
if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig ||
|
||||
view.Judged.Judgement != string(delivery.JudgedUnchanged) || view.Judged.WaitsForYes || view.Diff != nil {
|
||||
view.Judged.Judgement != judge.JudgedUnchanged || view.Judged.WaitsForYes || view.Diff != nil {
|
||||
t.Errorf("db's running revision, asked by its digest, reads %+v (%v)", view, err)
|
||||
}
|
||||
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
|
||||
t.Error("a digest no revision has was answered")
|
||||
}
|
||||
// Issue 774 (ace, 2026-11-04 01:29): the node's own revision shows its own fields, the provisioner client's account
|
||||
// and the adoption among them, and what the composer's last pass held.
|
||||
ownKey, ownRaw, ownDig := encodedAs(t, compositions.Composed{Node: node, Own: compositions.NodeOwn{Provisioner: "mesh-provisioner-client",
|
||||
Adoption: &declaration.Adoption{Taken: []string{"web"}}}}, func() string { k, _ := compositions.NodeKey(node); return k }())
|
||||
keep(ownKey, ownRaw)
|
||||
if view, err := b.ag.Composition(context.Background(), NodeModule, ownDig, false); err != nil || view.Own == nil ||
|
||||
view.Own.Provisioner != "mesh-provisioner-client" || view.Own.Adoption == nil || view.Own.Adoption.Taken[0] != "web" ||
|
||||
view.Own.Held["mesh-build-go"] == "" {
|
||||
t.Errorf("the node's own composition reads %+v %+v (%v)", view, view.Own, err)
|
||||
}
|
||||
// novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
|
||||
// that revision by the digest its have and want name, its resources' (the plan left out of the digest).
|
||||
planned, _ := delivery.DecodeComposed(draw)
|
||||
planned, _ := compositions.Decode(draw)
|
||||
planned.Plan = &catalogue.Plan{}
|
||||
praw, err := delivery.EncodeComposed(planned)
|
||||
praw, err := compositions.Encode(planned)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1509,7 +1724,7 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
b.comps.put(dk, draw)
|
||||
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
|
||||
h := b.haves.last("db")
|
||||
return h.Phase == string(delivery.PhaseRunning) && h.Composed == ddig && h.Waits == ""
|
||||
return h.Phase == string(nodeengineclient.PhaseRunning) && h.Composed == ddig && h.Waits == ""
|
||||
})
|
||||
if got := len(b.appliedList()); got != 4 {
|
||||
t.Fatalf("a composition equal to what runs was applied again: %d applies", got)
|
||||
@@ -1520,8 +1735,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
// node-engine restart it): its have alone says what runs. Its move waits for a yes as db's does, and its have keeps
|
||||
// what runs (fingerprint, digest, sealed values) rather than naming nothing (review of L2, round 3).
|
||||
recFP, recDig := fpOf("d"), "sha256:"+strings.Repeat("8", 64)
|
||||
b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(delivery.PhaseRunning),
|
||||
Sealed: delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}})
|
||||
b.haves.Write(context.Background(), Have{Module: "rec", FP: recFP, Composed: recDig, Phase: string(nodeengineclient.PhaseRunning),
|
||||
Sealed: compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}})
|
||||
rk, rraw, rdig := unsigned("rec", fpB, container("rec@sha256:"+strings.Repeat("3", 64)))
|
||||
b.comps.put(dk2, draw2)
|
||||
b.comps.put(rk, rraw)
|
||||
@@ -1530,12 +1745,12 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
applied := len(b.appliedList())
|
||||
for _, row := range []struct {
|
||||
module, fp, composed, waits string
|
||||
sealed delivery.Seals
|
||||
sealed compositions.Seals
|
||||
alone bool
|
||||
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, delivery.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} {
|
||||
}{{"db", fp, "", ddig2, nil, false}, {"rec", recFP, recDig, rdig, compositions.Seals{"rec.key#sealed": `{"box":"rec"}`}, true}} {
|
||||
eventually(t, row.module+"'s move did not wait for a yes", func() bool {
|
||||
h := b.haves.last(row.module)
|
||||
return h.Phase == string(delivery.PhaseWaitingYes) && h.Waits == row.waits
|
||||
return h.Phase == string(nodeengineclient.PhaseWaitingYes) && h.Waits == row.waits
|
||||
})
|
||||
h := b.haves.last(row.module)
|
||||
if h.FP != row.fp || (row.composed != "" && h.Composed != row.composed) || h.Composed == "" ||
|
||||
@@ -1553,8 +1768,8 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
|
||||
want("rec", fpB, rdig)
|
||||
eventually(t, "the yes did not apply the move", func() bool {
|
||||
h, r := b.haves.last("db"), b.haves.last("rec")
|
||||
return len(b.appliedList()) == applied+2 && h.Phase == string(delivery.PhaseRunning) && h.FP == fpB &&
|
||||
r.Phase == string(delivery.PhaseRunning) && r.FP == fpB && r.Composed == rdig
|
||||
return len(b.appliedList()) == applied+2 && h.Phase == string(nodeengineclient.PhaseRunning) && h.FP == fpB &&
|
||||
r.Phase == string(nodeengineclient.PhaseRunning) && r.FP == fpB && r.Composed == rdig
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1594,14 +1809,14 @@ func testAnEngineStartReconcilesAModuleFromTheMachinesFacts(t *testing.T) {
|
||||
}
|
||||
if c.held {
|
||||
b.haves.stored = map[string]Have{"web": {Module: "web", FP: fpOf("0"), Composed: "sha256:" + strings.Repeat("0", 64),
|
||||
Phase: string(delivery.PhaseHeld), Detail: "restic: the repository is locked"}}
|
||||
Phase: string(nodeengineclient.PhaseHeld), Detail: "restic: the repository is locked"}}
|
||||
}
|
||||
if c.kept {
|
||||
b.kept = map[string][]json.RawMessage{"web": rs}
|
||||
}
|
||||
var mu sync.Mutex
|
||||
asked := false
|
||||
b.env.Inactive = func(context.Context, delivery.Composed) bool {
|
||||
b.env.Inactive = func(context.Context, compositions.Composed) bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
asked = true
|
||||
@@ -1645,7 +1860,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
|
||||
stop := b.run()
|
||||
defer stop()
|
||||
eventually(t, "web was not settled", func() bool { return b.haves.last("web").Composed == digW })
|
||||
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(delivery.PhaseHeld) })
|
||||
eventually(t, "db was not held", func() bool { return b.haves.last("db").Phase == string(nodeengineclient.PhaseHeld) })
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("applied at the start: %v", got)
|
||||
}
|
||||
@@ -1660,13 +1875,13 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
|
||||
if got := b.appliedList(); len(got) != 0 {
|
||||
t.Fatalf("a held module and an unwanted one were applied: %v", got)
|
||||
}
|
||||
if h := b.haves.last("db"); h.Phase != string(delivery.PhaseHeld) {
|
||||
if h := b.haves.last("db"); h.Phase != string(nodeengineclient.PhaseHeld) {
|
||||
t.Fatalf("the held module's have changed: %+v", h)
|
||||
}
|
||||
|
||||
b.ag.Reconcile("web")
|
||||
eventually(t, "web was not applied again", func() bool { return len(b.appliedList()) == 1 })
|
||||
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(delivery.PhaseRunning) })
|
||||
eventually(t, "web is not running again", func() bool { return b.haves.last("web").Phase == string(nodeengineclient.PhaseRunning) })
|
||||
|
||||
b.mu.Lock()
|
||||
b.failWith = "systemctl start web.service: exit status 1"
|
||||
@@ -1674,7 +1889,7 @@ func testReconcileAppliesARunningModuleAgainAndLeavesTheHeldAndTheUnwantedAlone(
|
||||
b.ag.Reconcile("web")
|
||||
eventually(t, "the failed re-apply did not write the have failed with its words", func() bool {
|
||||
h := b.haves.last("web")
|
||||
return h.Phase == string(delivery.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
|
||||
return h.Phase == string(nodeengineclient.PhaseFailed) && h.Detail == "systemctl start web.service: exit status 1"
|
||||
})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if got := b.appliedList(); len(got) != 2 {
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"github.com/novox/mesh-host/internal/judge"
|
||||
)
|
||||
|
||||
@@ -20,6 +22,13 @@ type CompositionView struct {
|
||||
Module string `json:"module"`
|
||||
// Want is the module's want as the agent holds it; nil when it holds none.
|
||||
Want *WantView `json:"want,omitempty"`
|
||||
// Attempt is what the agent holds of the module's last apply that did not end running: failed, failed for a moment
|
||||
// of the bus, or held for a failed backup, with the want's revision that hold took as answered; nil when none
|
||||
// (issue 1585: why a module stays held was not readable from the engine).
|
||||
Attempt *AttemptView `json:"attempt,omitempty"`
|
||||
// Started says the agent has looked at the module once since it started (its baseline): what runs was taken from its
|
||||
// have, or it was applied.
|
||||
Started bool `json:"started"`
|
||||
// Have is what the module's have key says now; nil when it says nothing.
|
||||
Have *HaveView `json:"have,omitempty"`
|
||||
// Composition is the revision read: the latest of the want's build, or the one whose digest was asked.
|
||||
@@ -32,14 +41,56 @@ type CompositionView struct {
|
||||
Judged JudgedView `json:"judged"`
|
||||
// Diff is every resource that differs from what runs, when asked.
|
||||
Diff []judge.ResourceChange `json:"diff,omitempty"`
|
||||
// Own is, of the node's own composition (module node), its own fields beside what runs names (issue 774: ace's
|
||||
// own composition dropped the provisioner client's account, and nothing read could show it).
|
||||
Own *OwnView `json:"own,omitempty"`
|
||||
}
|
||||
|
||||
// WantView is a want as the verb shows it.
|
||||
// OwnView is the node's own fields of a composition (engcomp.NodeOwn) beside what runs of them, and what the node's
|
||||
// composer held in its last pass.
|
||||
type OwnView struct {
|
||||
// Provisioner is the account the provisioner's client runs as, as the revision read names it; RunsProvisioner as
|
||||
// what runs names it. Empty: the declaration names none, and every filled write is refused.
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
RunsProvisioner string `json:"runs_provisioner_account,omitempty"`
|
||||
// Adoption is the revision's adoption: the node adopted, and which of its modules are taken; nil for a converged node.
|
||||
Adoption *declaration.Adoption `json:"adoption,omitempty"`
|
||||
// Identities, Groups and Libraries count what the revision names of each.
|
||||
Identities int `json:"identities,omitempty"`
|
||||
Groups int `json:"groups,omitempty"`
|
||||
Libraries int `json:"libraries,omitempty"`
|
||||
// Held is each module the node's composer held in its last pass, with why.
|
||||
Held map[string]string `json:"held,omitempty"`
|
||||
}
|
||||
|
||||
// WantView is a want as the verb shows it: as the agent holds it, at the revision of its key it last read.
|
||||
type WantView struct {
|
||||
FP string `json:"fp"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
Yes string `json:"yes,omitempty"`
|
||||
Removed bool `json:"removed,omitempty"`
|
||||
// Self says the node composes the module itself (ADR 0547 §2).
|
||||
Self bool `json:"self,omitempty"`
|
||||
// Backup is the operator's answer to a failed backup the want carries, again or skip (ADR 0539 §16); empty when none.
|
||||
Backup string `json:"backup,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
// Revision is the revision of the want's key the agent read last.
|
||||
Revision uint64 `json:"revision"`
|
||||
}
|
||||
|
||||
// AttemptView is the agent's record of a module's last apply that did not end running.
|
||||
type AttemptView struct {
|
||||
FP string `json:"fp"`
|
||||
Composed string `json:"composed,omitempty"`
|
||||
// WantRevision and CompositionRevision are the revisions of the want's and the composition's keys it was tried at.
|
||||
WantRevision uint64 `json:"want_revision"`
|
||||
CompositionRevision uint64 `json:"composition_revision"`
|
||||
// Held says it is held for a failed backup; AnsweredRevision is the want's revision the hold took, so an answer
|
||||
// (again or skip) in a later revision releases it.
|
||||
Held bool `json:"held,omitempty"`
|
||||
AnsweredRevision uint64 `json:"answered_revision,omitempty"`
|
||||
// Transient says it failed because the bus did not answer for a moment, and is tried again by the clock.
|
||||
Transient bool `json:"transient,omitempty"`
|
||||
}
|
||||
|
||||
// HaveView is a have as the verb shows it.
|
||||
@@ -96,7 +147,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
|
||||
have = &h
|
||||
phase := h.Phase
|
||||
if phase == "" {
|
||||
phase = string(delivery.PhaseRunning)
|
||||
phase = string(nodeengineclient.PhaseRunning)
|
||||
}
|
||||
view.Have = &HaveView{FP: h.FP, Composed: h.Composed, Phase: phase, Detail: h.Detail, Judgement: h.Judgement, Waits: h.Waits}
|
||||
}
|
||||
@@ -105,11 +156,17 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
|
||||
w := a.wants[module]
|
||||
fp := ""
|
||||
if w.Present {
|
||||
fp = w.Want.FP
|
||||
view.Want = &WantView{FP: w.Want.FP, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed}
|
||||
fp = w.Want.Fp
|
||||
view.Want = &WantView{FP: w.Want.Fp, Composed: w.Want.Composed, Yes: w.Want.Yes, Removed: w.Want.Removed,
|
||||
Self: w.Want.Self, Backup: w.Want.Backup, Words: w.Want.Words, Revision: w.Rev}
|
||||
} else if have != nil {
|
||||
fp = have.FP
|
||||
}
|
||||
if att, ok := a.attempt[module]; ok {
|
||||
view.Attempt = &AttemptView{FP: att.FP, Composed: att.Composed, WantRevision: att.WantRev, CompositionRevision: att.CompRev,
|
||||
Held: att.Held, AnsweredRevision: att.AnsweredRev, Transient: att.Transient}
|
||||
}
|
||||
view.Started = a.checked[module]
|
||||
latest, haveLatest := a.compositionFor(module, fp)
|
||||
r, ran := a.running[module]
|
||||
from, known := a.runningComposition(module)
|
||||
@@ -162,20 +219,31 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
|
||||
view.Composition.Refused = read.Err.Error()
|
||||
return view, nil
|
||||
}
|
||||
var running *delivery.Composed
|
||||
var running *engcomp.Composed
|
||||
if known {
|
||||
running = &from
|
||||
rv := &RunsView{From: source, Resources: len(from.Resources), FP: from.FP}
|
||||
if ran {
|
||||
rv.FP, rv.Composed, rv.Phase = r.FP, r.Composed, string(r.Phase)
|
||||
rv.FP, rv.Composed, rv.Phase = r.Fp, r.Composed, string(r.Phase)
|
||||
}
|
||||
view.Runs = rv
|
||||
}
|
||||
if module == NodeModule {
|
||||
o := read.C.Own
|
||||
view.Own = &OwnView{Provisioner: o.Provisioner, Adoption: o.Adoption, Identities: len(o.Identities), Groups: len(o.Groups),
|
||||
Libraries: len(o.Libraries)}
|
||||
if running != nil {
|
||||
view.Own.RunsProvisioner = running.Own.Provisioner
|
||||
}
|
||||
if a.env.Held != nil {
|
||||
view.Own.Held = a.env.Held()
|
||||
}
|
||||
}
|
||||
j := judge.JudgeMove(running, read.C)
|
||||
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
|
||||
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
|
||||
switch j.Judgement {
|
||||
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
|
||||
case nodeengineclient.JudgedRecreates, nodeengineclient.JudgedMovesData, nodeengineclient.JudgedCannot:
|
||||
view.Judged.WaitsForYes = a.env.Policy(ctx, module) == catalogue.PolicyRecord
|
||||
}
|
||||
}
|
||||
@@ -188,9 +256,9 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
|
||||
// compositionKey is a module's composition key at a build, or the node's own.
|
||||
func (a *Agent) compositionKey(module, fp string) (string, error) {
|
||||
if module == NodeModule {
|
||||
return delivery.ComposedNodeKey(a.env.Node)
|
||||
return engcomp.NodeKey(a.env.Node)
|
||||
}
|
||||
return delivery.ComposedKey(a.env.Node, module, fp)
|
||||
return engcomp.Key(a.env.Node, module, fp)
|
||||
}
|
||||
|
||||
// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
|
||||
@@ -209,12 +277,12 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
|
||||
if u.Deleted || u.Key != key {
|
||||
continue
|
||||
}
|
||||
c, err := delivery.DecodeComposed(u.Value)
|
||||
c, err := engcomp.Decode(u.Value)
|
||||
if err != nil {
|
||||
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
|
||||
continue
|
||||
}
|
||||
dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
|
||||
dig, err := engcomp.DigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
|
||||
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
+107
-10
@@ -610,7 +610,7 @@ func applyScoped(
|
||||
err = fmt.Errorf("%w: %s", errRemovalWaits, said)
|
||||
}
|
||||
default:
|
||||
action, detail, err = remove(ctx, sys, orphan, run, made)
|
||||
action, detail, err = remove(ctx, sys, orphan, run, made, journalDir(windows))
|
||||
}
|
||||
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
|
||||
// **A former target of a kind the host cannot remove is left in place and forgotten,
|
||||
@@ -939,6 +939,13 @@ func applyScoped(
|
||||
}
|
||||
}
|
||||
setRunnerWarnings(warnings)
|
||||
// **Every new image of a module is on the machine before any of its containers is set aside** (design 51, issue 1966
|
||||
// ENG-7). A container replaced after the first one's turn pulled its image only then, and for a module whose first
|
||||
// container serves the store its second image comes from, that pull met the stopped one. A pull that fails
|
||||
// replaces nothing of the module: its containers keep running what they ran, the failure names the image, and what
|
||||
// follows in the module is skipped as it is for any step that did not complete.
|
||||
in.pulled = map[string]bool{}
|
||||
pullFirst(ctx, ordered, d, in.pulled, run, &failures, gated, gatedWhy, log)
|
||||
for i, resource := range ordered {
|
||||
if !orphansRemoved && i == guardFirst {
|
||||
// **Only a guard that is up may let the filter go.** Removing the derived filter's
|
||||
@@ -1572,7 +1579,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
||||
case *declaration.User:
|
||||
return applyUser(ctx, sys, res, run, previous, in.groups, in.handed)
|
||||
case *declaration.Archive:
|
||||
return applyArchive(ctx, res, previous, in.away)
|
||||
return applyArchive(ctx, res, previous, in.away, journalDir(in.windows))
|
||||
case *declaration.Process:
|
||||
return applyProcess(ctx, res, run, changed, previous, in.away)
|
||||
case *declaration.Action:
|
||||
@@ -2483,7 +2490,7 @@ func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service,
|
||||
//
|
||||
// made is the units whose unit file this host wrote where there was none (meshMadeUnits).
|
||||
func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
made map[string]bool) (string, string, error) {
|
||||
made map[string]bool, journals string) (string, string, error) {
|
||||
switch declaration.Type(a.Type) {
|
||||
case declaration.TypeDirectory, declaration.TypeFile, declaration.TypeArchive:
|
||||
// Removal below a home follows no link an account put there either (novox/hq ADR 0266).
|
||||
@@ -2609,7 +2616,7 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||
case declaration.TypeArchive:
|
||||
// Exactly what it unpacked, and the directories the host made for it once they are empty
|
||||
// (novox/hq issue 162).
|
||||
return removeArchive(a)
|
||||
return removeArchive(a, journals)
|
||||
|
||||
default:
|
||||
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
|
||||
@@ -2759,6 +2766,9 @@ type inputs struct {
|
||||
files map[string]bool
|
||||
// handed is every group a record that went this apply handed to a resource still asking for it.
|
||||
handed Handed
|
||||
// pulled is every image this apply found on the machine, or pulled, before any container was replaced (pullFirst):
|
||||
// applyContainer does not ask for it again.
|
||||
pulled map[string]bool
|
||||
}
|
||||
|
||||
// fileDigest is what a file the container reads holds, by digest.
|
||||
@@ -3165,8 +3175,10 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner,
|
||||
// gone: an image the registry does not hold then left the service down and every later apply
|
||||
// failing. Pulled (or found present) first, an unobtainable image fails this resource loudly,
|
||||
// naming the image, and the old container keeps running.
|
||||
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
|
||||
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
|
||||
if !in.pulled[r.Image] {
|
||||
if err := ensureImage(ctx, cri, r.Image, run); err != nil {
|
||||
return out, fmt.Errorf("not replacing container %s, which keeps running: %w", r.Name, err)
|
||||
}
|
||||
}
|
||||
// **The running container is set aside, never removed, until its replacement runs** (issue 1945): stopped and
|
||||
// renamed, so its name and ports are free, and started again under its own name when the replacement does not
|
||||
@@ -3469,16 +3481,17 @@ func ensureImage(ctx context.Context, cri, image string, run Runner) error {
|
||||
// already hold — built here, or handed over. There is no registry that answers for it, so
|
||||
// pulling would fail somewhere that names a network problem instead of a missing image.
|
||||
if strings.HasPrefix(image, "sha256:") {
|
||||
return fmt.Errorf(
|
||||
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf(
|
||||
"image %s is not on this machine, and an image named by its own digest cannot be "+
|
||||
"fetched: nothing serves it. Build it here, or load it, before applying this",
|
||||
image)
|
||||
image)}
|
||||
}
|
||||
started := time.Now()
|
||||
if _, err := run(ctx, cri, "pull", image); err != nil {
|
||||
return fmt.Errorf("pulling image %s: %w", image, err)
|
||||
return &PullError{Image: image, Started: started, Err: fmt.Errorf("pulling image %s: %w", image, err)}
|
||||
}
|
||||
if _, err := run(ctx, cri, "image", "inspect", image); err != nil {
|
||||
return fmt.Errorf("image %s is not present after pulling it: %w", image, err)
|
||||
return &PullError{Image: image, Started: time.Now(), Err: fmt.Errorf("image %s is not present after pulling it: %w", image, err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -4340,3 +4353,87 @@ func ownerText(info os.FileInfo) string {
|
||||
}
|
||||
return fmt.Sprintf("%d:%d", uid, gid)
|
||||
}
|
||||
|
||||
// pullFirst finds on the machine, or pulls, the image of each container the declaration runs before any is replaced
|
||||
// (ENG-7) for a module of two containers or more. A container that is run once is left to its marker, and a runtime that
|
||||
// does not answer to applyContainer, which says so. The first image that cannot be had fails its container with the image named and gates its module, so none of
|
||||
// its containers is touched; the images found before it stay pulled.
|
||||
func pullFirst(ctx context.Context, ordered []declaration.Resource, d *declaration.Declaration, pulled map[string]bool,
|
||||
run Runner, failures *[]*Error, gated map[string]bool, gatedWhy map[string]string, log func(string)) {
|
||||
// A module of one container already pulls its image before it sets that container aside (applyContainer), and nothing
|
||||
// of it comes before: only a module of two or more has an image pulled after one of its containers was replaced.
|
||||
count := map[string]int{}
|
||||
for _, resource := range ordered {
|
||||
if c, isContainer := resource.(*declaration.Container); isContainer && c.Image != "" && !c.RunOnce && c.Schedule == "" {
|
||||
module, _ := d.ModuleOfID(c.Identity())
|
||||
count[module]++
|
||||
}
|
||||
}
|
||||
var cri string
|
||||
for _, resource := range ordered {
|
||||
c, isContainer := resource.(*declaration.Container)
|
||||
if !isContainer || c.Image == "" || c.RunOnce || c.Schedule != "" || pulled[c.Image] {
|
||||
continue
|
||||
}
|
||||
module, ours := d.ModuleOfID(c.Identity())
|
||||
if count[module] < 2 {
|
||||
continue
|
||||
}
|
||||
if ours && gated[module] {
|
||||
continue
|
||||
}
|
||||
if cri == "" {
|
||||
var err error
|
||||
if cri, err = containerRuntime(ctx, run); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := ensureImage(ctx, cri, c.Image, run); err != nil {
|
||||
err = fmt.Errorf("not replacing container %s, or any other container of its module, which keep running: %w", c.Name, err)
|
||||
*failures = append(*failures, &Error{Resource: c.Identity(), Err: err})
|
||||
log(fmt.Sprintf(" failed %s (%s): %v", c.Identity(), c.Target(), err))
|
||||
if ours {
|
||||
gated[module] = true
|
||||
gatedWhy[module] = "its image " + c.Image + " could not be had, so none of its containers was replaced"
|
||||
}
|
||||
continue
|
||||
}
|
||||
pulled[c.Image] = true
|
||||
}
|
||||
}
|
||||
|
||||
// PullError is an image that could not be found or pulled, as ensureImage fails: what the node agent reads to tell a failed
|
||||
// pull from another failed apply (ENG-8).
|
||||
type PullError struct {
|
||||
Image string
|
||||
// Started is when the pull began, which the node agent compares with when a store's holder settled (ENG-11).
|
||||
Started time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
// PullErrors are the pull errors of every failure an apply reports, the first among them and the rest in Failures, in the
|
||||
// order they happened: what the node agent tells a failed pull from another failed apply by (ENG-8). Nil for an error
|
||||
// that is no apply's, or whose failures met none.
|
||||
func PullErrors(err error) []*PullError {
|
||||
var out []*PullError
|
||||
var e *Error
|
||||
if errors.As(err, &e) {
|
||||
for _, f := range e.Failures {
|
||||
var p *PullError
|
||||
if errors.As(f.Err, &p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
var p *PullError
|
||||
if errors.As(err, &p) {
|
||||
out = append(out, p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *PullError) Error() string { return e.Err.Error() }
|
||||
func (e *PullError) Unwrap() error { return e.Err }
|
||||
|
||||
@@ -35,9 +35,14 @@ import (
|
||||
// enough for a desktop theme and small enough to notice.
|
||||
const maxArchive = 512 << 20
|
||||
|
||||
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway) (Outcome, error) {
|
||||
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied, away *storeAway, journals string) (Outcome, error) {
|
||||
out := begin(r)
|
||||
out.Action = "unchanged"
|
||||
recorded := previous.Wrote != ""
|
||||
previous, err := withJournal(journals, previous, r.Path)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
body, err := fetchMinding(ctx, r.Source, away)
|
||||
if err != nil {
|
||||
@@ -78,8 +83,12 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
|
||||
return out, err
|
||||
}
|
||||
out.unpacked = &unpacked
|
||||
// At once, before the rest of the apply can fail or the engine stop (novox/hq issue 1585).
|
||||
if err := keepJournal(journals, r.Path, got, &unpacked); err != nil {
|
||||
return out, fmt.Errorf("%s was unpacked, and what it unpacked could not be written down: %w", r.Path, err)
|
||||
}
|
||||
out.Action = "updated"
|
||||
if previous.Wrote == "" {
|
||||
if !recorded {
|
||||
out.Action = "created"
|
||||
}
|
||||
out.Detail = fmt.Sprintf("%d file(s)", written)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package apply
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
)
|
||||
|
||||
// What an archive unpacked is written down the moment it is unpacked (novox/hq issue 1585).
|
||||
//
|
||||
// The node's state is saved at the end of a module's apply, so an engine that stopped, was replaced or lost its
|
||||
// state between the unpack and that save held files on the machine it had no record of. The next apply then found
|
||||
// its own files and refused them as ones "the mesh did not put there" (issue 162), and a module could not move to
|
||||
// its new build until a person removed them. So, at once and atomically, the host keeps the digest it unpacked and
|
||||
// the files and directories it placed; an apply that holds no record of the archive reads this instead of treating
|
||||
// everything there as somebody's. The record in the state stays the authority: the journal is read only when there
|
||||
// is none.
|
||||
//
|
||||
// **In the node-engine's own state directory, readable and writable by root alone, never beside the archive.** The
|
||||
// archive's directory may be writable by the module's account (a home, a data directory): a journal there could be
|
||||
// forged, and a root engine would then remove, or take as its own, any path the forger named. And an entry is read
|
||||
// only when it names a path inside the archive's directory (filepath.IsLocal), and a parent only when it is above it.
|
||||
|
||||
// JournalDirName is the directory under the node's state directory that keeps the journals.
|
||||
const JournalDirName = "unpacked"
|
||||
|
||||
type unpackJournal struct {
|
||||
Target string `json:"target"`
|
||||
Wrote string `json:"wrote"`
|
||||
Unpacked *store.Unpacked `json:"unpacked"`
|
||||
}
|
||||
|
||||
// journalDir is where the journals of this apply are kept: under the state directory every applier on the machine
|
||||
// shares (the one its windows are kept in). Empty for a caller with no state directory (a test), which keeps none.
|
||||
func journalDir(w *Windows) string {
|
||||
if w == nil || w.dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(filepath.Dir(w.dir), JournalDirName)
|
||||
}
|
||||
|
||||
// journalPath names the journal of the archive at target: by the digest of its path, so no path names a file
|
||||
// outside the journals' directory.
|
||||
func journalPath(dir, target string) string {
|
||||
sum := sha256.Sum256([]byte(filepath.Clean(target)))
|
||||
return filepath.Join(dir, hex.EncodeToString(sum[:])+".json")
|
||||
}
|
||||
|
||||
// keepJournal writes down what an archive unpacked at path.
|
||||
func keepJournal(dir, path, wrote string, u *store.Unpacked) error {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.Marshal(unpackJournal{Target: filepath.Clean(path), Wrote: wrote, Unpacked: u})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomicallyByPath(journalPath(dir, path), raw, 0o600)
|
||||
}
|
||||
|
||||
// journalOf is what the journal says of path: false when there is none, and an error when there is one that cannot
|
||||
// be read or names a path outside the archive's directory, which is never believed.
|
||||
func journalOf(dir, path string) (unpackJournal, bool, error) {
|
||||
if dir == "" {
|
||||
return unpackJournal{}, false, nil
|
||||
}
|
||||
raw, err := os.ReadFile(journalPath(dir, path))
|
||||
if err != nil {
|
||||
return unpackJournal{}, false, nil
|
||||
}
|
||||
var j unpackJournal
|
||||
if err := json.Unmarshal(raw, &j); err != nil {
|
||||
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s is unreadable: %w", path, err)
|
||||
}
|
||||
if j.Wrote == "" || j.Unpacked == nil {
|
||||
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s names no digest or files", path)
|
||||
}
|
||||
if err := checkJournal(j, filepath.Clean(path)); err != nil {
|
||||
return unpackJournal{}, false, err
|
||||
}
|
||||
return j, true, nil
|
||||
}
|
||||
|
||||
// checkJournal refuses a journal of another target, an entry that is not a path inside the archive's directory, and
|
||||
// a parent that is not above it.
|
||||
func checkJournal(j unpackJournal, target string) error {
|
||||
if j.Target != target {
|
||||
return fmt.Errorf("the journal kept for %s names %q", target, j.Target)
|
||||
}
|
||||
for _, rel := range append(append([]string(nil), j.Unpacked.Files...), j.Unpacked.Dirs...) {
|
||||
if !filepath.IsLocal(filepath.FromSlash(rel)) {
|
||||
return fmt.Errorf("the journal of what was unpacked at %s names %q, which is not inside it: not believed", target, rel)
|
||||
}
|
||||
}
|
||||
for _, p := range j.Unpacked.Parents {
|
||||
if p = filepath.Clean(p); !filepath.IsAbs(p) || p == target || !strings.HasPrefix(target, p+string(filepath.Separator)) {
|
||||
return fmt.Errorf("the journal of what was unpacked at %s names the parent %q, which is not above it: not believed", target, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withJournal is previous, or, when the state holds no record of the archive, what the journal says the host
|
||||
// unpacked at path. A journal not believed is an error: the apply stops and says so rather than guess.
|
||||
func withJournal(dir string, previous store.Applied, path string) (store.Applied, error) {
|
||||
if previous.Wrote != "" {
|
||||
return previous, nil
|
||||
}
|
||||
j, ok, err := journalOf(dir, path)
|
||||
if err != nil || !ok {
|
||||
return previous, err
|
||||
}
|
||||
previous.Wrote, previous.Target, previous.Unpacked = j.Wrote, path, j.Unpacked
|
||||
return previous, nil
|
||||
}
|
||||
|
||||
// dropJournal removes the journal of the archive at target, once the archive is taken off; whether the state's
|
||||
// record of it still said what it unpacked or not.
|
||||
func dropJournal(dir, target string) {
|
||||
if dir == "" || target == "" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(journalPath(dir, target))
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package apply
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -152,6 +153,98 @@ func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Fatalf("%d entries beside the directory", len(entries))
|
||||
}
|
||||
|
||||
// Issue 1585: what the host unpacked is written down at once, in its own state directory, so an archive whose
|
||||
// record in the state was lost is still the host's: the next version replaces its files, drops the ones it no
|
||||
// longer has and leaves a file somebody added. A file the host never unpacked still refuses it, and so does a
|
||||
// journal naming a path outside the archive's directory. The journal goes with the archive, record lost or not.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
journaled bool // the first version was unpacked by the host
|
||||
foreign string
|
||||
forged func(j *unpackJournal)
|
||||
refused string
|
||||
}{
|
||||
{"unpacked by the host, record lost", true, "", nil, ""},
|
||||
{"unpacked by the host, somebody's file beside", true, "mine.txt", nil, ""},
|
||||
{"never unpacked by the host", false, "", nil, "did not put there"},
|
||||
{"a journal naming a path outside", true, "", func(j *unpackJournal) {
|
||||
j.Unpacked.Files = append(j.Unpacked.Files, "../../etc/passwd")
|
||||
}, "not inside it"},
|
||||
{"a journal naming a parent not above", true, "", func(j *unpackJournal) {
|
||||
j.Unpacked.Parents = []string{"/etc"}
|
||||
}, "not above it"},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
dir, state := t.TempDir(), t.TempDir()
|
||||
windows, journals := WindowsIn(state), filepath.Join(state, JournalDirName)
|
||||
applyIt := func(decl string, known store.State) (store.State, error) {
|
||||
_, after, err := ApplyMindingWindows(context.Background(), archHost(t), declare(t, decl), known,
|
||||
store.OriginDeclared, noServices, nil, nil, nil, windows)
|
||||
return after, err
|
||||
}
|
||||
target := filepath.Join(dir, "tools")
|
||||
first, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "one", "old": "o"}, "")
|
||||
if row.journaled {
|
||||
if _, err := applyIt(first, store.State{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||
t.Fatalf("%d entries beside the archive: the journal is the state directory's", len(entries))
|
||||
}
|
||||
info, err := os.Stat(journalPath(journals, target))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("no journal of root's alone after the unpack: %v %v", info, err)
|
||||
}
|
||||
} else {
|
||||
os.MkdirAll(target, 0o755)
|
||||
os.WriteFile(filepath.Join(target, "bin"), []byte("one"), 0o644)
|
||||
}
|
||||
if row.forged != nil {
|
||||
j, _, err := journalOf(journals, target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
row.forged(&j)
|
||||
raw, _ := json.Marshal(j)
|
||||
os.WriteFile(journalPath(journals, target), raw, 0o600)
|
||||
}
|
||||
if row.foreign != "" {
|
||||
os.WriteFile(filepath.Join(target, row.foreign), []byte("x"), 0o644)
|
||||
}
|
||||
second, _ := archiveDecl(t, "m.tools", target, map[string]string{"bin": "two"}, "")
|
||||
// The state is empty: the record of the first unpack was lost.
|
||||
after, err := applyIt(second, store.State{})
|
||||
if row.refused != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), row.refused) {
|
||||
t.Fatalf("refused? %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(target, "bin")); string(got) != "two" {
|
||||
t.Fatalf("bin is %q", got)
|
||||
}
|
||||
if exists(filepath.Join(target, "old")) {
|
||||
t.Fatal("a file the new version no longer has is still there")
|
||||
}
|
||||
if row.foreign != "" && !exists(filepath.Join(target, row.foreign)) {
|
||||
t.Fatal("somebody's file went")
|
||||
}
|
||||
// Taken off, with a record that no longer says what it unpacked: the journal goes too.
|
||||
for i := range after.Resources {
|
||||
after.Resources[i].Unpacked = nil
|
||||
}
|
||||
if _, err := applyIt(`{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`, after); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exists(journalPath(journals, target)) {
|
||||
t.Fatal("the journal outlived the archive")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
|
||||
|
||||
@@ -364,7 +364,16 @@ func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.U
|
||||
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
|
||||
// open stays readable until it closes it. The runtime is told the module went by its own membership,
|
||||
// not by the files disappearing.
|
||||
func removeArchive(a store.Applied) (string, string, error) {
|
||||
func removeArchive(a store.Applied, journals string) (string, string, error) {
|
||||
action, detail, err := removeArchiveFiles(a)
|
||||
// And its journal (issue 1585), once the record goes: whether the record said what it unpacked or not.
|
||||
if err == nil {
|
||||
dropJournal(journals, a.Target)
|
||||
}
|
||||
return action, detail, err
|
||||
}
|
||||
|
||||
func removeArchiveFiles(a store.Applied) (string, string, error) {
|
||||
if store.IsFormer(a.ID) {
|
||||
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
|
||||
// open; the launcher retires the host's own versions, not the apply (issue 194).
|
||||
|
||||
@@ -159,7 +159,7 @@ func TestRemovalThroughALinkUnderAHomeIsRefused(t *testing.T) {
|
||||
home, outside := aLinkedHome(t)
|
||||
link(t, outside, filepath.Join(home, ".claude"))
|
||||
_, _, err := remove(context.Background(), archHost(t), store.Applied{ID: "m.f", Type: "file",
|
||||
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil)
|
||||
Target: filepath.Join(home, ".claude", "shadow")}, nil, nil, "")
|
||||
mustBeLinkRefusal(t, err)
|
||||
if _, err := os.Stat(filepath.Join(outside, "shadow")); err != nil {
|
||||
t.Fatal("removed through the link")
|
||||
|
||||
@@ -47,6 +47,63 @@ func TestARunningContainerIsKeptWhenItsReplacementImageCannotBeObtained(t *testi
|
||||
if !strings.Contains(err.Error(), pinned) {
|
||||
t.Errorf("the failure does not name the image %s: %v", pinned, err)
|
||||
}
|
||||
var pull *PullError
|
||||
if !errors.As(err, &pull) || pull.Image != pinned {
|
||||
t.Errorf("the failure is not a pull error naming %s: %v", pinned, err)
|
||||
}
|
||||
|
||||
// design 51, ENG-7: a module of two containers whose second new image cannot be pulled replaces neither, and says the
|
||||
// image; with both pullable, both are pulled before the first is set aside.
|
||||
second := strings.Replace(pinned, "postgres", "other", 1)
|
||||
two := `{"declaration":1,"resources":[
|
||||
{"id":"a.first","type":"container","name":"first","image":"` + pinned + `","env":{"X":"1"}},
|
||||
{"id":"a.second","type":"container","name":"second","image":"` + second + `","env":{"X":"2"}}
|
||||
]}`
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
unpullable string
|
||||
wantTouched bool
|
||||
wantPulled string
|
||||
}{
|
||||
{"the second image cannot be pulled", second, false, "pull " + pinned},
|
||||
{"both can", "", true, "pull " + pinned + "; pull " + second},
|
||||
} {
|
||||
d := parseTrusted(t, two)
|
||||
var steps []string
|
||||
present := map[string]bool{}
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
args = oldShape(name, args)
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return "27.0\n", nil
|
||||
case "container":
|
||||
return "true\tsome-older-spec", nil
|
||||
case "image":
|
||||
if present[args[len(args)-1]] {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("no such image")
|
||||
case "pull":
|
||||
if args[1] == c.unpullable {
|
||||
return "", errors.New("manifest unknown")
|
||||
}
|
||||
present[args[1]] = true
|
||||
steps = append(steps, "pull "+args[1])
|
||||
case "rm", "rename", "stop", "run":
|
||||
steps = append(steps, args[0]+" "+strings.Join(args[1:], " "))
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
_, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
got := strings.Join(steps, "; ")
|
||||
touched := strings.Contains(got, "rename") || strings.Contains(got, "run ")
|
||||
if touched != c.wantTouched || !strings.HasPrefix(got, c.wantPulled) {
|
||||
t.Errorf("%s: steps %q (touched %v), want pulls %q first and touched=%v", c.name, got, touched, c.wantPulled, c.wantTouched)
|
||||
}
|
||||
if !c.wantTouched && (err == nil || !strings.Contains(err.Error(), second)) {
|
||||
t.Errorf("%s: the failure does not name %s: %v", c.name, second, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing.T) {
|
||||
@@ -54,6 +111,16 @@ func TestAReplacementImageIsPresentBeforeTheRunningContainerIsRemoved(t *testing
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
want := containerSpec(d.Resources[0].(*declaration.Container), inputs{})
|
||||
// The seat and holder a routed image names (ENG-10) are no part of what a container is: another holder is the same spec,
|
||||
// so a declaration composed again for it replaces no container.
|
||||
for _, holder := range []string{"distribution", "elsewhere"} {
|
||||
named := parseTrusted(t, `{"declaration":1,"resources":[
|
||||
{"id":"store","type":"container","name":"store","image":"`+pinned+`","image-seat":"mesh-artifact-store","image-holder":"`+holder+`","env":{"PGDATA":"/data"}}
|
||||
]}`)
|
||||
if got := containerSpec(named.Resources[0].(*declaration.Container), inputs{}); got != want {
|
||||
t.Errorf("a container naming the holder %s has another spec than one naming none", holder)
|
||||
}
|
||||
}
|
||||
var order []string
|
||||
created := false
|
||||
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
|
||||
@@ -183,7 +183,7 @@ type Options struct {
|
||||
// SDKSource is where the mesh's shared library is built from. It is published to the package
|
||||
// registry before the base is built, because the base resolves it by version (novox/hq ADR 0076).
|
||||
SDKSource Source
|
||||
// Site is where this machine sits, for the private network's placement.
|
||||
// Site is where this machine sits, for the private network's assignment.
|
||||
Site string
|
||||
// Answers are the choices, answered by flag: name → answer. What a terminal would be asked.
|
||||
Answers map[string]string
|
||||
@@ -815,7 +815,6 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste
|
||||
if err != nil {
|
||||
return Published{}, failed(StepRegistry, err)
|
||||
}
|
||||
|
||||
// ---- 8. publish -----------------------------------------------------------------------
|
||||
say("publish — the control plane's image gets its first manifest digest")
|
||||
// **The image this mesh built, not the one the installer carried.** The carried one is the
|
||||
|
||||
@@ -69,8 +69,9 @@ func adoptTheBus(ctx context.Context, o Options, control controlPlane, bus *decl
|
||||
return nil
|
||||
}
|
||||
|
||||
// meshBuiltImage is an image the mesh built, as it records it: `artifact-store://<module>/<artifact>@sha256:…`.
|
||||
var meshBuiltImage = regexp.MustCompile(`artifact-store://([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
|
||||
// meshBuiltImage is an image the mesh built, as it records it in either form it reads (ADR 0605 §9, issue 1966 ENG-4):
|
||||
// `artifact-store://<module>/<artifact>@sha256:…`, or the registry seat's `container-image-registry/<module>/<artifact>@sha256:…`.
|
||||
var meshBuiltImage = regexp.MustCompile(`(?:artifact-store://|container-image-registry/)([a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9-]*@sha256:[0-9a-f]{64})`)
|
||||
|
||||
// theModulesBusImage is the nats module's server image, as the build just made it, pulled from this
|
||||
// mesh's registry: the image that carries the snapshot program.
|
||||
|
||||
@@ -139,4 +139,12 @@ func TestTheBundlesBusRunsTheReleaseTheModulesImageIsBuiltOn(t *testing.T) {
|
||||
if got, want := containerNamed(rewritten.Declaration, "mesh-broker-nats").Image, repository+"@"+digest; got != want {
|
||||
t.Errorf("the bundle raises the bus on %s; the nats module's server is %s (%s)", got, want, from)
|
||||
}
|
||||
|
||||
// The image the mesh built is read in either form it is recorded in (ENG-4).
|
||||
sum := strings.Repeat("c", 64)
|
||||
for _, said := range []string{"artifact-store://nats/server@sha256:" + sum, "container-image-registry/nats/server@sha256:" + sum} {
|
||||
if m := meshBuiltImage.FindStringSubmatch("build 1: " + said); m == nil || m[1] != "nats/server@sha256:"+sum {
|
||||
t.Errorf("%s is read as %v", said, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ func InstallFromCatalogue(ctx context.Context, o Options, control controlPlane,
|
||||
//
|
||||
// **Assigning is not being on the network** — a lesson paid for: the module installed, the names
|
||||
// file was written with no names in it, and everything reported success, because nobody had said
|
||||
// where this machine IS. So placement is part of the step, not a separate act.
|
||||
// where this machine IS. So assignment is part of the step, not a separate act.
|
||||
func PlaceOnTheNetwork(ctx context.Context, o Options, control controlPlane,
|
||||
brokerAddress string, say func(string)) error {
|
||||
|
||||
|
||||
@@ -69,17 +69,24 @@ func PublishControlPlane(ctx context.Context, o Options, d Deps, imageID string,
|
||||
func publishAs(ctx context.Context, o Options, d Deps, imageID, repository string,
|
||||
say func(string)) (Published, error) {
|
||||
|
||||
remote := o.Registry + "/" + repository
|
||||
// Pushed to the store's own loopback with no credential (StoreLoopback); recorded under the seat's reach, the
|
||||
// address every node pulls by, with the digest the store gave.
|
||||
loopback, err := StoreLoopback(o.Catalogue)
|
||||
if err != nil {
|
||||
return Published{}, err
|
||||
}
|
||||
remote := loopback + "/" + repository
|
||||
out := Published{Tagged: remote + ":" + genesisTag}
|
||||
reach := func(digest string) string { return o.Registry + "/" + repository + strings.TrimPrefix(digest, remote) }
|
||||
|
||||
// Asked first. A digest already served is a fact about the registry, and re-pushing an image
|
||||
// the registry already holds is asking it to store what it already has under the name it
|
||||
// already has.
|
||||
if held, err := digestOf(ctx, o, d, remote); err != nil {
|
||||
if held, err := digestOf(ctx, o, d, loopback, repository); err != nil {
|
||||
return out, err
|
||||
} else if held != "" {
|
||||
out.Reference, out.Already = held, true
|
||||
say(" already published " + held)
|
||||
out.Reference, out.Already = reach(held), true
|
||||
say(" already published " + out.Reference)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -93,12 +100,12 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
"over the mesh's own network, which is already the encrypted and authenticated "+
|
||||
"thing. A runtime refusing it for being insecure is refusing a registry on %s, "+
|
||||
"which it does not do for a loopback address",
|
||||
out.Tagged, err, o.Registry)
|
||||
out.Tagged, err, loopback)
|
||||
}
|
||||
|
||||
// Read back, from the registry's own answer rather than computed here. What matters is what
|
||||
// the registry will serve for that reference, and only it can say (novox/hq ADR 0018).
|
||||
pinned, err := digestOf(ctx, o, d, remote)
|
||||
pinned, err := digestOf(ctx, o, d, loopback, repository)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -107,10 +114,10 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
"%s was pushed and the registry does not serve it.\n"+
|
||||
"The next step names this module by the digest this was supposed to produce, so "+
|
||||
"there is nothing to name. Check `docker push` and "+
|
||||
"http://%s/v2/%s/tags/list", out.Tagged, o.Registry, repository)
|
||||
"http://%s/v2/%s/tags/list", out.Tagged, loopback, repository)
|
||||
}
|
||||
out.Reference = pinned
|
||||
say(" published " + pinned)
|
||||
out.Reference = reach(pinned)
|
||||
say(" published " + out.Reference)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -121,13 +128,13 @@ func publishAs(ctx context.Context, o Options, d Deps, imageID, repository strin
|
||||
// whether the registry still has it — a registry whose volume was recreated would leave the
|
||||
// runtime remembering a digest nothing serves, and the module registered against it would pin the
|
||||
// mesh to an image that cannot be pulled.
|
||||
func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, error) {
|
||||
func digestOf(ctx context.Context, o Options, d Deps, store, repository string) (string, error) {
|
||||
remote := store + "/" + repository
|
||||
asking, cancel := context.WithTimeout(ctx, o.Timeout)
|
||||
status, body, err := d.Fetch(asking,
|
||||
"http://"+o.Registry+"/v2/"+ControlPlaneRepository+"/tags/list")
|
||||
status, body, err := d.Fetch(asking, "http://"+store+"/v2/"+repository+"/tags/list")
|
||||
cancel()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", o.Registry, err)
|
||||
return "", fmt.Errorf("cannot ask the registry at %s what it holds: %w", store, err)
|
||||
}
|
||||
if status == http.StatusNotFound {
|
||||
// Nothing has ever been pushed under this name. An answer, not a failure.
|
||||
@@ -135,14 +142,14 @@ func digestOf(ctx context.Context, o Options, d Deps, remote string) (string, er
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return "", fmt.Errorf("the registry answered %d when asked what it holds for %s",
|
||||
status, ControlPlaneRepository)
|
||||
status, repository)
|
||||
}
|
||||
var listed struct {
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &listed); err != nil {
|
||||
return "", fmt.Errorf("the registry's answer about %s is not readable: %w",
|
||||
ControlPlaneRepository, err)
|
||||
repository, err)
|
||||
}
|
||||
if !contains(listed.Tags, genesisTag) {
|
||||
return "", nil
|
||||
|
||||
@@ -22,7 +22,8 @@ func publishing(t *testing.T, fetch func(string) (int, string, error),
|
||||
// Each value named before it is returned: a return of several composite literals spanning lines is laid
|
||||
// out differently by the gofmt of one Go release and the next, and the build seat's is the one that
|
||||
// judges (novox/hq issue 286).
|
||||
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0}
|
||||
options := Options{Registry: "127.0.0.1:5000", Timeout: time.Second, Wait: 0,
|
||||
Catalogue: catalogueWith(t, RegistryModule, upstreamRegistryManifest)}
|
||||
deps := Deps{
|
||||
Run: runtime.run,
|
||||
Fetch: func(_ context.Context, url string) (int, string, error) {
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegistryModule is the module that provides the mesh's artifact store.
|
||||
// RegistryModule is the module that holds the mesh's artifact store.
|
||||
//
|
||||
// **Named for the software, not the job.** The job is `artifact-store`, which is the provision this
|
||||
// module offers; the software is Distribution, the OCI reference implementation. Calling the module
|
||||
// **Named for the software, not the job.** The job is the artifact store, which this module holds as
|
||||
// the seat container-image-registry (novox/hq ADR 0632 §1); the software is Distribution, the OCI reference implementation. Calling the module
|
||||
// `registry` named neither — and promised that any registry could sit there, which is the false
|
||||
// genericity the naming rule forbids (novox/hq ADR 0040).
|
||||
const RegistryModule = "distribution"
|
||||
@@ -95,9 +95,14 @@ func InstallRegistry(ctx context.Context, o Options, d Deps, control controlPlan
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// waitForTheRegistry asks `/v2/` until it answers.
|
||||
// waitForTheRegistry asks the store's own `/v2/`, on its loopback (StoreLoopback), until it answers: the pushes that
|
||||
// follow go there, and the store's front is not served yet on a fresh mesh.
|
||||
func waitForTheRegistry(ctx context.Context, d Deps, o Options, say func(string)) (int, error) {
|
||||
where := "http://" + o.Registry + "/v2/"
|
||||
loopback, err := StoreLoopback(o.Catalogue)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
where := "http://" + loopback + "/v2/"
|
||||
|
||||
deadline := time.Now().Add(o.Wait)
|
||||
var last string
|
||||
@@ -207,3 +212,43 @@ func containerIn(manifest []byte, id string) (name, image string, err error) {
|
||||
"the %s module declares no container %q, so the installer does not know what to wait for. "+
|
||||
"It declares: %s", m.Module, id, strings.Join(containers, ", "))
|
||||
}
|
||||
|
||||
// storeContainerPort is the port the registry image listens on inside its container (Distribution's own default).
|
||||
const storeContainerPort = "5000"
|
||||
|
||||
// StoreLoopback is host:port where this machine reaches the store's container itself, read from the port its manifest
|
||||
// publishes for it: on a fresh mesh the bootstrap pushes the holders' own images there, with no credential, because the
|
||||
// store's front (distribution's program, a bundle the mesh builds) is not served until the builder has built it (the
|
||||
// publish contract allows a push on the store's own node to its loopback). A port published on every address is reached
|
||||
// on 127.0.0.1. What the bootstrap records is named under the seat's reach (Options.Registry), never this address.
|
||||
func StoreLoopback(catalogue string) (string, error) {
|
||||
manifest, err := readManifest(catalogue, RegistryModule)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var m struct {
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Ports []string `json:"ports"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(manifest, &m); err != nil {
|
||||
return "", fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r.ID != registryResource {
|
||||
continue
|
||||
}
|
||||
for _, p := range r.Ports {
|
||||
parts := strings.Split(strings.SplitN(p, "/", 2)[0], ":")
|
||||
switch {
|
||||
case len(parts) == 3 && parts[2] == storeContainerPort:
|
||||
return parts[0] + ":" + parts[1], nil
|
||||
case len(parts) == 2 && parts[1] == storeContainerPort:
|
||||
return "127.0.0.1:" + parts[0], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("the %s module's container %q publishes no port for the store's %s, so the bootstrap cannot "+
|
||||
"reach the store to push to it", RegistryModule, registryResource, storeContainerPort)
|
||||
}
|
||||
|
||||
@@ -226,8 +226,15 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) {
|
||||
// moving the trust after the push, or dropping it, fails this test.
|
||||
func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`)
|
||||
o.Catalogue = catalogueWith(t, RegistryModule, upstreamRegistryManifest)
|
||||
// The store as distribution's manifest declares it: its container on this machine's loopback, its front not yet served,
|
||||
// and its state directory, where no credential is ever written (design 51 *On a fresh mesh*, a design limit of issue 1966).
|
||||
stateDir := t.TempDir()
|
||||
o.Catalogue = catalogueWith(t, RegistryModule, strings.NewReplacer(
|
||||
`"path": "/var/lib/mesh/registry"`, `"path": "`+stateDir+`"`,
|
||||
`"ports": ["5000:5000"]`, `"ports": ["127.0.0.1:5001:5000"]`).Replace(upstreamRegistryManifest))
|
||||
o.Registry = "192.0.2.10:5100"
|
||||
const loopback = "127.0.0.1:5001"
|
||||
var fetched []string
|
||||
|
||||
pushed := false
|
||||
mesh := aMeshThatAgrees(nil)
|
||||
@@ -241,7 +248,8 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
case "tag":
|
||||
return "", nil
|
||||
case "push":
|
||||
trusted := false
|
||||
// The runtime trusts a loopback registry with no configuration; any other only once told.
|
||||
trusted := strings.HasPrefix(args[1], "127.0.0.1:")
|
||||
for _, n := range runtime.trusted {
|
||||
trusted = trusted || strings.HasPrefix(args[1], n+"/")
|
||||
}
|
||||
@@ -251,12 +259,13 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
pushed = true
|
||||
return "", nil
|
||||
case "image":
|
||||
return `["` + o.Registry + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
|
||||
return `["` + loopback + `/mesh-controller@sha256:` + strings.Repeat("a", 64) + `"]`, nil
|
||||
}
|
||||
}
|
||||
return mesh(name, args)
|
||||
}
|
||||
d := Deps{Run: run, Fetch: func(_ context.Context, url string) (int, string, error) {
|
||||
fetched = append(fetched, url)
|
||||
if strings.HasSuffix(url, "/v2/") || pushed {
|
||||
return http.StatusOK, `{"name":"mesh-controller","tags":["genesis"]}`, nil
|
||||
}
|
||||
@@ -287,6 +296,23 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) {
|
||||
t.Errorf("the trust was not written (%d) and confirmed (%d) before the first tag or push (%d): %v",
|
||||
reloaded, confirmed, firstPush, runtime.commands)
|
||||
}
|
||||
// The push went to the store on its loopback, and nothing the run asked or pushed reached the seat's reach; the
|
||||
// reference is named under the reach all the same, and no credential file was written for the store's holder.
|
||||
for _, c := range runtime.commands {
|
||||
if strings.HasPrefix(c, "docker tag") || strings.HasPrefix(c, "docker push") {
|
||||
if !strings.Contains(c, " "+loopback+"/mesh-controller:") {
|
||||
t.Errorf("a push not to the loopback store: %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, u := range fetched {
|
||||
if !strings.HasPrefix(u, "http://"+loopback+"/v2/") {
|
||||
t.Errorf("the run asked %s, not the loopback store", u)
|
||||
}
|
||||
}
|
||||
if entries, err := os.ReadDir(stateDir); err != nil || len(entries) != 0 {
|
||||
t.Errorf("the run wrote into the store's state directory: %v (%v)", entries, err)
|
||||
}
|
||||
if fmt.Sprint(result.RegistryTrusted) != "[192.0.2.10:5100 anchor.internal:5100]" {
|
||||
t.Errorf("the result says it trusted %v", result.RegistryTrusted)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package buckets holds the names of the bus buckets the node-engine reads and writes, and how many revisions it keeps of
|
||||
// each. A contract carries the local name of a state, not the module that holds it (ADR 0625 §4, §7); the node-engine
|
||||
// reads through grants composed in code, so the holder's name is kept here, in its own code (ADR 0634 §1, finding F4).
|
||||
package buckets
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
vault "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
catalog "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
wants "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
)
|
||||
|
||||
// The node-engine's own states and the have state, written by every node's engine for its own keys.
|
||||
const (
|
||||
// Have holds a module's have on a node, `<node>.<module>`, and the node's own facts, `<node>.node`.
|
||||
Have = "node-engine_have"
|
||||
// Data holds a module's data items on a node.
|
||||
Data = "mesh-host_data"
|
||||
// Health holds a node's own health report.
|
||||
Health = "mesh-host_health"
|
||||
)
|
||||
|
||||
// HaveHistory is the number of revisions the have bucket keeps per key.
|
||||
const HaveHistory = 20
|
||||
|
||||
// The states of the assigner seat's holder and of the wants seat's holder that the engine reads, named by the holder's
|
||||
// module as every state is (ADR 0634 §1, finding F4: the holder's name is kept here, in the engine's code).
|
||||
const (
|
||||
// Assignments holds a node's assignments, `<node>.node`, and each of its modules, `<node>.<module>`.
|
||||
Assignments = AssignerHolder + "_" + seat.StateAssignments
|
||||
// ProposalCandidates holds a node's candidate of a proposal, `<proposal>.<node>`.
|
||||
ProposalCandidates = AssignerHolder + "_" + seat.StateCandidates
|
||||
// Wants holds what each node should run, `want.<node>.<module>` and `want.<node>.node`.
|
||||
Wants = AssignerHolder + "_" + wants.StateWants
|
||||
|
||||
// AssignerHolder is the module holding the seat mesh-module-assigner and the seat mesh-wants.
|
||||
AssignerHolder = "mesh-delivery-assigner"
|
||||
)
|
||||
|
||||
// The state of the provisioner seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// ProvisionerHolder is the module holding the seat mesh-provisioner; Provisions its state provisions, which the engine
|
||||
// watches for the key of a provision it fills (`<node>.>`), ProvisionsRead the name a manifest's reads gives it.
|
||||
ProvisionerHolder = "mesh-provisioner"
|
||||
Provisions = ProvisionerHolder + "_provisions"
|
||||
ProvisionsRead = ProvisionerHolder + ".provisions"
|
||||
)
|
||||
|
||||
// The states of the catalogue seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// Modules holds a module's source, `<module>`; Manifests a manifest by its digest; Verdicts the judge's verdict of a
|
||||
// manifest by its digest; Candidates a group's candidate, by the group's key part (CandidateKey).
|
||||
Modules = CatalogHolder + "_modules"
|
||||
Manifests = CatalogHolder + "_manifests"
|
||||
Verdicts = CatalogHolder + "_verdicts"
|
||||
Candidates = CatalogHolder + "_candidates"
|
||||
|
||||
// CatalogHolder is the module holding the seat mesh-catalog.
|
||||
CatalogHolder = "mesh-delivery-indexer"
|
||||
)
|
||||
|
||||
// CandidateKey is the key of a group's candidate in Candidates: the group as a key part, a branch such as `fix/1.2`
|
||||
// escaped so it keys.
|
||||
func CandidateKey(group string) (string, error) {
|
||||
if group == "" {
|
||||
return "", errors.New("buckets: the group name is empty")
|
||||
}
|
||||
return catalog.CandidatesByGroupKey(keys.EscapeName(group)), nil
|
||||
}
|
||||
|
||||
// The states of the vault seat's holder that the engine reads, named by the holder's module (ADR 0634 §1, finding F4).
|
||||
const (
|
||||
// Signers holds the vault's public signing halves; NodeLogins each node's logins, `<node>`.
|
||||
Signers = vaultHolder + "_" + vault.StateSigners
|
||||
NodeLogins = vaultHolder + "_" + vault.StateNodeLogins
|
||||
// SignersRead and NodeLoginsRead are the states as a manifest's reads names them.
|
||||
SignersRead = vaultHolder + "." + vault.StateSigners
|
||||
NodeLoginsRead = vaultHolder + "." + vault.StateNodeLogins
|
||||
|
||||
vaultHolder = "mesh-vault"
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
package buckets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The engine's own states are named <module>_<state> by the manifest; a constant that drifts from it reads and writes a
|
||||
// bucket nobody else knows (ADR 0634 §1, finding F4: the holder's name is kept in code and held to the manifest by this row).
|
||||
func TestTheOwnStatesAreTheManifestsStates(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
State []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"state"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, s := range m.State {
|
||||
declared[m.Module+"_"+s.Name] = true
|
||||
}
|
||||
for name, bucket := range map[string]string{"data": Data, "health": Health} {
|
||||
if !declared[bucket] {
|
||||
t.Errorf("the bucket %s for the state %s is not one the manifest of %s declares (%v)", bucket, name, m.Module, declared)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A group's candidate key is its name as a key part: a branch with a slash keeps it, a dot is escaped, and an empty group does not.
|
||||
func TestACandidateKeyEscapesTheGroup(t *testing.T) {
|
||||
for _, group := range []string{"mesh-sdk#7", "fix/940-no-retire", "fix/1.2"} {
|
||||
key, err := CandidateKey(group)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", group, err)
|
||||
}
|
||||
if strings.Contains(key, ".") {
|
||||
t.Errorf("the key %q of %q holds a dot", key, group)
|
||||
}
|
||||
if back, err := keys.UnescapeName(key); err != nil || back != group {
|
||||
t.Errorf("%q came back as %q (%v)", group, back, err)
|
||||
}
|
||||
}
|
||||
if _, err := CandidateKey(""); err == nil {
|
||||
t.Error("a key was made for no group")
|
||||
}
|
||||
}
|
||||
|
||||
// The holders' buckets are the names the grants part grants and the holders' manifests declare: no client carries a
|
||||
// holder (ADR 0625 §4), so these rows are the one place the node-engine's code says them.
|
||||
func TestTheHoldersBucketsAreTheirStatesBuckets(t *testing.T) {
|
||||
for name, c := range map[string][2]string{
|
||||
"Assignments": {Assignments, "mesh-delivery-assigner_assignments"}, "ProposalCandidates": {ProposalCandidates, "mesh-delivery-assigner_candidates"},
|
||||
"Wants": {Wants, "mesh-delivery-assigner_wants"}, "Modules": {Modules, "mesh-delivery-indexer_modules"},
|
||||
"Manifests": {Manifests, "mesh-delivery-indexer_manifests"}, "Verdicts": {Verdicts, "mesh-delivery-indexer_verdicts"},
|
||||
"Candidates": {Candidates, "mesh-delivery-indexer_candidates"}, "Provisions": {Provisions, "mesh-provisioner_provisions"},
|
||||
"ProvisionsRead": {ProvisionsRead, "mesh-provisioner.provisions"},
|
||||
} {
|
||||
if c[0] != c[1] {
|
||||
t.Errorf("%s = %q, want %q", name, c[0], c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package bucketstest writes the values of the states the engine only reads (a want, a assignment candidate) as their
|
||||
// holders do, for the tests that stand in for the holders.
|
||||
package bucketstest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
)
|
||||
|
||||
// Version is the value version the holders write.
|
||||
const Version = 1
|
||||
|
||||
// Want is a module's want as the wants seat's holder writes it.
|
||||
func Want(w meshwants.WantsByWantNodeModuleValue) ([]byte, error) {
|
||||
w.V = Version
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// NodeWant is a node's own want as the wants seat's holder writes it.
|
||||
func NodeWant(w meshwants.WantsByWantNodeNodeValue) ([]byte, error) {
|
||||
w.V = Version
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// Candidate is a node's candidate of a proposal as the assignment seat's holder writes it.
|
||||
func Candidate(c meshmoduleassigner.CandidatesByProposalNodeValue) ([]byte, error) {
|
||||
c.V = Version
|
||||
if c.Assignments != nil {
|
||||
p := *c.Assignments
|
||||
p.V = Version
|
||||
c.Assignments = &p
|
||||
}
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// GroupCandidate is a group's candidate as the catalogue seat's holder writes it.
|
||||
func GroupCandidate(c meshcatalog.CandidatesByGroupValue) ([]byte, error) {
|
||||
c.V = Version
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
// Pull is one pull request of a candidate, from its head.
|
||||
func Pull(head meshdeliveryclient.Head, modules []string, fps map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue) meshcatalog.CandidatesByGroupValuePullsItem {
|
||||
return meshcatalog.CandidatesByGroupValuePullsItem{Repository: head.Repository, Number: int64(head.Number), Head: head.Head, Modules: modules, Fps: fps}
|
||||
}
|
||||
|
||||
// Raw is each value as JSON, as a composer case carries verdicts and assignments.
|
||||
func Raw[V any](in map[string]V) map[string]json.RawMessage {
|
||||
out := map[string]json.RawMessage{}
|
||||
for k, v := range in {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
out[k] = raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -8,23 +8,26 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
"github.com/novox/mesh-host/internal/registryforms"
|
||||
)
|
||||
|
||||
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
|
||||
// (`<node>.node`), by key (engcomp.Key). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
|
||||
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
|
||||
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned
|
||||
// (ADR 0593 §6) and not yet by digest (Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped []string, err error) {
|
||||
out, skipped, _, err = compositions(in, node, fps, mode, false)
|
||||
return out, skipped, err
|
||||
}
|
||||
@@ -41,29 +44,38 @@ const BusUsersStandIn = "the bus's user list"
|
||||
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]engcomp.Composed, []string, error) {
|
||||
out, skipped, _, err := compositions(in, node, fps, Reading, true)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
|
||||
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// assignment candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
|
||||
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
|
||||
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
|
||||
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, Foreseeing, false)
|
||||
}
|
||||
|
||||
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
|
||||
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
|
||||
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
|
||||
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
// Folds says whether a module the node runs inside its own composition (inOwn, Node.InOwn) is folded there at the
|
||||
// fingerprint fp it is composed at: only at no fingerprint of the new form. At one (src2:) it has a composition of its
|
||||
// own, composed from its build, so every reader of a build (frombus) takes the build in exactly where this says it is
|
||||
// not folded (issue 1585: nats on novox, folded by the reader and composed at src2: here, refused for its server
|
||||
// artifact no build had published).
|
||||
func Folds(inOwn bool, fp string) bool {
|
||||
return inOwn && !strings.HasPrefix(fp, meshcatalogclient.SourcePrefix2)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]engcomp.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
@@ -84,7 +96,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
|
||||
inOwn[overlay.Name] = true
|
||||
folds := func(module string) bool {
|
||||
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
|
||||
return module != "" && Folds(inOwn[module], fps[module])
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
@@ -93,7 +105,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
|
||||
// ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
|
||||
carried := map[string]bool{}
|
||||
var runsOwn *delivery.Composed
|
||||
var runsOwn *engcomp.Composed
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsOwn == nil {
|
||||
@@ -255,13 +267,13 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
filled = append(filled, p)
|
||||
}
|
||||
}
|
||||
out, err = delivery.SplitComposed(node, kept, fps, filled,
|
||||
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
out, err = engcomp.Split(node, kept, fps, filled,
|
||||
engcomp.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(ownFilled) > 0 {
|
||||
own, err := delivery.ComposedNodeKey(node)
|
||||
own, err := engcomp.NodeKey(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -287,7 +299,7 @@ func compositions(in Input, node string, fps map[string]string, mode Mode, appli
|
||||
}
|
||||
|
||||
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
|
||||
func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
func RunsAnyOf(runs *engcomp.Composed, module string) bool {
|
||||
if runs == nil {
|
||||
return false
|
||||
}
|
||||
@@ -315,7 +327,7 @@ func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
func ToolsRunning(running []engcomp.Composed) map[string][]catalogue.Bundle {
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
@@ -344,7 +356,7 @@ func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
|
||||
if r.Path == resolution.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: registryforms.Forms.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,7 +479,7 @@ func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
|
||||
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
|
||||
// of is its Stub, as before.
|
||||
func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
|
||||
func Carried(m catalogue.Manifest, module string, runs *engcomp.Composed) catalogue.Manifest {
|
||||
if runs == nil {
|
||||
return Stub(m)
|
||||
}
|
||||
@@ -512,7 +524,7 @@ func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catal
|
||||
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
|
||||
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
|
||||
// not among them; a file among them is a folded path.
|
||||
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
|
||||
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *engcomp.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
|
||||
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -2,17 +2,18 @@ package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
func fpFor(c byte) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
|
||||
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
|
||||
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
|
||||
@@ -36,14 +37,14 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
}
|
||||
var got []string
|
||||
for key, c := range out {
|
||||
node, module, fp, isNode, err := delivery.ParseComposedKey(key)
|
||||
node, module, fp, isNode, err := engcomp.ParseKey(key)
|
||||
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
|
||||
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
|
||||
}
|
||||
if !isNode && fp != fps[module] {
|
||||
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(c); err != nil {
|
||||
if _, err := engcomp.Encode(c); err != nil {
|
||||
t.Errorf("%s: %v", key, err)
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
@@ -81,7 +82,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
|
||||
nodeKey, _ := delivery.ComposedNodeKey("laptop")
|
||||
nodeKey, _ := engcomp.NodeKey("laptop")
|
||||
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
|
||||
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
|
||||
}
|
||||
@@ -92,7 +93,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
|
||||
}
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
|
||||
if _, err := engcomp.Encode(all[nodeKey]); err != nil {
|
||||
t.Errorf("the node's own with the folded modules: %v", err)
|
||||
}
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
@@ -109,7 +110,8 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
|
||||
Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
@@ -120,35 +122,35 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
runtimeRuns := func(dropped bool) engcomp.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
|
||||
return engcomp.Composed{Node: "anchor", Module: "node-tools", Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
set func(n *Node) engcomp.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) delivery.Composed {
|
||||
{"folded at a build of the old form", func(n *Node) engcomp.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
|
||||
{"folded, the runtime already dropped it", func(n *Node) engcomp.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
n.RunsOwn = &engcomp.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) delivery.Composed {
|
||||
{"held, its new build not delivered", func(n *Node) engcomp.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
lr := engcomp.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
@@ -160,7 +162,7 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", "node-tools", "lamp")
|
||||
tools.BusMinted = []string{"anchor." + "node-tools"}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
anchor.RunsTools = ToolsRunning([]engcomp.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps["node-tools"] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
@@ -184,18 +186,3 @@ func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
|
||||
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
|
||||
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, "the bus", catalogue.FromData, catalogue.FromDefault} { // "the bus": an older reader's word, still read
|
||||
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
|
||||
if _, err := delivery.EncodeComposedDigest(v); err != nil {
|
||||
t.Errorf("%s from %s: %v", p, from, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if delivery.PolicyRecord != catalogue.PolicyRecord {
|
||||
t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing c
|
||||
if !held && mode == Foreseeing {
|
||||
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
|
||||
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
|
||||
// foreseen, never refused: the vault mints it from the grants part's wanted list once the assignment is written
|
||||
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
|
||||
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
|
||||
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// secretValue is a value the Input carries that must never appear in anything the composition says.
|
||||
@@ -118,7 +118,7 @@ func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
|
||||
|
||||
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
|
||||
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
|
||||
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
|
||||
// mints it once the assignment is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
|
||||
// "has no bus credential: nothing was issued for ace.mesh-self-check").
|
||||
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
|
||||
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -11,14 +14,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
||||
// Answer is a node's answer to its proposal's assignment candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Assignments laid over the trunk's assignment (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (compositions.CompareComposed). It applies nothing and writes
|
||||
// nothing; the proposal and the revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
@@ -26,16 +28,16 @@ import (
|
||||
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
|
||||
refuse := func(err error) delivery.CandidateAnswer {
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []compositions.Composed, proposal string, revision uint64) nodeengine.CandidateAnswer {
|
||||
a := nodeengine.CandidateAnswer{Proposal: proposal, Revision: int64(revision)}
|
||||
refuse := func(err error) nodeengine.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = delivery.CandidateRefused
|
||||
a.Verdict = nodeengine.CandidateAnswerVerdictRefused
|
||||
return a
|
||||
}
|
||||
trunk, err := r.Placements(ctx)
|
||||
trunk, err := r.Assignments(ctx)
|
||||
if err != nil {
|
||||
return refuse(fmt.Errorf("frombus: placement: %w", err))
|
||||
return refuse(fmt.Errorf("frombus: assignment: %w", err))
|
||||
}
|
||||
if s.Running == nil {
|
||||
s.Running = running
|
||||
@@ -79,7 +81,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
if len(failed) == 0 {
|
||||
return refuse(err)
|
||||
}
|
||||
failed[delivery.HaveNodeWord] = err.Error()
|
||||
failed[nodeengineclient.NodeWord] = err.Error()
|
||||
composed, skipped, stood = nil, nil, nil
|
||||
}
|
||||
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
||||
@@ -95,19 +97,19 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, whole := failed[delivery.HaveNodeWord]; whole {
|
||||
if _, whole := failed[nodeengineclient.NodeWord]; whole {
|
||||
// Nothing composed to compare: every refusal is said, and nothing else.
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
|
||||
}
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
a.Verdict = compositions.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
|
||||
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
|
||||
if len(s.InOwn) > 0 {
|
||||
var judged []delivery.Composed
|
||||
var judged []compositions.Composed
|
||||
for _, c := range running {
|
||||
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
|
||||
judged = append(judged, c)
|
||||
@@ -115,7 +117,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
}
|
||||
running = judged
|
||||
}
|
||||
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
|
||||
cmp := compositions.CompareComposedWith(composed, running, s.SameValue)
|
||||
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
|
||||
budget := MaxShown
|
||||
for i := range cmp.Differs {
|
||||
@@ -125,7 +127,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
budget -= n
|
||||
kept = append(kept, b)
|
||||
} else {
|
||||
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
|
||||
kept = append(kept, nodeengine.CandidateBodies{ID: b.ID, Why: b.Why})
|
||||
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
|
||||
fmt.Sprint(MaxShown) + " bytes of them"
|
||||
}
|
||||
@@ -136,13 +138,13 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
|
||||
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
|
||||
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
|
||||
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
|
||||
a.Differs = slices.DeleteFunc(cmp.Differs, func(d nodeengine.CandidateDiffers) bool { return !notFailed(d.Module) })
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d nodeengine.CandidateAdded) bool { return !notFailed(d.Module) })
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
||||
|
||||
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Placements {
|
||||
for node, p := range s.Assignments {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
@@ -171,17 +173,17 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
} // another node's addition: the module here that carries it differs, which the assigner judges
|
||||
case module == "" || node != s.Node:
|
||||
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
||||
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
|
||||
refusals[nodeengineclient.NodeWord] = append(refusals[nodeengineclient.NodeWord], x)
|
||||
default:
|
||||
refusals[module] = append(refusals[module], x)
|
||||
}
|
||||
}
|
||||
addedHere := map[string]delivery.ModuleAdded{}
|
||||
addedHere := map[string]nodeengine.CandidateAdded{}
|
||||
for _, m := range cmp.Added {
|
||||
addedHere[m.Module] = m
|
||||
}
|
||||
for _, m := range skipped { // placed, and no build given to compose it at
|
||||
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
addedHere[m] = nodeengine.CandidateAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
}
|
||||
for m, xs := range unissued {
|
||||
got := addedHere[m]
|
||||
@@ -199,14 +201,14 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
if _, isFailed := failed[m]; isFailed {
|
||||
continue // said below, as why it could not be composed
|
||||
}
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
a.Refusals = append(a.Refusals, nodeengine.CandidateRefusal{Module: m, Why: failed[m]})
|
||||
}
|
||||
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
a.Verdict = compositions.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -214,7 +216,7 @@ func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]st
|
||||
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
|
||||
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||
func heldAsToday(a *nodeengine.CandidateAnswer, today map[string]string) {
|
||||
if len(today) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -236,7 +238,7 @@ func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
|
||||
const maxCulprits = 8
|
||||
|
||||
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
|
||||
// MaxShown bounds the bytes of shown resource bodies in one answer (nodeengine.CandidateDiffers.Bodies).
|
||||
const MaxShown = 256 << 10
|
||||
|
||||
var (
|
||||
@@ -290,13 +292,13 @@ func standsInFor(x, node string) (string, string) {
|
||||
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
|
||||
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
|
||||
// and the hold says so.
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
|
||||
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []compositions.Composed, failed map[string]string,
|
||||
ran func(module string) (catalogue.Manifest, bool)) (out map[string]compositions.Composed, skipped, stood []string, err error) {
|
||||
mode := compose.Foreseeing
|
||||
if applied {
|
||||
mode = compose.Reading
|
||||
}
|
||||
runs := map[string]*delivery.Composed{}
|
||||
runs := map[string]*compositions.Composed{}
|
||||
for i := range running {
|
||||
if m := running[i].Module; m != "" {
|
||||
runs[m] = &running[i]
|
||||
@@ -428,7 +430,7 @@ const (
|
||||
leaveOwn = "own-secrets"
|
||||
)
|
||||
|
||||
// assignedOn is the modules placed on node, in placement order.
|
||||
// assignedOn is the modules placed on node, in assignment order.
|
||||
func assignedOn(in compose.Input, node string) []string {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
@@ -452,7 +454,7 @@ func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// first, in assignment order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||
@@ -542,7 +544,7 @@ func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
|
||||
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
|
||||
// repository on a node it never ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *compositions.Composed) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
|
||||
@@ -4,17 +4,23 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
|
||||
@@ -35,48 +41,48 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
|
||||
mk := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
|
||||
}
|
||||
b := &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
|
||||
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
|
||||
builds: map[string]*delivery.BuilderBuild{}}
|
||||
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(lemurs): lemurs},
|
||||
builds: map[string]*builds.BuilderBuild{}}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
raw := manifest(m)
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
|
||||
// the new form at all.
|
||||
for i, m := range []string{"web", "api"} {
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fp(string(rune('1' + i)))
|
||||
mod.Fp2 = fp(string(rune('1' + i)))
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
|
||||
b.builds[m+" "+mod.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
given := func(b *bus) map[string]*delivery.Placement {
|
||||
p := b.placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
given := func(b *bus) map[string]*meshmoduleassigner.Assignments {
|
||||
p := b.assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
|
||||
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
|
||||
b := mk("lemurs", "web", "api", "spiegel")
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b)},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
var refused []string
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
}
|
||||
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
|
||||
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
@@ -86,27 +92,27 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
|
||||
}
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
|
||||
}
|
||||
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
|
||||
if lemursOK := slices.ContainsFunc(a.Added, func(m nodeengine.CandidateAdded) bool { return m.Module == "lemurs" }); !lemursOK {
|
||||
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
|
||||
}
|
||||
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
|
||||
// refused module held today the verdict is no refusal, and each is named under Held.
|
||||
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
|
||||
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
|
||||
if today.Verdict == nodeengine.CandidateAnswerVerdictRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
|
||||
t.Errorf("held as today: %+v", today)
|
||||
}
|
||||
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
|
||||
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
|
||||
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), HeldToday: map[string]string{"api": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
if part.Verdict != nodeengine.CandidateAnswerVerdictRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
t.Errorf("held as today in part: %+v", part)
|
||||
}
|
||||
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
|
||||
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
if a := (nodeengine.CandidateAnswer{Refusals: []nodeengine.CandidateRefusal{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
{Module: "mailu", Why: "no build"}}}); true {
|
||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||
@@ -117,9 +123,9 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
|
||||
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
|
||||
b = mk("lemurs", overlay.Name, "spiegel")
|
||||
p := b.placements["n1"]
|
||||
p := b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
fps := map[string]string{"lemurs": fp("e")}
|
||||
stub := Scope{Node: "n1"}
|
||||
in, err := Input(ctx, b, Mesh{}, stub)
|
||||
@@ -136,14 +142,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
|
||||
t.Errorf("a refusal naming no placed module was laid on %s", m)
|
||||
}
|
||||
ownKey, _ := delivery.ComposedNodeKey("n1")
|
||||
ownKey, _ := engcomp.NodeKey("n1")
|
||||
own := base[ownKey]
|
||||
carriedRes := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
// They run after the node's first own resource.
|
||||
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
|
||||
var running []delivery.Composed
|
||||
var running []engcomp.Composed
|
||||
for k, c := range base {
|
||||
if k == ownKey {
|
||||
c = own
|
||||
@@ -163,27 +169,27 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if string(gotOwn) != string(wantOwn) {
|
||||
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
|
||||
}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
|
||||
if a.Verdict != delivery.CandidateSame {
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictSame {
|
||||
t.Errorf("the fold's answer: %+v, want the same", a)
|
||||
}
|
||||
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
|
||||
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
|
||||
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
|
||||
keptToo := append(append([]engcomp.Composed{}, running...), engcomp.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame || len(a.Removed) != 0 {
|
||||
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
|
||||
}
|
||||
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
|
||||
// the same, not 'in what runs, not composed' (seed #531, novox).
|
||||
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
|
||||
withMembership := make([]delivery.Composed, 0, len(running))
|
||||
withMembership := make([]engcomp.Composed, 0, len(running))
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
|
||||
}
|
||||
withMembership = append(withMembership, c)
|
||||
}
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != nodeengine.CandidateAnswerVerdictSame {
|
||||
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
|
||||
}
|
||||
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
|
||||
@@ -191,6 +197,28 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
|
||||
}
|
||||
// Issue 1585 (nats on novox, met twice): wanted at a build of the new form, a module in InOwn is no longer folded
|
||||
// (compose.Folds), so its build is taken in and it composes with its image, never from the indexer's manifest as
|
||||
// "not built for this mesh".
|
||||
{
|
||||
bb := mk("lemurs", overlay.Name, "spiegel")
|
||||
bp := bb.assignments["n1"]
|
||||
bp.Decided.Endpoint = "n1.example:51820"
|
||||
bb.assignments["n1"] = bp
|
||||
sp := bb.modules["spiegel"]
|
||||
sp.Fp2 = fp("3")
|
||||
bb.modules["spiegel"] = sp
|
||||
image := "registry.example:5100/spiegel/site@sha256:" + strings.Repeat("7", 64)
|
||||
bb.builds["spiegel "+sp.Fp2] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: sp.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "site", Kind: "upstream", Reference: image, Digest: "sha256:" + strings.Repeat("7", 64)}}}
|
||||
read, err := ComposingInput(ctx, bb, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}},
|
||||
map[string]string{"lemurs": fp("e"), "spiegel": sp.Fp2})
|
||||
raw, _ := json.Marshal(read.Catalogue["spiegel"])
|
||||
if err != nil || read.ComposedHow.Held["spiegel"] != "" || !strings.Contains(string(raw), strings.Repeat("7", 64)) {
|
||||
t.Errorf("spiegel in InOwn wanted at %s: %v, held %v, read as %s; want its build's image", sp.Fp2, err,
|
||||
read.ComposedHow, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
|
||||
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
|
||||
@@ -206,17 +234,17 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
|
||||
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "spiegel" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
p.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
@@ -287,7 +315,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
|
||||
f1runs := engcomp.Composed{Node: "ace", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
|
||||
@@ -344,14 +372,14 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
@@ -363,7 +391,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
|
||||
if !slices.ContainsFunc(a.Added, func(x nodeengine.CandidateAdded) bool { return x.Module == m }) {
|
||||
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
|
||||
}
|
||||
}
|
||||
@@ -403,11 +431,11 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
|
||||
if key, _ := engcomp.Key("n1", m, fps[m]); got[key].Module != m {
|
||||
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
carries := func(c delivery.Composed, what string) {
|
||||
carries := func(c engcomp.Composed, what string) {
|
||||
t.Helper()
|
||||
ownRes, _ := json.Marshal(c.Resources)
|
||||
for _, raw := range spiegelRuns {
|
||||
@@ -428,7 +456,7 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
|
||||
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
|
||||
}
|
||||
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
|
||||
spiegelKey, _ := engcomp.Key("n1", "de-spiegel", fps["de-spiegel"])
|
||||
if _, ok := got[spiegelKey]; ok {
|
||||
t.Errorf("held de-spiegel's own composition is handed")
|
||||
}
|
||||
@@ -444,26 +472,26 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
|
||||
b = mk("lemurs", overlay.Name, "site", "nft")
|
||||
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p = b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "site" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
p.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
rec = b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
|
||||
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
|
||||
siteRuns := engcomp.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
nftKey, _ := engcomp.Key("n1", "nft", fps["nft"])
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
|
||||
}
|
||||
@@ -485,22 +513,22 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
|
||||
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
|
||||
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
|
||||
b2.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
b2.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
}
|
||||
p2 := b2.placements["n1"]
|
||||
p2 := b2.assignments["n1"]
|
||||
p2.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p2.Modules {
|
||||
if p2.Modules[i].Module == "site" {
|
||||
p2.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
p2.Modules[i].Ports = map[string]int64{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b2.placements["n1"] = p2
|
||||
b2.assignments["n1"] = p2
|
||||
b2.records["n1"] = rec
|
||||
b2.held = true
|
||||
fps["app"] = fp("9")
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
appKey, _ := engcomp.Key("n1", "app", fps["app"])
|
||||
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
|
||||
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
|
||||
}
|
||||
@@ -528,17 +556,17 @@ func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
|
||||
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||
b.manifests[delivery.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(moved)}
|
||||
for i := range p.Modules {
|
||||
p.Modules[i].Ports = nil
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
|
||||
siteRuns.FP = fp("7")
|
||||
b.manifests[delivery.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
b.manifests[meshcatalogclient.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []engcomp.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
|
||||
}
|
||||
@@ -563,19 +591,19 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
|
||||
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
|
||||
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
|
||||
b := &bus{placements: map[string]delivery.Placement{
|
||||
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
|
||||
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
|
||||
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
|
||||
given := map[string]*delivery.Placement{}
|
||||
for n, p := range b.placements {
|
||||
b := &bus{assignments: map[string]meshmoduleassigner.Assignments{
|
||||
"n1": {Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
|
||||
"n2": {Node: "n2", Decided: meshmoduleassigner.NodeDecided{Address: "10.77.0.2"}, Modules: []meshmoduleassigner.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"prov": {Manifest: meshcatalogclient.ManifestDigest(prov)}, "cons": {Manifest: meshcatalogclient.ManifestDigest(cons)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(prov): prov, meshcatalogclient.ManifestDigest(cons): cons}}
|
||||
given := map[string]*meshmoduleassigner.Assignments{}
|
||||
for n, p := range b.assignments {
|
||||
p := p
|
||||
given[n] = &p
|
||||
}
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
|
||||
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Assignments: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
|
||||
if a.Verdict != nodeengine.CandidateAnswerVerdictRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
t.Errorf("a refusal for want of n2's provider = %+v", a)
|
||||
}
|
||||
}
|
||||
@@ -584,13 +612,15 @@ func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
// "reaches no artifact store", and nothing said why).
|
||||
func noStoreReachedSaysWhy(t *testing.T) {
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
|
||||
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
|
||||
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
|
||||
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
|
||||
"distribution": {Module: "distribution", Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}}},
|
||||
// A module serving the retired provision artifact-store is no store (ADR 0632 §1).
|
||||
"old-store": {Module: "old-store", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
|
||||
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita", "old-store"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
|
||||
if got := storeSought(in); got != "the composition holds g14, novox; container-image-registry is held by distribution on novox, none of it on the private network with a listen open to the mesh" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
in.Nodes = in.Nodes[:1]
|
||||
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
|
||||
if got := storeSought(in); got != "the composition holds g14; no module placed there holds container-image-registry" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,21 @@ package frombus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
sdkkeys "git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
)
|
||||
|
||||
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
|
||||
// Export is one node's assignment as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only export verb
|
||||
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
|
||||
// stable for the same store, since adopt checks its digest again at the warrant.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
func Export(in compose.Input, node string) (meshmoduleassigner.Assignments, error) {
|
||||
var n *compose.Node
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
@@ -23,14 +24,14 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
}
|
||||
}
|
||||
if n == nil {
|
||||
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
return meshmoduleassigner.Assignments{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
}
|
||||
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
|
||||
// At the version a assignment key is read at, with a list of modules even when none is placed: the export is the
|
||||
// seed's value, and the reader refuses a value of version 0 or without its modules.
|
||||
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
|
||||
p := meshmoduleassigner.Assignments{V: sdkkeys.Version, Node: node, Modules: []meshmoduleassigner.PlacedModule{}, Decided: meshmoduleassigner.NodeDecided{
|
||||
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
|
||||
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
|
||||
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
|
||||
AgentAccountHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
|
||||
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
|
||||
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
|
||||
if t, ok := in.Tunnels[node]; ok {
|
||||
@@ -51,27 +52,27 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
}
|
||||
}
|
||||
for _, module := range n.Assigned {
|
||||
m := delivery.PlacedModule{Module: module}
|
||||
m := meshmoduleassigner.PlacedModule{Module: module}
|
||||
for port, at := range n.Ports[module] {
|
||||
if m.Ports == nil {
|
||||
m.Ports = map[string]int{}
|
||||
m.Ports = map[string]int64{}
|
||||
}
|
||||
m.Ports[strconv.Itoa(port)] = at
|
||||
m.Ports[strconv.Itoa(port)] = int64(at)
|
||||
}
|
||||
for provision, to := range n.Bound[module] {
|
||||
if m.Bindings == nil {
|
||||
m.Bindings = map[string]delivery.Provider{}
|
||||
m.Bindings = map[string]meshmoduleassigner.Provider{}
|
||||
}
|
||||
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
m.Bindings[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
|
||||
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
|
||||
// the assignment. A module in the config form has mesh-config's values laid into its layer, which are not.
|
||||
settingsForm := !in.Catalogue[module].DeclaresConfig()
|
||||
for _, layer := range n.Settings[module] {
|
||||
if layer.From == catalogue.MeshWideLayer {
|
||||
m.HasMesh, m.MeshWide = true, layer.Placement
|
||||
m.HasMeshWide, m.MeshWide = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.MeshValues = layer.Values
|
||||
m.MeshWideValues = layer.Values
|
||||
}
|
||||
} else {
|
||||
m.HasLayer, m.Layer = true, layer.Placement
|
||||
@@ -84,13 +85,13 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
}
|
||||
for provision, to := range n.Pins {
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]delivery.Provider{}
|
||||
p.Pins = map[string]meshmoduleassigner.Provider{}
|
||||
}
|
||||
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
p.Pins[provision] = meshmoduleassigner.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
for _, h := range in.Holdings {
|
||||
if h.Node == node {
|
||||
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
p.Holds = append(p.Holds, meshmoduleassigner.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(p.Holds, func(i, j int) bool {
|
||||
@@ -102,11 +103,11 @@ func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
|
||||
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
func recordTunnelOf(t compose.Tunnel) meshmoduleassigner.FoundTunnel {
|
||||
out := meshmoduleassigner.FoundTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int64(t.Port), Mtu: int64(t.MTU),
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, peer := range t.Peers {
|
||||
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
|
||||
out.Peers = append(out.Peers, meshmoduleassigner.FoundTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+191
-127
@@ -2,18 +2,18 @@
|
||||
// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
|
||||
//
|
||||
// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a
|
||||
// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-module-assigner's holder judging a
|
||||
// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
|
||||
// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
|
||||
//
|
||||
// **What it reads, and from whom.**
|
||||
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
|
||||
// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1);
|
||||
// - which modules run on each node, with their pins, holdings, host ports, bindings and settings assignment: the
|
||||
// seat mesh-module-assigner's state `assignment`, one key per node (ADR 0567 §1);
|
||||
// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and
|
||||
// accounts): the same placement key, Placement.Decided;
|
||||
// accounts): the same assignment key, Assignments.Decided;
|
||||
// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the
|
||||
// node's own have key, `<node>.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided,
|
||||
// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
|
||||
// in its assignment, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
|
||||
// - every module's manifest: the indexer's `modules.<module>` and its manifests by digest; for each module placed on
|
||||
// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint
|
||||
// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule
|
||||
@@ -41,26 +41,35 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
"github.com/novox/mesh-host/internal/registryforms"
|
||||
"github.com/novox/mesh-host/internal/takein"
|
||||
)
|
||||
|
||||
// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
|
||||
// read that failed, never an absent key (an absent key is an empty answer).
|
||||
type Reader interface {
|
||||
// Placements is every node's placement, by node (delivery.PlacementBucket).
|
||||
Placements(ctx context.Context) (map[string]delivery.Placement, error)
|
||||
// Assignments is every node's assignment, by node (buckets.Assignments).
|
||||
Assignments(ctx context.Context) (map[string]meshmoduleassigner.Assignments, error)
|
||||
// Records is every node's record, by node: HaveNode.Record of each node's own have key, `<node>.node` of
|
||||
// delivery.HaveBucket (ADR 0555 §3).
|
||||
Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
|
||||
// Modules is every module the indexer holds, by module (delivery.ModulesBucket).
|
||||
Modules(ctx context.Context) (map[string]delivery.Module, error)
|
||||
// Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
|
||||
// buckets.Have (ADR 0555 §3).
|
||||
Records(ctx context.Context) (map[string]nodeengine.NodeRecord, error)
|
||||
// Modules is every module the indexer holds, by module (buckets.Modules).
|
||||
Modules(ctx context.Context) (map[string]meshcatalog.ModulesByModuleValue, error)
|
||||
// Manifest is the raw manifest of a digest (buckets.Manifests).
|
||||
Manifest(ctx context.Context, digest string) ([]byte, error)
|
||||
// Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`,
|
||||
// keys `<node>.<module>.<key>`), decoded; nil when it has none.
|
||||
@@ -72,11 +81,11 @@ type Reader interface {
|
||||
// and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key
|
||||
// the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it.
|
||||
Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error)
|
||||
// Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil
|
||||
// Build is a module's build at a fingerprint, the builder's key (builds.BuilderBuildsBucket, BuilderBuildKey); nil
|
||||
// when the builder holds none. The composing node composes each of its modules from its build (issue 1126).
|
||||
Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error)
|
||||
Build(ctx context.Context, module, fp string) (*builds.BuilderBuild, error)
|
||||
// Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it
|
||||
// (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
|
||||
// (registryforms.Forms.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
|
||||
// controller's take-in reads it (takein.Built).
|
||||
Archive(ctx context.Context, url, digest string) ([]byte, error)
|
||||
// Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`,
|
||||
@@ -86,11 +95,11 @@ type Reader interface {
|
||||
Served(ctx context.Context, node, module string, keys []string) (map[string]any, error)
|
||||
}
|
||||
|
||||
// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket,
|
||||
// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
|
||||
// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (buckets.Signers,
|
||||
// key meshvault.SignersByAuthorityKey(); ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
|
||||
// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before.
|
||||
type SignersReader interface {
|
||||
// VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the
|
||||
// VaultAuthority is the vault's authority certificate as PEM, checked (meshvaultclient.DecodeSignerAuthority); empty when the
|
||||
// vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the
|
||||
// state yet composes as before rather than not at all.
|
||||
VaultAuthority(ctx context.Context) string
|
||||
@@ -98,9 +107,10 @@ type SignersReader interface {
|
||||
|
||||
// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
|
||||
// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
|
||||
// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
|
||||
// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
|
||||
// composes by the manifest and the machine, as before.
|
||||
// each module's assignment). Never the rule: the node hands by the manifest, its build and the machine
|
||||
// (resolution.Handing, issue 1966), and the plans it composes are what the vault reads next; the vault's word is said
|
||||
// where the two disagree (resolution.VaultDisagrees). Nil, or a Reader without it (a vault older than the field), says
|
||||
// nothing.
|
||||
type HandedReader interface {
|
||||
Handed(ctx context.Context, node string) map[string]bool
|
||||
}
|
||||
@@ -127,11 +137,11 @@ type Mesh struct {
|
||||
// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource
|
||||
// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done
|
||||
// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer
|
||||
// read it: the check of every node judges placement, and reads no archive.
|
||||
// read it: the check of every node judges assignment, and reads no archive.
|
||||
//
|
||||
// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates
|
||||
// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with
|
||||
// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always.
|
||||
// Assignments, when set, stands in for the assignment of the nodes it names: a proposal's candidates
|
||||
// (meshmoduleassigner.CandidatesByProposalNodeValue), so a node composes in shadow with a proposal laid over and compares the result with
|
||||
// what it runs (ADR 0567 §3); a nil value removes the node's assignment. The rest is read as always.
|
||||
//
|
||||
// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again
|
||||
// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126).
|
||||
@@ -140,18 +150,18 @@ type Mesh struct {
|
||||
// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried
|
||||
// as it runs (issue 1126). Nil composes every folded module from its manifest.
|
||||
type Scope struct {
|
||||
Node string
|
||||
Every bool
|
||||
KeptAt map[string]string
|
||||
Placements map[string]*delivery.Placement
|
||||
InOwn []string
|
||||
Running []delivery.Composed
|
||||
Node string
|
||||
Every bool
|
||||
KeptAt map[string]string
|
||||
Assignments map[string]*meshmoduleassigner.Assignments
|
||||
InOwn []string
|
||||
Running []engcomp.Composed
|
||||
// HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
|
||||
// how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
|
||||
// behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
|
||||
HeldToday map[string]string
|
||||
// SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
|
||||
// key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
|
||||
// key), so a differing resource shows a value sealed again as such (compositions.CompareComposedWith). Nil shows them
|
||||
// as differing.
|
||||
SameValue func(a, b string) (same, known bool)
|
||||
// Kept is told, in words, each module composed as what runs shows because its manifest does not yet say it (the tool
|
||||
@@ -164,7 +174,7 @@ type Scope struct {
|
||||
// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module
|
||||
// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own
|
||||
// composition keeps them as it runs them until a want names a build of the new form for one.
|
||||
func InOwn(running []delivery.Composed, p delivery.Placement) []string {
|
||||
func InOwn(running []engcomp.Composed, p meshmoduleassigner.Assignments) []string {
|
||||
placed := map[string]bool{}
|
||||
for _, m := range p.Modules {
|
||||
placed[m.Module] = true
|
||||
@@ -193,7 +203,7 @@ func InOwn(running []delivery.Composed, p delivery.Placement) []string {
|
||||
// Input is the composition input for s, read through r.
|
||||
//
|
||||
// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently
|
||||
// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing
|
||||
// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (meshcatalogclient.TakeVerdict, a missing
|
||||
// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the
|
||||
// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in
|
||||
// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on.
|
||||
@@ -204,10 +214,10 @@ func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, er
|
||||
// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict
|
||||
// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a
|
||||
// manifest not read past a field it set aside holds its name alone and is held the same.
|
||||
func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) {
|
||||
if v := m.VerdictOf(name); v != nil {
|
||||
if refused, _ := delivery.TakeManifestVerdict(v); refused {
|
||||
return unusable(name, raw), v.Why()
|
||||
func taken(name string, raw []byte, m meshcatalog.ModulesByModuleValue) (man catalogue.Manifest, why string) {
|
||||
if v := meshcatalogclient.ModuleVerdict(name, m); v != nil {
|
||||
if refused, _ := meshcatalogclient.TakeVerdict(v); refused {
|
||||
return unusable(name, raw), meshcatalogclient.VerdictWhy(*v)
|
||||
}
|
||||
}
|
||||
man, err := catalogue.Decode(raw)
|
||||
@@ -234,6 +244,7 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
}
|
||||
in, err := b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
|
||||
if err == nil {
|
||||
runningProvisioner(&in, s.Node, s.Running)
|
||||
// What runs is kept while a manifest does not yet say what shared code no longer infers (ADR 0601 §4).
|
||||
for _, k := range keepWhatRuns(&in, s.Node, s.Running) {
|
||||
if s.Kept != nil {
|
||||
@@ -247,10 +258,10 @@ func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module
|
||||
// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
|
||||
// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
|
||||
type base struct {
|
||||
in compose.Input
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
in compose.Input
|
||||
assignments map[string]meshmoduleassigner.Assignments
|
||||
records map[string]nodeengine.NodeRecord
|
||||
modules map[string]meshcatalog.ModulesByModuleValue
|
||||
// ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
|
||||
// read: every scope holds it.
|
||||
ownRefused [][2]string
|
||||
@@ -280,29 +291,29 @@ func (b base) cloned() compose.Input {
|
||||
return in
|
||||
}
|
||||
|
||||
// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
|
||||
// readBase is input up to the builds: assignment, records, the indexer's modules and manifests, each node's values.
|
||||
func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
var b base
|
||||
placements, err := r.Placements(ctx)
|
||||
assignments, err := r.Assignments(ctx)
|
||||
if err != nil {
|
||||
return b, fmt.Errorf("frombus: placement: %w", err)
|
||||
return b, fmt.Errorf("frombus: assignment: %w", err)
|
||||
}
|
||||
if len(s.Placements) > 0 {
|
||||
given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
|
||||
for node, p := range placements {
|
||||
if len(s.Assignments) > 0 {
|
||||
given := make(map[string]meshmoduleassigner.Assignments, len(assignments)+len(s.Assignments))
|
||||
for node, p := range assignments {
|
||||
given[node] = p
|
||||
}
|
||||
for node, p := range s.Placements {
|
||||
for node, p := range s.Assignments {
|
||||
if p == nil {
|
||||
delete(given, node)
|
||||
continue
|
||||
}
|
||||
if p.Node != node {
|
||||
return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
return b, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
given[node] = *p
|
||||
}
|
||||
placements = given
|
||||
assignments = given
|
||||
}
|
||||
records, err := r.Records(ctx)
|
||||
if err != nil {
|
||||
@@ -312,8 +323,8 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
if err != nil {
|
||||
return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
|
||||
}
|
||||
if _, ok := placements[s.Node]; !ok && !s.Every {
|
||||
return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
|
||||
if _, ok := assignments[s.Node]; !ok && !s.Every {
|
||||
return b, fmt.Errorf("frombus: %s has no assignment in the catalogue", s.Node)
|
||||
}
|
||||
|
||||
in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
|
||||
@@ -336,7 +347,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
// against the bundle. The controller's stays while a certificate it issued is in a node's record.
|
||||
if sr, ok := r.(SignersReader); ok {
|
||||
if vault := sr.VaultAuthority(ctx); vault != "" {
|
||||
in.Authority = delivery.AuthorityBundle(in.Authority, vault)
|
||||
in.Authority = meshvaultclient.AuthorityBundle(in.Authority, vault)
|
||||
}
|
||||
}
|
||||
named := func(field *map[string]string, module, why string) {
|
||||
@@ -346,7 +357,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
(*field)[module] = why
|
||||
}
|
||||
ownPlaced := map[string]bool{}
|
||||
for _, pm := range placements[s.Node].Modules {
|
||||
for _, pm := range assignments[s.Node].Modules {
|
||||
ownPlaced[pm.Module] = true
|
||||
}
|
||||
for _, name := range sortedKeys(modules) {
|
||||
@@ -377,7 +388,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
|
||||
// The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
|
||||
// with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
|
||||
for _, name := range delivery.ProvidedModules() {
|
||||
for _, name := range overlay.ProvidedModules() {
|
||||
raw, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
return b, err
|
||||
@@ -390,9 +401,9 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, node := range nodesOf(placements, records) {
|
||||
for _, node := range nodesOf(assignments, records) {
|
||||
rec, hasRecord := records[node]
|
||||
p := placements[node]
|
||||
p := assignments[node]
|
||||
if !hasRecord {
|
||||
if len(p.Modules) > 0 {
|
||||
missing = append(missing, node)
|
||||
@@ -415,7 +426,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
strings.Join(keys, ", "), err)
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil {
|
||||
if n.Settings == nil {
|
||||
n.Settings = map[string][]catalogue.Layer{}
|
||||
}
|
||||
@@ -464,7 +475,7 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
}
|
||||
}
|
||||
in.Nodes = append(in.Nodes, n)
|
||||
in.Holdings = append(in.Holdings, p.Held()...)
|
||||
in.Holdings = append(in.Holdings, meshmoduleassignerclient.Held(p)...)
|
||||
// A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the
|
||||
// controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over
|
||||
// (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers).
|
||||
@@ -481,19 +492,19 @@ func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
|
||||
}
|
||||
}
|
||||
if in.Adopted != nil && in.Adopted.Adopted {
|
||||
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
|
||||
in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, assignments[in.Adopted.Hub].Decided.PeerNames, records)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
b.in, b.placements, b.records, b.modules = in, placements, records, modules
|
||||
b.in, b.assignments, b.records, b.modules = in, assignments, records, modules
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
|
||||
// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
|
||||
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error),
|
||||
mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
|
||||
var err error
|
||||
parse := parser(mesh)
|
||||
@@ -512,7 +523,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte
|
||||
if _, ok := b.records[s.Node]; !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
|
||||
}
|
||||
if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
|
||||
if err := fromBuilds(ctx, r, build, &in, s, b.assignments[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
|
||||
return compose.Input{}, err
|
||||
}
|
||||
// Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
|
||||
@@ -552,7 +563,7 @@ func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Conte
|
||||
// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a
|
||||
// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served
|
||||
// elsewhere, a stand-in for the node's own) refuses the node, err.
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
|
||||
func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]engcomp.Composed, skipped []string, held map[string]string, err error) {
|
||||
held = map[string]string{}
|
||||
// A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
|
||||
// 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
|
||||
@@ -606,7 +617,7 @@ func ComposingInput(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[s
|
||||
// ComposeSent composes node from an input ComposingInput read, through the path Compose takes (composeEach, the read's holds
|
||||
// first): what a check's sandbox runs. False when the input says no way of composing node (an input from a node-engine
|
||||
// before ComposedHow): the caller cannot compose it as the node does.
|
||||
func ComposeSent(in compose.Input, node string) (map[string]delivery.Composed, bool, error) {
|
||||
func ComposeSent(in compose.Input, node string) (map[string]engcomp.Composed, bool, error) {
|
||||
c := in.ComposedHow
|
||||
if c == nil || c.Node != node {
|
||||
return nil, false, nil
|
||||
@@ -761,7 +772,7 @@ func parser(Mesh) func([]byte) (catalogue.Manifest, error) {
|
||||
// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build
|
||||
// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to
|
||||
// others is taken of it, which needs no take-in of its artifacts.
|
||||
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
|
||||
func ranOf(ctx context.Context, r Reader, mesh Mesh, running []engcomp.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
|
||||
parse := parser(mesh)
|
||||
return func(module string) (catalogue.Manifest, bool) {
|
||||
for _, c := range running {
|
||||
@@ -769,7 +780,7 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
||||
continue
|
||||
}
|
||||
b, err := r.Build(ctx, module, c.FP)
|
||||
if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" {
|
||||
if err != nil || b == nil || b.State != builds.BuildDone || b.Manifest == "" {
|
||||
return catalogue.Manifest{}, false
|
||||
}
|
||||
raw, err := r.Manifest(ctx, b.Manifest)
|
||||
@@ -787,8 +798,8 @@ func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed
|
||||
// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
|
||||
// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
|
||||
// (issue 1126).
|
||||
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
|
||||
in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
|
||||
func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error),
|
||||
in *compose.Input, s Scope, p meshmoduleassigner.Assignments, modules map[string]meshcatalog.ModulesByModuleValue,
|
||||
read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
|
||||
refuse func(module string, err error), wanted map[string]string) error {
|
||||
// stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
|
||||
@@ -825,9 +836,9 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
case address == "":
|
||||
return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in))
|
||||
}
|
||||
return r.Archive(ctx, catalogue.Routed(reference, address), digest)
|
||||
return r.Archive(ctx, registryforms.Forms.Routed(reference, address), digest)
|
||||
}
|
||||
built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) {
|
||||
built := func(module, fp string, b *builds.BuilderBuild) (catalogue.Manifest, error) {
|
||||
if b.Manifest == "" {
|
||||
return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
|
||||
}
|
||||
@@ -875,8 +886,8 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
// the scope keeps at another build is.
|
||||
keptAt := maps.Clone(s.KeptAt)
|
||||
for _, pm := range p.Modules {
|
||||
if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
|
||||
keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
|
||||
if m, ok := modules[pm.Module]; ok && m.Fp2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.Fp2 &&
|
||||
keptAt[pm.Module] == "" && !compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
|
||||
if keptAt == nil {
|
||||
keptAt = map[string]string{}
|
||||
}
|
||||
@@ -891,9 +902,10 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
}
|
||||
for _, pm := range p.Modules {
|
||||
m, ok := modules[pm.Module]
|
||||
if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
|
||||
// A module it runs inside its own composition is composed there as before: no build of the new form is what
|
||||
// it runs (Scope.InOwn).
|
||||
if !ok || m.Fp2 == "" || keptAt[pm.Module] != "" || compose.Folds(slices.Contains(s.InOwn, pm.Module), wanted[pm.Module]) {
|
||||
// A module it runs inside its own composition is composed there as before while it is folded: no build of the
|
||||
// new form is what it runs (Scope.InOwn). Wanted at a build of the new form it is no longer folded (compose.Folds,
|
||||
// the composition's own rule), so its build is taken in (issue 1585).
|
||||
continue
|
||||
}
|
||||
if _, refused := in.Refused[pm.Module]; refused {
|
||||
@@ -902,26 +914,26 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
|
||||
continue // nothing is built of it: its manifest is what it composes from
|
||||
}
|
||||
b, err := build(ctx, pm.Module, m.FP2)
|
||||
b, err := build(ctx, pm.Module, m.Fp2)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.Fp2, err)
|
||||
if refuse == nil {
|
||||
return err
|
||||
}
|
||||
stub(pm.Module, err)
|
||||
continue
|
||||
}
|
||||
if b == nil || b.State != delivery.BuildDone {
|
||||
if b == nil || b.State != builds.BuildDone {
|
||||
if refuse != nil && runs[pm.Module] {
|
||||
// A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no
|
||||
// runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30:
|
||||
// node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused).
|
||||
stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+
|
||||
"keeps running what it runs", pm.Module, m.FP2))
|
||||
"keeps running what it runs", pm.Module, m.Fp2))
|
||||
}
|
||||
continue // nothing of it runs: composed from the indexer's manifest, as before
|
||||
}
|
||||
resolved, err := built(pm.Module, m.FP2, b)
|
||||
resolved, err := built(pm.Module, m.Fp2, b)
|
||||
if err != nil {
|
||||
if refuse == nil {
|
||||
return err
|
||||
@@ -941,7 +953,7 @@ func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, m
|
||||
switch {
|
||||
case err != nil:
|
||||
err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
|
||||
case b == nil || b.State != delivery.BuildDone:
|
||||
case b == nil || b.State != builds.BuildDone:
|
||||
err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp)
|
||||
}
|
||||
var resolved catalogue.Manifest
|
||||
@@ -983,15 +995,24 @@ func declaresOwnSecrets(in compose.Input, node string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
|
||||
// attachedOf is a record's attached networks as the composer holds them (the same two fields).
|
||||
func attachedOf(in []nodeengine.AttachedNetwork) []catalogue.AttachedNetwork {
|
||||
var out []catalogue.AttachedNetwork
|
||||
for _, n := range in {
|
||||
out = append(out, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeOf is the composition's node from its record (what the machine observes) and its assignment (what a person
|
||||
// decided of it, and where its modules run).
|
||||
func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
func nodeOf(rec nodeengine.NodeRecord, p meshmoduleassigner.Assignments) compose.Node {
|
||||
n := compose.Node{
|
||||
Name: rec.Node,
|
||||
Place: compose.Place{Key: rec.TunnelKey},
|
||||
SealingKey: rec.SealingKey,
|
||||
OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
|
||||
OutwardLinks: rec.OutwardLinks, Networks: attachedOf(rec.Networks), NetworksSaid: rec.NetworksSaid,
|
||||
ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: int(rec.Contract), Stated: rec.Contract > 0,
|
||||
}
|
||||
placeNode(&n, p)
|
||||
if len(rec.Capabilities) > 0 {
|
||||
@@ -1003,26 +1024,26 @@ func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
|
||||
return n
|
||||
}
|
||||
|
||||
// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
|
||||
// placeNode sets what a person decided of n and where its modules run, from its assignment; what the machine observes
|
||||
// (its keys, links, networks, certificate) and its settings layers are left as they are.
|
||||
func placeNode(n *compose.Node, p delivery.Placement) {
|
||||
func placeNode(n *compose.Node, p meshmoduleassigner.Assignments) {
|
||||
d := p.Decided
|
||||
n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
|
||||
n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
|
||||
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
|
||||
n.Assigned, n.PublicDomain = meshmoduleassignerclient.Assigned(p), d.PublicDomain
|
||||
n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentAccountHome
|
||||
n.Adopted, n.Taken = d.Adopted, d.Taken
|
||||
n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
|
||||
n.Pins, n.Bound, n.Ports = meshmoduleassignerclient.ChosenPins(p), meshmoduleassignerclient.Bound(p), meshmoduleassignerclient.HostPorts(p)
|
||||
}
|
||||
|
||||
// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
|
||||
// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
|
||||
// Placed is in with the assignments given standing in for those of the nodes they name (issue 1126): each such node's
|
||||
// decided parts, modules, pins, bindings, host ports, settings assignment and holdings are the given assignment's, its
|
||||
// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
|
||||
// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
|
||||
// kept. A nil assignment leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
|
||||
// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
|
||||
// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
|
||||
// Placements).
|
||||
func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
|
||||
if len(placements) == 0 {
|
||||
// assignment. It is how a check composes a change's own assignment over an input already filled (composer.Case's
|
||||
// Assignments).
|
||||
func Placed(in compose.Input, assignments map[string]*meshmoduleassigner.Assignments) (compose.Input, error) {
|
||||
if len(assignments) == 0 {
|
||||
return in, nil
|
||||
}
|
||||
out := in
|
||||
@@ -1031,17 +1052,17 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
|
||||
for i, n := range out.Nodes {
|
||||
index[n.Name] = i
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
p := placements[node]
|
||||
for _, node := range sortedKeys(assignments) {
|
||||
p := assignments[node]
|
||||
i, ok := index[node]
|
||||
if !ok {
|
||||
return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
|
||||
return compose.Input{}, fmt.Errorf("frombus: a assignment is given for %s, which the input holds no node for", node)
|
||||
}
|
||||
if p == nil {
|
||||
p = &delivery.Placement{Node: node}
|
||||
p = &meshmoduleassigner.Assignments{Node: node}
|
||||
}
|
||||
if p.Node != node {
|
||||
return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
|
||||
return compose.Input{}, fmt.Errorf("frombus: the assignment given for %s names the node %q", node, p.Node)
|
||||
}
|
||||
n := out.Nodes[i]
|
||||
placeNode(&n, *p)
|
||||
@@ -1053,7 +1074,7 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
|
||||
values = l.Values
|
||||
}
|
||||
}
|
||||
if layers := m.Layers(node, values); layers != nil {
|
||||
if layers := meshmoduleassignerclient.Layers(m, node, values); layers != nil {
|
||||
settings[m.Module] = layers
|
||||
}
|
||||
}
|
||||
@@ -1065,20 +1086,20 @@ func Placed(in compose.Input, placements map[string]*delivery.Placement) (compos
|
||||
}
|
||||
out.Holdings = nil
|
||||
for _, h := range in.Holdings {
|
||||
if _, given := placements[h.Node]; !given {
|
||||
if _, given := assignments[h.Node]; !given {
|
||||
out.Holdings = append(out.Holdings, h)
|
||||
}
|
||||
}
|
||||
for _, node := range sortedKeys(placements) {
|
||||
if p := placements[node]; p != nil {
|
||||
out.Holdings = append(out.Holdings, p.Held()...)
|
||||
for _, node := range sortedKeys(assignments) {
|
||||
if p := assignments[node]; p != nil {
|
||||
out.Holdings = append(out.Holdings, meshmoduleassignerclient.Held(*p)...)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
|
||||
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
func tunnelOf(t meshmoduleassigner.FoundTunnel) compose.Tunnel {
|
||||
out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: int(t.Port), MTU: int(t.Mtu),
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, p := range t.Peers {
|
||||
out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
|
||||
@@ -1088,7 +1109,7 @@ func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
|
||||
|
||||
// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key
|
||||
// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers).
|
||||
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer {
|
||||
func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]nodeengine.NodeRecord) []compose.CarriedPeer {
|
||||
byKey := map[string]string{}
|
||||
for node, rec := range records {
|
||||
if rec.TunnelKey != "" {
|
||||
@@ -1111,10 +1132,10 @@ func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]
|
||||
return out
|
||||
}
|
||||
|
||||
// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
|
||||
func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string {
|
||||
// nodesOf is every node named by a assignment or a record, by name: the order the controller reads them in.
|
||||
func nodesOf(assignments map[string]meshmoduleassigner.Assignments, records map[string]nodeengine.NodeRecord) []string {
|
||||
seen := map[string]bool{}
|
||||
for n := range placements {
|
||||
for n := range assignments {
|
||||
seen[n] = true
|
||||
}
|
||||
for n := range records {
|
||||
@@ -1133,16 +1154,14 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
}
|
||||
|
||||
// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and
|
||||
// where each module serving the artifact store is placed, so the refusal names why (issue 1126).
|
||||
// where each module claiming the store's seat is placed, so the refusal names why (issue 1126, ADR 0632 §1).
|
||||
func storeSought(in compose.Input) string {
|
||||
var nodes, where []string
|
||||
for _, n := range in.Nodes {
|
||||
nodes = append(nodes, n.Name)
|
||||
for _, a := range n.Assigned {
|
||||
if m, ok := in.Catalogue[a]; ok {
|
||||
if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves {
|
||||
where = append(where, a+" on "+n.Name)
|
||||
}
|
||||
if m, ok := in.Catalogue[a]; ok && claimsStore(m) {
|
||||
where = append(where, a+" on "+n.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1150,14 +1169,24 @@ func storeSought(in compose.Input) string {
|
||||
sort.Strings(where)
|
||||
said := "the composition holds " + strings.Join(nodes, ", ")
|
||||
if len(where) == 0 {
|
||||
return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision
|
||||
return said + "; no module placed there holds " + compose.StoreSeat
|
||||
}
|
||||
return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") +
|
||||
", none of it on the private network with a port"
|
||||
return said + "; " + compose.StoreSeat + " is held by " + strings.Join(where, ", ") +
|
||||
", none of it on the private network with a listen open to the mesh"
|
||||
}
|
||||
|
||||
// claimsStore says whether m claims a name of the store (resolution.StoreSeats) at the mesh's scope.
|
||||
func claimsStore(m catalogue.Manifest) bool {
|
||||
for _, cl := range m.Claims {
|
||||
if cs, ok := catalogue.SeatNamed(cl.Name); ok && slices.Contains(resolution.StoreSeats, cs.Name) && cl.At() == catalogue.ScopeMesh {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil.
|
||||
func keptOf(c delivery.Composed) map[string]any {
|
||||
func keptOf(c engcomp.Composed) map[string]any {
|
||||
for _, raw := range c.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() {
|
||||
@@ -1169,7 +1198,7 @@ func keptOf(c delivery.Composed) map[string]any {
|
||||
|
||||
// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own
|
||||
// composition, or "".
|
||||
func membershipOf(own delivery.Composed) string {
|
||||
func membershipOf(own engcomp.Composed) string {
|
||||
for _, raw := range own.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
@@ -1230,3 +1259,38 @@ func withoutHeldData(in compose.Input, node string) compose.Input {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningProvisioner names, on node, the account its provisioner client runs as now (compose.Node.RunningProvisioner): the
|
||||
// account its own composition as it runs names, else the user of the running process of the module that runs the client.
|
||||
func runningProvisioner(in *compose.Input, node string, running []engcomp.Composed) {
|
||||
account := ""
|
||||
for _, c := range running {
|
||||
if c.Module == "" && c.Own.Provisioner != "" {
|
||||
account = c.Own.Provisioner
|
||||
}
|
||||
}
|
||||
for _, c := range running {
|
||||
if account != "" {
|
||||
break
|
||||
}
|
||||
man, ok := in.Catalogue[c.Module]
|
||||
if c.Module == "" || !ok || !man.ClaimsSeat(compose.NodeProvisionerSeat) {
|
||||
continue
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.Type == "process" && r.User != "" && r.User != "root" {
|
||||
account = r.User
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
in.Nodes[i].RunningProvisioner = account
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
nodeengine "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/compositions"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -18,30 +26,29 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
vaultAuthority string
|
||||
handed map[string]bool
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
assignments map[string]meshmoduleassigner.Assignments
|
||||
records map[string]nodeengine.NodeRecord
|
||||
modules map[string]meshcatalog.ModulesByModuleValue
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
|
||||
// module its placement does not put on the node.
|
||||
// module its assignment does not put on the node.
|
||||
asked []compose.CredentialKey
|
||||
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
|
||||
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
|
||||
builds map[string]*delivery.BuilderBuild
|
||||
builds map[string]*builds.BuilderBuild
|
||||
archives map[string][]byte
|
||||
fetched []string
|
||||
served map[string]map[string]any
|
||||
@@ -51,7 +58,7 @@ type bus struct {
|
||||
held bool
|
||||
}
|
||||
|
||||
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
func (b *bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
b.buildsRead = append(b.buildsRead, module)
|
||||
return b.builds[module+" "+fp], nil
|
||||
}
|
||||
@@ -76,13 +83,17 @@ func (b *bus) Served(_ context.Context, node, module string, keys []string) (map
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.placements, nil
|
||||
func (b *bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
|
||||
return b.assignments, nil
|
||||
}
|
||||
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
|
||||
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
|
||||
func (b *bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
|
||||
return b.records, nil
|
||||
}
|
||||
func (b *bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
|
||||
return b.modules, nil
|
||||
}
|
||||
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
|
||||
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
|
||||
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
|
||||
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
|
||||
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.manifests[digest]
|
||||
if !ok {
|
||||
@@ -98,7 +109,7 @@ func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider
|
||||
b.asked = append(b.asked, asConsumer...)
|
||||
for _, k := range asConsumer {
|
||||
placed := false
|
||||
for _, m := range b.placements[node].Modules {
|
||||
for _, m := range b.assignments[node].Modules {
|
||||
placed = placed || m.Module == k.ConsumerModule
|
||||
}
|
||||
if !placed {
|
||||
@@ -132,7 +143,7 @@ func publicKey(t *testing.T) string {
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
|
||||
// Issue 1126 (S3, S4, S5): the bus's assignment, node records, manifests and config values fill the same compose.Input the
|
||||
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
|
||||
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
|
||||
func TestInput(t *testing.T) {
|
||||
@@ -154,35 +165,35 @@ func TestInput(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := delivery.ManifestDigest(xterm)
|
||||
digest := meshcatalogclient.ManifestDigest(xterm)
|
||||
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
|
||||
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
|
||||
// layers, not mesh-config, so the assignment carries them (MeshValues, Values) and mesh-config's are not read for it.
|
||||
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
|
||||
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
|
||||
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
|
||||
mediaDigest := delivery.ManifestDigest(media)
|
||||
mediaDigest := meshcatalogclient.ManifestDigest(media)
|
||||
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
|
||||
caps := []string{"container-runtime", "systemd", "wireguard"}
|
||||
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
tunnel := &meshmoduleassigner.FoundTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []meshmoduleassigner.FoundTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
mk := func() *bus {
|
||||
return &bus{
|
||||
placements: map[string]delivery.Placement{
|
||||
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
|
||||
assignments: map[string]meshmoduleassigner.Assignments{
|
||||
"anchor": {Node: "anchor", Decided: meshmoduleassigner.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
|
||||
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
|
||||
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
|
||||
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []meshmoduleassigner.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
|
||||
"laptop": {Node: "laptop", Decided: meshmoduleassigner.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int64{"80": 8080}},
|
||||
{Module: "media", HasMeshWide: true, MeshWideValues: map[string]any{"name": "mesh"}, HasLayer: true,
|
||||
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
|
||||
},
|
||||
records: map[string]delivery.NodeRecord{
|
||||
records: map[string]nodeengine.NodeRecord{
|
||||
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
|
||||
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
|
||||
},
|
||||
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
|
||||
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
|
||||
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
|
||||
"laptop.media": {"lines": "not mesh-config's"}},
|
||||
@@ -278,37 +289,37 @@ func TestInput(t *testing.T) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// Issue 1126: a candidate assignment equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
|
||||
// here. Placed puts the same placement over the store's input unchanged.
|
||||
running := make([]delivery.Composed, 0, len(fromBus))
|
||||
// here. Placed puts the same assignment over the store's input unchanged.
|
||||
running := make([]compositions.Composed, 0, len(fromBus))
|
||||
for _, c := range fromBus {
|
||||
running = append(running, c)
|
||||
}
|
||||
laptop := mk().placements["laptop"]
|
||||
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
|
||||
laptop := mk().assignments["laptop"]
|
||||
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the placement: %+v", c)
|
||||
if c := compositions.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the assignment: %+v", c)
|
||||
}
|
||||
moved := mk().placements["anchor"]
|
||||
moved := mk().assignments["anchor"]
|
||||
moved.Decided.Endpoint = "anchor.example:51821"
|
||||
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
if c := compositions.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
if c := compositions.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
t.Errorf("nothing runs: %+v", c)
|
||||
}
|
||||
|
||||
@@ -324,16 +335,16 @@ func TestInput(t *testing.T) {
|
||||
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
|
||||
one := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: m})
|
||||
}
|
||||
return &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
|
||||
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
|
||||
return &bus{assignments: map[string]meshmoduleassigner.Assignments{"n1": p},
|
||||
records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{"keeper": {Manifest: meshcatalogclient.ManifestDigest(keeper)}, "lemurs": {Manifest: meshcatalogclient.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: meshcatalogclient.ManifestDigest(objects)}, "files": {Manifest: meshcatalogclient.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{meshcatalogclient.ManifestDigest(keeper): keeper, meshcatalogclient.ManifestDigest(lemurs): lemurs,
|
||||
meshcatalogclient.ManifestDigest(objects): objects, meshcatalogclient.ManifestDigest(files): files},
|
||||
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
|
||||
}
|
||||
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
|
||||
@@ -345,7 +356,7 @@ func TestInput(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []delivery.Composed
|
||||
var runsNow []compositions.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
@@ -356,12 +367,12 @@ func TestInput(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumerRuns []delivery.Composed
|
||||
var consumerRuns []compositions.Composed
|
||||
for _, c := range objectsRun {
|
||||
consumerRuns = append(consumerRuns, c)
|
||||
}
|
||||
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
|
||||
var out []delivery.Composed
|
||||
withoutOwn := func(cs []compositions.Composed) []compositions.Composed {
|
||||
var out []compositions.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
out = append(out, c)
|
||||
@@ -374,16 +385,16 @@ func TestInput(t *testing.T) {
|
||||
// node's own, each in its place, its module taken off; the rest split by module.
|
||||
tunnelled := func() *bus {
|
||||
b := one("keeper", "lemurs", overlay.Name)
|
||||
p := b.placements["n1"]
|
||||
p := b.assignments["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
return b
|
||||
}
|
||||
tunnelledAs := func() map[string]*delivery.Placement {
|
||||
p := tunnelled().placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
tunnelledAs := func() map[string]*meshmoduleassigner.Assignments {
|
||||
p := tunnelled().assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
folded := func() []delivery.Composed {
|
||||
folded := func() []compositions.Composed {
|
||||
b := tunnelled()
|
||||
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
@@ -416,40 +427,40 @@ func TestInput(t *testing.T) {
|
||||
raw, _ := json.Marshal(r)
|
||||
resources = append(resources, raw)
|
||||
}
|
||||
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
|
||||
split, err := compositions.Split("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, compositions.NodeOwn{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []delivery.Composed
|
||||
var out []compositions.Composed
|
||||
for _, c := range split {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
|
||||
if got := InOwn(folded, tunnelled().assignments["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
|
||||
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
|
||||
}
|
||||
placedAs := func(modules ...string) map[string]*delivery.Placement {
|
||||
p := one(modules...).placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
placedAs := func(modules ...string) map[string]*meshmoduleassigner.Assignments {
|
||||
p := one(modules...).assignments["n1"]
|
||||
return map[string]*meshmoduleassigner.Assignments{"n1": &p}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
b *bus
|
||||
given map[string]*delivery.Placement
|
||||
running []delivery.Composed
|
||||
given map[string]*meshmoduleassigner.Assignments
|
||||
running []compositions.Composed
|
||||
verdict string
|
||||
check func(delivery.CandidateAnswer) bool
|
||||
check func(nodeengine.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, nodeengine.CandidateAnswerVerdictSame,
|
||||
func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
|
||||
}},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), nodeengine.CandidateAnswerVerdictSame,
|
||||
func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, nodeengine.CandidateAnswerVerdictDiffers,
|
||||
func(a nodeengine.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
|
||||
nil, nodeengine.CandidateAnswerVerdictDiffers, func(a nodeengine.CandidateAnswer) bool {
|
||||
for _, m := range a.Added {
|
||||
if m.Module == "keeper" {
|
||||
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
|
||||
@@ -458,62 +469,62 @@ func TestInput(t *testing.T) {
|
||||
return false
|
||||
}},
|
||||
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
|
||||
}},
|
||||
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
|
||||
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, nodeengine.CandidateAnswerVerdictDiffers,
|
||||
func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
|
||||
}},
|
||||
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
|
||||
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
runsNow, nodeengine.CandidateAnswerVerdictRefused, func(a nodeengine.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
|
||||
}},
|
||||
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
|
||||
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
"cannot compose": {one("keeper"), map[string]*meshmoduleassigner.Assignments{"n1": {Node: "n2"}}, runsNow, nodeengine.CandidateAnswerVerdictRefused,
|
||||
func(a nodeengine.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
|
||||
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
|
||||
// neither is added.
|
||||
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
|
||||
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
folded, nodeengine.CandidateAnswerVerdictSame, func(a nodeengine.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
} {
|
||||
fps := oneFps
|
||||
if name == "mesh-wireguard and an old-form module, folded" {
|
||||
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
|
||||
}
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Assignments: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
if raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Candidates: map[string]nodeengine.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
|
||||
}
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own placement changes the input: %v", err)
|
||||
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own assignment changes the input: %v", err)
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
|
||||
if got, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no assignment: %+v, %v", got.Nodes[1], err)
|
||||
}
|
||||
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
|
||||
if _, err := Placed(want, map[string]*meshmoduleassigner.Assignments{"stranger": {Node: "stranger"}}); err == nil {
|
||||
t.Error("Placed a node the input holds no node for")
|
||||
}
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given placement naming another node was taken")
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Assignments: map[string]*meshmoduleassigner.Assignments{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given assignment naming another node was taken")
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// placement the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().placements {
|
||||
// assignment the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().assignments {
|
||||
got, err := Export(want, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
|
||||
pj, _ := delivery.EncodePlacement(placed)
|
||||
if gj, _ := meshmoduleassignerclient.EncodeAssignments(got); !meshmoduleassignerclient.SameAssignments(got, placed) {
|
||||
pj, _ := meshmoduleassignerclient.EncodeAssignments(placed)
|
||||
t.Errorf("%s exported as %s, want %s", node, gj, pj)
|
||||
}
|
||||
}
|
||||
@@ -549,8 +560,8 @@ func TestInput(t *testing.T) {
|
||||
kept.manifests["old"] = oldXterm
|
||||
oldFP := "src2:" + strings.Repeat("9", 64)
|
||||
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
|
||||
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
|
||||
kept.builds = map[string]*builds.BuilderBuild{"xterm " + oldFP: {State: builds.BuildDone, Manifest: "old",
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
|
||||
parsedOld, err := catalogue.Decode(oldXterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -577,8 +588,8 @@ func TestInput(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
store := []byte(`{"module":"store","version":"1",` +
|
||||
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
|
||||
texts := map[string]string{}
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
@@ -602,35 +613,35 @@ func TestInput(t *testing.T) {
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archiveDigest := delivery.ManifestDigest(packed.Bytes())
|
||||
archiveDigest := meshcatalogclient.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64)}
|
||||
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
resticArtifacts := []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
|
||||
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
|
||||
clientArtifacts := []builds.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
|
||||
movedMesh := func(built bool) *bus {
|
||||
b := one()
|
||||
p := b.placements["n1"]
|
||||
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
|
||||
p := b.assignments["n1"]
|
||||
p.Modules = []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
|
||||
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
|
||||
b.placements["n1"] = p
|
||||
b.assignments["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
|
||||
b.records["n1"] = rec
|
||||
b.modules = map[string]delivery.Module{}
|
||||
b.modules = map[string]meshcatalog.ModulesByModuleValue{}
|
||||
b.manifests = map[string][]byte{}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
|
||||
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[module] = meshcatalog.ModulesByModuleValue{Fp2: movedFps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
}
|
||||
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
if built {
|
||||
b.builds = map[string]*delivery.BuilderBuild{
|
||||
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
|
||||
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
|
||||
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
b.builds = map[string]*builds.BuilderBuild{
|
||||
"restic " + movedFps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
|
||||
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
|
||||
"store " + movedFps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
|
||||
}
|
||||
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
|
||||
}
|
||||
@@ -657,13 +668,13 @@ func TestInput(t *testing.T) {
|
||||
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
|
||||
t.Errorf("the templates archive was read at %v", builtBus.fetched)
|
||||
}
|
||||
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
|
||||
resticKey, _ := compositions.Key("n1", "restic", movedFps["restic"])
|
||||
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
|
||||
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
|
||||
}
|
||||
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
|
||||
unreferenced := movedMesh(true)
|
||||
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
|
||||
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []builds.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
|
||||
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
|
||||
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
|
||||
}
|
||||
@@ -760,11 +771,11 @@ func TestInput(t *testing.T) {
|
||||
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
||||
withMailer := func(set bool) *bus {
|
||||
b := mk()
|
||||
p := b.placements["anchor"]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
|
||||
b.placements["anchor"] = p
|
||||
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
|
||||
b.manifests[delivery.ManifestDigest(mailer)] = mailer
|
||||
p := b.assignments["anchor"]
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "mailer"})
|
||||
b.assignments["anchor"] = p
|
||||
b.modules["mailer"] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest(mailer)}
|
||||
b.manifests[meshcatalogclient.ManifestDigest(mailer)] = mailer
|
||||
if set {
|
||||
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
|
||||
}
|
||||
@@ -787,7 +798,7 @@ func TestInput(t *testing.T) {
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"no assignment": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no assignment"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
|
||||
} {
|
||||
@@ -829,34 +840,34 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
}
|
||||
out[state][key] = raw
|
||||
}
|
||||
placements, _ := b.Placements(ctx)
|
||||
for _, p := range placements {
|
||||
values, err := delivery.PlacementValues(p)
|
||||
assignments, _ := b.Assignments(ctx)
|
||||
for _, p := range assignments {
|
||||
values, err := meshmoduleassignerclient.AssignmentsValues(p)
|
||||
for k, raw := range values {
|
||||
put(PlacementRead, k, raw, err)
|
||||
put(AssignmentsRead, k, raw, err)
|
||||
}
|
||||
}
|
||||
records, _ := b.Records(ctx)
|
||||
for node, r := range records {
|
||||
k, _ := delivery.HaveNodeKey(node)
|
||||
k, _ := nodeengineclient.HaveNodeKey(node)
|
||||
rec := r
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
|
||||
put(HaveRead, k, raw, err)
|
||||
}
|
||||
modules, _ := b.Modules(ctx)
|
||||
for name, m := range modules {
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
raw, err := meshcatalogclient.EncodeModule(m)
|
||||
put(ModulesRead, name, raw, err)
|
||||
var mf struct{ Module string }
|
||||
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
|
||||
k, _ := delivery.ManifestsKey(m.Manifest)
|
||||
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
|
||||
k, _ := meshcatalogclient.ManifestsKey(m.Manifest)
|
||||
raw, err = meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: mf.Module, Manifest: string(b.manifests[m.Manifest])})
|
||||
put(ManifestsRead, k, raw, err)
|
||||
}
|
||||
for key, build := range b.builds {
|
||||
module, fp, _ := strings.Cut(key, " ")
|
||||
k, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
k, _ := builds.BuilderBuildKey(module, fp)
|
||||
raw, err := builds.EncodeBuilderBuild(*build)
|
||||
put(BuildsRead, k, raw, err)
|
||||
}
|
||||
for nm, values := range b.values {
|
||||
@@ -874,7 +885,7 @@ func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
// composed as what runs shows, said, by what runs and never by a name; a manifest that says it, or nothing running so, marks
|
||||
// nothing.
|
||||
func whatRunsIsKeptWhileAManifestDoesNotSayIt(t *testing.T) {
|
||||
running := []delivery.Composed{
|
||||
running := []compositions.Composed{
|
||||
{Node: "a", Module: "runner-x", Resources: []json.RawMessage{json.RawMessage(`{"id":"runner-x.runtime","type":"process"}`)}},
|
||||
{Node: "a", Module: "prov-y", Resources: []json.RawMessage{json.RawMessage(`{"id":"prov-y.need-broker","type":"file","vault-sealed":{"box":"b"}}`)}},
|
||||
{Node: "a", Module: "app", Resources: []json.RawMessage{json.RawMessage(`{"id":"app.need-broker","type":"file"}`)}},
|
||||
|
||||
@@ -12,11 +12,14 @@ import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
@@ -24,26 +27,30 @@ var testdata embed.FS
|
||||
|
||||
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
|
||||
type Bus struct {
|
||||
Placement map[string]delivery.Placement
|
||||
Record map[string]delivery.NodeRecord
|
||||
Module map[string]delivery.Module
|
||||
ByNode map[string]meshmoduleassigner.Assignments
|
||||
Record map[string]nodeengine.NodeRecord
|
||||
Module map[string]meshcatalog.ModulesByModuleValue
|
||||
Manifests map[string][]byte
|
||||
Value map[string]map[string]any // by node.module
|
||||
Own []compose.OwnSecretRow
|
||||
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
|
||||
Archives map[string][]byte // by digest
|
||||
Serves map[string]map[string]any // another node's values, by node.module
|
||||
Builds map[string]*builds.BuilderBuild // by "<module> <fp>"
|
||||
Archives map[string][]byte // by digest
|
||||
Serves map[string]map[string]any // another node's values, by node.module
|
||||
Held bool
|
||||
}
|
||||
|
||||
var _ frombus.Reader = (*Bus)(nil)
|
||||
|
||||
func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
return b.Placement, nil
|
||||
func (b *Bus) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
|
||||
return b.ByNode, nil
|
||||
}
|
||||
func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
|
||||
func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
|
||||
func (b *Bus) VaultAuthority(context.Context) string { return "" }
|
||||
func (b *Bus) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
|
||||
return b.Record, nil
|
||||
}
|
||||
func (b *Bus) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
|
||||
return b.Module, nil
|
||||
}
|
||||
func (b *Bus) VaultAuthority(context.Context) string { return "" }
|
||||
|
||||
func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
raw, ok := b.Manifests[digest]
|
||||
@@ -73,7 +80,7 @@ func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []
|
||||
return out, issued, nil
|
||||
}
|
||||
|
||||
func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
func (b *Bus) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
return b.Builds[module+" "+fp], nil
|
||||
}
|
||||
|
||||
@@ -119,8 +126,8 @@ func Provisioned() (*Bus, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
store := []byte(`{"module":"store","version":"1",` +
|
||||
`"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`)
|
||||
postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
|
||||
`"serves":{"postgres-database":{"port":5432}}}`)
|
||||
|
||||
@@ -145,33 +152,33 @@ func Provisioned() (*Bus, error) {
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
archive := delivery.ManifestDigest(packed.Bytes())
|
||||
archive := meshcatalogclient.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
fps := FPs()
|
||||
b := &Bus{
|
||||
Placement: map[string]delivery.Placement{Node: {Node: Node,
|
||||
Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
|
||||
Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
|
||||
Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
|
||||
ByNode: map[string]meshmoduleassigner.Assignments{Node: {Node: Node,
|
||||
Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
|
||||
Modules: []meshmoduleassigner.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
|
||||
Record: map[string]nodeengine.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
|
||||
SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
|
||||
Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
|
||||
Module: map[string]meshcatalog.ModulesByModuleValue{}, Manifests: map[string][]byte{},
|
||||
Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
|
||||
Builds: map[string]*delivery.BuilderBuild{
|
||||
"restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
|
||||
Builds: map[string]*builds.BuilderBuild{
|
||||
"restic " + fps["restic"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(resticRaw), Artifacts: []builds.BuiltArtifact{
|
||||
{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
|
||||
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
|
||||
"mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(clientRaw),
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "client", Kind: "bundle",
|
||||
Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
|
||||
"store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
"postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
|
||||
"store " + fps["store"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(store)},
|
||||
"postgres " + fps["postgres"]: {State: builds.BuildDone, Manifest: meshcatalogclient.ManifestDigest(postgres)},
|
||||
},
|
||||
Archives: map[string][]byte{archive: packed.Bytes()},
|
||||
Held: true,
|
||||
}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
|
||||
b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.Manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.Module[module] = meshcatalog.ModulesByModuleValue{Fp2: fps[module], Manifest: meshcatalogclient.ManifestDigest(raw)}
|
||||
b.Manifests[meshcatalogclient.ManifestDigest(raw)] = raw
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
@@ -2,13 +2,22 @@ package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose/frombus"
|
||||
"github.com/novox/mesh-host/internal/compose/fromruntime"
|
||||
)
|
||||
@@ -61,22 +70,22 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
}
|
||||
if row.bad != "" {
|
||||
if !row.elsewhere {
|
||||
p := b.Placement[Node]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
|
||||
b.Placement[Node] = p
|
||||
p := b.ByNode[Node]
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: "backup"})
|
||||
b.ByNode[Node] = p
|
||||
}
|
||||
digest := delivery.ManifestDigest([]byte(row.bad))
|
||||
digest := meshcatalogclient.ManifestDigest([]byte(row.bad))
|
||||
b.Manifests[digest] = []byte(row.bad)
|
||||
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
|
||||
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest}
|
||||
if row.refused != "" {
|
||||
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
|
||||
b.Module["backup"] = meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Verdict: meshcatalogclient.VerdictRefused,
|
||||
Problems: []string{row.refused}}
|
||||
}
|
||||
if row.built != "" {
|
||||
builtDigest := delivery.ManifestDigest([]byte(row.built))
|
||||
builtDigest := meshcatalogclient.ManifestDigest([]byte(row.built))
|
||||
b.Manifests[builtDigest] = []byte(row.built)
|
||||
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
b.Builds["backup "+fp] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if row.dataOnly {
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
@@ -103,7 +112,7 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: the node's composition: %v", row.name, err)
|
||||
}
|
||||
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
|
||||
if key, _ := engcomp.Key(Node, "restic", fps["restic"]); out[key].Module != "restic" {
|
||||
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
|
||||
@@ -157,19 +166,19 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
byKey := map[string]int{}
|
||||
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
byKey[state+" watched"]++
|
||||
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
return handle(stdio.StateChange{Key: Node + ".node", Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
|
||||
w := fromruntime.Watched{WatchedStates: ws}
|
||||
for range 2 {
|
||||
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
|
||||
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
|
||||
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
|
||||
t.Error("the watched reader does not answer the placement from its copy")
|
||||
if _, found, _ := w.Get(frombus.AssignmentsRead, Node+".node"); !found {
|
||||
t.Error("the watched reader does not answer the assignment from its copy")
|
||||
}
|
||||
}
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
|
||||
byKey[frombus.PlacementRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.AssignmentsRead] != 0 ||
|
||||
byKey[frombus.AssignmentsRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the assignment watched once", byKey)
|
||||
}
|
||||
fetched := 0
|
||||
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
|
||||
@@ -204,6 +213,92 @@ func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
|
||||
// Issue 774 (ace, 2026-11-04 01:29): the provisioner client held as its stub (its build not done, so the node keeps
|
||||
// running what it runs) keeps the account the node's own composition names, and restic stays handed to it with its
|
||||
// plan; without it every filled write was refused and no module was handed.
|
||||
if b, err = Provisioned(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fps, ownKey := FPs(), ""
|
||||
ownKey, _ = engcomp.NodeKey(Node)
|
||||
resticKey, _ := engcomp.Key(Node, "restic", fps["restic"])
|
||||
before, _, _, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
account := before[ownKey].Own.Provisioner
|
||||
if err != nil || account == "" || len(before[resticKey].Filled) == 0 {
|
||||
t.Fatalf("the node composes with its client: account %q, restic's filled files %v: %v", account, before[resticKey].Filled, err)
|
||||
}
|
||||
var running []engcomp.Composed
|
||||
for _, c := range before {
|
||||
running = append(running, c)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
own, placed bool // the account read from the running own composition, else the client's running process; the client placed
|
||||
userless bool // the client placed and not held, its process naming no user: it composes no account
|
||||
// filledHeld holds every module with a filled file (restic, its build not done) and not the client: the node still
|
||||
// names the account (issue 774, ace 2026-11-04: its build modules and ssh-client held, no filled file, no account)
|
||||
filledHeld bool
|
||||
}{{true, true, false, false}, {false, true, false, false}, {true, false, false, false}, {false, false, false, false},
|
||||
{true, true, true, false}, {true, true, false, true}} {
|
||||
own := row.own
|
||||
runs := running
|
||||
if !own { // what runs names no account of its own: the client's running process says it
|
||||
runs = nil
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Own.Provisioner = ""
|
||||
}
|
||||
runs = append(runs, c)
|
||||
}
|
||||
}
|
||||
bb, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := account
|
||||
if row.userless {
|
||||
// placed and not held, composing no account of its own: no stale account is kept from what runs
|
||||
mod := bb.Module["mesh-provisioner-client"]
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(bb.Manifests[mod.Manifest], &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m["resources"].([]any) {
|
||||
delete(r.(map[string]any), "user")
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
mod.Manifest = meshcatalogclient.ManifestDigest(raw)
|
||||
bb.Module["mesh-provisioner-client"], bb.Manifests[mod.Manifest] = mod, raw
|
||||
built := *bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]]
|
||||
built.Manifest = mod.Manifest
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &built
|
||||
want = ""
|
||||
} else if row.filledHeld {
|
||||
bb.Builds["restic "+fps["restic"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
|
||||
} else if row.placed {
|
||||
// held: its build not done while it runs
|
||||
bb.Builds["mesh-provisioner-client "+fps["mesh-provisioner-client"]] = &builds.BuilderBuild{State: builds.BuildBuilding}
|
||||
} else {
|
||||
// taken out of the node's assignment while what runs still names it: the account goes with it, never kept stale
|
||||
p := bb.ByNode[Node]
|
||||
p.Modules = slices.DeleteFunc(slices.Clone(p.Modules), func(m meshmoduleassigner.PlacedModule) bool { return m.Module == "mesh-provisioner-client" })
|
||||
bb.ByNode[Node] = p
|
||||
want = ""
|
||||
}
|
||||
after, _, held, err := frombus.Compose(context.Background(), bb, frombus.Mesh{}, frombus.Scope{Node: Node, Running: runs}, fps)
|
||||
if _, isHeld := held["mesh-provisioner-client"]; err != nil || isHeld != (row.placed && !row.userless && !row.filledHeld) {
|
||||
t.Fatalf("%+v: held %v: %v", row, held, err)
|
||||
}
|
||||
if got := after[ownKey].Own.Provisioner; got != want {
|
||||
t.Errorf("%+v: the node's own names %q, want %q", row, got, want)
|
||||
}
|
||||
if _, resticHeld := held["restic"]; row.filledHeld && !resticHeld {
|
||||
t.Errorf("%+v: restic held %v", row, resticHeld)
|
||||
}
|
||||
if row.placed && !row.userless && !row.filledHeld && !reflect.DeepEqual(after[resticKey].Filled, before[resticKey].Filled) {
|
||||
t.Errorf("%+v: restic's filled files %v, want %v", row, after[resticKey].Filled, before[resticKey].Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
@@ -219,28 +314,33 @@ func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for node, p := range b.Placement {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(frombus.PlacementRead, "placement."+node, raw, err)
|
||||
for _, p := range b.ByNode {
|
||||
vals, err := meshmoduleassignerclient.AssignmentsValues(p)
|
||||
if err != nil {
|
||||
put(frombus.AssignmentsRead, p.Node+".node", nil, err)
|
||||
}
|
||||
for key, raw := range vals {
|
||||
put(frombus.AssignmentsRead, key, raw, nil)
|
||||
}
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
raw, err := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := delivery.ModulesKey(module)
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
key, _ := meshcatalogclient.ModulesKey(module)
|
||||
raw, err := meshcatalogclient.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := delivery.ManifestsKey(digest)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
|
||||
key, _ := meshcatalogclient.ManifestsKey(digest)
|
||||
raw, err := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "m", Manifest: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
key, _ := builds.BuilderBuildKey(module, fp)
|
||||
raw, err := builds.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
)
|
||||
|
||||
// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus
|
||||
// (novox/hq ADR 0635 §1); catalogue compiles none.
|
||||
func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) }
|
||||
@@ -5,29 +5,33 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
|
||||
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
|
||||
p := meshmoduleassigner.Assignments{Node: "n1", Decided: meshmoduleassigner.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
b := &bus{records: map[string]nodeengine.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]meshcatalog.ModulesByModuleValue{}, manifests: map[string][]byte{}, builds: map[string]*builds.BuilderBuild{}}
|
||||
fps := map[string]string{}
|
||||
for i, raw := range manifests {
|
||||
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
|
||||
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
|
||||
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
|
||||
p.Modules = append(p.Modules, meshmoduleassigner.PlacedModule{Module: module})
|
||||
b.modules[module] = meshcatalog.ModulesByModuleValue{Manifest: meshcatalogclient.ManifestDigest([]byte(raw))}
|
||||
b.manifests[meshcatalogclient.ManifestDigest([]byte(raw))] = []byte(raw)
|
||||
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
|
||||
}
|
||||
b.placements = map[string]delivery.Placement{"n1": p}
|
||||
b.assignments = map[string]meshmoduleassigner.Assignments{"n1": p}
|
||||
return b, fps
|
||||
}
|
||||
|
||||
@@ -48,7 +52,7 @@ func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
|
||||
// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
|
||||
func refuse(b *bus, module string, problems ...string) {
|
||||
m := b.modules[module]
|
||||
m.Verdict, m.Problems = delivery.VerdictRefused, problems
|
||||
m.Verdict, m.Problems = meshcatalogclient.VerdictRefused, problems
|
||||
b.modules[module] = m
|
||||
}
|
||||
|
||||
@@ -119,10 +123,10 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
tools := `{"module":"node-tools","version":"1","tool-runner":true,"own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
|
||||
running := []delivery.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{
|
||||
running := []engcomp.Composed{{Node: "n1", Module: "node-tools", FP: old, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
|
||||
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
@@ -141,14 +145,14 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
b.held = true // every grant held, so a contribution's credential stands in for nothing
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
mod := b.modules["node-tools"]
|
||||
mod.FP2 = next
|
||||
mod.Fp2 = next
|
||||
b.modules["node-tools"] = mod
|
||||
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
b.builds["node-tools "+old] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
|
||||
b.builds["node-tools "+next] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
if row.done {
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
|
||||
b.builds["node-tools "+next] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
|
||||
}
|
||||
fps["node-tools"] = row.want
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
@@ -156,14 +160,14 @@ func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
t.Errorf("%s: the node is refused: %v", row.name, err)
|
||||
continue
|
||||
}
|
||||
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
|
||||
if lampKey, _ := engcomp.Key("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
|
||||
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
|
||||
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
|
||||
}
|
||||
if row.digest != "" {
|
||||
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
|
||||
key, _ := engcomp.Key("n1", "node-tools", row.want)
|
||||
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
|
||||
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
|
||||
}
|
||||
@@ -215,7 +219,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
|
||||
}
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"claims":[{"name":"container-image-registry","scope":"mesh"}],"listens":[{"name":"registry","port":5100,"from":"mesh"}],` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
@@ -251,12 +255,12 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
name = "runtime"
|
||||
}
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fps[m]
|
||||
mod.Fp2 = fps[m]
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
|
||||
b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: builds.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []builds.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
|
||||
if m == "node-tools" && row.building {
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
b.builds[m+" "+fps[m]] = &builds.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
}
|
||||
}
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
@@ -267,7 +271,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
|
||||
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
|
||||
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
|
||||
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
|
||||
storeKey, _ := engcomp.Key("n1", "artifacts", fps["artifacts"])
|
||||
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
|
||||
// not issued is composed without that contribution, never with "foreseen: made by the next send".
|
||||
for key, c := range out {
|
||||
@@ -284,7 +288,7 @@ func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
|
||||
key, _ := delivery.ComposedKey("n1", m, fps[m])
|
||||
key, _ := engcomp.Key("n1", m, fps[m])
|
||||
_, composed := out[key]
|
||||
why, isHeld := held[m]
|
||||
want, wantHeld := row.held[m]
|
||||
|
||||
@@ -3,13 +3,13 @@ package frombus
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// keepWhatRuns marks, in in, each module placed on node that what runs shows is the tool runner or carries the core's
|
||||
// credentials, where its manifest does not say so; it returns, sorted by module, what it marked, in words. The tool runner is
|
||||
// marked only where no placed module's manifest says it is one.
|
||||
func keepWhatRuns(in *compose.Input, node string, running []delivery.Composed) []KeptAsRuns {
|
||||
func keepWhatRuns(in *compose.Input, node string, running []engcomp.Composed) []KeptAsRuns {
|
||||
placed := assignedOn(*in, node)
|
||||
if len(placed) == 0 || len(running) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/seatlist/seatlisttest"
|
||||
)
|
||||
|
||||
// The seats the tests judge against are given to catalogue as the node-engine gives the ones it read from the bus
|
||||
// (novox/hq ADR 0635 §1); catalogue compiles none.
|
||||
func init() { catalogue.UseSeats(catalogue.SeatsFromDefinitions(seatlisttest.Definitions())) }
|
||||
@@ -2,7 +2,7 @@ package frombus
|
||||
|
||||
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
|
||||
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
|
||||
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
|
||||
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: assignment, each
|
||||
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
|
||||
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
|
||||
// runtime states and an HTTP fetch are compose/fromruntime's.
|
||||
@@ -15,23 +15,32 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"github.com/novox/mesh-host/internal/buckets"
|
||||
"github.com/novox/mesh-host/internal/compose"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
)
|
||||
|
||||
// The states a module names in its manifest's `reads` to compose through StatesReader.
|
||||
const (
|
||||
PlacementRead = delivery.PlacementRead
|
||||
ModulesRead = delivery.IndexerModule + ".modules"
|
||||
ManifestsRead = delivery.IndexerModule + ".manifests"
|
||||
BuildsRead = delivery.BuilderModule + ".builds"
|
||||
HaveRead = "node-engine.have"
|
||||
ResolvedRead = "mesh-config.resolved"
|
||||
// The holders' names are kept here, in the engine's own code, as the names of the states a manifest's reads use
|
||||
// (ADR 0634 §1, finding F4); a seat's contract names a state locally, not the module that holds it.
|
||||
AssignmentsRead = buckets.AssignerHolder + ".assignment"
|
||||
ModulesRead = buckets.CatalogHolder + ".modules"
|
||||
ManifestsRead = buckets.CatalogHolder + ".manifests"
|
||||
BuildsRead = builds.BuilderModule + ".builds"
|
||||
HaveRead = "node-engine.have"
|
||||
ResolvedRead = "mesh-config.resolved"
|
||||
)
|
||||
|
||||
// StatesReads are those reads, in a manifest's order.
|
||||
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
|
||||
var StatesReads = []string{AssignmentsRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
|
||||
|
||||
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
|
||||
type States interface {
|
||||
@@ -76,19 +85,19 @@ func (r statesReader) each(read string, visit func(key string, value []byte)) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
keys := delivery.NewPlacementKeys()
|
||||
err := r.each(PlacementRead, func(k string, v []byte) {
|
||||
func (r statesReader) Assignments(context.Context) (map[string]meshmoduleassigner.Assignments, error) {
|
||||
keys := meshmoduleassignerclient.NewKeys()
|
||||
err := r.each(AssignmentsRead, func(k string, v []byte) {
|
||||
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
|
||||
})
|
||||
return keys.All(), err
|
||||
}
|
||||
|
||||
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
|
||||
out := map[string]delivery.NodeRecord{}
|
||||
func (r statesReader) Records(context.Context) (map[string]nodeengine.NodeRecord, error) {
|
||||
out := map[string]nodeengine.NodeRecord{}
|
||||
err := r.each(HaveRead, func(k string, v []byte) {
|
||||
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
|
||||
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
|
||||
if node, _, isNode, err := nodeengineclient.ParseHaveKey(k); err == nil && isNode {
|
||||
if h, err := nodeengineclient.DecodeHaveNode(v); err == nil && h.Record != nil {
|
||||
out[node] = *h.Record
|
||||
}
|
||||
}
|
||||
@@ -96,11 +105,11 @@ func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
|
||||
out := map[string]delivery.Module{}
|
||||
func (r statesReader) Modules(context.Context) (map[string]meshcatalog.ModulesByModuleValue, error) {
|
||||
out := map[string]meshcatalog.ModulesByModuleValue{}
|
||||
err := r.each(ModulesRead, func(k string, v []byte) {
|
||||
if module, err := delivery.ParseModulesKey(k); err == nil {
|
||||
if m, err := delivery.DecodeModule(v); err == nil {
|
||||
if module, err := meshcatalogclient.ParseModulesKey(k); err == nil {
|
||||
if m, err := meshcatalogclient.DecodeModule(v); err == nil {
|
||||
out[module] = m
|
||||
}
|
||||
}
|
||||
@@ -109,7 +118,7 @@ func (r statesReader) Modules(context.Context) (map[string]delivery.Module, erro
|
||||
}
|
||||
|
||||
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
key, err := meshcatalogclient.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,11 +129,11 @@ func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error)
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
|
||||
}
|
||||
m, err := delivery.DecodeManifest(v)
|
||||
m, err := meshcatalogclient.DecodeManifest(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(m.JSON), nil
|
||||
return []byte(m.Manifest), nil
|
||||
}
|
||||
|
||||
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
@@ -153,8 +162,8 @@ func (statesReader) Credentials(context.Context, string, []compose.CredentialKey
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
func (r statesReader) Build(_ context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
key, err := builds.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,7 +171,7 @@ func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.Bui
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(v)
|
||||
b, err := builds.DecodeBuilderBuild(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package frombus
|
||||
|
||||
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
|
||||
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
|
||||
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
|
||||
// placed module. Both are composed from one read of the bus (assignment, records, the indexer's modules and manifests,
|
||||
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
|
||||
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
|
||||
|
||||
@@ -12,11 +12,11 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
|
||||
@@ -75,13 +75,13 @@ func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newe
|
||||
|
||||
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
|
||||
// builds of the same moment.
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
type answer struct {
|
||||
b *delivery.BuilderBuild
|
||||
b *builds.BuilderBuild
|
||||
err error
|
||||
}
|
||||
read := map[string]answer{}
|
||||
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
return func(ctx context.Context, module, fp string) (*builds.BuilderBuild, error) {
|
||||
key := module + " " + fp
|
||||
if a, ok := read[key]; ok {
|
||||
return a.b, a.err
|
||||
@@ -101,7 +101,7 @@ type takenInKeeper interface {
|
||||
|
||||
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
|
||||
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
|
||||
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
|
||||
func takenInKey(module, fp, address string, b builds.BuilderBuild) (string, bool) {
|
||||
raw, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", false
|
||||
|
||||
@@ -34,7 +34,7 @@ func (States) Keys(state string) ([]string, error) { return stdio.State(state).K
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
|
||||
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
|
||||
// (assignment, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
|
||||
type Watched struct{ *stdio.WatchedStates }
|
||||
|
||||
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
|
||||
|
||||
@@ -19,11 +19,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
engcomp "github.com/novox/mesh-host/internal/compositions"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
|
||||
@@ -159,8 +159,9 @@ type Node struct {
|
||||
// Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
|
||||
Assigned []string
|
||||
// Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
|
||||
// secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
|
||||
// (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
|
||||
// secrets (catalogue.AssignmentHands of the plan in its assignment). Never the rule (resolution.Handing, issue 1966):
|
||||
// read only to say where the vault and this composition disagree (resolution.VaultDisagrees). Nil where the vault said
|
||||
// nothing of it, as for the controller.
|
||||
Handed map[string]bool
|
||||
// Capabilities are the ones present on it (inv.ProfileOf).
|
||||
Capabilities map[string]bool
|
||||
@@ -223,7 +224,12 @@ type Node struct {
|
||||
// there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds,
|
||||
// so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
|
||||
// after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
|
||||
RunsOwn *delivery.Composed
|
||||
RunsOwn *engcomp.Composed
|
||||
// RunningProvisioner is the account the provisioner's client runs as on this node now, read from what runs (its own
|
||||
// composition's provisioner account, else the user of the provisioner client's running process), empty when nothing
|
||||
// says it. A client held as its stub this pass composes no process, so the node keeps this account and its handing
|
||||
// (provisionerAccount; issue 774: ace, 2026-11-04 01:29, every filled write refused and postgres's consumers unhanded).
|
||||
RunningProvisioner string
|
||||
// Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when
|
||||
// nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what
|
||||
// Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs
|
||||
@@ -248,13 +254,13 @@ type ComposedHow struct {
|
||||
// Held is each module the read held, with why: composed as what runs of it, and the rest without it.
|
||||
Held map[string]string
|
||||
// Running is what runs of each held module, its own composition, by which it is carried as it runs.
|
||||
Running []delivery.Composed
|
||||
Running []engcomp.Composed
|
||||
// HeldBefore is each module the node holds composing without the change (the candidate's base), held by the read or
|
||||
// blamed later for a refusal: the change refuses none of them (issue 774, review round 1).
|
||||
HeldBefore map[string]string
|
||||
}
|
||||
|
||||
// Place is where a node is, as its placement says (inv.Overlay).
|
||||
// Place is where a node is, as its assignment says (inv.Overlay).
|
||||
type Place struct {
|
||||
Key string
|
||||
Endpoint string
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// What an input says of its held modules on the wire (issue 774, the group check of feat/l3-final 278aa5f0ccbe). A held
|
||||
// module stands in the input as its stub (Stub), and the stub's mark (catalogue.Manifest.Stubbed) is no field of a
|
||||
// manifest's JSON: an input a node-engine sent the check's sandbox lost it, so the sandbox read node-tools, held by its
|
||||
// manifest's verdict, as a tool runner with no bundle and refused every module of ace, g14 and shanks for "its build
|
||||
// produced 0 bundle(s)" instead of naming the verdict. So the input carries the mark itself, by module, for its catalogue
|
||||
// and for each node's kept manifests, and a reader marks each again. An input from a node-engine before the mark (no
|
||||
// Held) is read by its contract: a module Refused names stands in Catalogue as its stub.
|
||||
|
||||
// heldMarks are the modules an input holds as their stubs: those of its catalogue, and those each node keeps, by node.
|
||||
type heldMarks struct {
|
||||
Catalogue []string `json:",omitempty"`
|
||||
Kept map[string][]string `json:",omitempty"`
|
||||
}
|
||||
|
||||
// inputFields is Input without its JSON methods.
|
||||
type inputFields Input
|
||||
|
||||
// inputWire is an input as it crosses the bus: its fields, and the modules it holds as their stubs.
|
||||
type inputWire struct {
|
||||
inputFields
|
||||
Held *heldMarks `json:",omitempty"`
|
||||
}
|
||||
|
||||
// MarshalJSON writes the input with the modules it holds as their stubs named beside it (heldMarks).
|
||||
func (in Input) MarshalJSON() ([]byte, error) {
|
||||
w := inputWire{inputFields: inputFields(in)}
|
||||
marks := heldMarks{Catalogue: stubbedOf(in.Catalogue)}
|
||||
for _, n := range in.Nodes {
|
||||
if kept := stubbedOf(n.Kept); len(kept) > 0 {
|
||||
if marks.Kept == nil {
|
||||
marks.Kept = map[string][]string{}
|
||||
}
|
||||
marks.Kept[n.Name] = kept
|
||||
}
|
||||
}
|
||||
if len(marks.Catalogue) > 0 || len(marks.Kept) > 0 {
|
||||
w.Held = &marks
|
||||
}
|
||||
return json.Marshal(w)
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads an input and marks again each module it holds as its stub: those it names held, and, by the
|
||||
// input's contract, every module Refused names that its catalogue holds.
|
||||
func (in *Input) UnmarshalJSON(raw []byte) error {
|
||||
var w inputWire
|
||||
if err := json.Unmarshal(raw, &w); err != nil {
|
||||
return err
|
||||
}
|
||||
*in = Input(w.inputFields)
|
||||
held := map[string]bool{}
|
||||
for m := range in.Refused {
|
||||
held[m] = true
|
||||
}
|
||||
if w.Held != nil {
|
||||
for _, m := range w.Held.Catalogue {
|
||||
held[m] = true
|
||||
}
|
||||
}
|
||||
for m := range held {
|
||||
if man, ok := in.Catalogue[m]; ok {
|
||||
man.Stubbed = true
|
||||
in.Catalogue[m] = man
|
||||
}
|
||||
}
|
||||
if w.Held != nil {
|
||||
for i := range in.Nodes {
|
||||
for _, m := range w.Held.Kept[in.Nodes[i].Name] {
|
||||
if man, ok := in.Nodes[i].Kept[m]; ok {
|
||||
man.Stubbed = true
|
||||
in.Nodes[i].Kept[m] = man
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubbedOf is each module of manifests held as its stub, sorted.
|
||||
func stubbedOf(manifests map[string]catalogue.Manifest) []string {
|
||||
var out []string
|
||||
for name, m := range manifests {
|
||||
if m.Stubbed {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// The private network: who is on it, where, and what they are called. Ported from mesh-controller
|
||||
@@ -123,10 +123,10 @@ func (c *composer) network(on map[string]bool, refused map[string]string) (*over
|
||||
Site: p.Site, Hub: p.Hub, Address: p.Address,
|
||||
}
|
||||
if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
|
||||
// Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
|
||||
// Only an adopted node is told to take the found unit over. A hub whose assignment disagrees with the tunnel is
|
||||
// refused, not composed.
|
||||
if wrong := disagrees(p, t.Tunnel); wrong != "" {
|
||||
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
|
||||
return nil, fmt.Errorf("%s takes over the tunnel on %s and its assignment disagrees with it: %s. "+
|
||||
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
|
||||
"nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port)
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (c *composer) generators() (map[string]catalogue.Generator, error) {
|
||||
return map[string]catalogue.Generator{overlay.Name: net}, nil
|
||||
}
|
||||
|
||||
// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its
|
||||
// disagrees says how a node's assignment differs from the tunnel it takes over — its address not the tunnel's, its
|
||||
// endpoint not on the tunnel's port — or nothing when both agree.
|
||||
func disagrees(p Place, t Tunnel) string {
|
||||
var wrong []string
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in
|
||||
@@ -80,7 +80,7 @@ func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go
|
||||
// A takeover is composed only for a hub whose assignment agrees with the tunnel (mesh-controller network_test.go
|
||||
// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop
|
||||
// the found interface and raise the mesh's where no peer is listening.
|
||||
func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
|
||||
|
||||
@@ -115,6 +115,11 @@ func (d Declared) Body() ([]byte, error) {
|
||||
}
|
||||
if len(d.Filled) > 0 {
|
||||
envelope["filled_files"] = d.Filled
|
||||
}
|
||||
// The provisioner client's account is the node's, named whenever a client runs here, filled files or not: a node
|
||||
// whose every filled-file module is held this pass names none of its files, and still keeps them, so the client's
|
||||
// writes of them must be accepted (issue 774, ace 2026-11-04: its build modules and ssh-client held, no account).
|
||||
if d.Provisioner != "" {
|
||||
envelope["provisioner_account"] = d.Provisioner
|
||||
}
|
||||
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
|
||||
@@ -202,9 +207,7 @@ func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
|
||||
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
|
||||
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
|
||||
if len(composed.Filled) > 0 {
|
||||
out.Provisioner = with.Provisioner
|
||||
}
|
||||
out.Provisioner = with.Provisioner
|
||||
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
|
||||
for module := range composed.Plans {
|
||||
if by := resolution.RequiredBy(plan, module); by != "" {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
func resourceIDs(d Declared) []string {
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
|
||||
|
||||
@@ -78,7 +78,7 @@ func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
|
||||
}
|
||||
r := placed.Rendering
|
||||
out.ForeseenPorts = placed.ForeseenPorts
|
||||
// A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774).
|
||||
// A plan carries its contributions' machine ports, refused where the assignment assigned none (issue 774).
|
||||
r.Planned = mode == Planning
|
||||
r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound
|
||||
out.Withheld, out.Unbound = withheld, unbound
|
||||
|
||||
+113
-15
@@ -3,12 +3,13 @@ package compose
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/compose/resolution"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them
|
||||
@@ -74,6 +75,12 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
|
||||
// not sent the field they would refuse.
|
||||
r.Hands = takesHanded(node)
|
||||
r.Provisioner = provisionerAccount(plan)
|
||||
if r.Provisioner == "" && provisionerHeld(plan) {
|
||||
// The provisioner's client is placed here and held this pass (its stub, or carried as it runs, composes no process
|
||||
// of its own): it keeps running as it runs, so the account it runs as stays named, and every module stays handed
|
||||
// to it. A client not held that composes no account, or none placed, names none: no stale account is kept.
|
||||
r.Provisioner = node.RunningProvisioner
|
||||
}
|
||||
r.Assigned = node.Handed
|
||||
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
|
||||
r.JudgesRoot = node.Stated && node.Contract >= RootContract
|
||||
@@ -161,7 +168,21 @@ func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
|
||||
|
||||
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR 0222).
|
||||
if store != "" {
|
||||
r.SeatReach = map[string]string{"mesh-artifact-store": store}
|
||||
// Every name the store is reached through answers the one address (ENG-3, ADR 0632 §1), each with its holder from the
|
||||
// holdings on record, written beside every image routed through it (design 51, ENG-10).
|
||||
r.SeatReach, r.SeatHolder = map[string]string{}, map[string]string{}
|
||||
for _, seat := range resolution.StoreSeats {
|
||||
r.SeatReach[seat] = store
|
||||
for _, h := range c.in.Holdings {
|
||||
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat && h.Scope == catalogue.ScopeMesh && h.Module != "" {
|
||||
r.SeatHolder[seat] = h.Module
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.SeatHolder) == 0 {
|
||||
r.SeatHolder = nil
|
||||
}
|
||||
}
|
||||
r.Settings = settings
|
||||
r.Given = given
|
||||
@@ -190,6 +211,21 @@ func provisionerAccount(plan catalogue.Resolution) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// provisionerHeld says the plan places a module claiming the provisioner client's seat on the machine that this pass
|
||||
// holds (its stand-in: Stub, or Carried as it runs), which composes none of the client's process.
|
||||
func provisionerHeld(plan catalogue.Resolution) bool {
|
||||
for _, m := range plan.Modules {
|
||||
if m.Stubbed && m.ClaimsSeat(NodeProvisionerSeat) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunsTheProvisionerClient says a module runs its machine's provisioner client (runsTheProvisionerClient), for a reader
|
||||
// of what runs.
|
||||
func RunsTheProvisionerClient(m catalogue.Manifest) bool { return runsTheProvisionerClient(m) }
|
||||
|
||||
// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
|
||||
// secret that account is delivered in.
|
||||
func runsTheProvisionerClient(m catalogue.Manifest) bool {
|
||||
@@ -291,18 +327,74 @@ func (c *composer) namesInTheMesh() map[string]string {
|
||||
return out
|
||||
}
|
||||
|
||||
// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on.
|
||||
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
|
||||
providers := map[string]catalogue.Manifest{}
|
||||
for name, m := range c.in.Catalogue {
|
||||
if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
|
||||
providers[name] = m
|
||||
// StoreSeat is the seat whose holder is the artifact store (novox/hq ADR 0632 §1): the store's address is that holder's
|
||||
// listen open to the mesh, on the machine assignment puts it on. The provision artifact-store it was found by before is
|
||||
// retired; no module serves it.
|
||||
const StoreSeat = "container-image-registry"
|
||||
|
||||
// storeHolders are the modules holding the store's seat: the holdings on record, and only where none is on record every
|
||||
// module of the catalogue claiming it at the mesh's scope, as a seat holder's reach is read (replicatedHolders). Any name
|
||||
// of the store counts (resolution.StoreSeats): until every counted engine states the new reading, the manifest every
|
||||
// node reads is the holder's old one, which claims only mesh-artifact-store (ADR 0632 §2), and an engine that finds the
|
||||
// store by the seat must find it there too.
|
||||
func (c *composer) storeHolders() map[string]catalogue.Manifest {
|
||||
isStore := func(claim string) bool {
|
||||
hs, ok := catalogue.SeatNamed(claim)
|
||||
return ok && slices.Contains(resolution.StoreSeats, hs.Name)
|
||||
}
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for _, h := range c.in.Holdings {
|
||||
if isStore(h.Claim) && h.Scope == catalogue.ScopeMesh && h.Module != "" {
|
||||
if m, known := c.in.Catalogue[h.Module]; known {
|
||||
out[h.Module] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
if len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
for name, m := range c.in.Catalogue {
|
||||
for _, cl := range m.Claims {
|
||||
if isStore(cl.Name) && cl.At() == catalogue.ScopeMesh {
|
||||
out[name] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// storeListen is the store holder's listen every machine pulls from: its one listen open to the mesh. A holder opening
|
||||
// none, or more than one, says no address, by name: which of two ports is the store is no guess the composer makes.
|
||||
func storeListen(m catalogue.Manifest) (catalogue.Listening, error) {
|
||||
var open []catalogue.Listening
|
||||
for _, l := range m.Listens {
|
||||
if l.From == catalogue.FromMesh && l.At() == "tcp" {
|
||||
open = append(open, l)
|
||||
}
|
||||
}
|
||||
switch len(open) {
|
||||
case 1:
|
||||
return open[0], nil
|
||||
case 0:
|
||||
return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens no tcp listen to the mesh: the artifact store has no "+
|
||||
"address (novox/hq ADR 0632 §1)", m.Module, StoreSeat)
|
||||
}
|
||||
var said []string
|
||||
for _, l := range open {
|
||||
said = append(said, fmt.Sprintf("%s (%d)", l.Name, l.Port))
|
||||
}
|
||||
return catalogue.Listening{}, fmt.Errorf("%s holds %s and opens %s to the mesh: the artifact store's address is its one "+
|
||||
"listen open to the mesh (novox/hq ADR 0632 §1)", m.Module, StoreSeat, strings.Join(said, ", "))
|
||||
}
|
||||
|
||||
// artifactStoreOnNetwork is the machine on this network that holds the store's seat, and the port that machine put the
|
||||
// holder's listen on (novox/hq ADR 0632 §1).
|
||||
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
|
||||
holders := c.storeHolders()
|
||||
if len(holders) == 0 {
|
||||
return "", "", false, nil
|
||||
}
|
||||
// In a stated order, so two machines offering it would always answer the same one.
|
||||
// In a stated order, so two machines holding it would always answer the same one.
|
||||
machines := make([]string, 0, len(on))
|
||||
for machine := range on {
|
||||
machines = append(machines, machine)
|
||||
@@ -314,17 +406,23 @@ func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string
|
||||
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
|
||||
}
|
||||
for _, a := range n.Assigned {
|
||||
m, offers := providers[a]
|
||||
if !offers {
|
||||
m, holds := holders[a]
|
||||
if !holds {
|
||||
continue
|
||||
}
|
||||
serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision)
|
||||
l, err := storeListen(m)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
ports, _, err := c.portsGivenOn(machine, m)
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
|
||||
}
|
||||
if p, ok := serves["port"]; ok {
|
||||
return machine, fmt.Sprintf("%v", p), true, nil
|
||||
at := l.Port
|
||||
if given, ok := ports[l.Port]; ok {
|
||||
at = given
|
||||
}
|
||||
return machine, fmt.Sprintf("%d", at), true, nil
|
||||
}
|
||||
}
|
||||
return "", "", false, nil
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/overlay"
|
||||
)
|
||||
|
||||
// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide).
|
||||
@@ -254,7 +254,8 @@ func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) {
|
||||
func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
|
||||
Listens: []catalogue.Listening{{Name: "registry", Port: 5100, From: catalogue.FromMesh}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts")
|
||||
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" {
|
||||
@@ -272,6 +273,87 @@ func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *test
|
||||
if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] {
|
||||
t.Errorf("built is %v", got)
|
||||
}
|
||||
// Its holder, from the holdings on record, beside the address (design 51, ENG-10): none on record names none, and a change
|
||||
// of the holder is the rendering's.
|
||||
if got := placed(t, in, "anchor").Rendering.SeatHolder; got != nil {
|
||||
t.Errorf("a store with no holder on record names %v", got)
|
||||
}
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-artifact-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "artifacts"}}
|
||||
if got := placed(t, in, "laptop").Rendering.SeatHolder["mesh-artifact-store"]; got != "" {
|
||||
t.Errorf("a machine that reaches no store names its holder %q", got)
|
||||
}
|
||||
if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "artifacts" {
|
||||
t.Errorf("the store's holder is %q", got)
|
||||
}
|
||||
in.Holdings[0].Module = "distribution"
|
||||
if got := placed(t, in, "anchor").Rendering.SeatHolder["mesh-artifact-store"]; got != "distribution" {
|
||||
t.Errorf("after the holder changed it is %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The store's address is the image seat's holder's listen open to the mesh, on the port its node put it on, and every
|
||||
// name of the store answers it (novox/hq ADR 0632 §1): a module serving the retired provision artifact-store is no store,
|
||||
// and a holder opening two listens to the mesh says which is the store by no guess.
|
||||
func TestTheStoreIsFoundByTheImageSeatsHolderAndItsListen(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["old"] = catalogue.Manifest{Module: "old", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "old")
|
||||
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" {
|
||||
t.Errorf("a module serving artifact-store is reached as the store at %q", got)
|
||||
}
|
||||
in.Catalogue["registry"] = catalogue.Manifest{Module: "registry", Version: "1",
|
||||
Claims: []catalogue.Claim{{Name: "container-image-registry", Scope: catalogue.ScopeMesh}},
|
||||
Listens: []catalogue.Listening{{Name: "local", Port: 5001, From: catalogue.FromMachine},
|
||||
{Name: "registry", Port: 5000, From: catalogue.FromMesh}}}
|
||||
anchor.Assigned = append(anchor.Assigned, "registry")
|
||||
anchor.Ports = map[string]map[int]int{"registry": {5000: 5200}}
|
||||
r := placed(t, in, "laptop").Rendering
|
||||
if r.ArtifactStore != "anchor.internal:5200" {
|
||||
t.Errorf("laptop reaches the store at %q, want the holder's listen open to the mesh on anchor's port", r.ArtifactStore)
|
||||
}
|
||||
for _, seat := range []string{"mesh-artifact-store", "container-image-registry", "oci-artifact-registry"} {
|
||||
if r.SeatReach[seat] != "anchor.internal:5200" {
|
||||
t.Errorf("${seat:%s:reach} is %q", seat, r.SeatReach[seat])
|
||||
}
|
||||
}
|
||||
// main's distribution, the manifest every node reads while the indexer holds the new one (ADR 0632 §2): it claims
|
||||
// only mesh-artifact-store and serves the retired provision; the engine finds the store by that claim and its listen.
|
||||
old := in
|
||||
old.Catalogue = map[string]catalogue.Manifest{}
|
||||
for k, v := range in.Catalogue {
|
||||
if k != "registry" {
|
||||
old.Catalogue[k] = v
|
||||
}
|
||||
}
|
||||
old.Catalogue["distribution"] = catalogue.Manifest{Module: "distribution", Version: "2",
|
||||
Provides: []catalogue.Offer{{Name: catalogue.ArtifactStoreProvision, Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-artifact-store", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}},
|
||||
Listens: []catalogue.Listening{{Name: "registry", Port: 5000, Protocol: "tcp", From: catalogue.FromMesh}}}
|
||||
old.Nodes = append([]Node(nil), in.Nodes...)
|
||||
for i := range old.Nodes {
|
||||
if old.Nodes[i].Name == "anchor" {
|
||||
var kept []string
|
||||
for _, a := range old.Nodes[i].Assigned {
|
||||
if a != "registry" && a != "old" {
|
||||
kept = append(kept, a)
|
||||
}
|
||||
}
|
||||
old.Nodes[i].Assigned = append(kept, "distribution")
|
||||
old.Nodes[i].Ports = map[string]map[int]int{"distribution": {5000: 5400}}
|
||||
}
|
||||
}
|
||||
if got := placed(t, old, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5400" {
|
||||
t.Errorf("main's distribution, claiming only mesh-artifact-store, is reached at %q, want anchor.internal:5400", got)
|
||||
}
|
||||
m := in.Catalogue["registry"]
|
||||
m.Listens = append(m.Listens, catalogue.Listening{Name: "other", Port: 5300, From: catalogue.FromMesh})
|
||||
in.Catalogue["registry"] = m
|
||||
if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "opens registry (5000), other (5300) to the mesh") {
|
||||
t.Errorf("a holder opening two listens to the mesh: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"github.com/novox/mesh-host/internal/registryforms"
|
||||
)
|
||||
|
||||
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
|
||||
@@ -32,25 +33,26 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, kept := mcat.InArtifactStore(written); kept {
|
||||
if _, kept := registryforms.Forms.InArtifactStore(written); kept {
|
||||
if with.ArtifactStore == "" {
|
||||
return fmt.Errorf(
|
||||
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
|
||||
"artifact store on its network to fetch it from — nothing assigned offers "+
|
||||
"%s, or the machine offering it is not on the private network",
|
||||
module, resource["id"], written, mcat.ArtifactStoreProvision)
|
||||
"artifact store on its network to fetch it from — nothing assigned holds the seat "+
|
||||
"container-image-registry, or the machine holding it is not on the private network",
|
||||
module, resource["id"], written)
|
||||
}
|
||||
resource[key] = mcat.Routed(written, with.ArtifactStore)
|
||||
resource[key] = registryforms.Forms.Routed(written, with.ArtifactStore)
|
||||
imageSeatInto(resource, key, written, with)
|
||||
continue
|
||||
}
|
||||
if with.ArtifactStore == "" {
|
||||
continue
|
||||
}
|
||||
recorded := mcat.Recorded(written)
|
||||
recorded := registryforms.Forms.Recorded(written)
|
||||
if recorded == written {
|
||||
continue
|
||||
}
|
||||
path, _ := mcat.InArtifactStore(recorded)
|
||||
path, _ := registryforms.Forms.InArtifactStore(recorded)
|
||||
repository := path
|
||||
if at := strings.IndexAny(path, "@"); at >= 0 {
|
||||
repository = path[:at]
|
||||
@@ -58,8 +60,33 @@ func artifactsInto(resource map[string]any, module string, with mcat.Rendering)
|
||||
repository = path[:blobs]
|
||||
}
|
||||
if with.Built[repository] {
|
||||
resource[key] = mcat.Routed(recorded, with.ArtifactStore)
|
||||
resource[key] = registryforms.Forms.Routed(recorded, with.ArtifactStore)
|
||||
imageSeatInto(resource, key, written, with)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// artifactSeat is the seat an image is routed through: the artifact store's (the registries' four seats follow as the
|
||||
// references name them).
|
||||
const artifactSeat = "mesh-artifact-store"
|
||||
|
||||
// imageSeatInto writes beside a container's routed image the seat the reference came through and that seat's holder (design 51
|
||||
// *distribution's first delivery*, issue 1966 ENG-10), so the node-engine finds by the declaration whom a failed pull waits
|
||||
// on, never by a module's name. Neither is part of what a container is: a declaration composed again for a new holder
|
||||
// replaces no container. Nothing is written where the holder is not on record, or for a resource that is no container.
|
||||
func imageSeatInto(resource map[string]any, key, written string, with mcat.Rendering) {
|
||||
if key != "image" || resource["type"] != "container" {
|
||||
return
|
||||
}
|
||||
// The seat a reference of the registry form names; the older forms came through the artifact store's.
|
||||
seat := artifactSeat
|
||||
if named, _, ok := registryforms.Forms.InRegistrySeat(written); ok {
|
||||
seat = named
|
||||
}
|
||||
if with.SeatHolder[seat] == "" {
|
||||
return
|
||||
}
|
||||
resource["image-seat"] = seat
|
||||
resource["image-holder"] = with.SeatHolder[seat]
|
||||
}
|
||||
|
||||
@@ -46,6 +46,45 @@ func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
||||
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
|
||||
}
|
||||
|
||||
// The seat the image came through and its holder, beside it (ENG-10); a holder not on record writes neither, a public
|
||||
// image and an archive carry none, and a holder changing changes the declaration but not the container's spec.
|
||||
if got := fileNamed(out, "gitea.server"); got["image-seat"] != nil || got["image-holder"] != nil {
|
||||
t.Errorf("a holder not on record was written: %v %v", got["image-seat"], got["image-holder"])
|
||||
}
|
||||
held := mcat.Rendering{ArtifactStore: "anchor.internal:5101", SeatHolder: map[string]string{"mesh-artifact-store": "distribution"}}
|
||||
held1, err := Declaration(r, held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(held1, "gitea.server"); got["image-seat"] != "mesh-artifact-store" || got["image-holder"] != "distribution" {
|
||||
t.Errorf("the routed image names its seat and holder as %v %v", got["image-seat"], got["image-holder"])
|
||||
}
|
||||
for _, id := range []string{"gitea.config", "gitea.cache"} {
|
||||
if got := fileNamed(held1, id); got["image-seat"] != nil || got["image-holder"] != nil || got["seat"] != nil {
|
||||
t.Errorf("%s names a seat: %v", id, got)
|
||||
}
|
||||
}
|
||||
// A reference in the form a registry seat answers is routed the same way, names the seat it came through (ENG-4), and its
|
||||
// bundle is fetched as an archive from the store.
|
||||
seatForm := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-gitea", "image": "container-image-registry/gitea/server@" + digest},
|
||||
{"id": "config", "type": "archive", "path": "/etc/gitea", "source": "oci-artifact-registry/gitea/config@" + digest}}}}}
|
||||
held.SeatHolder["container-image-registry"] = "distribution"
|
||||
seated, err := Declaration(seatForm, held)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fileNamed(seated, "gitea.server"); got["image"] != "anchor.internal:5101/gitea/server@"+digest || got["image-seat"] != "container-image-registry" || got["image-holder"] != "distribution" {
|
||||
t.Errorf("a seat-form image is %v, naming %v %v", got["image"], got["image-seat"], got["image-holder"])
|
||||
}
|
||||
if got := fileNamed(seated, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||
t.Errorf("a seat-form archive is fetched from %v", got)
|
||||
}
|
||||
held.SeatHolder = map[string]string{"mesh-artifact-store": "other-holder"}
|
||||
if held2, err := Declaration(r, held); err != nil || fileNamed(held2, "gitea.server")["image-holder"] != "other-holder" {
|
||||
t.Errorf("a new holder is not written: %v", err)
|
||||
}
|
||||
|
||||
// And the store moves: the same record, another address, without a rebuild.
|
||||
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
|
||||
if err != nil {
|
||||
|
||||
@@ -102,7 +102,13 @@ func plainly(value any) string {
|
||||
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
|
||||
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
|
||||
// that already; this is the same rule where the file is made, for a manifest registered before it.
|
||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
|
||||
//
|
||||
// **A key the binding does not say is refused with why the node-engine fills the file** (issue 1966): filledHere
|
||||
// (notHandedWhy). What a provider's helper answers reaches only a handed module's files, which the provisioner's client
|
||||
// fills; a file the client should fill (handedFile) refused here is a handed module not handed, and says so, never only
|
||||
// the keys it lacks.
|
||||
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool,
|
||||
handedFile bool, filledHere string) error {
|
||||
if fmt.Sprint(resource["type"]) != "file" {
|
||||
return nil
|
||||
}
|
||||
@@ -135,10 +141,16 @@ func boundInto(resource map[string]any, known map[string]map[string]string, modu
|
||||
return token
|
||||
}
|
||||
value, said := values[key]
|
||||
if !said && handedFile {
|
||||
refused = fmt.Errorf(
|
||||
"handed module not handed: %s asks its %s binding for %q, which only the provisioner's client fills, "+
|
||||
"and %s (issue 1966)", module, provision, key, filledHere)
|
||||
return token
|
||||
}
|
||||
if !said {
|
||||
refused = fmt.Errorf(
|
||||
"%s asks its %s binding for %q, and what answers it says %s",
|
||||
module, provision, key, orNothing(namesOfKeys(values)))
|
||||
"%s asks its %s binding for %q, and what answers it says %s (%s)",
|
||||
module, provision, key, orNothing(namesOfKeys(values)), filledHere)
|
||||
return token
|
||||
}
|
||||
return value
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// controlRecordsDecision is the record that made the two seats.
|
||||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||||
|
||||
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
|
||||
func wantsVerbs() []mcat.Verb {
|
||||
return []mcat.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() []mcat.Verb {
|
||||
return []mcat.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"})},
|
||||
}
|
||||
}
|
||||
@@ -415,6 +415,11 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
|
||||
// family's member (ADR 0283), so the module reads its absence as not given.
|
||||
continue
|
||||
}
|
||||
if why := VaultDisagrees(with, m); sealed == "" && why != "" {
|
||||
// The vault left it out of what it answered this node, its plan handing it: never a secret to make.
|
||||
return nil, fmt.Errorf("handed module not handed: %s needs its own secret %q, which the vault answers "+
|
||||
"its provisioner's client alone, %s (issue 1966)", m.Module, name, why)
|
||||
}
|
||||
if sealed == "" {
|
||||
// Declared and not made. Refused rather than skipped: a module whose own
|
||||
// credential is silently absent starts, fails to authenticate, and the reason is
|
||||
@@ -912,7 +917,8 @@ func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]
|
||||
// **Substituted here, not on the machine.** A bound value is not secret — the mesh
|
||||
// holds it in the clear — so there is nothing for the host to be the only witness of,
|
||||
// and sending it already filled in means the host learns no new field.
|
||||
if err := boundInto(copied, known, m.Module, absent); err != nil {
|
||||
filledHere, handedFile := notHandedWhy(with, m, unsettled)
|
||||
if err := boundInto(copied, known, m.Module, absent, handedFile, filledHere); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// And what the module could not have written: the machine it turned out to be
|
||||
@@ -1514,7 +1520,7 @@ func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with
|
||||
}
|
||||
|
||||
// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
|
||||
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
|
||||
// `${port:<software port>}` gives m itself and its declaration publishes (machinePort, port_into.go), the assigner's
|
||||
// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
|
||||
// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
|
||||
//
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
|
||||
func deliveryRemovedInR6(name string) mcat.Verb {
|
||||
return mcat.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + mcat.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
|
||||
return mcat.Verb{Name: name, Description: meshdeliveryclient.RemovedPrefix + meshdeliveryclient.RemovedVerbs()[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
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
)
|
||||
|
||||
// Each verb release 6 removed from the delivery seat is a row refusing it by name with the replacement the seat's client
|
||||
// gives (the list is the contract's, not the SDK's: novox/hq ADR 0634 §2), and the rows are exactly those verbs.
|
||||
func TestTheDeliverySeatRefusesTheRemovedVerbsByNameWithTheClientsReplacement(t *testing.T) {
|
||||
removed := meshdeliveryclient.RemovedVerbs()
|
||||
rows := 0
|
||||
for _, v := range deliveryVerbs() {
|
||||
want, isRemoved := removed[v.Name]
|
||||
if !isRemoved {
|
||||
continue
|
||||
}
|
||||
rows++
|
||||
if v.Description != meshdeliveryclient.RemovedPrefix+want || !v.Optional {
|
||||
t.Errorf("%s = %q (optional %v), want %q", v.Name, v.Description, v.Optional, meshdeliveryclient.RemovedPrefix+want)
|
||||
}
|
||||
}
|
||||
if rows != len(removed) {
|
||||
t.Errorf("%d rows refuse a removed verb, the client names %d", rows, len(removed))
|
||||
}
|
||||
if len(removed) != 5 {
|
||||
t.Errorf("the removed verbs are %v", removed)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// engineSeat is the seat's row in the closed set.
|
||||
func engineSeat() mcat.Seat {
|
||||
return mcat.Seat{Name: mcat.EngineSeat, Scope: mcat.ScopeNode, Serves: mcat.EngineVerbs(), Emits: append([]string(nil), mcat.EngineEvents...),
|
||||
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
|
||||
}
|
||||
@@ -15,22 +15,22 @@ func enrolmentVerbs() []mcat.Verb {
|
||||
}
|
||||
return []mcat.Verb{
|
||||
{Name: mcat.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 " +
|
||||
"refused at once, naming every finding, for a name that is not a node name or that a node record, an assignments " +
|
||||
"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. " +
|
||||
"warrant, adds the node to the assignments 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.
|
||||
// enters the assignments 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: mcat.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 " +
|
||||
"vault for the node's account and certificate, writes the node into the assignments, 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
@@ -14,73 +13,6 @@ const filesAsAccount = "as the node's operator account, through sudo -n where it
|
||||
|
||||
const 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() []mcat.Verb {
|
||||
var verbs []mcat.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) mcat.Verb {
|
||||
in["reason"] = "why the file is changed, for the audit trail"
|
||||
in["override"] = overrideWord
|
||||
return mcat.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"), mcat.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"})}, mcat.
|
||||
// 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"})}, mcat.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"),
|
||||
)
|
||||
}
|
||||
|
||||
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
|
||||
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
|
||||
// module's manifest places (declaredPaths). A seat's path wins where both name one.
|
||||
|
||||
@@ -42,12 +42,13 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
Resources: []map[string]any{{"id": "stub", "type": "file", "path": mcat.ResolverFile}}}
|
||||
uplink := mcat.Manifest{Module: "networkmanager", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}},
|
||||
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": mcat.ResolverFile}}}
|
||||
compiled := seats
|
||||
t.Cleanup(func() { seats = compiled })
|
||||
compiled := mcat.Seats()
|
||||
t.Cleanup(func() { mcat.UseSeats(compiled) })
|
||||
for _, order := range []string{"compiled", "reversed"} {
|
||||
if order == "reversed" {
|
||||
seats = append([]mcat.Seat(nil), compiled...)
|
||||
slices.Reverse(seats)
|
||||
reversed := append([]mcat.Seat(nil), compiled...)
|
||||
slices.Reverse(reversed)
|
||||
mcat.UseSeats(reversed)
|
||||
}
|
||||
both := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, uplink, resolver}}
|
||||
w, err := resolutionFilesWords(both, files, nil, mcat.Rendering{})
|
||||
@@ -59,7 +60,7 @@ func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
|
||||
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, mcat.ResolverFile, o[mcat.ResolverFile], mcat.ResolverSeat)
|
||||
}
|
||||
}
|
||||
seats = compiled
|
||||
mcat.UseSeats(compiled)
|
||||
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
|
||||
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
|
||||
carried := mcat.Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
|
||||
|
||||
@@ -56,7 +56,9 @@ func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) {
|
||||
docker := catalogueManifest(t, "docker")
|
||||
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{docker}}
|
||||
out, err := Declaration(r, mcat.Rendering{
|
||||
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"},
|
||||
// Every name of the store answers the one address, as the composer fills them (ADR 0632 §1).
|
||||
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100",
|
||||
"container-image-registry": "anchor.internal:5100", "oci-artifact-registry": "anchor.internal:5100"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
package resolution
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// 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() []mcat.Seat {
|
||||
const decided = "novox/hq ADR 0208"
|
||||
return []mcat.Seat{
|
||||
{Name: mcat.LoginManagerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.DisplayServerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.DisplaySessionSeat, Scope: mcat.ScopeNode, Decision: decided,
|
||||
Receives: []mcat.Receivable{{Kind: "config", Comment: "#"}}, Serves: []mcat.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: mcat.TerminalEmulatorSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.LauncherSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.NotifierSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.LockScreenSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
|
||||
{Name: "lock", Description: "Lock the operator's session now.",
|
||||
Input: schema(map[string]string{}, nil)},
|
||||
}},
|
||||
{Name: mcat.ClipboardSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.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: mcat.BarSeat, Scope: mcat.ScopeNode, Decision: decided,
|
||||
Receives: []mcat.Receivable{{Kind: mcat.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
|
||||
{Name: mcat.CompositorSeat, Scope: mcat.ScopeNode, Decision: decided},
|
||||
{Name: mcat.SecretServiceSeat, Scope: mcat.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
|
||||
}
|
||||
|
||||
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
|
||||
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
|
||||
// renders it (the shape's examples) before it is accepted.
|
||||
var barShows = map[string]map[string]barOption{
|
||||
// The machine's battery: its charge, and how long it lasts or takes to fill.
|
||||
"battery": {"device": {kind: "text"}},
|
||||
// One line a command prints, run every interval seconds: what the bar cannot know itself.
|
||||
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
|
||||
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
|
||||
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
|
||||
// holder is granted to read it and shows it however its bar shows a changing value; no file and
|
||||
// no command passes between the two modules.
|
||||
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
|
||||
}
|
||||
|
||||
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
|
||||
func barBlockReads(data map[string]any) []mcat.StateRead {
|
||||
if data["shows"] != "state" {
|
||||
return nil
|
||||
}
|
||||
options, _ := data["options"].(map[string]any)
|
||||
s, ok := options["state"].(string)
|
||||
if !ok || s == "" {
|
||||
return nil
|
||||
}
|
||||
key, _ := options["key"].(string)
|
||||
return []mcat.StateRead{{Read: s, Key: key}}
|
||||
}
|
||||
|
||||
type barOption struct {
|
||||
kind string // text, or seconds (a whole number from 1 to 86400)
|
||||
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() *mcat.Shape {
|
||||
return &mcat.Shape{
|
||||
Text: true,
|
||||
Places: []mcat.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 {
|
||||
var problems []string
|
||||
for field := range data {
|
||||
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
|
||||
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
|
||||
}
|
||||
}
|
||||
shows, _ := data["shows"].(string)
|
||||
takes, known := barShows[shows]
|
||||
if !known {
|
||||
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
|
||||
strings.Join(sortedKeys(barShows), ", ")))
|
||||
}
|
||||
options := map[string]any{}
|
||||
if raw, has := data["options"]; has {
|
||||
o, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return append(problems, "a block's options are an object")
|
||||
}
|
||||
options = o
|
||||
}
|
||||
for name, v := range options {
|
||||
opt, ok := takes[name]
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
|
||||
orNothing(sortedKeys(takes))))
|
||||
continue
|
||||
}
|
||||
switch opt.kind {
|
||||
case "text":
|
||||
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
|
||||
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
|
||||
}
|
||||
case "seconds":
|
||||
if _, whole := wholeIn(v, 1, 86400); !whole {
|
||||
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range sortedKeys(takes) {
|
||||
if _, has := options[name]; takes[name].required && !has {
|
||||
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user