A module can tell its provider what it needs
`requires` said a thing must be there. It never said what to do with it,
so a web application requiring a reverse proxy had nowhere to put "this
name, this port". The two modules that needed it most went round the
outside and opened a connection to the control plane's database, which is
why every node holds a credential to it permanently.
Two fields close it:
contributes: {reverse-proxy: {host: board, port: 8080}}
receives: {reverse-proxy: /etc/traefik/dynamic/mesh.json}
The control plane collects every contribution on a node and writes them
to the path the provider named, ordered by module so the file does not
churn. Contributing to something is requiring it — asking to be published
means a publisher must exist, and a module that had to say both would
eventually say one.
The control plane does not know what a reverse proxy is and does not
write one's configuration. It delivers facts; the module turns them into
whatever it runs. That is why swapping the proxy touches nothing that
publishes through it, and why the host needs no new vocabulary — a
received file is a file.
Settings reach a contribution the same way they reach a file, because a
hostname is exactly what differs between one mesh and the next.
Two things found by running it:
- the file had a `//` header, so it said "do not edit" to a person and
failed to parse for the program meant to read it. The note is inside
the document now.
- a provider with no consumers gets an empty file rather than none. It
cannot otherwise tell "nothing asked for me" from "the mesh never
wrote it", and those want different responses.
Also `plan <node> --json`, which is how the declaration gets handed to
the host's own parser.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other half of an edge.
|
||||
//
|
||||
// `requires` says a thing must be there. It never said what to do with it — a web application
|
||||
// requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put
|
||||
// that. The two modules that needed it most, the proxy and the VPN, went round the outside and
|
||||
// opened a connection to the control plane's database, which is why every node held a credential
|
||||
// to it permanently.
|
||||
|
||||
func published(module, host string, port int) Manifest {
|
||||
return Manifest{Module: module, Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"host": host, "port": port},
|
||||
}}
|
||||
}
|
||||
|
||||
func proxy() Manifest {
|
||||
return Manifest{Module: "traefik", Version: "1",
|
||||
Provides: []string{"reverse-proxy"},
|
||||
Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"},
|
||||
Resources: []map[string]any{
|
||||
{"id": "up", "type": "service", "unit": "traefik", "state": "running",
|
||||
"restart-on": []any{ReceivedID("reverse-proxy")}},
|
||||
}}
|
||||
}
|
||||
|
||||
// received digs the delivered contributions back out of a declaration.
|
||||
func received(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/etc/traefik/mesh.json" {
|
||||
continue
|
||||
}
|
||||
body := r["content"].(string)
|
||||
var parsed struct {
|
||||
Requirement string `json:"requirement"`
|
||||
Given []Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
||||
t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body)
|
||||
}
|
||||
if parsed.Requirement != "reverse-proxy" {
|
||||
t.Fatalf("the file does not say what it is about: %q", parsed.Requirement)
|
||||
}
|
||||
return parsed.Given
|
||||
}
|
||||
t.Fatalf("the provider was given no file at all: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) {
|
||||
// It is written for a program, and the first version put a `//` header above the JSON — a
|
||||
// file that says "do not edit" to a person and fails to parse for the thing meant to read it.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
for _, r := range mustDeclare(t, got) {
|
||||
if r["path"] != "/etc/traefik/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var any map[string]any
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil {
|
||||
t.Fatalf("the file is not parseable: %v\n%s", err, r["content"])
|
||||
}
|
||||
if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") {
|
||||
// Inside the document rather than above it, so it reaches a person without
|
||||
// breaking the parse.
|
||||
t.Fatalf("the file does not say it is generated: %v", any["generated"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no received file")
|
||||
}
|
||||
|
||||
func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if len(given) != 1 || given[0].From != "board" {
|
||||
t.Fatalf("the proxy was not told about board: %v", given)
|
||||
}
|
||||
if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) {
|
||||
t.Fatalf("the contribution did not survive: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributingToSomethingIsRequiringIt(t *testing.T) {
|
||||
// Asking to be published means a publisher must exist. A module that had to say both would
|
||||
// eventually say one, and the failure would be a machine where nothing serves the route.
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(strings.Join(names(got), " "), "traefik") {
|
||||
t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingToContributeToIsRefused(t *testing.T) {
|
||||
_, err := Resolve(shelf(published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("a module was published through a proxy that does not exist")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reverse-proxy") {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(),
|
||||
published("board", "board", 8080),
|
||||
published("archive", "archive", 9000),
|
||||
), []string{"board", "archive"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 2 {
|
||||
t.Fatalf("the proxy was told about %d of 2: %v", len(given), given)
|
||||
}
|
||||
// Ordered by module, because this becomes a file and a file whose lines move about looks
|
||||
// changed when nothing changed — which would restart the proxy for ever.
|
||||
if given[0].From != "archive" || given[1].From != "board" {
|
||||
t.Fatalf("the order is not stable: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) {
|
||||
// Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me"
|
||||
// from "the mesh never wrote it", and those want completely different responses — the same
|
||||
// rule the host follows about a service that does not exist.
|
||||
got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if given := received(t, mustDeclare(t, got)); len(given) != 0 {
|
||||
t.Fatalf("got %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) {
|
||||
// A proxy that got a new route and did not reload is a route that silently does not work.
|
||||
// The same fault the private network had when a peer list changed under a running interface,
|
||||
// which is why the received file has a name a module can point at.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
out := mustDeclare(t, got)
|
||||
for _, r := range out {
|
||||
if r["type"] != "service" {
|
||||
continue
|
||||
}
|
||||
reflects, ok := r["restart-on"].([]any)
|
||||
if !ok || len(reflects) != 1 {
|
||||
t.Fatalf("the proxy does not reflect anything: %v", r)
|
||||
}
|
||||
if reflects[0] != "traefik."+ReceivedID("reverse-proxy") {
|
||||
t.Fatalf("it reflects %v, which is not the file it was given", reflects[0])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no service in the declaration")
|
||||
}
|
||||
|
||||
func TestARouteCanBeSetPerMesh(t *testing.T) {
|
||||
// The hostname is exactly the kind of thing that differs between one mesh and the next. A
|
||||
// module whose route could not be set would have to be edited to be reused anywhere.
|
||||
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)),
|
||||
[]string{"board"}, workstation(), nil)
|
||||
out, err := got.Declaration(Rendering{Settings: SettingsBy{
|
||||
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, out)
|
||||
if given[0].Values["host"] != "dashboard" {
|
||||
t.Fatalf("the setting did not reach the route: %v", given[0].Values)
|
||||
}
|
||||
if given[0].Values["port"] != float64(8080) {
|
||||
t.Fatalf("setting the host dropped the port: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
||||
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
||||
"receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module received contributions to something it does not provide")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyContributionIsRefused(t *testing.T) {
|
||||
// Either a mistake or a requirement written the long way round, and both are better said.
|
||||
_, err := ParseManifest([]byte(`{"module":"board","version":"1",
|
||||
"contributes":{"reverse-proxy":{}}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module contributed nothing and was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "require it") {
|
||||
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -83,8 +83,56 @@ type Manifest struct {
|
||||
// doing the same job, and every machine with an address was on the network whether or not
|
||||
// anybody wanted it there.
|
||||
Computed string `json:"computed,omitempty"`
|
||||
|
||||
// Contributes is what this module tells whatever answers a requirement.
|
||||
//
|
||||
// The other half of an edge. `requires` says a thing must be there; this says what to do with
|
||||
// it — a web application requiring a reverse proxy has to say *which name, which port*, and
|
||||
// until now there was nowhere to put that. Every module that needed it was reduced to
|
||||
// reaching into the control plane's database directly, which is how two of them came to hold
|
||||
// a credential to it permanently.
|
||||
//
|
||||
// Keyed by the requirement, because that is what the contribution is *about*. Contributing to
|
||||
// something is requiring it: asking to be published means a publisher must exist, and a
|
||||
// module that had to say both would eventually say one.
|
||||
Contributes map[string]map[string]any `json:"contributes,omitempty"`
|
||||
|
||||
// Receives is where this module wants its consumers' contributions written, per requirement
|
||||
// it provides.
|
||||
//
|
||||
// A file, in the mesh's own shape, replaced whenever the set changes. **The control plane
|
||||
// does not know what a reverse proxy is** and does not write one's configuration — it
|
||||
// delivers the facts, and the module turns them into whatever it runs. That boundary is why
|
||||
// swapping the proxy does not touch a single module that publishes through it.
|
||||
Receives map[string]string `json:"receives,omitempty"`
|
||||
}
|
||||
|
||||
// Wants is everything that must be provided on the same node: what this module requires, and what
|
||||
// it contributes to.
|
||||
func (m Manifest) Wants() []string {
|
||||
out := append([]string{}, m.Requires...)
|
||||
for to := range m.Contributes {
|
||||
var already bool
|
||||
for _, r := range m.Requires {
|
||||
if r == to {
|
||||
already = true
|
||||
}
|
||||
}
|
||||
if !already {
|
||||
out = append(out, to)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ReceivedID is the resource identity of the file a provider is given its contributions in.
|
||||
//
|
||||
// Named rather than positional so a module can point `restart-on` at it: a proxy that got a new
|
||||
// route and did not reload is a route that silently does not work, which is the same fault the
|
||||
// overlay had when a peer list changed under a running interface.
|
||||
func ReceivedID(requirement string) string { return "received-" + requirement }
|
||||
|
||||
// ParseManifest reads a module manifest, refusing anything it cannot act on.
|
||||
//
|
||||
// Every problem is reported rather than the first, because somebody writing a manifest fixes
|
||||
@@ -138,6 +186,38 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
||||
"%s has resources of its own and says they are computed by %q; it is one or the other",
|
||||
m.Module, m.Computed))
|
||||
}
|
||||
for to, values := range m.Contributes {
|
||||
if !name.MatchString(to) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to))
|
||||
}
|
||||
if len(values) == 0 {
|
||||
// An empty contribution is either a mistake or a requirement written the long way
|
||||
// round, and both are better said plainly.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s contributes nothing to %q; if it only needs one, require it", m.Module, to))
|
||||
}
|
||||
}
|
||||
for to, where := range m.Receives {
|
||||
if !name.MatchString(to) {
|
||||
problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to))
|
||||
}
|
||||
if !strings.HasPrefix(where, "/") {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s receives %q at %q, which is not an absolute path", m.Module, to, where))
|
||||
}
|
||||
var offered bool
|
||||
for _, o := range m.Offers() {
|
||||
if o == to {
|
||||
offered = true
|
||||
}
|
||||
}
|
||||
if !offered {
|
||||
// Receiving contributions to something you do not provide would create a file nobody
|
||||
// ever writes to, on a machine where nothing asked for it.
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s receives contributions to %q and does not provide it", m.Module, to))
|
||||
}
|
||||
}
|
||||
for i, r := range m.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
if id == "" {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -150,7 +151,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, elsewh
|
||||
because[m.Module] = fmt.Sprintf("required by %s", because[want])
|
||||
}
|
||||
|
||||
for _, r := range m.Requires {
|
||||
for _, r := range m.Wants() {
|
||||
if _, ok := because[r]; !ok {
|
||||
because[r] = m.Module
|
||||
}
|
||||
@@ -305,9 +306,21 @@ type Rendering struct {
|
||||
// both call something "config", and without this the second would silently replace the first —
|
||||
// the node applying one of them and reporting success.
|
||||
func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
given, err := r.contributions(with.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []map[string]any
|
||||
for _, m := range r.Modules {
|
||||
resources := m.Resources
|
||||
for _, to := range sortedKeys(m.Receives) {
|
||||
file, err := receivedFile(to, m.Receives[to], given[to])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resources = append(append([]map[string]any{}, resources...), file)
|
||||
}
|
||||
if m.Computed != "" {
|
||||
generator, known := with.Generators[m.Computed]
|
||||
if !known {
|
||||
@@ -351,3 +364,76 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Contribution is one module telling the answer to a requirement what it needs from it.
|
||||
type Contribution struct {
|
||||
// From is the module that said it, so the provider and a person reading the file can tell
|
||||
// which route belongs to what.
|
||||
From string `json:"from"`
|
||||
// Values are the module's own, with settings applied. What the keys mean is agreed by the
|
||||
// requirement's name — everything providing `reverse-proxy` understands the same shape, which
|
||||
// is what makes swapping one for another cost nothing.
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// contributions collects what every module in this set contributes, by requirement.
|
||||
//
|
||||
// Ordered by contributing module, because the result becomes a file on a machine and a file whose
|
||||
// lines move about is a file that looks changed when nothing changed.
|
||||
func (r Resolution) contributions(settings SettingsBy) (map[string][]Contribution, error) {
|
||||
out := map[string][]Contribution{}
|
||||
modules := append([]Manifest{}, r.Modules...)
|
||||
sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module })
|
||||
|
||||
for _, m := range modules {
|
||||
for _, to := range sortedKeys(m.Contributes) {
|
||||
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
||||
// exactly the kind of thing that differs between one mesh and the next, and a module
|
||||
// that could not have it set would have to be edited to be reused.
|
||||
values, err := settle(m.Contributes[to], settings[m.Module], nil,
|
||||
m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// receivedFile is the file a provider is given its consumers' contributions in.
|
||||
func receivedFile(requirement, path string, given []Contribution) (map[string]any, error) {
|
||||
if given == nil {
|
||||
// Nobody contributed. The file is still written, empty, rather than left absent: a
|
||||
// provider that finds no file cannot tell "nothing asked for me" from "the mesh never
|
||||
// wrote it", and the two want completely different responses.
|
||||
given = []Contribution{}
|
||||
}
|
||||
// The note goes *inside* the document, not above it. The first version wrote a `//` header
|
||||
// and produced a file that says "do not edit" to a person and fails to parse for the program
|
||||
// meant to read it — which is the whole audience.
|
||||
body, err := json.MarshalIndent(map[string]any{
|
||||
"contributions": 1,
|
||||
"requirement": requirement,
|
||||
"generated": "by the mesh — do not edit; replaced whenever a module contributing to " +
|
||||
requirement + " arrives or leaves",
|
||||
"given": given,
|
||||
}, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"id": ReceivedID(requirement), "type": "file", "path": path, "mode": "0644",
|
||||
"content": string(body) + "\n",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
|
||||
func sortedKeys[V any](m map[string]V) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -63,18 +63,9 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
||||
protected[k] = true
|
||||
}
|
||||
|
||||
merged := deepCopy(base)
|
||||
for _, layer := range layers {
|
||||
for key, value := range layer.Values {
|
||||
if protected[key] {
|
||||
// The module said it must own this one. Refused rather than ignored: a setting
|
||||
// that is quietly dropped is somebody believing they changed something.
|
||||
return nil, fmt.Errorf(
|
||||
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
|
||||
layer.From, key, resource["id"], key)
|
||||
}
|
||||
merged[key] = mergeValue(merged[key], value)
|
||||
}
|
||||
merged, err := settle(base, layers, protected, fmt.Sprint(resource["id"]))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := map[string]any{}
|
||||
@@ -94,6 +85,29 @@ func ApplySettings(resource map[string]any, layers []Layer) (map[string]any, err
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// settle lays the layers over a module's own values, in order.
|
||||
//
|
||||
// Shared by a file's content and a module's contributions, because they are the same act: the
|
||||
// module says what it means by default, and somebody says what it means here. A contribution that
|
||||
// could not be settled would have to be edited to be reused anywhere else.
|
||||
func settle(base map[string]any, layers []Layer, protected map[string]bool, what string) (
|
||||
map[string]any, error) {
|
||||
merged := deepCopy(base)
|
||||
for _, layer := range layers {
|
||||
for key, value := range layer.Values {
|
||||
if protected[key] {
|
||||
// The module said it must own this one. Refused rather than ignored: a setting
|
||||
// that is quietly dropped is somebody believing they changed something.
|
||||
return nil, fmt.Errorf(
|
||||
"%s sets %q on %v, and that module keeps %q for itself — it is not settable",
|
||||
layer.From, key, what, key)
|
||||
}
|
||||
merged[key] = mergeValue(merged[key], value)
|
||||
}
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// mergeValue combines one value with the one over it.
|
||||
//
|
||||
// Two objects merge key by key, so setting one field of a nested block does not delete its
|
||||
@@ -130,20 +144,29 @@ func deepCopy(in map[string]any) map[string]any {
|
||||
// nothing — and would find out by the machine not behaving differently, which is the slowest
|
||||
// way there is. This is what makes that visible at the moment they set it.
|
||||
func UnusedSettings(m Manifest, layers []Layer) []string {
|
||||
mergeable := false
|
||||
for _, r := range m.Resources {
|
||||
if how, _ := r["merge"].(string); how != "" {
|
||||
mergeable = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if mergeable {
|
||||
// A contribution is a destination too. A route's hostname is exactly the kind of thing that
|
||||
// differs between one mesh and the next, and calling it stray would refuse the one setting
|
||||
// most modules that publish anything will have.
|
||||
if len(m.Contributes) > 0 {
|
||||
return nil
|
||||
}
|
||||
// A computed module has no resources here to look at — they are worked out per node, and
|
||||
// whether a setting lands is not knowable until then. Silence rather than a wrong answer:
|
||||
// claiming every setting on the private network is stray would be worse than saying nothing.
|
||||
if m.Computed != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var unused []string
|
||||
for _, layer := range layers {
|
||||
for key := range layer.Values {
|
||||
unused = append(unused, fmt.Sprintf("%s sets %q, and %s has no file to merge it into",
|
||||
unused = append(unused, fmt.Sprintf(
|
||||
"%s sets %q, and %s has no file or contribution to merge it into",
|
||||
layer.From, key, m.Module))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestSettingsThatReachNothingAreNamed(t *testing.T) {
|
||||
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
|
||||
}}
|
||||
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], "no file to merge it into") {
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], "no file or contribution to merge it into") {
|
||||
t.Errorf("settings that reached nothing were not named: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user