An operator ran `mesh-host reconcile` on an adopted control-node with twelve modules assigned. It applied the bundle the host carries — the genesis declaration, foundation only, converged: recreated the store, failed on the broker's held port, wrote the converged base filter and started its service, and stopped at the first failing action. The filter closed the machine for forty-five minutes. The host reported the node adopted in every report, the declaration said converged, and nothing compared the two; nothing was printed before acting (hq issue 104). The host now records the node's mode — from every declaration the mesh sends, and at genesis from what the operator said — and refuses, at the point of application, a declaration that says the other mode, naming both and the act that changes it. Only a declaration the link delivers, signed, changes the mode: that is how `converge` and `adopt` arrive, so the flip still works and nothing else can do it. Genesis marks the bundle consumed, with the digest of what it applied, so `reconcile` holds a node the mesh has spoken to against what the mesh last said and never the bundle, and refuses the carried bytes when they are not what genesis applied. A file is refused when it is not what the mesh last said: a declaration carries no sequence and no issued-at, so the host cannot tell older from newer, and says so. Both commands print what they would change — a hold, a removal, an action named as one — before touching anything, and --dry-run is that list and nothing more.
303 lines
11 KiB
Go
303 lines
11 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// Retired is what step 10 did.
|
|
type Retired struct {
|
|
// Container is the temporary control plane that was dropped.
|
|
Container string
|
|
// Gone is true when the machine no longer has it.
|
|
Gone bool
|
|
// Already is true when it was gone before this step ran.
|
|
Already bool
|
|
// Bundle is where the bundle without it was written.
|
|
Bundle string
|
|
// Removed is how many resources the re-apply reported removing.
|
|
Removed int
|
|
}
|
|
|
|
// RetireTheTemporaryControlPlane drops it from the bundle and lets the host take it away.
|
|
//
|
|
// **Destruction by omission, which is the host's ordinary behaviour and not a new mechanism.** The
|
|
// host owns what it has applied and removes what it owns and is no longer declared. So retiring the
|
|
// temporary control plane is not a verb anybody had to invent: the bundle stops declaring it, the
|
|
// bundle is applied again, and the removal pass does what it does for every other resource that
|
|
// leaves a declaration.
|
|
//
|
|
// That is the whole of why the rename at step 3 mattered. Had the foundation and the module both
|
|
// called their container `mesh-controller`, this apply would have removed the module's container —
|
|
// the host would have been asked to take away something it believed it owned, and it would have
|
|
// been right. Two names, two owners, and the removal is unambiguous.
|
|
//
|
|
// **It is the last step for a reason.** Until step 9 has a control plane that answers, the
|
|
// temporary one is the only thing that can tell this machine anything, and a machine left with no
|
|
// control plane cannot be fixed remotely (novox/hq ADR 0067). So this runs after the permanent one
|
|
// has been proved, and a run interrupted before it leaves two control planes, which is untidy and
|
|
// harmless — a re-run reaches this step and finishes.
|
|
func RetireTheTemporaryControlPlane(ctx context.Context, o Options, sys system.System,
|
|
produced []byte, run Runner, say func(string)) (Retired, error) {
|
|
|
|
out := Retired{Bundle: o.Out}
|
|
|
|
current, err := declaration.ParseFileTrusted(produced)
|
|
if err != nil {
|
|
return out, fmt.Errorf("the bundle this installer produced is not a declaration: %w", err)
|
|
}
|
|
temporary, err := controlPlaneIn(current)
|
|
if err != nil {
|
|
// The bundle already declares no control plane, which is what a re-run after this step
|
|
// finds. Nothing to drop, and nothing to be alarmed about.
|
|
say(" already dropped the bundle declares no temporary control plane")
|
|
out.Already = true
|
|
return out, nil
|
|
}
|
|
out.Container = temporary.Name
|
|
|
|
// Textual, for the reason the rewrite at step 3 is textual: the produced bundle is meant to be
|
|
// READ, and a person coming to a machine after a pivot should be able to open the file the
|
|
// installer applied and see the foundation they recognise with the control plane gone from it.
|
|
// Re-serialising a parsed declaration would drop every comment in it.
|
|
bundle, err := removeResource(produced, ControlPlaneID)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
without, err := declaration.ParseFileTrusted(bundle)
|
|
if err != nil {
|
|
return out, fmt.Errorf(
|
|
"taking the temporary control plane out of the bundle broke it: %w", err)
|
|
}
|
|
if _, err := controlPlaneIn(without); err == nil {
|
|
return out, fmt.Errorf(
|
|
"the bundle still declares %q after it was taken out, so nothing was removed and the "+
|
|
"apply below would change nothing", ControlPlaneID)
|
|
}
|
|
if err := writeBundleFile(o.Out, bundle); err != nil {
|
|
return out, err
|
|
}
|
|
say(fmt.Sprintf(" wrote %s (%d resources) — without %s",
|
|
o.Out, len(without.Resources), temporary.Name))
|
|
|
|
// Applied the same way everything else here is applied, under the same origin, against the
|
|
// same state file. What makes this a removal rather than a no-op is that the state file
|
|
// records the container as something this installer applied, and the declaration no longer
|
|
// asks for it.
|
|
report, err := ApplyBundle(ctx, o, sys, without, bundle, run, say)
|
|
if err != nil {
|
|
return out, fmt.Errorf(
|
|
"%w\n\nThe permanent control plane is running and the temporary one is still here. "+
|
|
"That is untidy and it is not broken: two control planes on one mesh are both "+
|
|
"stateless and both correct. Run this installer again to finish", err)
|
|
}
|
|
for _, outcome := range report.Outcomes {
|
|
if outcome.Action == "removed" {
|
|
out.Removed++
|
|
}
|
|
}
|
|
|
|
// Read back. A removal that reported success and left the container running would leave two
|
|
// control planes consuming the same broker queues for ever, which is the state this step
|
|
// exists to end.
|
|
gone, err := isGone(ctx, run, o.Timeout, o.Wait, temporary.Name)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
out.Gone = gone
|
|
if !gone {
|
|
return out, fmt.Errorf(
|
|
"the apply reported the temporary control plane removed and %q is still running.\n"+
|
|
"Two control planes are consuming this mesh's broker queues. Neither is wrong and "+
|
|
"the mesh is not damaged, but the pivot is not finished: `docker rm -f %s` ends "+
|
|
"it, and this installer will then agree", temporary.Name, temporary.Name)
|
|
}
|
|
say(" gone " + temporary.Name)
|
|
return out, nil
|
|
}
|
|
|
|
// removeResource takes one resource out of a bundle's text, comments and all.
|
|
//
|
|
// It walks the `resources` array counting braces, skipping over strings and comments so that a
|
|
// `//` inside a connection string is not read as the start of one — the foundation's own bundle
|
|
// contains `postgres://…` several times, and a scanner that did not know the difference would
|
|
// treat the rest of the line as a comment and lose a brace.
|
|
//
|
|
// What is removed is the element AND whatever precedes it back to the previous element, which is
|
|
// where the comment explaining it lives. A comment that outlives the thing it describes is worse
|
|
// than no comment: it is the file telling somebody the machine has a control plane it does not.
|
|
func removeResource(bundle []byte, id string) ([]byte, error) {
|
|
previous, from, to, err := resourceAt(bundle, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cut(bundle, previous, from, to), nil
|
|
}
|
|
|
|
// resourceAt finds one resource's object in a bundle's text by its id: where the one before it
|
|
// ended, and where it starts and ends — comments and strings skipped, so an id quoted in a comment
|
|
// or a command is never mistaken for the resource.
|
|
func resourceAt(bundle []byte, id string) (previous, from, to int, err error) {
|
|
array := indexOutsideStrings(bundle, `"resources"`)
|
|
if array < 0 {
|
|
return 0, 0, 0, fmt.Errorf("this bundle has no resources array, so there is nothing to take out of it")
|
|
}
|
|
open := indexOutsideStrings(bundle[array:], "[")
|
|
if open < 0 {
|
|
return 0, 0, 0, fmt.Errorf("this bundle's resources are not a list")
|
|
}
|
|
open += array
|
|
|
|
depth := 0
|
|
from, previous = -1, open
|
|
inString, escaped, inLine, inBlock := false, false, false, false
|
|
for i := open + 1; i < len(bundle); i++ {
|
|
c := bundle[i]
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
case inString && c == '\\':
|
|
escaped = true
|
|
case inString:
|
|
if c == '"' {
|
|
inString = false
|
|
}
|
|
case inLine:
|
|
if c == '\n' {
|
|
inLine = false
|
|
}
|
|
case inBlock:
|
|
if c == '*' && i+1 < len(bundle) && bundle[i+1] == '/' {
|
|
inBlock, i = false, i+1
|
|
}
|
|
case c == '"':
|
|
inString = true
|
|
case c == '/' && i+1 < len(bundle) && bundle[i+1] == '/':
|
|
inLine, i = true, i+1
|
|
case c == '/' && i+1 < len(bundle) && bundle[i+1] == '*':
|
|
inBlock, i = true, i+1
|
|
case c == '{':
|
|
if depth == 0 {
|
|
from = i
|
|
}
|
|
depth++
|
|
case c == '}':
|
|
depth--
|
|
if depth != 0 {
|
|
break
|
|
}
|
|
if isResource(bundle[from:i+1], id) {
|
|
return previous, from, i + 1, nil
|
|
}
|
|
previous = i + 1
|
|
from = -1
|
|
case c == ']' && depth == 0:
|
|
return 0, 0, 0, fmt.Errorf(
|
|
"this bundle declares no %q, so there is nothing to take out of it", id)
|
|
}
|
|
}
|
|
return 0, 0, 0, fmt.Errorf("this bundle's resources list does not end")
|
|
}
|
|
|
|
// isResource reports whether one resource's text is the one wanted.
|
|
//
|
|
// Whitespace-insensitive on the pair, quotes included, so `"id": "control-plane"` and
|
|
// `"id":"control-plane"` are the same answer and `"id": "control-planes"` is not.
|
|
func isResource(resource []byte, id string) bool {
|
|
var tight []byte
|
|
for _, c := range resource {
|
|
if c != ' ' && c != '\t' && c != '\n' && c != '\r' {
|
|
tight = append(tight, c)
|
|
}
|
|
}
|
|
return bytes.Contains(tight, []byte(`"id":"`+id+`"`))
|
|
}
|
|
|
|
// cut removes an element and what leads up to it, leaving the list valid.
|
|
//
|
|
// Whether the comma before or the comma after goes depends on where the element sits: an element
|
|
// with something before it takes the comma that joined them, and the first element takes the one
|
|
// after it. Getting this wrong produces a trailing comma, which is JSON nothing will parse —
|
|
// caught by the re-parse either way, and better not produced.
|
|
func cut(bundle []byte, previous, from, to int) []byte {
|
|
start := from
|
|
for i := previous; i < from; i++ {
|
|
if bundle[i] == ',' {
|
|
start = i
|
|
break
|
|
}
|
|
}
|
|
end := to
|
|
if start == from {
|
|
// Nothing before it, so the comma that follows is the one that would be left dangling.
|
|
for i := to; i < len(bundle); i++ {
|
|
if bundle[i] == ',' {
|
|
end = i + 1
|
|
break
|
|
}
|
|
if bundle[i] == ']' {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
out := make([]byte, 0, len(bundle))
|
|
out = append(out, bundle[:start]...)
|
|
return append(out, bundle[end:]...)
|
|
}
|
|
|
|
// indexOutsideStrings finds a fragment that is not inside a JSON string.
|
|
func indexOutsideStrings(haystack []byte, needle string) int {
|
|
inString, escaped := false, false
|
|
for i := 0; i < len(haystack); i++ {
|
|
switch {
|
|
case escaped:
|
|
escaped = false
|
|
continue
|
|
case haystack[i] == '\\' && inString:
|
|
escaped = true
|
|
continue
|
|
case haystack[i] == '"':
|
|
// The needle may itself start with a quote, so the match is tried before the quote is
|
|
// consumed.
|
|
if !inString && bytes.HasPrefix(haystack[i:], []byte(needle)) {
|
|
return i
|
|
}
|
|
inString = !inString
|
|
continue
|
|
case inString:
|
|
continue
|
|
}
|
|
if bytes.HasPrefix(haystack[i:], []byte(needle)) {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// isGone waits for a container to stop existing.
|
|
//
|
|
// Waited for rather than asked once, because a container being removed is a container that is
|
|
// stopping first, and a runtime answers about it until it has finished.
|
|
func isGone(ctx context.Context, run Runner, probe, wait time.Duration, name string) (bool, error) {
|
|
deadline := time.Now().Add(wait)
|
|
for {
|
|
if _, err := containerRunning(ctx, run, probe, name); err != nil {
|
|
// The runtime does not know it. That is the answer being waited for.
|
|
return true, nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false, ctx.Err()
|
|
case <-time.After(answerEvery):
|
|
}
|
|
}
|
|
}
|