Merge origin/main into feat/335, and pin mesh-host at #98's new head
This commit is contained in:
@@ -58,7 +58,42 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dependenciesOf(entries, against, nil), nil
|
||||
held, err := i.Held(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dependenciesWith(entries, against, held), nil
|
||||
}
|
||||
|
||||
// packagesHeld is, by package name, the module whose artifact the mesh holds as that package: a
|
||||
// `package` artifact's reference is `<name>@<version>` (the builder's publishPackage), never an
|
||||
// artifact-store reference, so a build that stood on it recorded the package's name and version.
|
||||
func packagesHeld(held map[string]string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, reference := range held {
|
||||
module, _, ok := strings.Cut(key, "/")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if name := packageName(reference); name != "" {
|
||||
out[name] = module
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// packageName is the name of a `<name>@<version>` package reference, or "" for anything else: a
|
||||
// kept or pinned image (`…@sha256:…`), a URL, or a reference with no version.
|
||||
func packageName(reference string) string {
|
||||
if strings.HasPrefix(reference, catalogue.ArtifactStoreScheme) || strings.Contains(reference, "@sha256:") ||
|
||||
strings.Contains(reference, "://") {
|
||||
return ""
|
||||
}
|
||||
at := strings.LastIndex(reference, "@")
|
||||
if at <= 0 || at == len(reference)-1 {
|
||||
return ""
|
||||
}
|
||||
return reference[:at]
|
||||
}
|
||||
|
||||
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
|
||||
@@ -66,6 +101,20 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
|
||||
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
|
||||
// the planner, never through the relation.
|
||||
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
|
||||
return dependenciesWith(entries, against, nil)
|
||||
}
|
||||
|
||||
// dependenciesWith is the relation with the artifacts the mesh holds, "<module>/<artifact>" to its
|
||||
// reference (Held), so a package a build stood on is read as its module's.
|
||||
//
|
||||
// **A built manifest carries no `build`** (catalogue.Resolve drops it), so what a module declared it
|
||||
// is built on is read from what is kept of it once it is built (novox/hq issue 614): the bases its
|
||||
// newest build was handed (`against`) — an artifact-store reference, or a package's `<name>@<version>`
|
||||
// — and the language of every bundle it compiled (`Bundles`). Read only from records the mesh already
|
||||
// keeps, so it holds for every module built before it, and a controller rolled back to one without it
|
||||
// reads the same records as before.
|
||||
func dependenciesWith(entries []Entry, against map[string][]string, held map[string]string) []Edge {
|
||||
packages := packagesHeld(held)
|
||||
known := map[string]bool{}
|
||||
var builders []string
|
||||
for _, e := range entries {
|
||||
@@ -111,6 +160,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
|
||||
}
|
||||
}
|
||||
}
|
||||
// **And once it is built**, from the bundles its resolved manifest keeps (novox/hq issue 614): the
|
||||
// `build` it was declared with is gone by then, and a toolchain that moved rebuilt none of them.
|
||||
for _, b := range e.Manifest.Bundles {
|
||||
if chain, err := builder.ToolchainFor(b.Language); err == nil {
|
||||
add(name, chain.Base, EdgeStandsOn)
|
||||
}
|
||||
}
|
||||
for _, ref := range against[name] {
|
||||
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
|
||||
// A copy of an upstream image is no module's artifact (novox/hq ADR 0257): its
|
||||
@@ -121,6 +177,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
|
||||
if base, _, found := strings.Cut(rest, "/"); found {
|
||||
add(name, base, EdgeStandsOn)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A package base (`build.on` naming a module's `package` artifact) is recorded as the
|
||||
// package's name and version (novox/hq issue 614): the module that publishes that package
|
||||
// is what it stood on, whichever version the mesh holds now.
|
||||
if base, ok := packages[packageName(ref)]; ok {
|
||||
add(name, base, EdgeStandsOn)
|
||||
}
|
||||
}
|
||||
if e.Source.Repository != "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
@@ -174,3 +175,74 @@ func TestACopyOfAnUpstreamImageIsNoEdge(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **A built module keeps its build edges** (novox/hq issue 614). A manifest the mesh registers from a
|
||||
// build carries no `build` (catalogue.Resolve drops it), so mesh-tools' `build.on` naming mesh-sdk's
|
||||
// `lib` package and a provider's TypeScript bundle drew no edge once built, and a new SDK rebuilt
|
||||
// neither. The package base is read from what the build was handed (`@novox/mesh-sdk@0.1.13`, the
|
||||
// package's name whichever version the mesh holds now), the toolchain from the bundles it keeps.
|
||||
func TestABuiltModuleKeepsItsBuildEdges(t *testing.T) {
|
||||
built := func(name string, bundles ...catalogue.Bundle) Entry {
|
||||
return Entry{Manifest: catalogue.Manifest{Module: name, Bundles: bundles},
|
||||
Source: Source{Repository: "http://forge/novox/" + name + ".git"}}
|
||||
}
|
||||
entries := []Entry{
|
||||
built("mesh-sdk"),
|
||||
built("mesh-tools"),
|
||||
built("mesh-tools-go"),
|
||||
built("minio", catalogue.Bundle{Name: "code", Language: "typescript"}),
|
||||
built("umami", catalogue.Bundle{Name: "code", Language: "TypeScript"}),
|
||||
built("dnsmasq", catalogue.Bundle{Name: "tools", Language: "go"}),
|
||||
built("plain"),
|
||||
}
|
||||
against := map[string][]string{
|
||||
"mesh-tools": {"@novox/mesh-sdk@0.1.13",
|
||||
catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node@sha256:" + strings.Repeat("0", 64)},
|
||||
// A package nobody in the mesh publishes is no edge.
|
||||
"plain": {"left-pad@1.3.0"},
|
||||
}
|
||||
held := map[string]string{
|
||||
"mesh-sdk/lib": "@novox/mesh-sdk@0.1.14",
|
||||
"mesh-tools/runtime": catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("1", 64),
|
||||
}
|
||||
got := dependenciesWith(entries, against, held)
|
||||
has := func(from, to string) bool {
|
||||
for _, e := range got {
|
||||
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, want := range [][2]string{
|
||||
{"mesh-tools", "mesh-sdk"},
|
||||
{"minio", "mesh-tools"},
|
||||
{"umami", "mesh-tools"},
|
||||
{"dnsmasq", "mesh-tools-go"},
|
||||
} {
|
||||
if !has(want[0], want[1]) {
|
||||
t.Errorf("no %s stands-on %s: %+v", want[0], want[1], got)
|
||||
}
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.From == "plain" && e.Kind != EdgeBuiltBy {
|
||||
t.Errorf("an unknown package drew an edge: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageNameIsReadOnlyFromAPackageReference(t *testing.T) {
|
||||
for ref, want := range map[string]string{
|
||||
"@novox/mesh-sdk@0.1.14": "@novox/mesh-sdk",
|
||||
"left-pad@1.3.0": "left-pad",
|
||||
"@novox/mesh-sdk": "",
|
||||
"novox.internal:5100/mesh-tools/build@sha256:" + strings.Repeat("0", 64): "",
|
||||
catalogue.ArtifactStoreScheme + "mesh-tools/build@sha256:0": "",
|
||||
"http://store/v2/x/blobs/sha256:0": "",
|
||||
"": "",
|
||||
} {
|
||||
if got := packageName(ref); got != want {
|
||||
t.Errorf("packageName(%q) = %q, want %q", ref, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,13 @@ type Wait struct {
|
||||
Part string `json:"part"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Setting string `json:"setting,omitempty"`
|
||||
What string `json:"what"`
|
||||
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
|
||||
// (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision.
|
||||
Provision string `json:"provision,omitempty"`
|
||||
What string `json:"what"`
|
||||
// Act is the line that gives a provision's credential, filled by the controller when it excuses the wait
|
||||
// (ADR 0337 §7); never said by a machine.
|
||||
Act string `json:"act,omitempty"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -36,7 +36,16 @@ type MadeOf struct {
|
||||
BootedAt *time.Time `json:"booted_at"`
|
||||
Timezone string `json:"timezone"`
|
||||
Filesystems []MountedFilesystem `json:"filesystems"`
|
||||
Unknown map[string]string `json:"unknown"`
|
||||
// Networks is every network the machine is directly attached to on a link facing outside it, as
|
||||
// link and network (novox/hq ADR 0340 §7): what a port reached from `local-network` resolves from.
|
||||
Networks []AttachedNetwork `json:"networks"`
|
||||
Unknown map[string]string `json:"unknown"`
|
||||
}
|
||||
|
||||
// AttachedNetwork is one network a machine said it is attached to, and the link it is on.
|
||||
type AttachedNetwork struct {
|
||||
Link string `json:"link"`
|
||||
Network string `json:"network"`
|
||||
}
|
||||
|
||||
// MountedFilesystem is one local filesystem a machine has mounted.
|
||||
|
||||
@@ -40,3 +40,18 @@ func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) {
|
||||
t.Error("a node that never reported read as having reported")
|
||||
}
|
||||
}
|
||||
|
||||
// The networks a machine is attached to (novox/hq ADR 0340 §7), in the node-engine's words: what a port
|
||||
// reached from `local-network` resolves from.
|
||||
func TestAMachinesAttachedNetworksAreRead(t *testing.T) {
|
||||
a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux",
|
||||
"machine":{"observed_at":"2026-10-13T09:00:00Z",
|
||||
"networks":[{"link":"eth0","network":"192.168.1.0/24"},{"link":"eth1","network":"203.0.113.0/24"}]}}`))
|
||||
if err != nil || !had || a.Machine == nil {
|
||||
t.Fatalf("%+v had %v err %v", a, had, err)
|
||||
}
|
||||
n := a.Machine.Networks
|
||||
if len(n) != 2 || n[0].Link != "eth0" || n[0].Network != "192.168.1.0/24" || n[1].Network != "203.0.113.0/24" {
|
||||
t.Fatalf("networks read as %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/secrets"
|
||||
)
|
||||
|
||||
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164): a Servarr
|
||||
// app's API key, an indexer's key, a downloader's password. The provider's software holds the value; the mesh can
|
||||
// neither make one that would work nor fetch it. So the mesh never makes one (§2), a person gives it once per
|
||||
// provider and it is sealed to every consumer of that provider (§3), and until then a consumer holds no credential
|
||||
// for it and is told so (§4), and the rest of it runs.
|
||||
|
||||
// NotGivenError is a pair credential only a person can give, which no person gave for this pair under the keys its
|
||||
// two ends have now: the consumer receives no credential for it, and waits (ADR 0337 §4, §8). It is not a fault of
|
||||
// the machine, so the plan does not refuse the machine for it.
|
||||
type NotGivenError struct {
|
||||
Provision, Consumer, ConsumerModule, Provider, ProviderModule, Local string
|
||||
// Own is set when the credential is the provider's own secret issued outside (ADR 0158, ADR 0228), and names it.
|
||||
Own string
|
||||
// Why is why no value is held, in words that never carry one.
|
||||
Why string
|
||||
}
|
||||
|
||||
func (e *NotGivenError) Error() string {
|
||||
if e.Own != "" {
|
||||
return fmt.Sprintf("%s on %s holds no %s from %s: %s; give it with `secret accept %s %s %s`",
|
||||
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, e.Provider, e.ProviderModule, e.Own)
|
||||
}
|
||||
return fmt.Sprintf("%s on %s holds no %s from %s: %s; it is issued outside the mesh and given with `%s`",
|
||||
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, acceptLine(e.Provider, e.ProviderModule, e.Provision))
|
||||
}
|
||||
|
||||
// IsNotGiven is whether an error is a credential only a person gives, not given yet.
|
||||
func IsNotGiven(err error) bool {
|
||||
var ng *NotGivenError
|
||||
return errors.As(err, &ng)
|
||||
}
|
||||
|
||||
// acceptLine is the one line that gives a provision's credential issued outside, for every consumer of a provider.
|
||||
func acceptLine(provider, providerModule, provision string) string {
|
||||
if providerModule == "" {
|
||||
providerModule = "<provider module>"
|
||||
}
|
||||
return fmt.Sprintf("secret accept %s %s %s", provider, providerModule, provision)
|
||||
}
|
||||
|
||||
// AcceptLine is acceptLine, for the controller's words.
|
||||
func AcceptLine(provider, providerModule, provision string) string {
|
||||
return acceptLine(provider, providerModule, provision)
|
||||
}
|
||||
|
||||
// issuedOutside is whether a module in the catalogue offers the provision with a credential issued outside the
|
||||
// mesh, and which.
|
||||
func (i *Inventory) issuedOutside(ctx context.Context, provision string) (string, bool, error) {
|
||||
shelf, err := i.Catalogue(ctx)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
module, outside := catalogue.OffersIssuedOutside(shelf, provision)
|
||||
return module, outside, nil
|
||||
}
|
||||
|
||||
// ownIssuedOutside is whether a provider's own secret says only an outside party issues it (ADR 0228).
|
||||
func (i *Inventory) ownIssuedOutside(ctx context.Context, providerModule, own string) (string, bool, error) {
|
||||
if providerModule == "" {
|
||||
return own, false, nil
|
||||
}
|
||||
m, err := i.declared(ctx, providerModule)
|
||||
if err != nil {
|
||||
return own, false, err
|
||||
}
|
||||
return own, m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside, nil
|
||||
}
|
||||
|
||||
// GivenPairSecret is the credential of a pair whose provision is issued outside the mesh, as a person gave it, and
|
||||
// never one the mesh makes (ADR 0337 §2, §4). It writes nothing. A value is answered only when a person gave it
|
||||
// (`accepted`) and it is sealed to the keys both ends have now; otherwise a NotGivenError says why — never given, or
|
||||
// given under a key one end no longer has (§8), or a value the mesh made before the rule, which is not given and
|
||||
// never delivered again (§2).
|
||||
func (i *Inventory) GivenPairSecret(ctx context.Context, name, consumer, consumerModule, provider, providerModule,
|
||||
local string) (Secret, error) {
|
||||
consumerKey, err := i.SealingKeyOf(ctx, consumer)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
providerKey, err := i.SealingKeyOf(ctx, provider)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
consumerNode, err := i.NodeByName(ctx, consumer)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
providerNode, err := i.NodeByName(ctx, provider)
|
||||
if err != nil {
|
||||
return Secret{}, err
|
||||
}
|
||||
notGiven := func(why string) (Secret, error) {
|
||||
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
|
||||
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
|
||||
}
|
||||
var held Secret
|
||||
err = i.store.Pool().QueryRow(ctx,
|
||||
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
|
||||
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
|
||||
name, consumerNode.ID, consumerModule, providerNode.ID, local).
|
||||
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin)
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
return notGiven("nobody gave it")
|
||||
case err != nil:
|
||||
return Secret{}, err
|
||||
case held.Origin != OriginAccepted:
|
||||
return notGiven("the value held was made by the mesh, which " + providerModule + "'s software would refuse")
|
||||
case consumerKey == "" || providerKey == "" || held.ConsumerKey != consumerKey || held.ProviderKey != providerKey:
|
||||
return notGiven("it was given under a sealing key one end no longer has")
|
||||
}
|
||||
held.Name, held.Consumer, held.Provider = name, consumer, provider
|
||||
held.ConsumerModule, held.Local = consumerModule, local
|
||||
return held, nil
|
||||
}
|
||||
|
||||
// PairEnd is one consumer of a provision from one provider: the machine, the module there and its local name.
|
||||
type PairEnd struct {
|
||||
Consumer, ConsumerModule, Local string
|
||||
}
|
||||
|
||||
// ProvisionAccepted is what one accept for a provider sealed (ADR 0337 §3): the consumers it reached, those it could
|
||||
// not (no sealing key on their machine), and the machines to send.
|
||||
type ProvisionAccepted struct {
|
||||
Sealed, Unsealable []PairEnd
|
||||
Machines []string
|
||||
}
|
||||
|
||||
// AcceptSecretForProvision seals one value a person gave for a provision issued outside the mesh to the provider's
|
||||
// machine, to every consumer named, and to the operator, in one act under one generation, each row `accepted`
|
||||
// (ADR 0337 §3, issue 165); the plaintext is not kept. The consumers are every one that binds the provision from
|
||||
// this provider now, which the caller finds by resolving every machine. A consumer whose machine has no sealing key
|
||||
// is not sealed to, and said.
|
||||
func (i *Inventory) AcceptSecretForProvision(ctx context.Context, provision, provider, providerModule, value string,
|
||||
consumers []PairEnd) (ProvisionAccepted, error) {
|
||||
m, err := i.declared(ctx, providerModule)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
if !m.IssuedOutsideOf(provision) {
|
||||
return ProvisionAccepted{}, fmt.Errorf("%s does not offer %q with a credential issued outside the mesh "+
|
||||
"(\"credential\": {\"issued-by\": \"outside\"}, ADR 0337), so its consumers' credentials are not given "+
|
||||
"once for the provider", providerModule, provision)
|
||||
}
|
||||
if value == "" {
|
||||
return ProvisionAccepted{}, errors.New("there is nothing to seal")
|
||||
}
|
||||
providerNode, err := i.NodeByName(ctx, provider)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
providerKey, err := i.SealingKeyOf(ctx, provider)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
if providerKey == "" {
|
||||
return ProvisionAccepted{}, fmt.Errorf("%s has no sealing key, so nothing can be sealed to it; a node joins to get one", provider)
|
||||
}
|
||||
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
generation := secrets.Stamp()
|
||||
var out ProvisionAccepted
|
||||
machines := map[string]bool{provider: true}
|
||||
tx, err := i.store.Pool().Begin(ctx)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for _, c := range consumers {
|
||||
consumerNode, err := i.NodeByName(ctx, c.Consumer)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
consumerKey, err := i.SealingKeyOf(ctx, c.Consumer)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
if consumerKey == "" {
|
||||
out.Unsealable = append(out.Unsealable, c)
|
||||
continue
|
||||
}
|
||||
sealed, err := secrets.Accept(value, consumerKey, providerKey)
|
||||
if err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
|
||||
consumer_key, provider_key, operator_sealed, operator_key, origin, local, generation)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
on conflict (name, local, consumer, consumer_module, provider) do update set
|
||||
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
|
||||
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
|
||||
created_at = now(), origin = excluded.origin, generation = excluded.generation,
|
||||
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
|
||||
provision, consumerNode.ID, c.ConsumerModule, providerNode.ID,
|
||||
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
|
||||
forOperator, operatorKey, OriginAccepted, c.Local, generation); err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
out.Sealed = append(out.Sealed, c)
|
||||
machines[c.Consumer] = true
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return ProvisionAccepted{}, err
|
||||
}
|
||||
for n := range machines {
|
||||
out.Machines = append(out.Machines, n)
|
||||
}
|
||||
sort.Strings(out.Machines)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GivenPairsOf is every provision a module on a machine holds a value a person gave for, sealed to the keys both
|
||||
// ends have now: what a wait for a provision's credential is checked against (ADR 0337 §6).
|
||||
func (i *Inventory) GivenPairsOf(ctx context.Context, machine, module string) (map[string]bool, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
`select s.name
|
||||
from secret s
|
||||
join node c on c.id = s.consumer
|
||||
join node p on p.id = s.provider
|
||||
where c.name = $1 and s.consumer_module = $2 and s.origin = 'accepted'
|
||||
and s.consumer_key = c.sealing_key and s.provider_key = p.sealing_key`, machine, module)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[name] = true
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// outsideProvision registers a provider offering tv-api with a credential issued outside the mesh (novox/hq ADR
|
||||
// 0337 §1) and two consumers requiring it, on twoNodesWithKeys' machines.
|
||||
func outsideProvision(t *testing.T, inv *Inventory) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "tv", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
|
||||
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []string{"requests", "indexer"} {
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
|
||||
Secrets: map[string]string{"tv-api": "/run/tv-api.secret"}}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pairRows is how many pair rows the store holds for tv-api.
|
||||
func pairRows(t *testing.T, inv *Inventory) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := inv.store.Pool().QueryRow(t.Context(), `select count(*) from secret where name = 'tv-api'`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// A credential only a person can give is never made (ADR 0337 §2, issue 164): not for a pair with no row, which is
|
||||
// what a newly bound consumer has, and not after a key change; a value the mesh made before the rule is not given.
|
||||
func TestACredentialIssuedOutsideIsNeverMade(t *testing.T) {
|
||||
inv, ctx := twoNodesWithKeys(t)
|
||||
outsideProvision(t, inv)
|
||||
|
||||
_, err := inv.SecretFor(ctx, "tv-api", "consumer", "requests", "provider", "")
|
||||
if !IsNotGiven(err) {
|
||||
t.Fatalf("SecretFor made or refused a credential only a person gives, rather than saying it was not given: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
|
||||
t.Fatalf("not given does not name the one line that gives it: %v", err)
|
||||
}
|
||||
if n := pairRows(t, inv); n != 0 {
|
||||
t.Fatalf("the store holds %d rows for tv-api, which nobody gave", n)
|
||||
}
|
||||
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) {
|
||||
t.Fatalf("a pair nobody gave read as given: %v", err)
|
||||
}
|
||||
if n := pairRows(t, inv); n != 0 {
|
||||
t.Fatalf("reading the pair wrote %d rows", n)
|
||||
}
|
||||
|
||||
// A value the mesh made before the rule (the minted value of issue 164) is not given.
|
||||
if _, err := inv.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider,
|
||||
for_consumer, for_provider, consumer_key, provider_key, local)
|
||||
select 'tv-api', c.id, 'indexer', p.id, 'x', 'y', c.sealing_key, p.sealing_key, ''
|
||||
from node c, node p where c.name = 'consumer' and p.name = 'provider'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "indexer", "provider", "tv", ""); !IsNotGiven(err) ||
|
||||
!strings.Contains(err.Error(), "made by the mesh") {
|
||||
t.Fatalf("a value the mesh made before the rule read as given: %v", err)
|
||||
}
|
||||
|
||||
// Given, then a key changes: not given, and nothing is made in its place (ADR 0337 §8).
|
||||
if _, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key",
|
||||
[]PairEnd{{Consumer: "consumer", ConsumerModule: "requests"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); err != nil {
|
||||
t.Fatalf("the value given does not read as given: %v", err)
|
||||
}
|
||||
cn, _ := inv.NodeByName(ctx, "consumer")
|
||||
key, _ := aSealingKey(t)
|
||||
if err := inv.RecordSealingKey(ctx, cn.ID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) ||
|
||||
!strings.Contains(err.Error(), "sealing key") {
|
||||
t.Fatalf("a value given under a key the consumer no longer has read as given: %v", err)
|
||||
}
|
||||
var origin string
|
||||
if err := inv.store.Pool().QueryRow(ctx, `select origin from secret where name = 'tv-api' and consumer_module = 'requests'`).
|
||||
Scan(&origin); err != nil || origin != OriginAccepted {
|
||||
t.Fatalf("the given row was replaced (origin %q, %v)", origin, err)
|
||||
}
|
||||
|
||||
// Rotate refuses it, naming the accept (ADR 0337 §9).
|
||||
err = inv.RotateSecret(ctx, "tv-api", "consumer", "requests", "provider", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
|
||||
t.Fatalf("rotate of a credential issued outside was not refused naming the accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One accept for a provider is sealed to every consumer named, to the provider and to the operator, under one
|
||||
// generation, each row accepted (ADR 0337 §3, issue 165); a consumer with no sealing key is said, not sealed to.
|
||||
func TestOneAcceptForAProviderIsSealedToEveryConsumer(t *testing.T) {
|
||||
inv, ctx := twoNodesWithKeys(t)
|
||||
outsideProvision(t, inv)
|
||||
operatorKey, operatorOpens := aSealingKey(t)
|
||||
if _, err := inv.SetOperatorKey(ctx, operatorKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
consumerKey, consumerOpens := aSealingKey(t)
|
||||
cn, _ := inv.NodeByName(ctx, "consumer")
|
||||
if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := inv.AddNode(ctx, "keyless"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
accepted, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key", []PairEnd{
|
||||
{Consumer: "consumer", ConsumerModule: "requests"}, {Consumer: "consumer", ConsumerModule: "indexer"},
|
||||
{Consumer: "keyless", ConsumerModule: "requests"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(accepted.Sealed) != 2 || len(accepted.Unsealable) != 1 || accepted.Unsealable[0].Consumer != "keyless" {
|
||||
t.Fatalf("the accept did not seal to both consumers with keys and say the keyless one: %+v", accepted)
|
||||
}
|
||||
if strings.Join(accepted.Machines, ",") != "consumer,provider" {
|
||||
t.Fatalf("the machines to send are the provider's and every consumer's sealed to: %v", accepted.Machines)
|
||||
}
|
||||
rows, err := inv.store.Pool().Query(ctx, `select consumer_module, origin, coalesce(generation, ''), for_consumer,
|
||||
coalesce(operator_sealed, '') from secret where name = 'tv-api'`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
generations := map[string]bool{}
|
||||
seen := 0
|
||||
for rows.Next() {
|
||||
var module, origin, generation, forConsumer, forOperator string
|
||||
if err := rows.Scan(&module, &origin, &generation, &forConsumer, &forOperator); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen++
|
||||
if origin != OriginAccepted || generation == "" {
|
||||
t.Fatalf("%s's row is not accepted under a generation (origin %q)", module, origin)
|
||||
}
|
||||
generations[generation] = true
|
||||
if opened, ok := consumerOpens(forConsumer); !ok || string(opened) != "the app's key" {
|
||||
t.Fatalf("%s's copy does not open to the value given", module)
|
||||
}
|
||||
if opened, ok := operatorOpens(forOperator); !ok || string(opened) != "the app's key" {
|
||||
t.Fatalf("%s's operator copy does not open to the value given", module)
|
||||
}
|
||||
}
|
||||
if seen != 2 || len(generations) != 1 {
|
||||
t.Fatalf("%d rows under %d generations; want 2 under one", seen, len(generations))
|
||||
}
|
||||
given, err := inv.GivenPairsOf(ctx, "consumer", "indexer")
|
||||
if err != nil || !given["tv-api"] {
|
||||
t.Fatalf("the provision given does not read as given for indexer (%v)", err)
|
||||
}
|
||||
|
||||
// Only an offer that says so is given this way.
|
||||
if _, err := inv.AcceptSecretForProvision(ctx, "secret", "provider", "gitea", "x", nil); err == nil {
|
||||
t.Fatal("a provision not issued outside was given once for its provider")
|
||||
}
|
||||
}
|
||||
|
||||
// The provider's own secret issued outside the mesh, named as a provision's shared credential (ADR 0158, ADR 0228),
|
||||
// is never made for a consumer that binds: the consumer is told it was not given (ADR 0337 §2, §4).
|
||||
func TestASharedOwnSecretIssuedOutsideIsNeverMade(t *testing.T) {
|
||||
inv, ctx := twoNodesWithKeys(t)
|
||||
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "downloader", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password", Taken: catalogue.TakenAtStart,
|
||||
IssuedBy: catalogue.IssuedOutside}},
|
||||
Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh,
|
||||
Credential: &catalogue.OfferCredential{Own: "password"}}}}, Source{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "gitea", "provider", "downloader", "", "password")
|
||||
if !IsNotGiven(err) {
|
||||
t.Fatalf("a shared own secret issued outside was made or refused rather than not given: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from module_secret where module = 'downloader'`).Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("the mesh made %d values of an own secret issued outside (%v)", n, err)
|
||||
}
|
||||
if err := inv.remakeShared(ctx, nil, "", "downloader", "password", "", nil, "", "", ""); err == nil ||
|
||||
!strings.Contains(err.Error(), "issued outside") {
|
||||
t.Fatalf("remakeShared made a value of an own secret issued outside: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -220,6 +220,11 @@ type PlanDelivery struct {
|
||||
// Withheld are, on a walk cut from a batch a stop walked merges again in, the modules it did not build: a
|
||||
// stopped merge moves them, and built only as dependents at the branch they would deliver it (ADR 0299).
|
||||
Withheld []string `json:"withheld,omitempty"`
|
||||
// Unchanged are the modules a merge it answers changed files in the directory of, whose recorded build source
|
||||
// holds none of them, each with why (novox/hq issue 611, ADR 0267 §5): not built, because the build the mesh
|
||||
// holds is their build. A delivery plan that read them whole named them; this says they matched what runs,
|
||||
// rather than that the walk left them unbuilt. Modules keeps meaning what the walk builds.
|
||||
Unchanged map[string]string `json:"unchanged,omitempty"`
|
||||
// Batch is the window of a batch; nil once it is cut into a walk.
|
||||
Batch *PlanBatch `json:"batch,omitempty"`
|
||||
// Alone says the walk walks one merge on its own commit, after a failed walk carried it in a later one:
|
||||
@@ -281,6 +286,9 @@ type PlanModule struct {
|
||||
FirstAt *time.Time `json:"first_at,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Why string `json:"why,omitempty"`
|
||||
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
|
||||
// request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here.
|
||||
Made string `json:"made,omitempty"`
|
||||
// Build is the id of the build the plan asked for this module (novox/hq ADR 0219), so the plan
|
||||
// matches its outcome by id — the one thing every outcome echoes, a failed one that never learnt
|
||||
// its module's name included. Empty in a plan from before it was kept, which is matched by
|
||||
@@ -318,13 +326,25 @@ type PlanGate struct {
|
||||
// machine's report is held against. Absent on a gate kept before it was, which reads the report
|
||||
// against the send made last, as before.
|
||||
Sent map[string]SentDeclaration `json:"sent,omitempty"`
|
||||
// Applied is, per machine, the time of the first report the gate read of its send applied: the
|
||||
// node-engine started the build before it, so a witness window counted from it ends no earlier than the
|
||||
// node-engine's own (novox/hq ADR 0351 decisions 1 and 4).
|
||||
Applied map[string]time.Time `json:"applied,omitempty"`
|
||||
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
|
||||
// does not resets them.
|
||||
Passes int `json:"passes,omitempty"`
|
||||
// Wants is how many passes the send needs: a core module's three, a leaf module's one (novox/hq ADR 0282
|
||||
// decision 4 as ADR 0304 decision 3 changed it). Zero in a gate judged before it was said: three.
|
||||
// Wants is how many passes the send needs: three for the node-engine or the mesh's resolver, or fewer at two
|
||||
// minutes when healthy throughout, one for any other module (novox/hq ADR 0351 and ADR 0352, changing ADR
|
||||
// 0282 decision 4 and ADR 0304 decision 3). Zero in a gate
|
||||
// judged before it was said: three.
|
||||
Wants int `json:"wants,omitempty"`
|
||||
LastPass *time.Time `json:"last_pass,omitempty"`
|
||||
// Faulted says a judging found the send broken, or not healthy after a healthy reading: a module under the three-reading
|
||||
// rule then passes on its readings alone, never at its two minutes (novox/hq ADR 0352).
|
||||
Faulted bool `json:"faulted,omitempty"`
|
||||
// HealthySeen says a judging found the send healthy, counted or not: one taken before a module's floor
|
||||
// counts too, so a dip after it is a fault (novox/hq ADR 0352 decision 3).
|
||||
HealthySeen bool `json:"healthy_seen,omitempty"`
|
||||
// Last is what the newest judging found wanting, while it still may pass.
|
||||
Last string `json:"last,omitempty"`
|
||||
// Verdict is empty while judging, then passed or failed, with Why, at JudgedAt, Took after Since.
|
||||
@@ -461,6 +481,20 @@ type PlanRelease struct {
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
// By is the person who released it, empty when the mesh did.
|
||||
By string `json:"by,omitempty"`
|
||||
// Settings is set on a settings walk (novox/hq ADR 0344): not the builds waiting for a gate, but a settings
|
||||
// layer the operator approved, sent to the machines it reaches one at a time, its module judged on each.
|
||||
Settings *PlanSettings `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
// PlanSettings is what a settings walk sends (novox/hq ADR 0344): the module whose layer the operator approved, the
|
||||
// layer's scope, the ask that approved it and what changed, in words.
|
||||
type PlanSettings struct {
|
||||
Module string `json:"module"`
|
||||
// Node is the machine the layer is for, empty for the whole mesh.
|
||||
Node string `json:"node,omitempty"`
|
||||
// Ask is the proposal the operator approved, and Change what its layer changed ("~ prefs").
|
||||
Ask string `json:"ask"`
|
||||
Change string `json:"change,omitempty"`
|
||||
}
|
||||
|
||||
// The states a plan passes through.
|
||||
|
||||
@@ -104,6 +104,21 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
|
||||
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
|
||||
}
|
||||
|
||||
// **Never made where only a person can give it** (novox/hq ADR 0337 §2, issue 164): a provision whose offer
|
||||
// says `issued-by: outside` gets no value of the mesh's, which its provider's software would refuse. Asked
|
||||
// here, on the one path that makes a value, so no caller can have one made by not knowing; the plan reads
|
||||
// such a pair with GivenPairSecret, which never makes one and delivers only a value a person gave.
|
||||
why := "nobody gave it"
|
||||
if err == nil {
|
||||
why = "the value held was sealed to a key one end no longer has"
|
||||
}
|
||||
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
|
||||
return Secret{}, oerr
|
||||
} else if outside {
|
||||
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
|
||||
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
|
||||
}
|
||||
|
||||
// And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that
|
||||
// makes a vault-provided secret recoverable, and nothing the mesh can open.
|
||||
operator, err := i.OperatorKey(ctx)
|
||||
@@ -239,6 +254,16 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo
|
||||
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
|
||||
and provider = $4 and local = $5`,
|
||||
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
|
||||
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
|
||||
return oerr
|
||||
} else if outside {
|
||||
// Only a person can give it, so only a person can give its next value (novox/hq ADR 0337 §9, ADR 0092 §7):
|
||||
// deleting the row would leave the consumer waiting for nothing new.
|
||||
return fmt.Errorf(
|
||||
"%s is issued outside the mesh: %s's software holds it and the mesh cannot make its replacement. "+
|
||||
"Give the new value with `%s`, which seals it to every consumer of %s on %s",
|
||||
name, providerModule, acceptLine(provider, providerModule, name), providerModule, provider)
|
||||
}
|
||||
if err == nil && origin == OriginAccepted {
|
||||
return fmt.Errorf(
|
||||
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
|
||||
@@ -886,6 +911,15 @@ func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, co
|
||||
held.ConsumerModule, held.Local = consumerModule, local
|
||||
return held, nil
|
||||
}
|
||||
if own, outside, err := i.ownIssuedOutside(ctx, providerModule, own); err != nil {
|
||||
return Secret{}, err
|
||||
} else if outside && (ownOrigin == nil || *ownOrigin != OriginAccepted) {
|
||||
// The provider's own secret only an outside party issues, which nobody gave (ADR 0228, issue 401): the
|
||||
// mesh makes no value for it here either, and the consumer is told it was not given (ADR 0337 §4).
|
||||
return Secret{}, &NotGivenError{Provision: provision, Consumer: consumer, ConsumerModule: consumerModule,
|
||||
Provider: provider, ProviderModule: providerModule, Local: local, Own: own,
|
||||
Why: "nobody gave " + providerModule + "'s own secret " + own + ", which is issued outside the mesh"}
|
||||
}
|
||||
if ownOrigin != nil && *ownOrigin == OriginAccepted {
|
||||
return Secret{}, fmt.Errorf(
|
||||
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
|
||||
@@ -912,6 +946,11 @@ func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKe
|
||||
if len(provisions) == 0 {
|
||||
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
|
||||
}
|
||||
if m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside {
|
||||
// Never made (novox/hq ADR 0228, ADR 0337 §2): only a person can give it, with `secret accept`.
|
||||
return fmt.Errorf("%s's own secret %q is issued outside the mesh, so the mesh makes no value for it; a "+
|
||||
"person gives it with `secret accept <machine> %s %s`", providerModule, own, providerModule, own)
|
||||
}
|
||||
operator, err := i.OperatorKey(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
)
|
||||
|
||||
// A build kept from before ADR 0326 §3 is still read and composed (novox/hq issue 574). betterbird's
|
||||
// builds from before mesh-catalog 8f80cd4 declare a preference and own a mergeable file that names no
|
||||
// keys: refused where a definition is written, but a machine still runs that build and a rollback may
|
||||
// put it back, so reading it again — ManifestAt, which a walk, the gate and a release compose from —
|
||||
// must not refuse it.
|
||||
func TestAStoredBuildWithASilentMergeableFileIsStillReadAndComposed(t *testing.T) {
|
||||
inv := fresh(t)
|
||||
ctx := context.Background()
|
||||
manifest, err := json.Marshal(map[string]any{
|
||||
"module": "mail-client", "version": "1",
|
||||
"settings": map[string]any{"prefs": map[string]any{
|
||||
"kind": catalogue.KindPreference, "default": "// none", "why": "its own defaults until a machine names one"}},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "user-js", "type": "file", "path": "/var/lib/mail-client/user.js", "mode": "0644",
|
||||
"content": "${setting:prefs}\n"},
|
||||
map[string]any{"id": "accounts", "type": "file", "path": "/var/lib/mail-client/accounts.json",
|
||||
"mode": "0600", "merge": "json", "content": "{\"accounts\": {}}\n"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := aBuild("before-574", "mail-client", "")
|
||||
b.Manifest = manifest
|
||||
if err := inv.RecordBuild(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
m, found, err := inv.ManifestAt(ctx, "mail-client", b.Commit)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("the stored build is not read again: found %v, %v", found, err)
|
||||
}
|
||||
if written := catalogue.WrittenSettingsProblems(m); len(written) != 1 {
|
||||
t.Fatalf("the rule this build predates should name its accounts file where it is written: %v", written)
|
||||
}
|
||||
|
||||
// And composed, in today's form: the accounts file still takes its layer's keys.
|
||||
with := catalogue.Rendering{Settings: catalogue.SettingsBy{"mail-client": {{From: "workstation",
|
||||
Values: map[string]any{"accounts": map[string]any{"work": map[string]any{"email": "a@example.invalid"}}}}}}}
|
||||
if err := catalogue.JudgeSettings(m, with.Settings["mail-client"], false); err != nil {
|
||||
t.Fatalf("the stored build's settings do not compose: %v", err)
|
||||
}
|
||||
out, err := catalogue.Resolution{Node: "workstation", Modules: []catalogue.Manifest{m}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatalf("the stored build is not composed: %v", err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r["id"] == "mail-client.accounts" {
|
||||
if content, _ := r["content"].(string); !strings.Contains(content, "a@example.invalid") {
|
||||
t.Fatalf("the accounts file did not take its layer: %v", content)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("the accounts file was not composed: %v", out)
|
||||
}
|
||||
Reference in New Issue
Block a user