Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420a85855d |
@@ -143,7 +143,24 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
|||||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||||
open, _ := anAdoptedAnchor(t)
|
open, _ := anAdoptedAnchor(t)
|
||||||
reportsHolding(t, open, heldContainer, heldFile)
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
said, err := take(t.Context(), open, "anchor", "hello-web", takeOptions{Yes: true})
|
ctx := t.Context()
|
||||||
|
// The machine holds something for the module, so the take acts on the preview the operator
|
||||||
|
// saw and names its digest (novox/hq ADR 0163).
|
||||||
|
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw := takeDigestIn(t, preview)
|
||||||
|
if !strings.Contains(preview, "nothing taken; `take anchor hello-web --yes "+saw+"`") {
|
||||||
|
t.Fatalf("the preview does not say how to act on it:\n%s", preview)
|
||||||
|
}
|
||||||
|
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
||||||
|
t.Fatal("the preview took something")
|
||||||
|
}
|
||||||
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true}); err == nil || !strings.Contains(err.Error(), "name its digest") {
|
||||||
|
t.Fatalf("--yes without the digest was not refused: %v", err)
|
||||||
|
}
|
||||||
|
said, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -153,6 +170,67 @@ func TestTakingNamesWhatItReplaces(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// takeDigestIn is the digest a take's preview printed.
|
||||||
|
func takeDigestIn(t *testing.T, preview string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(preview, "\n") {
|
||||||
|
if fields := strings.Fields(line); len(fields) == 2 && fields[0] == "preview" {
|
||||||
|
return fields[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("the preview printed no digest:\n%s", preview)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// A take acts on the preview the operator saw, and on an account of the machine that is still the
|
||||||
|
// machine: a changed preview and a stale account refuse (novox/hq ADR 0163, rule 1).
|
||||||
|
func TestATakeIsRefusedOnAChangedPreviewOrAStaleAccount(t *testing.T) {
|
||||||
|
open, _ := anAdoptedAnchor(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
reportsHolding(t, open, heldContainer, heldFile)
|
||||||
|
preview, err := take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw := takeDigestIn(t, preview)
|
||||||
|
|
||||||
|
// The machine reports again, and what it holds has changed: the found container now carries
|
||||||
|
// facts the preview never showed.
|
||||||
|
changed := heldContainer
|
||||||
|
changed.Facts = map[string]any{"image": "hello:2", "declared_image": "registry.example/hello"}
|
||||||
|
reportsHolding(t, open, changed, heldFile)
|
||||||
|
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "has changed since preview "+saw) {
|
||||||
|
t.Fatalf("a changed preview was acted on: %v", err)
|
||||||
|
}
|
||||||
|
if taken, _ := open.inventory.Taken(ctx, "anchor"); len(taken) != 0 {
|
||||||
|
t.Fatal("a refused take took something")
|
||||||
|
}
|
||||||
|
|
||||||
|
// And an account older than the flip allows.
|
||||||
|
preview, err = take(ctx, open, "anchor", "hello-web", takeOptions{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
saw = takeDigestIn(t, preview)
|
||||||
|
saved := reportFreshFor
|
||||||
|
reportFreshFor = -time.Second
|
||||||
|
defer func() { reportFreshFor = saved }()
|
||||||
|
_, err = take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "a take acts only on an account newer than") {
|
||||||
|
t.Fatalf("a stale account was acted on: %v", err)
|
||||||
|
}
|
||||||
|
reportFreshFor = saved
|
||||||
|
if _, err := take(ctx, open, "anchor", "hello-web", takeOptions{Yes: true, Digest: saw}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A module the machine holds nothing for has nothing to compare: --yes alone suffices.
|
||||||
|
if _, err := take(ctx, open, "anchor", "notes", takeOptions{Yes: true}); err == nil {
|
||||||
|
// notes holds a file, so this one needs the digest too.
|
||||||
|
t.Fatal("notes holds a found file and was taken without a digest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
func TestConvergingPreviewsThenChangesAndAdoptingKeepsWhatWasTaken(t *testing.T) {
|
||||||
open, sent := anAdoptedAnchor(t)
|
open, sent := anAdoptedAnchor(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
+209
-20
@@ -156,11 +156,25 @@ const DefaultFilter = "nftables"
|
|||||||
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
// take is a module's cutover on an adopted node: the operator's act, done when that module's data
|
||||||
// has moved. From the next push its resources converge there like any other, replacing what the
|
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||||
// node found and holds for it.
|
// node found and holds for it.
|
||||||
|
//
|
||||||
|
// **Previewed, and the preview is a comparison** (novox/hq ADR 0163): for every held thing the
|
||||||
|
// module would replace, what runs beside what the module declares, and the difference; the
|
||||||
|
// module's secrets on the machine and where each came from; its settings on the machine. Without
|
||||||
|
// --yes the comparison is printed and nothing changes. `--yes <digest>` cuts over exactly what was
|
||||||
|
// previewed, the way the flip is confirmed: the preview ends with a digest of what it said, and a
|
||||||
|
// take naming an older one, or acting on an account of the machine older than the flip allows, is
|
||||||
|
// refused. A module the machine holds nothing for has nothing to compare, and `--yes` suffices.
|
||||||
// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163).
|
// takeOptions is what a take was told about the differences it may pass (novox/hq ADR 0163).
|
||||||
type takeOptions struct {
|
type takeOptions struct {
|
||||||
Yes bool
|
Yes bool
|
||||||
|
// Digest is the preview's, named with --yes; required whenever the machine holds something
|
||||||
|
// for the module.
|
||||||
|
Digest string
|
||||||
Downgrade bool
|
Downgrade bool
|
||||||
Replace map[string]bool
|
Replace map[string]bool
|
||||||
|
// Mint names the secrets the service shall take a new value for, although the mesh minted
|
||||||
|
// one and the service already has its own (rule 2).
|
||||||
|
Mint map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) {
|
func take(ctx context.Context, open *stores, node, module string, opts takeOptions) (string, error) {
|
||||||
@@ -179,45 +193,128 @@ func take(ctx context.Context, open *stores, node, module string, opts takeOptio
|
|||||||
}
|
}
|
||||||
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
// The comparison first (novox/hq ADR 0163): every held thing the module would replace, beside
|
||||||
// what the module declares, and the differences that refuse unless named.
|
// what the module declares, and the differences that refuse unless named.
|
||||||
reported, err := inv.AdoptionOf(ctx, node)
|
c, err := comparisonFor(ctx, open, node, module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
preview, refusals := comparisonOf(reported.Held, module, opts)
|
preview, refusals, saw := comparisonOf(module, c, opts)
|
||||||
if len(refusals) > 0 {
|
if len(refusals) > 0 {
|
||||||
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
||||||
strings.Join(refusals, "\n "), preview)
|
strings.Join(refusals, "\n "), preview)
|
||||||
}
|
}
|
||||||
|
holds := len(heldOf(c.reported, module)) > 0
|
||||||
|
if holds {
|
||||||
|
preview += "\n preview " + saw
|
||||||
|
}
|
||||||
if !opts.Yes {
|
if !opts.Yes {
|
||||||
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` cuts it over as previewed", node, module), nil
|
if !holds {
|
||||||
|
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes` declares it as the mesh's own", node, module), nil
|
||||||
|
}
|
||||||
|
return preview + fmt.Sprintf("\nnothing taken; `take %s %s --yes %s` cuts it over as previewed", node, module, saw), nil
|
||||||
|
}
|
||||||
|
if holds {
|
||||||
|
// The take acts on the preview the operator saw, and on an account of the machine that
|
||||||
|
// is still the machine: the same two refusals the flip makes.
|
||||||
|
if age := time.Since(c.reported.At); age > reportFreshFor {
|
||||||
|
return preview, fmt.Errorf("%s last said what it holds %s ago, and a take acts only on "+
|
||||||
|
"an account newer than %s: run `push %s --wait 2m`, then preview again",
|
||||||
|
node, age.Round(time.Second), reportFreshFor, node)
|
||||||
|
}
|
||||||
|
if opts.Digest == "" {
|
||||||
|
return preview, fmt.Errorf("taking %s on %s acts on the preview you saw: name its digest, "+
|
||||||
|
"`take %s %s --yes %s`, once you have read it", module, node, node, module, saw)
|
||||||
|
}
|
||||||
|
if opts.Digest != saw {
|
||||||
|
return preview, fmt.Errorf("what taking %s on %s would replace has changed since preview %s "+
|
||||||
|
"(it is now %s): read the preview above, and run `take %s %s --yes %s` if it is "+
|
||||||
|
"what you want", module, node, opts.Digest, saw, node, module, saw)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := inv.Take(ctx, node, module); err != nil {
|
if err := inv.Take(ctx, node, module); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
said := fmt.Sprintf("%s is taken on %s", module, node)
|
said := fmt.Sprintf("%s is taken on %s", module, node)
|
||||||
if preview != "" {
|
if holds {
|
||||||
said += "; the next push replaces what the node found and holds for it:\n" + preview
|
said += "; the next push replaces what the node found and holds for it:\n" + preview
|
||||||
}
|
}
|
||||||
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
return said + fmt.Sprintf("\n run `push %s` to cut it over", node), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// comparison is everything a take puts beside what the module declares: the machine's account of
|
||||||
|
// what it holds and what is reachable on it, the module's secrets on the machine, its settings
|
||||||
|
// there, and which found networks a setting keeps for each of its containers (by held id).
|
||||||
|
type comparison struct {
|
||||||
|
reported inventory.Adoption
|
||||||
|
secrets []inventory.SecretState
|
||||||
|
layers []catalogue.Layer
|
||||||
|
keeps map[string][]string
|
||||||
|
// settingsRefused is why the module's settings cannot compose with its definition, when
|
||||||
|
// they cannot — the module would be left out of the declaration (rule 6).
|
||||||
|
settingsRefused string
|
||||||
|
}
|
||||||
|
|
||||||
|
func comparisonFor(ctx context.Context, open *stores, node, module string) (comparison, error) {
|
||||||
|
inv := open.inventory
|
||||||
|
var c comparison
|
||||||
|
var err error
|
||||||
|
if c.reported, err = inv.AdoptionOf(ctx, node); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if c.secrets, err = inv.SecretsOf(ctx, node, module); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if c.layers, err = inv.SettingsFor(ctx, node, module); err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
shelf, err := inv.Catalogue(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
if m, known := shelf[module]; known && len(c.layers) > 0 {
|
||||||
|
if err := catalogue.JudgeSettings(m, c.layers, true); err != nil {
|
||||||
|
c.settingsRefused = err.Error()
|
||||||
|
}
|
||||||
|
if kept, err := catalogue.KeptNetworks(m, c.layers, true); err == nil && len(kept) > 0 {
|
||||||
|
c.keeps = map[string][]string{}
|
||||||
|
for id, networks := range kept {
|
||||||
|
c.keeps[module+"."+id] = networks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// heldOf is what a node holds for one module.
|
||||||
|
func heldOf(reported inventory.Adoption, module string) []inventory.Held {
|
||||||
|
var out []inventory.Held
|
||||||
|
for _, h := range reported.Held {
|
||||||
|
if h.Module == module {
|
||||||
|
out = append(out, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the
|
// comparisonOf is a take's preview: for every held thing of the module, what runs beside what the
|
||||||
// module declares, and the refusals the differences earn unless the take named them
|
// module declares; its secrets and its settings on the machine; and the refusals the differences
|
||||||
// (novox/hq ADR 0163): an image older than the one running, a declared file that differs from the
|
// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a
|
||||||
// found one. A narrowed port and a shared network are said and not refused.
|
// declared file that differs from the found one, a secret the mesh minted for a service whose data
|
||||||
func comparisonOf(held []inventory.Held, module string, opts takeOptions) (string, []string) {
|
// was found. A narrowed port and a shared network are said and not refused. The digest is of what
|
||||||
|
// the preview says, so anything in it changing changes the digest.
|
||||||
|
func comparisonOf(module string, c comparison, opts takeOptions) (preview string, refusals []string, digest string) {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
var refusals []string
|
held := heldOf(c.reported, module)
|
||||||
|
foundData := false
|
||||||
for _, h := range held {
|
for _, h := range held {
|
||||||
if h.Module != module {
|
if h.Kind == "container" || h.Kind == "directory" {
|
||||||
continue
|
foundData = true
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, " %s", heldLine(h))
|
fmt.Fprintf(&b, " %s", heldLine(h))
|
||||||
if h.Kept != "" {
|
if h.Kept != "" {
|
||||||
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
||||||
}
|
}
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
for _, line := range comparisonLines(h) {
|
for _, line := range comparisonLinesWith(h, c.keeps[h.ID], c.reported) {
|
||||||
fmt.Fprintf(&b, " %s\n", line)
|
fmt.Fprintf(&b, " %s\n", line)
|
||||||
}
|
}
|
||||||
f := factsOf(h)
|
f := factsOf(h)
|
||||||
@@ -232,7 +329,52 @@ func comparisonOf(held []inventory.Held, module string, opts takeOptions) (strin
|
|||||||
h.Target, h.Target))
|
h.Target, h.Target))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return b.String(), refusals
|
// The module's secrets on the machine (rule 2 and 3): a service whose data was found already
|
||||||
|
// has a value for each, so one the mesh minted and nobody accepted refuses unless --mint says
|
||||||
|
// the service shall take a new one.
|
||||||
|
for _, sec := range c.secrets {
|
||||||
|
name := sec.Name
|
||||||
|
if sec.Local != "" {
|
||||||
|
name += " (" + sec.Local + ")"
|
||||||
|
}
|
||||||
|
what := "own secret"
|
||||||
|
accept := fmt.Sprintf("`secret accept <node> %s %s`", module, sec.Name)
|
||||||
|
if !sec.Own() {
|
||||||
|
what = "secret from " + sec.Provider
|
||||||
|
accept = fmt.Sprintf("`secret accept <node> %s %s --provider %s`", module, sec.Name, sec.Provider)
|
||||||
|
if sec.Local != "" {
|
||||||
|
accept = strings.TrimSuffix(accept, "`") + " --local " + sec.Local + "`"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case sec.Origin == inventory.OriginAccepted:
|
||||||
|
fmt.Fprintf(&b, " %s %s: accepted from a person, carried in as it is\n", what, name)
|
||||||
|
case opts.Mint[sec.Name]:
|
||||||
|
fmt.Fprintf(&b, " %s %s: minted by the mesh; the service takes the new value, as --mint said\n", what, name)
|
||||||
|
case foundData:
|
||||||
|
fmt.Fprintf(&b, " %s %s: MINTED by the mesh and not accepted — the running service already has one\n", what, name)
|
||||||
|
refusals = append(refusals, fmt.Sprintf("%s: the mesh minted a value and the service whose data was found "+
|
||||||
|
"already uses its own; %s carries the existing value in, or `--mint %s` says the service shall take "+
|
||||||
|
"the new one", name, accept, sec.Name))
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(&b, " %s %s: minted by the mesh\n", what, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And its settings on this machine, composed against its definition (rule 1, rule 6).
|
||||||
|
for _, layer := range c.layers {
|
||||||
|
keys := make([]string, 0, len(layer.Values))
|
||||||
|
for k := range layer.Values {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
fmt.Fprintf(&b, " settings from %s: %s\n", layer.From, strings.Join(keys, ", "))
|
||||||
|
}
|
||||||
|
if c.settingsRefused != "" {
|
||||||
|
fmt.Fprintf(&b, " SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: %s\n", c.settingsRefused)
|
||||||
|
}
|
||||||
|
preview = strings.TrimRight(b.String(), "\n")
|
||||||
|
sum := sha256.Sum256([]byte(preview))
|
||||||
|
return preview, refusals, hex.EncodeToString(sum[:])[:12]
|
||||||
}
|
}
|
||||||
|
|
||||||
// facts is a held thing's facts as the preview reads them.
|
// facts is a held thing's facts as the preview reads them.
|
||||||
@@ -286,6 +428,13 @@ func factsOf(h inventory.Held) facts {
|
|||||||
|
|
||||||
// comparisonLines says a held thing's facts the way a person weighs them.
|
// comparisonLines says a held thing's facts the way a person weighs them.
|
||||||
func comparisonLines(h inventory.Held) []string {
|
func comparisonLines(h inventory.Held) []string {
|
||||||
|
return comparisonLinesWith(h, nil, inventory.Adoption{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// comparisonLinesWith is comparisonLines knowing which found networks this machine's setting keeps
|
||||||
|
// for the container (rule 4) and what the machine reports reachable, so a published port's reach
|
||||||
|
// is said beside the port (rule 1).
|
||||||
|
func comparisonLinesWith(h inventory.Held, keeps []string, reported inventory.Adoption) []string {
|
||||||
f := factsOf(h)
|
f := factsOf(h)
|
||||||
var out []string
|
var out []string
|
||||||
if f.image != "" || f.declaredImage != "" {
|
if f.image != "" || f.declaredImage != "" {
|
||||||
@@ -311,14 +460,46 @@ func comparisonLines(h inventory.Held) []string {
|
|||||||
}
|
}
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
for _, n := range names {
|
for _, n := range names {
|
||||||
if members := f.networks[n]; len(members) > 0 {
|
members := f.networks[n]
|
||||||
out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network",
|
if len(members) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if slices.Contains(keeps, n) {
|
||||||
|
out = append(out, fmt.Sprintf("on the network %s with %s — kept by this machine's setting, so they still reach it by name once taken",
|
||||||
n, strings.Join(members, ", ")))
|
n, strings.Join(members, ", ")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, fmt.Sprintf("on the network %s with %s, which may reach it by name and will not once it moves to the module's own network"+
|
||||||
|
" (`settings set %s --node <node>` with {%q: {<container>: [%q]}} keeps it)",
|
||||||
|
n, strings.Join(members, ", "), h.Module, catalogue.NetworksSetting, n))
|
||||||
|
}
|
||||||
|
for _, n := range keeps {
|
||||||
|
if _, found := f.networks[n]; !found {
|
||||||
|
out = append(out, fmt.Sprintf("keeps the network %s by this machine's setting, which the found container is not on", n))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(f.ports) > 0 || len(f.declaredPorts) > 0 {
|
if len(f.ports) > 0 || len(f.declaredPorts) > 0 {
|
||||||
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
||||||
orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " "))))
|
orNone(strings.Join(f.ports, " ")), orNone(strings.Join(f.declaredPorts, " "))))
|
||||||
|
// How far each published port reaches now, as the machine reported it: the listener the
|
||||||
|
// runtime publishes for this container. The found firewall's and the guard's rules are
|
||||||
|
// not read; what they let through is said as what was reported reachable.
|
||||||
|
var reach []string
|
||||||
|
for _, r := range reported.Reachable {
|
||||||
|
if r.By == h.Target && r.Published {
|
||||||
|
reach = append(reach, fmt.Sprintf("%s:%d (%s, container port %d)", r.Address, r.Port, r.Protocol, r.ContainerPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(reach) > 0:
|
||||||
|
line := "reachable now at " + strings.Join(reach, ", ")
|
||||||
|
if reported.Firewall != "" && reported.Firewall != "none" {
|
||||||
|
line += ", behind the found firewall (" + reported.Firewall + "), whose rules are not read"
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
case len(f.ports) > 0 && len(reported.Reachable) > 0:
|
||||||
|
out = append(out, "not reported reachable on the machine")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 {
|
if len(f.mounts) > 0 || len(f.declaredVolumes) > 0 {
|
||||||
out = append(out, fmt.Sprintf("mounts %s; the module declares %s",
|
out = append(out, fmt.Sprintf("mounts %s; the module declares %s",
|
||||||
@@ -767,21 +948,29 @@ func adopt(ctx context.Context, open *stores, node string) (string, error) {
|
|||||||
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
// takeCommand, convergeCommand and adoptCommand are the command line's adapters to the acts above.
|
||||||
func takeCommand(ctx context.Context, args []string) error {
|
func takeCommand(ctx context.Context, args []string) error {
|
||||||
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
set := flag.NewFlagSet("take", flag.ContinueOnError)
|
||||||
yes := set.Bool("yes", false, "cut over as previewed; without it the comparison is printed and nothing is taken")
|
yes := set.Bool("yes", false, "cut over as previewed, naming the digest the preview printed after it; "+
|
||||||
|
"without it the comparison is printed and nothing is taken")
|
||||||
downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running")
|
downgrade := set.Bool("downgrade", false, "take it although the module's image is older than the one running")
|
||||||
var replace stringList
|
var replace, mint stringList
|
||||||
set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)")
|
set.Var(&replace, "replace", "a found file's path whose content the module may replace although it differs (repeatable; * for every one)")
|
||||||
|
set.Var(&mint, "mint", "a secret the service shall take the mesh's minted value for, although it already has its own (repeatable)")
|
||||||
positionals, err := parseAround(set, args)
|
positionals, err := parseAround(set, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(positionals) != 2 {
|
if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) {
|
||||||
return errors.New("take <node> <module> [--yes] [--downgrade] [--replace <path>]...")
|
return errors.New("take <node> <module> [--yes <digest>] [--downgrade] [--replace <path>]... [--mint <secret>]...")
|
||||||
|
}
|
||||||
|
opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}, Mint: map[string]bool{}}
|
||||||
|
if len(positionals) == 3 {
|
||||||
|
opts.Digest = positionals[2]
|
||||||
}
|
}
|
||||||
opts := takeOptions{Yes: *yes, Downgrade: *downgrade, Replace: map[string]bool{}}
|
|
||||||
for _, r := range replace {
|
for _, r := range replace {
|
||||||
opts.Replace[r] = true
|
opts.Replace[r] = true
|
||||||
}
|
}
|
||||||
|
for _, m := range mint {
|
||||||
|
opts.Mint[m] = true
|
||||||
|
}
|
||||||
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) })
|
return runAct(ctx, func(open *stores) (string, error) { return take(ctx, open, positionals[0], positionals[1], opts) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
|||||||
}))
|
}))
|
||||||
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
// Adoption (novox/hq ADR 0100): the same acts as `take`, `converge` and `adopt`.
|
||||||
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /take", acting(who, true, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return take(ctx, open, in.Node, in.Module, takeOptions{Yes: true})
|
return take(ctx, open, in.Node, in.Module, takeOptions{Yes: in.Yes, Digest: in.Digest})
|
||||||
}))
|
}))
|
||||||
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
mux.HandleFunc("POST /converge", acting(who, false, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||||
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
return converge(ctx, open, in.Node, in.Yes, in.Digest, in.Filter)
|
||||||
@@ -124,8 +124,8 @@ func commands(who Authenticator) http.Handler {
|
|||||||
type request struct {
|
type request struct {
|
||||||
Node string `json:"node"`
|
Node string `json:"node"`
|
||||||
Module string `json:"module"`
|
Module string `json:"module"`
|
||||||
// Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the
|
// Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest
|
||||||
// preview it acts on, and which module loads the mesh's filter.
|
// of the preview it acts on, and (converge) which module loads the mesh's filter.
|
||||||
Yes bool `json:"yes,omitempty"`
|
Yes bool `json:"yes,omitempty"`
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
Filter string `json:"filter,omitempty"`
|
Filter string `json:"filter,omitempty"`
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ func usage() {
|
|||||||
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here
|
||||||
assign <node> <module> put a module on a node
|
assign <node> <module> put a module on a node
|
||||||
unassign <node> <module> take it off
|
unassign <node> <module> take it off
|
||||||
take <node> <module> cut a module over on an adopted node, once its data has moved
|
take <node> <module> preview a module's cutover on an adopted node: what runs beside
|
||||||
|
what it declares; --yes <digest> cuts it over as previewed
|
||||||
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
converge <node> [--yes <digest>] [--filter nftables] preview, then make, an adopted node converged
|
||||||
adopt <node> return a converged node to adopted; what was taken stays taken
|
adopt <node> return a converged node to adopted; what was taken stays taken
|
||||||
settings set <module> <file> what a module's config should say, for the whole mesh
|
settings set <module> <file> what a module's config should say, for the whole mesh
|
||||||
|
|||||||
+49
-15
@@ -186,8 +186,12 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
// Settings for everything that resolved, including modules nobody assigned directly: a
|
// Settings for everything that resolved, including modules nobody assigned directly: a
|
||||||
// requirement pulled in by something else is still configurable, and finding out that it is
|
// requirement pulled in by something else is still configurable, and finding out that it is
|
||||||
// not only when you try would be an arbitrary line nobody could predict.
|
// not only when you try would be an arbitrary line nobody could predict.
|
||||||
|
//
|
||||||
|
// A setting that reaches nothing, or cannot compose with the definition it was stored for,
|
||||||
|
// no longer refuses the machine here: it is judged where it is stored, and a definition that
|
||||||
|
// moved under it costs that module its place in the declaration, said by name (novox/hq ADR
|
||||||
|
// 0163, rule 6 — see Compose).
|
||||||
settings := catalogue.SettingsBy{}
|
settings := catalogue.SettingsBy{}
|
||||||
var stray []string
|
|
||||||
for _, m := range resolved.Modules {
|
for _, m := range resolved.Modules {
|
||||||
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -197,18 +201,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
settings[m.Module] = layers
|
settings[m.Module] = layers
|
||||||
stray = append(stray, catalogue.UnusedSettings(m, layers)...)
|
|
||||||
}
|
|
||||||
if len(stray) > 0 {
|
|
||||||
// Somebody set something that reaches no file. Said here rather than discovered by the
|
|
||||||
// machine not behaving differently, which is the slowest way there is.
|
|
||||||
//
|
|
||||||
// Marked like a set that will not compose, and for the same reason: it is a standing fact
|
|
||||||
// about this node's own configuration, not a question the mesh could not answer. A gatherer
|
|
||||||
// passes over it as it always did — one node's stray setting must not stop every other node
|
|
||||||
// being described (novox/hq 04-ISSUES/152).
|
|
||||||
return catalogue.Resolution{}, nil, notResolvable{fmt.Errorf(
|
|
||||||
"these settings reach nothing:\n - %s", strings.Join(stray, "\n - "))}
|
|
||||||
}
|
}
|
||||||
return resolved, settings, nil
|
return resolved, settings, nil
|
||||||
}
|
}
|
||||||
@@ -404,7 +396,32 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
return sendable{Resources: composed.Resources, Adoption: adoption}, nil
|
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||||
|
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortedKeysOf is a map's keys, sorted — so what a declaration says it left out does not move
|
||||||
|
// for a reordering nobody made.
|
||||||
|
func sortedKeysOf(m map[string]string) []string {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportLeftOut says which of a machine's modules its declaration leaves out and why (novox/hq ADR
|
||||||
|
// 0163, rule 6), one line each: the machine is told everything else, and is told it was left out.
|
||||||
|
func reportLeftOut(node string, declared sendable) {
|
||||||
|
for _, m := range declared.LeftOut {
|
||||||
|
fmt.Printf("%s: %s left out — a setting stored for it cannot compose with its definition; "+
|
||||||
|
"what the machine holds for it is kept and its containers are untouched. %s\n",
|
||||||
|
node, m, declared.leftOutWhy[m])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
// renderingFor is everything a node's declaration is composed with, and the node's record.
|
||||||
@@ -444,7 +461,10 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
|||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return catalogue.Rendering{}, inventory.Node{}, err
|
// A given port its definition no longer publishes: the module is left out of the
|
||||||
|
// declaration, by name, when it is composed (novox/hq ADR 0163, rule 6) — never the
|
||||||
|
// machine refused here for it.
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if g != nil {
|
if g != nil {
|
||||||
given[m.Module] = g
|
given[m.Module] = g
|
||||||
@@ -999,6 +1019,20 @@ func planCommand(ctx context.Context, args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Which modules a push would leave out, and why — said before the plan, since the plan is of
|
||||||
|
// what the machine would be told (novox/hq ADR 0163, rule 6). Judged, never composed: `plan`
|
||||||
|
// without --json allocates nothing.
|
||||||
|
if record, err := open.inventory.NodeByName(ctx, args[0]); err == nil {
|
||||||
|
left := plan.LeftOut(settings, record.Adopted)
|
||||||
|
reportLeftOut(args[0], sendable{LeftOut: sortedKeysOf(left), leftOutWhy: left})
|
||||||
|
}
|
||||||
|
// And a setting that reaches nothing — refused where it is stored, and said here for one
|
||||||
|
// stored before its definition moved from under it.
|
||||||
|
for _, m := range plan.Modules {
|
||||||
|
for _, stray := range catalogue.UnusedSettings(m, settings[m.Module]) {
|
||||||
|
fmt.Printf("%s: a setting reaches nothing — %s\n", args[0], stray)
|
||||||
|
}
|
||||||
|
}
|
||||||
fmt.Printf("%s would run:\n", args[0])
|
fmt.Printf("%s would run:\n", args[0])
|
||||||
for _, m := range plan.Modules {
|
for _, m := range plan.Modules {
|
||||||
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module])
|
||||||
|
|||||||
@@ -353,7 +353,11 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
// The private network is in here with everything else. It used to be composed separately
|
// The private network is in here with everything else. It used to be composed separately
|
||||||
// and prepended, which meant every machine with an address was on it and no machine could
|
// and prepended, which meant every machine with an address was on it and no machine could
|
||||||
// be kept off. It is a module now, so it arrives the way a module does.
|
// be kept off. It is a module now, so it arrives the way a module does.
|
||||||
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err == nil {
|
||||||
|
reportLeftOut(node, declared)
|
||||||
|
}
|
||||||
|
return declared, err
|
||||||
})
|
})
|
||||||
|
|
||||||
sentDigest := map[string]string{}
|
sentDigest := map[string]string{}
|
||||||
@@ -458,7 +462,11 @@ func pushCommand(ctx context.Context, args []string) error {
|
|||||||
return sendable{}, err
|
return sendable{}, err
|
||||||
}
|
}
|
||||||
reportUnhostable(node, plan)
|
reportUnhostable(node, plan)
|
||||||
return declarationWith(held, open, node, plan, settings, gens, Allocating)
|
declared, err := declarationWith(held, open, node, plan, settings, gens, Allocating)
|
||||||
|
if err == nil {
|
||||||
|
reportLeftOut(node, declared)
|
||||||
|
}
|
||||||
|
return declared, err
|
||||||
},
|
},
|
||||||
func(s readyNode, body []byte) error {
|
func(s readyNode, body []byte) error {
|
||||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
||||||
@@ -670,6 +678,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
|||||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
reportLeftOut(name, declared)
|
||||||
sending = append(sending, readyNode{name, declared})
|
sending = append(sending, readyNode{name, declared})
|
||||||
}
|
}
|
||||||
if len(refusals) > 0 {
|
if len(refusals) > 0 {
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ type sendable struct {
|
|||||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
// Adoption is nil for a converged node, and then the body is byte for byte what it was before
|
||||||
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
// adoption existed: an older host parses the envelope strictly and would refuse the key.
|
||||||
Adoption *adoptionEnvelope
|
Adoption *adoptionEnvelope
|
||||||
|
// LeftOut is every module of the machine's set left out of this declaration because a stored
|
||||||
|
// setting cannot compose with its definition (novox/hq ADR 0163, rule 6), sorted. The host
|
||||||
|
// keeps that module's held things and touches none of its containers; a machine is told
|
||||||
|
// everything or nothing about what it IS told, and what it is not told is said. Absent from
|
||||||
|
// the body when empty, so a declaration that leaves nothing out is byte for byte what it was.
|
||||||
|
LeftOut []string
|
||||||
|
// leftOutWhy is why each was, for push and plan to say; never on the wire.
|
||||||
|
leftOutWhy map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
// adoptionEnvelope is what an adopted node is told about its mode. Taken is every module taken on
|
||||||
@@ -45,6 +53,9 @@ func (s sendable) Body() ([]byte, error) {
|
|||||||
if s.Sequence > 0 {
|
if s.Sequence > 0 {
|
||||||
envelope["sequence"] = s.Sequence
|
envelope["sequence"] = s.Sequence
|
||||||
}
|
}
|
||||||
|
if len(s.LeftOut) > 0 {
|
||||||
|
envelope["left_out"] = s.LeftOut
|
||||||
|
}
|
||||||
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
// An empty declaration is deliberate here — the node owns nothing the mesh put there
|
||||||
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
// (novox/hq issue 127) — and the host refuses an empty body unless it is told the emptiness
|
||||||
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
// is meant, so a truncated or mis-composed body is never mistaken for "own nothing".
|
||||||
|
|||||||
@@ -382,3 +382,62 @@ func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
|||||||
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
t.Fatalf("a non-empty declaration must not mark owns_nothing; got %v", env)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
||||||
|
// (novox/hq ADR 0163, rule 6): stored while it composed, a setting whose definition then moved from
|
||||||
|
// under it leaves that module out of the declaration — said in the envelope, so the host keeps the
|
||||||
|
// module's things — and the machine is told everything else.
|
||||||
|
func TestADefinitionMovingUnderAStoredSettingLeavesThatModuleOutNotTheMachine(t *testing.T) {
|
||||||
|
open := aMesh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
web := helloWeb()
|
||||||
|
web.Resources[1]["ports"] = []any{"8080"}
|
||||||
|
register(t, open, web)
|
||||||
|
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/notes.conf", "content": "x"}}})
|
||||||
|
for _, m := range []string{"hello-web", "notes"} {
|
||||||
|
if _, err := assign(ctx, open, "laptop", m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Judged where it is stored: a port the module does not publish is refused by name.
|
||||||
|
err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "hello-web on laptop") || !strings.Contains(err.Error(), "9999") {
|
||||||
|
t.Fatalf("an impossible setting was stored: %v", err)
|
||||||
|
}
|
||||||
|
if err := open.inventory.SetSettings(ctx, "laptop", "hello-web",
|
||||||
|
map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if declared := composed(t, open, "laptop"); len(declared.LeftOut) != 0 {
|
||||||
|
t.Fatalf("a setting that composes left a module out: %v", declared.LeftOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The definition moves: the container publishes another port now.
|
||||||
|
web.Version = "2"
|
||||||
|
web.Resources[1]["ports"] = []any{"9090"}
|
||||||
|
register(t, open, web)
|
||||||
|
declared := composed(t, open, "laptop")
|
||||||
|
if len(declared.LeftOut) != 1 || declared.LeftOut[0] != "hello-web" {
|
||||||
|
t.Fatalf("hello-web is not left out: %v", declared.LeftOut)
|
||||||
|
}
|
||||||
|
if !strings.Contains(declared.leftOutWhy["hello-web"], "no container of its publishes 8080") {
|
||||||
|
t.Fatalf("why it was left out is not said: %v", declared.leftOutWhy)
|
||||||
|
}
|
||||||
|
if hasID(declared.Resources, "hello-web.server") || !hasID(declared.Resources, "notes.conf") {
|
||||||
|
t.Fatalf("the machine was not told everything else: %v", declared.Resources)
|
||||||
|
}
|
||||||
|
body, err := declared.Body()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var env map[string]any
|
||||||
|
if err := json.Unmarshal(body, &env); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
left, _ := env["left_out"].([]any)
|
||||||
|
if len(left) != 1 || left[0] != "hello-web" {
|
||||||
|
t.Fatalf("the envelope does not say what was left out: %v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
"github.com/novox/mesh-controller/internal/inventory"
|
"github.com/novox/mesh-controller/internal/inventory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module
|
// What the forge's take compares, as a machine would report it.
|
||||||
// declares, and an older image or a differing file refuses unless named.
|
func aForgeComparison() comparison {
|
||||||
func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
return comparison{reported: inventory.Adoption{
|
||||||
held := []inventory.Held{
|
Firewall: "ufw",
|
||||||
|
Held: []inventory.Held{
|
||||||
{ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{
|
{ID: "forge.server", Module: "forge", Kind: "container", Target: "forge", Facts: map[string]any{
|
||||||
"image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z",
|
"image": "forge:1.27.3", "image_created": "2026-09-17T10:00:00Z",
|
||||||
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
||||||
@@ -20,10 +22,24 @@ func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
|||||||
{ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini",
|
{ID: "forge.config", Module: "forge", Kind: "file", Target: "/etc/forge/app.ini", Kept: "/var/lib/mesh/kept/app.ini",
|
||||||
Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}},
|
Facts: map[string]any{"differs": true, "difference": []any{"- private scope: local", "+ upstream: public"}}},
|
||||||
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
||||||
}
|
},
|
||||||
preview, refusals := comparisonOf(held, "forge", takeOptions{})
|
Reachable: []inventory.Reach{
|
||||||
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 3000, By: "forge", Published: true, ContainerPort: 3000},
|
||||||
|
{Protocol: "tcp", Address: "0.0.0.0", Port: 22, By: "sshd"},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A take is a comparison (novox/hq ADR 0163): the preview puts what runs beside what the module
|
||||||
|
// declares, and an older image or a differing file refuses unless named.
|
||||||
|
func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
preview, refusals, saw := comparisonOf("forge", c, takeOptions{})
|
||||||
for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE",
|
for _, want := range []string{"runs forge:1.27.3 (made 2026-09-17)", "declares forge:1.22.6 (made 2026-08-20)", "DOWNGRADE",
|
||||||
"on the network predecessor_default with office, db", "publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000",
|
"on the network predecessor_default with office, db", "will not once it moves to the module's own network",
|
||||||
|
"publishes 3000/tcp>0.0.0.0:3000; the module declares 3000:3000",
|
||||||
|
// How far the port reaches now, as the machine reported it (rule 1).
|
||||||
|
"reachable now at 0.0.0.0:3000 (tcp, container port 3000), behind the found firewall (ufw)",
|
||||||
"- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} {
|
"- private scope: local", "original kept at /var/lib/mesh/kept/app.ini"} {
|
||||||
if !strings.Contains(preview, want) {
|
if !strings.Contains(preview, want) {
|
||||||
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
@@ -35,15 +51,93 @@ func TestATakePreviewsTheComparisonAndRefusesWhatIsNotNamed(t *testing.T) {
|
|||||||
if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") {
|
if len(refusals) != 2 || !strings.Contains(refusals[0], "--downgrade") || !strings.Contains(refusals[1], "--replace /etc/forge/app.ini") {
|
||||||
t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals)
|
t.Fatalf("the downgrade and the differing file refuse, each naming its override: %v", refusals)
|
||||||
}
|
}
|
||||||
|
if len(saw) != 12 {
|
||||||
|
t.Fatalf("the preview's digest is %q", saw)
|
||||||
|
}
|
||||||
// Named, they pass.
|
// Named, they pass.
|
||||||
if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 {
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"/etc/forge/app.ini": true}}); len(refusals) != 0 {
|
||||||
t.Fatalf("named differences still refused: %v", refusals)
|
t.Fatalf("named differences still refused: %v", refusals)
|
||||||
}
|
}
|
||||||
if _, refusals := comparisonOf(held, "forge", takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
||||||
t.Fatalf("replace * did not cover the file: %v", refusals)
|
t.Fatalf("replace * did not cover the file: %v", refusals)
|
||||||
}
|
}
|
||||||
// A held thing with no facts yet — a host older than this — refuses nothing and says what it can.
|
// A held thing with no facts yet — a host older than this — refuses nothing and says what it can.
|
||||||
if preview, refusals := comparisonOf(held, "other", takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") {
|
if preview, refusals, _ := comparisonOf("other", c, takeOptions{}); len(refusals) != 0 || !strings.Contains(preview, "container other") {
|
||||||
t.Fatalf("a factless hold: %q %v", preview, refusals)
|
t.Fatalf("a factless hold: %q %v", preview, refusals)
|
||||||
}
|
}
|
||||||
|
// The digest is of what the preview says: a fact changing changes it.
|
||||||
|
c.reported.Held[0].Facts["image"] = "forge:1.27.4"
|
||||||
|
if _, _, again := comparisonOf("forge", c, takeOptions{}); again == saw {
|
||||||
|
t.Fatal("the found image changed and the digest did not")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A secret the mesh minted for a service whose data was found refuses: the running service already
|
||||||
|
// has a value (rule 2). Accepted, it is carried in; `--mint` says the service shall take the new one.
|
||||||
|
func TestAMintedSecretForFoundDataRefusesUnlessAcceptedOrMinted(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
c.secrets = []inventory.SecretState{
|
||||||
|
{Name: "admin", Origin: inventory.OriginMade},
|
||||||
|
{Name: "postgres-database", Origin: inventory.OriginMade, Provider: "anchor"},
|
||||||
|
{Name: "broker", Origin: inventory.OriginAccepted},
|
||||||
|
}
|
||||||
|
preview, refusals, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
for _, want := range []string{
|
||||||
|
"own secret admin: MINTED by the mesh and not accepted",
|
||||||
|
"secret from anchor postgres-database: MINTED by the mesh and not accepted",
|
||||||
|
"own secret broker: accepted from a person, carried in as it is",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(refusals) != 2 {
|
||||||
|
t.Fatalf("two minted secrets refuse: %v", refusals)
|
||||||
|
}
|
||||||
|
if !strings.Contains(refusals[0], "`secret accept <node> forge admin`") || !strings.Contains(refusals[0], "`--mint admin`") {
|
||||||
|
t.Errorf("the own secret's refusal names accepting it and minting it: %s", refusals[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(refusals[1], "`secret accept <node> forge postgres-database --provider anchor`") {
|
||||||
|
t.Errorf("the required secret's refusal names its provider: %s", refusals[1])
|
||||||
|
}
|
||||||
|
preview, refusals, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true},
|
||||||
|
Mint: map[string]bool{"admin": true, "postgres-database": true}})
|
||||||
|
if len(refusals) != 0 || !strings.Contains(preview, "admin: minted by the mesh; the service takes the new value, as --mint said") {
|
||||||
|
t.Fatalf("--mint did not pass the minted secrets: %v\n%s", refusals, preview)
|
||||||
|
}
|
||||||
|
// With no found data — only a file held — the service has no value of its own, and a minted
|
||||||
|
// secret is simply said.
|
||||||
|
c.reported.Held = c.reported.Held[1:2]
|
||||||
|
if _, refusals, _ := comparisonOf("forge", c, takeOptions{Replace: map[string]bool{"*": true}}); len(refusals) != 0 {
|
||||||
|
t.Fatalf("a minted secret refused with no data found: %v", refusals)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A found network a per-machine setting keeps is named in the preview (rule 4), and the module's
|
||||||
|
// settings are said with where each came from, composed or not (rules 1 and 6).
|
||||||
|
func TestTheKeptNetworkAndTheSettingsAreInThePreview(t *testing.T) {
|
||||||
|
c := aForgeComparison()
|
||||||
|
c.keeps = map[string][]string{"forge.server": {"predecessor_default"}}
|
||||||
|
c.layers = []catalogue.Layer{
|
||||||
|
{From: catalogue.MeshWideLayer, Values: map[string]any{"site": "x"}},
|
||||||
|
{From: "anchor", Values: map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}},
|
||||||
|
}
|
||||||
|
preview, _, _ := comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
for _, want := range []string{
|
||||||
|
"on the network predecessor_default with office, db — kept by this machine's setting, so they still reach it by name once taken",
|
||||||
|
"settings from the mesh: site",
|
||||||
|
"settings from anchor: networks",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(preview, want) {
|
||||||
|
t.Errorf("the preview lacks %q:\n%s", want, preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(preview, "will not once it moves") {
|
||||||
|
t.Errorf("a kept network is still said to be lost:\n%s", preview)
|
||||||
|
}
|
||||||
|
c.settingsRefused = "forge: ports is a { port: machine-port } map"
|
||||||
|
preview, _, _ = comparisonOf("forge", c, takeOptions{Downgrade: true, Replace: map[string]bool{"*": true}})
|
||||||
|
if !strings.Contains(preview, "SETTINGS DO NOT COMPOSE with the module's definition, so the push leaves it out: forge: ports") {
|
||||||
|
t.Errorf("settings that cannot compose are not said:\n%s", preview)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,12 +244,31 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
|||||||
type Composed struct {
|
type Composed struct {
|
||||||
Resources []map[string]any
|
Resources []map[string]any
|
||||||
Owner map[string]string
|
Owner map[string]string
|
||||||
|
// LeftOut is every module of this machine's set that was left out of its declaration, and
|
||||||
|
// why (novox/hq ADR 0163, rule 6): a setting stored for it that its definition can no longer
|
||||||
|
// compose. Its held things are kept and its containers untouched — the machine is told so —
|
||||||
|
// and it is told everything else.
|
||||||
|
LeftOut map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeftOut is which of this machine's modules a declaration composed with these settings leaves
|
||||||
|
// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer
|
||||||
|
// compose. Empty when every module composes. The same judgement SetSettings makes before storing.
|
||||||
|
func (r Resolution) LeftOut(settings SettingsBy, adopted bool) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
|
||||||
|
out[m.Module] = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose is Declaration with the owner of every resource said.
|
// Compose is Declaration with the owner of every resource said.
|
||||||
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
func (r Resolution) Compose(with Rendering) (Composed, error) {
|
||||||
owner := map[string]string{}
|
owner := map[string]string{}
|
||||||
resources, err := r.compose(with, owner)
|
leftOut := map[string]string{}
|
||||||
|
resources, err := r.compose(with, owner, leftOut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Composed{}, err
|
return Composed{}, err
|
||||||
}
|
}
|
||||||
@@ -261,7 +280,7 @@ func (r Resolution) Compose(with Rendering) (Composed, error) {
|
|||||||
"sealed": with.BusMembership, "mode": "0600",
|
"sealed": with.BusMembership, "mode": "0600",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Composed{Resources: resources, Owner: owner}, nil
|
return Composed{Resources: resources, Owner: owner, LeftOut: leftOut}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
// BusMembershipID names the resource carrying a machine's membership for the new bus, and
|
||||||
@@ -270,7 +289,25 @@ func BusMembershipID() string { return "bus-membership" }
|
|||||||
|
|
||||||
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
const BusMembershipPath = "/var/lib/mesh/membership-next.json"
|
||||||
|
|
||||||
func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[string]any, error) {
|
func (r Resolution) compose(with Rendering, owner map[string]string, leftOut map[string]string) ([]map[string]any, error) {
|
||||||
|
// **A setting is judged where it is stored, and an impossible one costs a module, not a
|
||||||
|
// machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
|
||||||
|
// this module uncomposable; it is left out of the declaration — its held things kept, its
|
||||||
|
// containers untouched, the machine told so by name — and the machine is told everything else.
|
||||||
|
// Before placing, because a placement is a setting too.
|
||||||
|
left := r.LeftOut(with.Settings, with.Adopted)
|
||||||
|
kept := make([]Manifest, 0, len(r.Modules))
|
||||||
|
for _, m := range r.Modules {
|
||||||
|
if why, isLeft := left[m.Module]; isLeft {
|
||||||
|
if leftOut != nil {
|
||||||
|
leftOut[m.Module] = why
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, m)
|
||||||
|
}
|
||||||
|
r.Modules = kept
|
||||||
|
|
||||||
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
// Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
|
||||||
// credentials and contributions land are resolved against this node's directories, so every
|
// credentials and contributions land are resolved against this node's directories, so every
|
||||||
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
// reader below — the binding files, the sealed secrets, the grant paths a contribution
|
||||||
@@ -693,6 +730,10 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
// Which of this module's resources its preparation runs before, if it prepares anything.
|
// Which of this module's resources its preparation runs before, if it prepares anything.
|
||||||
prepareBefore := preparationTarget(m)
|
prepareBefore := preparationTarget(m)
|
||||||
|
|
||||||
|
// Which found networks this machine's setting keeps for each of its containers (novox/hq
|
||||||
|
// ADR 0163, rule 4); judged above, so an invalid one is not here.
|
||||||
|
keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted)
|
||||||
|
|
||||||
for _, unsettled := range resources {
|
for _, unsettled := range resources {
|
||||||
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
resource, err := ApplySettings(unsettled, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -702,6 +743,16 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
for k, v := range resource {
|
for k, v := range resource {
|
||||||
copied[k] = v
|
copied[k] = v
|
||||||
}
|
}
|
||||||
|
if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps {
|
||||||
|
// The container also joins the found network the setting names, so a neighbour
|
||||||
|
// that resolves it there keeps resolving it. Passed to the host as its own field,
|
||||||
|
// which it joins after the container is made.
|
||||||
|
joins := make([]any, 0, len(networks))
|
||||||
|
for _, n := range networks {
|
||||||
|
joins = append(joins, n)
|
||||||
|
}
|
||||||
|
copied["networks"] = joins
|
||||||
|
}
|
||||||
if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
|
if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -947,8 +998,15 @@ func (r Resolution) filtersHere() string {
|
|||||||
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
// computed for this machine, and each module's per-node exposure. The same answer whether the node
|
||||||
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
// is adopted or converged — the one loads it as a filter, the other declares it as openings.
|
||||||
func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
func (r Resolution) Rules(with Rendering) ([]Rule, error) {
|
||||||
|
// A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163,
|
||||||
|
// rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let
|
||||||
|
// through.
|
||||||
|
left := r.LeftOut(with.Settings, with.Adopted)
|
||||||
exposure := map[string]map[int]string{}
|
exposure := map[string]map[int]string{}
|
||||||
for _, m := range r.Modules {
|
for _, m := range r.Modules {
|
||||||
|
if _, isLeft := left[m.Module]; isLeft {
|
||||||
|
continue
|
||||||
|
}
|
||||||
e, err := Exposure(m, with.Settings[m.Module])
|
e, err := Exposure(m, with.Settings[m.Module])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -75,17 +75,26 @@ func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the
|
||||||
// setting to write — not mounted as the literal, not skipped.
|
// setting to write — not mounted as the literal, not skipped. The refusal costs the module its
|
||||||
|
// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6).
|
||||||
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
func TestAnUnplacedAccessIsRefusedByName(t *testing.T) {
|
||||||
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = got.Declaration(placedBy(map[string]any{
|
with := placedBy(map[string]any{
|
||||||
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
AccessesSetting: map[string]any{"series": "/storage/media/series"},
|
||||||
}))
|
})
|
||||||
if err == nil || !strings.Contains(err.Error(), `"spool"`) || !strings.Contains(err.Error(), AccessesSetting) {
|
composed, err := got.Compose(with)
|
||||||
t.Fatalf("an access nobody placed was not refused by name: %v", err)
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
why := composed.LeftOut["arr"]
|
||||||
|
if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) {
|
||||||
|
t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut)
|
||||||
|
}
|
||||||
|
if _, declared := byID(composed.Resources)["arr.server"]; declared {
|
||||||
|
t.Fatal("the module with the unplaced access was declared anyway")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package catalogue
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -275,6 +276,11 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
|
|||||||
if key == AccessesSetting && len(m.Accesses) > 0 {
|
if key == AccessesSetting && len(m.Accesses) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// `networks` keeps a found network for a taken container on one adopted machine
|
||||||
|
// (novox/hq ADR 0163). Validated in KeptNetworks, so not stray.
|
||||||
|
if key == NetworksSetting {
|
||||||
|
continue
|
||||||
|
}
|
||||||
unused = append(unused, fmt.Sprintf(
|
unused = append(unused, fmt.Sprintf(
|
||||||
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
"%s sets %q, and %s has no file that merges it, asks for no ${setting:%s}, and "+
|
||||||
"declares no %q in what it contributes or serves",
|
"declares no %q in what it contributes or serves",
|
||||||
@@ -298,3 +304,125 @@ func stringsOf(v any) []string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NetworksSetting is the settings key that keeps a found network for a taken container, on one
|
||||||
|
// adopted machine (novox/hq ADR 0163, rule 4):
|
||||||
|
//
|
||||||
|
// {"networks": {"server": ["predecessor_default"]}}
|
||||||
|
//
|
||||||
|
// has the module's container `server` also join `predecessor_default` once taken, so a neighbour
|
||||||
|
// that resolves it by name on that network keeps resolving it. Migration scaffolding in the sense
|
||||||
|
// of ADR 0104: assigned only on an adopted machine, reported while it stands, removed when the
|
||||||
|
// neighbours are taken. Keyed by the container's resource id; the value is the networks it keeps.
|
||||||
|
const NetworksSetting = "networks"
|
||||||
|
|
||||||
|
// KeptNetworks reads which found networks each of a module's containers keeps, by container id.
|
||||||
|
//
|
||||||
|
// Refused from a mesh-wide layer — a found network is a fact about one machine — for an id the
|
||||||
|
// module declares no container under, for a name that is not a network's, and on a machine that
|
||||||
|
// is not adopted: the setting exists so neighbours the mesh has not taken yet keep reaching the
|
||||||
|
// container, and a converged machine has no such neighbours.
|
||||||
|
func KeptNetworks(m Manifest, layers []Layer, adopted bool) (map[string][]string, error) {
|
||||||
|
containers := map[string]bool{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) == "container" {
|
||||||
|
containers[fmt.Sprint(r["id"])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := map[string][]string{}
|
||||||
|
for _, layer := range layers {
|
||||||
|
raw, ok := layer.Values[NetworksSetting]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if layer.From == MeshWideLayer {
|
||||||
|
return nil, fmt.Errorf("%s: %s is given per node — a found network is a fact about one "+
|
||||||
|
"machine; set it with --node", m.Module, NetworksSetting)
|
||||||
|
}
|
||||||
|
if !adopted {
|
||||||
|
return nil, fmt.Errorf("%s: %s keeps a found network for neighbours the mesh has not taken "+
|
||||||
|
"yet, and %s is converged — nothing on it is found; clear the setting", m.Module,
|
||||||
|
NetworksSetting, layer.From)
|
||||||
|
}
|
||||||
|
blocks, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s: %s is a { container: [network, …] } map, and %q set it to "+
|
||||||
|
"something else", m.Module, NetworksSetting, layer.From)
|
||||||
|
}
|
||||||
|
for id, body := range blocks {
|
||||||
|
if !containers[id] {
|
||||||
|
return nil, fmt.Errorf("%s: %s names the container %q, which it does not declare — "+
|
||||||
|
"the setting reaches nothing; it declares %s", m.Module, NetworksSetting, id,
|
||||||
|
orNothing(sortedKeys(containers)))
|
||||||
|
}
|
||||||
|
names := stringsOf(body)
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s: %s for %q is a list of network names, and %q set it to %v",
|
||||||
|
m.Module, NetworksSetting, id, layer.From, body)
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if !networkName.MatchString(n) {
|
||||||
|
return nil, fmt.Errorf("%s: %s for %q names %q, which is not a network name",
|
||||||
|
m.Module, NetworksSetting, id, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
out[id] = names
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkName is what a container runtime accepts as a network's name.
|
||||||
|
var networkName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]*$`)
|
||||||
|
|
||||||
|
// JudgeSettings composes a module's settings against its definition and refuses the first thing
|
||||||
|
// that cannot work, naming the module, the layer and the key (novox/hq ADR 0163, rule 6).
|
||||||
|
//
|
||||||
|
// **The same judgement where a setting is stored and where a machine is declared.** Stored, a
|
||||||
|
// setting that cannot compose is refused before it is kept; composed later, a definition that has
|
||||||
|
// moved under a stored setting leaves that module out of the machine's declaration rather than
|
||||||
|
// the machine without one. Every reader of settings runs here: a port given, an exposure, a reach,
|
||||||
|
// an endpoint, a placement, an access, a kept network, a mergeable file's keys and a file's
|
||||||
|
// `${setting:…}`. A key that reaches nothing is not here: it cannot break a composition, so it is
|
||||||
|
// refused where it is stored (SetSettings, with UnusedSettings) and said where a plan is read,
|
||||||
|
// and never costs a module its place.
|
||||||
|
func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
|
||||||
|
// With no layers too: a definition may ask for a setting nobody made — an access placed by
|
||||||
|
// nobody, a file's ${setting:…} nothing sets — and that is the same statement, missing.
|
||||||
|
if _, err := GivenPorts(m, layers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := Reaches(m, layers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := Endpoints(m, layers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := Places(m, layers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, _, err := accessesFor(m, layers); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := KeptNetworks(m, layers, adopted); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
settled, err := ApplySettings(r, layers)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copied := map[string]any{}
|
||||||
|
for k, v := range settled {
|
||||||
|
copied[k] = v
|
||||||
|
}
|
||||||
|
if err := settingInto(copied, layers, m.Module); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
||||||
|
// (novox/hq ADR 0163, rule 6): the one judgement, used by SetSettings before storing and by
|
||||||
|
// Compose when a definition has moved under a stored setting.
|
||||||
|
func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *testing.T) {
|
||||||
|
web := Manifest{Module: "hello-web",
|
||||||
|
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||||
|
"ports": []any{"8080"}}}}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
layer map[string]any
|
||||||
|
refuse string
|
||||||
|
}{
|
||||||
|
{"a port the module does not publish", map[string]any{PortsSetting: map[string]any{"9999": 10000}},
|
||||||
|
"hello-web gives port 9999 a machine port, and no container of its publishes 9999"},
|
||||||
|
{"a mesh-wide port", map[string]any{PortsSetting: map[string]any{"8080": 10000}},
|
||||||
|
"a port is a fact about one machine"},
|
||||||
|
} {
|
||||||
|
from := "anchor"
|
||||||
|
if c.name == "a mesh-wide port" {
|
||||||
|
from = MeshWideLayer
|
||||||
|
}
|
||||||
|
err := JudgeSettings(web, []Layer{{From: from, Values: c.layer}}, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||||
|
t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := JudgeSettings(web, []Layer{{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
||||||
|
t.Fatalf("a port the module publishes was refused: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composed, a module whose stored setting no longer works is left out by name; the rest of
|
||||||
|
// the machine is declared.
|
||||||
|
r := anAdoptedAnchor()
|
||||||
|
with := anchorRendering(false)
|
||||||
|
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Values: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
||||||
|
composed, err := r.Compose(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
why, left := composed.LeftOut["hello-web"]
|
||||||
|
if !left || !strings.Contains(why, "no container of its publishes 9999") {
|
||||||
|
t.Fatalf("hello-web is not left out by name: %v", composed.LeftOut)
|
||||||
|
}
|
||||||
|
if len(composed.LeftOut) != 1 {
|
||||||
|
t.Fatalf("more than hello-web is left out: %v", composed.LeftOut)
|
||||||
|
}
|
||||||
|
got := byID(composed.Resources)
|
||||||
|
if _, declared := got["hello-web.server"]; declared {
|
||||||
|
t.Fatal("the left-out module's container is still declared")
|
||||||
|
}
|
||||||
|
if _, declared := got["distribution.store"]; !declared {
|
||||||
|
t.Fatal("the rest of the machine was not declared")
|
||||||
|
}
|
||||||
|
if left := r.LeftOut(with.Settings, false); len(left) != 1 || left["hello-web"] == "" {
|
||||||
|
t.Fatalf("the judgement a plan reads differs from what compose did: %v", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
|
||||||
|
// on an adopted machine only, for a container the module declares, and it reaches the container's
|
||||||
|
// declaration as the networks it also joins.
|
||||||
|
func TestAKeptNetworkReachesTheContainerOnAnAdoptedMachineOnly(t *testing.T) {
|
||||||
|
r := anAdoptedAnchor()
|
||||||
|
keep := SettingsBy{"hello-web": {{From: "anchor",
|
||||||
|
Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}}}}
|
||||||
|
|
||||||
|
with := anchorRendering(true)
|
||||||
|
with.Settings = keep
|
||||||
|
composed, err := r.Compose(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(composed.LeftOut) != 0 {
|
||||||
|
t.Fatalf("a kept network left a module out: %v", composed.LeftOut)
|
||||||
|
}
|
||||||
|
server := byID(composed.Resources)["hello-web.server"]
|
||||||
|
networks, _ := server["networks"].([]any)
|
||||||
|
if len(networks) != 1 || networks[0] != "predecessor_default" {
|
||||||
|
t.Fatalf("the container does not join the kept network: %v", server)
|
||||||
|
}
|
||||||
|
if _, has := byID(composed.Resources)["distribution.store"]["networks"]; has {
|
||||||
|
t.Fatal("another container joins a network nobody kept for it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converged, the setting reaches nothing it was for, and the module is left out saying so.
|
||||||
|
with = anchorRendering(false)
|
||||||
|
with.Settings = keep
|
||||||
|
composed, err = r.Compose(with)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if why := composed.LeftOut["hello-web"]; !strings.Contains(why, "anchor is converged") {
|
||||||
|
t.Fatalf("a kept network on a converged machine: %v", composed.LeftOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
web := r.Modules[4]
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
layer Layer
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"mesh-wide", Layer{From: MeshWideLayer, Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"x"}}}},
|
||||||
|
"a found network is a fact about one machine"},
|
||||||
|
{"an unknown container", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"db": []any{"x"}}}},
|
||||||
|
`names the container "db", which it does not declare`},
|
||||||
|
{"not a list", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": "x"}}},
|
||||||
|
"is a list of network names"},
|
||||||
|
{"not a network name", Layer{From: "anchor", Values: map[string]any{NetworksSetting: map[string]any{"server": []any{"a/b"}}}},
|
||||||
|
"which is not a network name"},
|
||||||
|
} {
|
||||||
|
_, err := KeptNetworks(web, []Layer{c.layer}, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kept, err := KeptNetworks(web, nil, false); err != nil || kept != nil {
|
||||||
|
t.Fatalf("no setting: %v %v", kept, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -605,6 +605,12 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Judged here, against the module's current definition, before it is kept (novox/hq ADR 0163,
|
||||||
|
// rule 6): a setting that cannot compose is refused where it is set, naming the node, the
|
||||||
|
// module, the layer and the key — never stored to refuse the whole machine where it is read.
|
||||||
|
if err := i.judgeSettings(ctx, nodeName, module, values); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if nodeName == "" {
|
if nodeName == "" {
|
||||||
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
// A port is a fact about one machine (novox/hq ADR 0100). Refused here, in composition's
|
||||||
// words: stored, it refuses every node running the module at composition, and the mesh
|
// words: stored, it refuses every node running the module at composition, and the mesh
|
||||||
@@ -661,6 +667,50 @@ func (i *Inventory) SetSettings(ctx context.Context, nodeName, module string, va
|
|||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// judgeSettings composes a layer somebody is about to store against the module's definition, with
|
||||||
|
// the mesh-wide layer under it when the layer is one node's, and refuses the first thing that
|
||||||
|
// cannot work (ADR 0163, rule 6). The same judgement composition makes; what passes here composes.
|
||||||
|
func (i *Inventory) judgeSettings(ctx context.Context, nodeName, module string, values map[string]any) error {
|
||||||
|
m, err := i.declared(ctx, module)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", ErrNoSuchModule, module)
|
||||||
|
}
|
||||||
|
where, from := "the mesh", catalogue.MeshWideLayer
|
||||||
|
adopted := false
|
||||||
|
var layers []catalogue.Layer
|
||||||
|
if nodeName != "" {
|
||||||
|
node, err := i.NodeByName(ctx, nodeName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
where, from, adopted = nodeName, nodeName, node.Adopted
|
||||||
|
var meshWide []byte
|
||||||
|
err = i.store.Pool().QueryRow(ctx,
|
||||||
|
`select values from settings where module = $1 and node is null`, module).Scan(&meshWide)
|
||||||
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(meshWide) > 0 {
|
||||||
|
var under map[string]any
|
||||||
|
if err := json.Unmarshal(meshWide, &under); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
layers = append(layers, catalogue.Layer{From: catalogue.MeshWideLayer, Values: under})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
layers = append(layers, catalogue.Layer{From: from, Values: values})
|
||||||
|
if err := catalogue.JudgeSettings(m, layers, adopted); err != nil {
|
||||||
|
return fmt.Errorf("refused: %s on %s cannot compose with the layer %q — %w", module, where, from, err)
|
||||||
|
}
|
||||||
|
// And a key that reaches nothing, refused here where somebody can still fix the spelling:
|
||||||
|
// stored, it would be a setting somebody believes they made.
|
||||||
|
if stray := catalogue.UnusedSettings(m, layers[len(layers)-1:]); len(stray) > 0 {
|
||||||
|
return fmt.Errorf("refused: %s on %s — these settings reach nothing:\n - %s", module, where,
|
||||||
|
strings.Join(stray, "\n - "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// givenIn is the machine ports a node-level settings layer gives a module, software port →
|
// givenIn is the machine ports a node-level settings layer gives a module, software port →
|
||||||
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
|
// machine port (novox/hq ADR 0100). Nothing when the layer gives none; what is not a port is left
|
||||||
// for composition to refuse in its own words.
|
// for composition to refuse in its own words.
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ func TestRegisteringAModuleAgainKeepsWhatTheMeshHoldsForIt(t *testing.T) {
|
|||||||
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
|
if err := inv.RecordSealingKey(ctx, node.ID, key); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// A mergeable file, so any setting composes (novox/hq ADR 0163, rule 6: a setting is judged
|
||||||
|
// where it is stored).
|
||||||
m := catalogue.Manifest{Module: "step-ca", Version: "1",
|
m := catalogue.Manifest{Module: "step-ca", Version: "1",
|
||||||
Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}}}
|
Provides: catalogue.Offers("acme-ca"), OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password"}},
|
||||||
|
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/step-ca.json", "content": "{}", "merge": "json"}}}
|
||||||
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -229,5 +232,9 @@ func TestAModuleStillAssignedRefusesBeforeAnythingAboutWhatItHolds(t *testing.T)
|
|||||||
// declares (novox/hq 04-ISSUES/078).
|
// declares (novox/hq 04-ISSUES/078).
|
||||||
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
|
func withOwnSecret(m catalogue.Manifest, name string) catalogue.Manifest {
|
||||||
m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
|
m.OwnSecrets = catalogue.OwnSecrets{name: {Path: "/run/" + name}}
|
||||||
|
// And a mergeable file, so any setting these tests store composes (novox/hq ADR 0163, rule 6:
|
||||||
|
// a setting is judged where it is stored).
|
||||||
|
m.Resources = append(m.Resources, map[string]any{"id": "conf", "type": "file",
|
||||||
|
"path": "/etc/" + m.Module + ".json", "content": "{}", "merge": "json"})
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,16 @@ func aNodeWithModules(t *testing.T, modules ...string) (*Inventory, string) {
|
|||||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
// Each publishes the port these tests give it a machine port for: a port given for one the
|
||||||
|
// module does not publish is refused where it is stored (novox/hq ADR 0163, rule 6).
|
||||||
|
publishes := map[string]string{"postgres": "5432", "another-database": "5432", "cache": "6379", "web": "8080"}
|
||||||
for _, m := range modules {
|
for _, m := range modules {
|
||||||
if err := inv.RegisterModule(ctx,
|
manifest := catalogue.Manifest{Module: m, Version: "1"}
|
||||||
catalogue.Manifest{Module: m, Version: "1"}, Source{}); err != nil {
|
if port, known := publishes[m]; known {
|
||||||
|
manifest.Resources = []map[string]any{{"id": "server", "type": "container", "name": m,
|
||||||
|
"image": "x", "ports": []any{port}}}
|
||||||
|
}
|
||||||
|
if err := inv.RegisterModule(ctx, manifest, Source{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -820,3 +820,52 @@ func (i *Inventory) SharedHolders(ctx context.Context, provider, providerModule,
|
|||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecretState is one secret a module holds on a machine, as a take compares it (novox/hq ADR
|
||||||
|
// 0163): its name, where it came from — made by the mesh or accepted from a person — and, for a
|
||||||
|
// credential the module requires from a provider, which node provides it and the local name it
|
||||||
|
// goes by where the module keeps several.
|
||||||
|
type SecretState struct {
|
||||||
|
Name string
|
||||||
|
// Local is the credential's name inside the module (ADR 0094); empty for an own secret or the
|
||||||
|
// ordinary one.
|
||||||
|
Local string
|
||||||
|
// Origin is OriginMade or OriginAccepted.
|
||||||
|
Origin string
|
||||||
|
// Provider is the node providing a required secret; empty for the module's own.
|
||||||
|
Provider string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own says the secret is the module's own rather than one it requires from a provider.
|
||||||
|
func (s SecretState) Own() bool { return s.Provider == "" }
|
||||||
|
|
||||||
|
// SecretsOf is every secret a module holds on a machine: its own, and each credential it requires
|
||||||
|
// from a provider — with where each value came from. What a take reads to refuse minting over a
|
||||||
|
// service that already has one (ADR 0163, rule 2).
|
||||||
|
func (i *Inventory) SecretsOf(ctx context.Context, node, module string) ([]SecretState, error) {
|
||||||
|
record, err := i.NodeByName(ctx, node)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := i.store.Pool().Query(ctx,
|
||||||
|
`select name, '' as local, origin, '' as provider from module_secret
|
||||||
|
where node = $1 and module = $2
|
||||||
|
union all
|
||||||
|
select s.name, s.local, s.origin, p.name from secret s
|
||||||
|
join node p on p.id = s.provider
|
||||||
|
where s.consumer = $1 and s.consumer_module = $2
|
||||||
|
order by 4, 1, 2`, record.ID, module)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []SecretState
|
||||||
|
for rows.Next() {
|
||||||
|
var s SecretState
|
||||||
|
if err := rows.Scan(&s.Name, &s.Local, &s.Origin, &s.Provider); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -934,3 +934,47 @@ func TestASharedCredentialIsOneValueSealedToEveryHolder(t *testing.T) {
|
|||||||
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
|
t.Fatalf("a consumer binding after an acceptance must be refused with the way out: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecretsOf is every secret a module holds on a machine with where each came from — what a take
|
||||||
|
// reads to refuse minting over a service that already has a value (novox/hq ADR 0163, rule 2).
|
||||||
|
func TestSecretsOfSaysEachSecretsOriginAndProvider(t *testing.T) {
|
||||||
|
inv, ctx := twoNodesWithKeys(t)
|
||||||
|
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "forge", Version: "1",
|
||||||
|
Requires: []string{"secret", "postgres-database"},
|
||||||
|
Secrets: map[string]string{"secret": "/run/secret", "postgres-database": "/run/pg"},
|
||||||
|
OwnSecrets: catalogue.OwnSecrets{"admin": {Path: "/run/admin"}}}, Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.SecretForModule(ctx, "consumer", "forge", "admin"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := inv.SecretFor(ctx, "postgres-database", "consumer", "forge", "provider", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.AcceptSecretForPair(ctx, "secret", "consumer", "forge", "provider", "", "hunter2"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := inv.SecretsOf(ctx, "consumer", "forge")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []SecretState{
|
||||||
|
{Name: "admin", Origin: OriginMade},
|
||||||
|
{Name: "postgres-database", Origin: OriginMade, Provider: "provider"},
|
||||||
|
{Name: "secret", Origin: OriginAccepted, Provider: "provider"},
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %+v", got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("secret %d: got %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !got[0].Own() || got[1].Own() {
|
||||||
|
t.Error("own and required are not told apart")
|
||||||
|
}
|
||||||
|
if other, _ := inv.SecretsOf(ctx, "consumer", "gitea"); len(other) != 0 {
|
||||||
|
t.Fatalf("another module's secrets: %+v", other)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package inventory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-controller/internal/catalogue"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A setting is judged where it is stored (novox/hq ADR 0163, rule 6): one that cannot compose with
|
||||||
|
// the module's definition is refused naming the node, the module, the layer and the key, and is not
|
||||||
|
// kept; one that reaches nothing is refused the same way.
|
||||||
|
func TestASettingIsJudgedWhereItIsStored(t *testing.T) {
|
||||||
|
inv := fresh(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
web := catalogue.Manifest{Module: "web", Version: "1",
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "server", "type": "container", "name": "web", "image": "x", "ports": []any{"8080"}},
|
||||||
|
{"id": "conf", "type": "file", "path": "/etc/web.json", "content": "{}", "merge": "json"},
|
||||||
|
}}
|
||||||
|
plain := catalogue.Manifest{Module: "plain", Version: "1",
|
||||||
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "plain", "image": "x"}}}
|
||||||
|
for _, m := range []catalogue.Manifest{web, plain} {
|
||||||
|
if err := inv.RegisterModule(ctx, m, Source{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"9999": 10000}})
|
||||||
|
for _, want := range []string{"refused: web on anchor", `layer "anchor"`, "9999"} {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("a port the module does not publish: %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if layers, _ := inv.SettingsFor(ctx, "anchor", "web"); len(layers) != 0 {
|
||||||
|
t.Fatalf("the refused layer was stored: %v", layers)
|
||||||
|
}
|
||||||
|
// A key that reaches nothing is refused too, where the spelling can still be fixed; a module
|
||||||
|
// with a mergeable file takes any key.
|
||||||
|
err = inv.SetSettings(ctx, "", "plain", map[string]any{"colour": "blue"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "reach nothing") || !strings.Contains(err.Error(), `"colour"`) {
|
||||||
|
t.Fatalf("a stray key was stored: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetSettings(ctx, "", "web", map[string]any{"colour": "blue"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The mesh-wide layer is under the node's when the node's is judged.
|
||||||
|
if err := inv.SetSettings(ctx, "anchor", "web", map[string]any{catalogue.PortsSetting: map[string]any{"8080": 10000}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A kept network is for an adopted machine only (rule 4).
|
||||||
|
keep := map[string]any{catalogue.NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}}
|
||||||
|
err = inv.SetSettings(ctx, "anchor", "plain", keep)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "anchor is converged") {
|
||||||
|
t.Fatalf("a kept network on a converged machine was stored: %v", err)
|
||||||
|
}
|
||||||
|
if err := inv.SetAdopted(ctx, "anchor", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SetSettings(ctx, "anchor", "plain", keep); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := inv.SetSettings(ctx, "anchor", "nothing", keep); err == nil {
|
||||||
|
t.Fatal("a setting for a module the mesh does not know was stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user