A take acts on the preview it showed; a setting is judged where it is stored; a kept network and a minted secret are said (hq ADR 0163)
take ends its preview with a digest and --yes names it, as the flip does; a changed preview or an account older than the flip allows is refused. A module the machine holds nothing for has nothing to compare, and --yes suffices. A published port's reach is said as the machine reported it. Every secret the module holds on the machine is listed with where it came from, and one the mesh minted for a service whose data was found refuses unless --mint names it. One judgement of a module's settings against its definition, in the catalogue: settings set refuses what cannot compose or reaches nothing, naming node, module, layer and key; Compose leaves out a module whose definition moved under a stored setting, the envelope says so (left_out), plan and push say it by name, and the machine is told everything else. A stray setting no longer refuses the whole machine where it is read (issue 096). The per-machine setting networks keeps a found network for a taken container, on an adopted machine only; the container's declaration carries it and the preview names it (rule 4).
This commit is contained in:
@@ -143,7 +143,24 @@ func TestTheFlipIsRefusedWhileAFoundContainerIsHeld(t *testing.T) {
|
||||
func TestTakingNamesWhatItReplaces(t *testing.T) {
|
||||
open, _ := anAdoptedAnchor(t)
|
||||
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 {
|
||||
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) {
|
||||
open, sent := anAdoptedAnchor(t)
|
||||
ctx := t.Context()
|
||||
|
||||
+210
-21
@@ -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
|
||||
// has moved. From the next push its resources converge there like any other, replacing what the
|
||||
// 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).
|
||||
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
|
||||
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) {
|
||||
@@ -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
|
||||
// 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 {
|
||||
return "", err
|
||||
}
|
||||
preview, refusals := comparisonOf(reported.Held, module, opts)
|
||||
preview, refusals, saw := comparisonOf(module, c, opts)
|
||||
if len(refusals) > 0 {
|
||||
return "", fmt.Errorf("taking %s on %s is refused:\n %s\n%s", module, node,
|
||||
strings.Join(refusals, "\n "), preview)
|
||||
}
|
||||
holds := len(heldOf(c.reported, module)) > 0
|
||||
if holds {
|
||||
preview += "\n preview " + saw
|
||||
}
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
// module declares, and the refusals the differences earn unless the take named them
|
||||
// (novox/hq ADR 0163): an image older than the one running, a declared file that differs from the
|
||||
// found one. A narrowed port and a shared network are said and not refused.
|
||||
func comparisonOf(held []inventory.Held, module string, opts takeOptions) (string, []string) {
|
||||
// module declares; its secrets and its settings on the machine; and the refusals the differences
|
||||
// earn unless the take named them (novox/hq ADR 0163): an image older than the one running, a
|
||||
// declared file that differs from the found one, a secret the mesh minted for a service whose data
|
||||
// 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 refusals []string
|
||||
held := heldOf(c.reported, module)
|
||||
foundData := false
|
||||
for _, h := range held {
|
||||
if h.Module != module {
|
||||
continue
|
||||
if h.Kind == "container" || h.Kind == "directory" {
|
||||
foundData = true
|
||||
}
|
||||
fmt.Fprintf(&b, " %s", heldLine(h))
|
||||
if h.Kept != "" {
|
||||
fmt.Fprintf(&b, ", original kept at %s", h.Kept)
|
||||
}
|
||||
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)
|
||||
}
|
||||
f := factsOf(h)
|
||||
@@ -232,7 +329,52 @@ func comparisonOf(held []inventory.Held, module string, opts takeOptions) (strin
|
||||
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.
|
||||
@@ -286,6 +428,13 @@ func factsOf(h inventory.Held) facts {
|
||||
|
||||
// comparisonLines says a held thing's facts the way a person weighs them.
|
||||
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)
|
||||
var out []string
|
||||
if f.image != "" || f.declaredImage != "" {
|
||||
@@ -311,14 +460,46 @@ func comparisonLines(h inventory.Held) []string {
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if members := f.networks[n]; len(members) > 0 {
|
||||
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",
|
||||
members := f.networks[n]
|
||||
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, ", ")))
|
||||
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 {
|
||||
out = append(out, fmt.Sprintf("publishes %s; the module declares %s",
|
||||
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 {
|
||||
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.
|
||||
func takeCommand(ctx context.Context, args []string) error {
|
||||
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")
|
||||
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(&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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(positionals) != 2 {
|
||||
return errors.New("take <node> <module> [--yes] [--downgrade] [--replace <path>]...")
|
||||
if len(positionals) < 2 || len(positionals) > 3 || (len(positionals) == 3 && !*yes) {
|
||||
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 {
|
||||
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) })
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ func commands(who Authenticator) http.Handler {
|
||||
}))
|
||||
// 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) {
|
||||
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) {
|
||||
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 {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
// Yes, Digest and Filter are converge's: do it rather than preview it, the digest of the
|
||||
// preview it acts on, and which module loads the mesh's filter.
|
||||
// Yes, Digest and Filter are converge's and take's: do it rather than preview it, the digest
|
||||
// of the preview it acts on, and (converge) which module loads the mesh's filter.
|
||||
Yes bool `json:"yes,omitempty"`
|
||||
Digest string `json:"digest,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
|
||||
assign <node> <module> put a module on a node
|
||||
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
|
||||
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
|
||||
|
||||
+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
|
||||
// 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.
|
||||
//
|
||||
// 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{}
|
||||
var stray []string
|
||||
for _, m := range resolved.Modules {
|
||||
layers, err := inv.SettingsFor(ctx, nodeName, m.Module)
|
||||
if err != nil {
|
||||
@@ -197,18 +201,6 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -405,7 +397,32 @@ func declarationWith(ctx context.Context, open *stores, node string,
|
||||
return sendable{}, err
|
||||
}
|
||||
return sendable{Resources: composed.Resources, Adoption: adoption,
|
||||
Received: composed.Received, Mesh: with.Mesh}, nil
|
||||
Received: composed.Received, Mesh: with.Mesh,
|
||||
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.
|
||||
@@ -445,7 +462,10 @@ func renderingFor(ctx context.Context, open *stores, node string,
|
||||
for _, m := range plan.Modules {
|
||||
g, err := catalogue.GivenPorts(m, settings[m.Module])
|
||||
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 {
|
||||
given[m.Module] = g
|
||||
@@ -1000,6 +1020,20 @@ func planCommand(ctx context.Context, args []string) error {
|
||||
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])
|
||||
for _, m := range plan.Modules {
|
||||
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
|
||||
// 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.
|
||||
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{}
|
||||
@@ -454,7 +458,11 @@ func pushCommand(ctx context.Context, args []string) error {
|
||||
return sendable{}, err
|
||||
}
|
||||
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 {
|
||||
if err := link.Declare(ctx, server.Bus(), ident, s.node, body,
|
||||
@@ -666,6 +674,7 @@ func sendTo(ctx context.Context, open *stores, names []string) error {
|
||||
refusals = append(refusals, fmt.Sprintf("%s:\n%v", name, err))
|
||||
continue
|
||||
}
|
||||
reportLeftOut(name, declared)
|
||||
sending = append(sending, readyNode{name, declared})
|
||||
}
|
||||
if len(refusals) > 0 {
|
||||
|
||||
@@ -31,6 +31,14 @@ type sendable struct {
|
||||
// the same composition as its received files, and every machine's private-network address.
|
||||
Received map[string]map[string][]catalogue.Contribution
|
||||
Mesh []string
|
||||
// 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
|
||||
@@ -51,6 +59,9 @@ func (s sendable) Body() ([]byte, error) {
|
||||
if s.Sequence > 0 {
|
||||
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
|
||||
// (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".
|
||||
|
||||
@@ -382,3 +382,62 @@ func TestAnEmptyDeclarationSaysOwnsNothing(t *testing.T) {
|
||||
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,26 +4,42 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/catalogue"
|
||||
"github.com/novox/mesh-controller/internal/inventory"
|
||||
)
|
||||
|
||||
// What the forge's take compares, as a machine would report it.
|
||||
func aForgeComparison() comparison {
|
||||
return comparison{reported: inventory.Adoption{
|
||||
Firewall: "ufw",
|
||||
Held: []inventory.Held{
|
||||
{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",
|
||||
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
||||
"networks": map[string]any{"predecessor_default": []any{"office", "db"}},
|
||||
"ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"},
|
||||
}},
|
||||
{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"}}},
|
||||
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
||||
},
|
||||
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) {
|
||||
held := []inventory.Held{
|
||||
{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",
|
||||
"declared_image": "forge:1.22.6", "declared_image_created": "2026-08-20T10:00:00Z", "downgrade": true,
|
||||
"networks": map[string]any{"predecessor_default": []any{"office", "db"}},
|
||||
"ports": []any{"3000/tcp>0.0.0.0:3000"}, "declared_ports": []any{"3000:3000"},
|
||||
}},
|
||||
{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"}}},
|
||||
{ID: "other.server", Module: "other", Kind: "container", Target: "other"},
|
||||
}
|
||||
preview, refusals := comparisonOf(held, "forge", takeOptions{})
|
||||
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",
|
||||
"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"} {
|
||||
if !strings.Contains(preview, want) {
|
||||
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") {
|
||||
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.
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user