One name per thing, per the HQ glossary: the module/container/image/binary/repo becomes mesh-controller, the seat the-controller, and the store+broker pair the foundation (embedded base bundles, default template and example lock renamed with their go:embed directives). No behaviour change — a pure vocabulary rename. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
292 lines
10 KiB
Go
292 lines
10 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, 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) {
|
|
array := indexOutsideStrings(bundle, `"resources"`)
|
|
if array < 0 {
|
|
return nil, 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 nil, fmt.Errorf("this bundle's resources are not a list")
|
|
}
|
|
open += array
|
|
|
|
depth, from := 0, -1
|
|
previous := 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 cut(bundle, previous, from, i+1), nil
|
|
}
|
|
previous = i + 1
|
|
from = -1
|
|
case c == ']' && depth == 0:
|
|
return nil, fmt.Errorf(
|
|
"this bundle declares no %q, so there is nothing to take out of it", id)
|
|
}
|
|
}
|
|
return nil, 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):
|
|
}
|
|
}
|
|
}
|