Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a963aec10 | ||
|
|
45d1c28a28 | ||
|
|
a3e7683c63 | ||
|
|
da394b45e6 |
@@ -76,7 +76,10 @@ func run() error {
|
|||||||
return pinCommand(ctx, args[1:], true)
|
return pinCommand(ctx, args[1:], true)
|
||||||
case "unpin":
|
case "unpin":
|
||||||
return pinCommand(ctx, args[1:], false)
|
return pinCommand(ctx, args[1:], false)
|
||||||
case "migrate":
|
// `prepare` is how the mesh asks any module to bring its state to the shape this version needs
|
||||||
|
// (novox/hq ADR 0135), and the control plane answers it the same way as everything else — its
|
||||||
|
// own schema is not a special case. `migrate` remains the word a person types.
|
||||||
|
case "prepare", "migrate":
|
||||||
return migrate(ctx)
|
return migrate(ctx)
|
||||||
case "node":
|
case "node":
|
||||||
return nodeCommand(ctx, args[1:])
|
return nodeCommand(ctx, args[1:])
|
||||||
@@ -138,6 +141,7 @@ func usage() {
|
|||||||
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
fmt.Fprint(os.Stderr, `mesh-controller — the control plane
|
||||||
|
|
||||||
migrate bring each context's schema up to date
|
migrate bring each context's schema up to date
|
||||||
|
prepare the same, asked the way the mesh asks any module (ADR 0135)
|
||||||
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
node add <name> [--adopted] create a node record; --adopted: the machine is in use
|
||||||
node list the nodes this mesh knows about
|
node list the nodes this mesh knows about
|
||||||
node show <name> what one machine reported it can do, and why
|
node show <name> what one machine reported it can do, and why
|
||||||
|
|||||||
@@ -623,6 +623,9 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
// one of them as its environment without saying so (ADR 0086, issue 041).
|
// one of them as its environment without saying so (ADR 0086, issue 041).
|
||||||
secretFiles := secretFilesOf(resources)
|
secretFiles := secretFilesOf(resources)
|
||||||
|
|
||||||
|
// Which of this module's resources its preparation runs before, if it prepares anything.
|
||||||
|
prepareBefore := preparationTarget(m)
|
||||||
|
|
||||||
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 {
|
||||||
@@ -708,6 +711,18 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
|||||||
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
|
||||||
copied["reload-on"] = renamed
|
copied["reload-on"] = renamed
|
||||||
}
|
}
|
||||||
|
// **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the
|
||||||
|
// module's own resource rather than declared beside it: what prepares the state is the
|
||||||
|
// module's own code, so what it is given has to be what that code is given — and a
|
||||||
|
// second resource written by hand is a second copy to drift from the first. Placed
|
||||||
|
// immediately before it, because a run-once step stops everything the declaration
|
||||||
|
// places after it (ADR 0052), which is how a version whose preparation failed does not
|
||||||
|
// serve.
|
||||||
|
if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore {
|
||||||
|
step := prepared(copied)
|
||||||
|
owner[fmt.Sprint(step["id"])] = m.Module
|
||||||
|
out = append(out, step)
|
||||||
|
}
|
||||||
owner[fmt.Sprint(copied["id"])] = m.Module
|
owner[fmt.Sprint(copied["id"])] = m.Module
|
||||||
out = append(out, copied)
|
out = append(out, copied)
|
||||||
}
|
}
|
||||||
@@ -1610,3 +1625,73 @@ func atMachinePort(serves map[string]any, module string, ports map[string]map[in
|
|||||||
func AtPublishedPort(values map[string]any, module string, published map[int]int) map[string]any {
|
func AtPublishedPort(values map[string]any, module string, published map[int]int) map[string]any {
|
||||||
return atMachinePort(values, module, map[string]map[int]int{module: published})
|
return atMachinePort(values, module, map[string]map[int]int{module: published})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreparationArgument is how the mesh asks a module to prepare its state: one word, to the module's
|
||||||
|
// own program, whatever that program is (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// **One word for every kind of module.** A module built as a Go binary receives it as its argument;
|
||||||
|
// one built as a bundle receives it through the runtime, whose entry takes the same word. So the
|
||||||
|
// mesh has one way of asking and a module has one way of answering, and neither learns the other's
|
||||||
|
// shape.
|
||||||
|
const PreparationArgument = "prepare"
|
||||||
|
|
||||||
|
// preparationTarget is the resource a module's preparation runs before: its own workload.
|
||||||
|
//
|
||||||
|
// The first container carrying an artifact this module built, and not itself a step — that is the
|
||||||
|
// thing that runs the module's code, and therefore the thing whose state must be ready. Empty when
|
||||||
|
// the module prepares nothing, or when nothing it declares could run its code.
|
||||||
|
//
|
||||||
|
// **A module with two own workloads gates the first of them.** Five modules in the catalogue declare
|
||||||
|
// more than one container of their own, none of them preparing anything today. If one ever does and
|
||||||
|
// its second workload shares the state, the gate is in front of the first — stated here because the
|
||||||
|
// alternative is a field asking an author to restate what the mesh can see.
|
||||||
|
func preparationTarget(m Manifest) string {
|
||||||
|
if !m.Prepares {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if fmt.Sprint(r["type"]) != "container" || !ownArtifact(r, m.Module) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if once, _ := r["run-once"].(bool); once {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Sprint(r["id"])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownArtifact is whether a resource runs something this module built, in either spelling a manifest
|
||||||
|
// may be in: naming the artifact, before a build resolved it, or carrying the reference a build
|
||||||
|
// recorded — this mesh's own store, under this module's name.
|
||||||
|
func ownArtifact(resource map[string]any, module string) bool {
|
||||||
|
if named, _ := resource["artifact"].(string); named != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
image, _ := resource["image"].(string)
|
||||||
|
return strings.HasPrefix(image, ArtifactStoreScheme+module+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepared is the module's own resource as the step that prepares its state: the same image, the same
|
||||||
|
// context, run to completion with the mesh's preparation argument.
|
||||||
|
//
|
||||||
|
// Three things are taken away rather than copied, each because the step runs while the version it
|
||||||
|
// prepares for is still running. A published port cannot be bound twice, and a step that tried would
|
||||||
|
// fail for a reason that has nothing to do with the state. A fixed address cannot be held twice, for
|
||||||
|
// the same reason. And a cadence is what a step is the opposite of: a container runs once and gates,
|
||||||
|
// or on a schedule, or stays up, never two (ADR 0053).
|
||||||
|
func prepared(from map[string]any) map[string]any {
|
||||||
|
step := map[string]any{}
|
||||||
|
for k, v := range from {
|
||||||
|
step[k] = v
|
||||||
|
}
|
||||||
|
step["id"] = fmt.Sprint(from["id"]) + ".prepare"
|
||||||
|
step["name"] = fmt.Sprint(from["name"]) + "-prepare"
|
||||||
|
step["run-once"] = true
|
||||||
|
step["args"] = []any{PreparationArgument}
|
||||||
|
delete(step, "ports")
|
||||||
|
delete(step, "ip")
|
||||||
|
delete(step, "schedule")
|
||||||
|
delete(step, "reload-on")
|
||||||
|
return step
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,6 +225,19 @@ type Manifest struct {
|
|||||||
// subscription to the queue it writes to (design 29 §2).
|
// subscription to the queue it writes to (design 29 §2).
|
||||||
Uses []string `json:"uses,omitempty"`
|
Uses []string `json:"uses,omitempty"`
|
||||||
|
|
||||||
|
// Prepares says this module has state that must be brought to the shape this version needs
|
||||||
|
// before this version runs, and that the module's own code does it (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// **A word, not an arrangement.** The mesh runs the module's own program in its preparation
|
||||||
|
// mode, in the module's own context — every binding, credential and setting its code receives,
|
||||||
|
// because it *is* its code. Nothing here names a container, a command, a mount or a variable:
|
||||||
|
// the module already said all of that once, and a second copy is a second thing to drift.
|
||||||
|
//
|
||||||
|
// **Declared, never inferred.** The control plane cannot read what is inside an artifact, so a
|
||||||
|
// module that ships a migration and does not say this breaks on its first upgrade. That is
|
||||||
|
// stated in the record rather than guarded here, because nothing mechanical can guard it.
|
||||||
|
Prepares bool `json:"prepares,omitempty"`
|
||||||
|
|
||||||
// Tools are the tools this module answers — request and reply, awaited.
|
// Tools are the tools this module answers — request and reply, awaited.
|
||||||
//
|
//
|
||||||
// **New, and not `serves`**, which this manifest already uses for the facts a consumer needs
|
// **New, and not `serves`**, which this manifest already uses for the facts a consumer needs
|
||||||
@@ -1276,6 +1289,17 @@ func ParseManifest(raw []byte) (Manifest, error) {
|
|||||||
"program that reads what the mesh delivered and reconciles",
|
"program that reads what the mesh delivered and reconciles",
|
||||||
m.Module, r["id"]))
|
m.Module, r["id"]))
|
||||||
}
|
}
|
||||||
|
// **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The
|
||||||
|
// preparation is the module's own program in its preparation mode, so it is derived from the
|
||||||
|
// resource that runs that program — and a module declaring none has asked for something the mesh
|
||||||
|
// cannot compose. Said here, where the manifest is read, rather than by a declaration that
|
||||||
|
// quietly prepares nothing.
|
||||||
|
if m.Prepares && preparationTarget(m) == "" {
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s says it prepares its state, and declares no container running an artifact it built — "+
|
||||||
|
"the preparation is this module's own program, so there has to be one for the mesh to "+
|
||||||
|
"run it in", m.Module))
|
||||||
|
}
|
||||||
// **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a
|
// **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a
|
||||||
// boolean modifier on the container shape — the host runs the container, requires it to exit 0,
|
// boolean modifier on the container shape — the host runs the container, requires it to exit 0,
|
||||||
// and starts whatever the declaration places after it only once it has. A value that is not a
|
// and starts whatever the declaration places after it only once it has. A value that is not a
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package catalogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A module version prepares its state before it runs (novox/hq ADR 0135).
|
||||||
|
//
|
||||||
|
// What the mesh derives is the module's own resource, run once with one word, placed immediately in
|
||||||
|
// front of the thing it prepares for. What matters in these tests is that the derivation is a copy
|
||||||
|
// rather than a second description: the failure it replaces was a hand-written step repeating six
|
||||||
|
// fields of the resource it preceded, each free to drift from it.
|
||||||
|
|
||||||
|
func aPreparingModule() Manifest {
|
||||||
|
return Manifest{
|
||||||
|
Module: "gitea",
|
||||||
|
Prepares: true,
|
||||||
|
Resources: []map[string]any{
|
||||||
|
{"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"},
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea-server",
|
||||||
|
"image": "gitea/gitea@" + digest, "ports": []any{"3000:3000"}},
|
||||||
|
{"id": "runtime", "type": "container", "name": "mesh-gitea",
|
||||||
|
"image": ArtifactStoreScheme + "gitea/runtime@" + digest, "network": "host",
|
||||||
|
"env": map[string]any{"MESH_GITEA_STATE_DIR": "/run/state"},
|
||||||
|
"volumes": []any{"/var/lib/gitea:/run/state:ro"},
|
||||||
|
"ports": []any{"9000:9000"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func declaredFor(t *testing.T, m Manifest) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
// The manifest as the mesh holds it: a build resolved the module's own artifact into the
|
||||||
|
// reference it recorded, which is also how the composition knows whose code a resource runs.
|
||||||
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(
|
||||||
|
Rendering{ArtifactStore: "anchor.internal:5100"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func idsOf(resources []map[string]any) []string {
|
||||||
|
var ids []string
|
||||||
|
for _, r := range resources {
|
||||||
|
ids = append(ids, fmt.Sprint(r["id"]))
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// The step runs the module's own code, and comes immediately before it — not before the upstream
|
||||||
|
// server the module packages, which may be the very thing the state lives in.
|
||||||
|
func TestThePreparationRunsTheModulesOwnCodeAndComesRightBeforeIt(t *testing.T) {
|
||||||
|
out := declaredFor(t, aPreparingModule())
|
||||||
|
ids := idsOf(out)
|
||||||
|
at := -1
|
||||||
|
for i, id := range ids {
|
||||||
|
if id == "gitea.runtime.prepare" {
|
||||||
|
at = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
t.Fatalf("nothing prepares this module's state: %v", ids)
|
||||||
|
}
|
||||||
|
if ids[at+1] != "gitea.runtime" {
|
||||||
|
t.Fatalf("the preparation is not immediately before the module's own code: %v", ids)
|
||||||
|
}
|
||||||
|
for _, id := range ids[:at] {
|
||||||
|
if id == "gitea.runtime" {
|
||||||
|
t.Fatalf("the module's own code runs before its state is prepared: %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// It is given exactly what the module's own code is given. Asserted field by field against the
|
||||||
|
// resource it was derived from, because writing it twice is the fault this replaces.
|
||||||
|
func TestThePreparationIsGivenWhatTheModuleIsGiven(t *testing.T) {
|
||||||
|
out := declaredFor(t, aPreparingModule())
|
||||||
|
declared := byID(out)
|
||||||
|
step, workload := declared["gitea.runtime.prepare"], declared["gitea.runtime"]
|
||||||
|
if step == nil || workload == nil {
|
||||||
|
t.Fatalf("expected both, got %v", idsOf(out))
|
||||||
|
}
|
||||||
|
for _, field := range []string{"image", "network", "env", "volumes", "type"} {
|
||||||
|
if fmt.Sprint(step[field]) != fmt.Sprint(workload[field]) {
|
||||||
|
t.Errorf("the preparation's %s is %v and the module's is %v", field, step[field], workload[field])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if once, _ := step["run-once"].(bool); !once {
|
||||||
|
t.Error("the preparation is not a step, so nothing waits for it and nothing is gated by it")
|
||||||
|
}
|
||||||
|
if fmt.Sprint(step["args"]) != fmt.Sprint([]any{PreparationArgument}) {
|
||||||
|
t.Errorf("the preparation is asked for as %v", step["args"])
|
||||||
|
}
|
||||||
|
if fmt.Sprint(step["name"]) == fmt.Sprint(workload["name"]) {
|
||||||
|
t.Error("the preparation and the workload have one name, so one removes the other")
|
||||||
|
}
|
||||||
|
// A published port cannot be bound twice, and the version being replaced is still running.
|
||||||
|
if _, published := step["ports"]; published {
|
||||||
|
t.Errorf("the preparation publishes a port the running version holds: %v", step["ports"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module that says nothing about preparing gets nothing, which is most modules.
|
||||||
|
func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) {
|
||||||
|
m := aPreparingModule()
|
||||||
|
m.Prepares = false
|
||||||
|
for _, id := range idsOf(declaredFor(t, m)) {
|
||||||
|
if id == "gitea.runtime.prepare" {
|
||||||
|
t.Fatal("a module that prepares nothing was given a preparation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest
|
||||||
|
// is read beats a declaration that quietly prepares nothing.
|
||||||
|
func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) {
|
||||||
|
m := Manifest{
|
||||||
|
Module: "gitea",
|
||||||
|
Prepares: true,
|
||||||
|
Resources: []map[string]any{
|
||||||
|
// Only the upstream server it packages: nothing here runs gitea's own code.
|
||||||
|
{"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(m)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := ParseManifest(raw); err == nil {
|
||||||
|
t.Fatal("a module that prepares its state with nothing of its own to run was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
"bus": "/var/lib/mesh/mesh-controller/bus"
|
"bus": "/var/lib/mesh/mesh-controller/bus"
|
||||||
},
|
},
|
||||||
"secrets-owner": "65534:65534",
|
"secrets-owner": "65534:65534",
|
||||||
|
"prepares": true,
|
||||||
"resources": [
|
"resources": [
|
||||||
{
|
{
|
||||||
"id": "mesh-state",
|
"id": "mesh-state",
|
||||||
|
|||||||
Reference in New Issue
Block a user