mesh/merge-gate head 0d12fb11a48b merged into trunk 59e6a6fa2a4a — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 0d12fb11a48b merged into trunk 59e6a6fa2a4a — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/check failed: mesh-build-go: no go build node qualifies: builders disagree: go1.27.1-X:nodwarf5 recipe 74744709d277ded9 on ace, g14, shanks; go1.…
On ace the postgres container was replaced at 22:11:51 mounting superuser.secret, a file the provisioner's client fills only after the apply. The runtime made the missing source a directory, and every fill after it failed with "is a directory". A container whose mount source is a file the declaration declares, not yet on the machine, now waits and is neither created nor replaced. The write of that file (WriteFilled) applies what reads it, as before, so the container is made once the file is there. What the runtime already left is cured by the mesh: a declared file whose path is an empty directory in the mesh's own tree (by its path and by where it leads), owned by the engine's account, has that directory removed before the file is written, and the outcome says so. A directory with anything in it, or outside the tree, is never removed.
236 lines
9.9 KiB
Go
236 lines
9.9 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
"github.com/novox/mesh-host/internal/system"
|
|
)
|
|
|
|
// WriteFilled writes what the provisioner's client filled into one file the declaration names as filled (novox/hq
|
|
// ADR 0349 §4), and makes what reads the file take the change, exactly as an apply that changed it would.
|
|
//
|
|
// **The declared file says everything but the content.** Its path, mode, owner and who owns it at runtime are the
|
|
// declaration's; the client's request carries the content alone. So the file is applied as the declared one with
|
|
// that content in place of the declaration's: an owned file whole through applyFile, a seed only when absent, a
|
|
// managed file's keys through applyInto or its block through applyBlock — the same guards on where nothing is
|
|
// placed, the same Keep of an original found there, the same record. What the declaration seals or fills with
|
|
// `${secret:}` the client has already filled, so the content is written as it came; a file that carried a secret
|
|
// keeps the mode a secret is written with when the declaration gives none.
|
|
//
|
|
// **Then what reads it, in the apply's order** (afterWhatTheyRead): a service naming it under restart-on is
|
|
// restarted and under reload-on reloaded, a process or container naming it under restart-on is restarted, and a
|
|
// running container that reads it at creation — an env-file, a file mounted into it — is recreated, because what
|
|
// it read is recorded by digest and this write changed the digest. Each of those is applied and recorded as an
|
|
// apply does, and what moves goes on moving what names it. Nothing else of the declaration is touched.
|
|
//
|
|
// The caller holds the apply's lock: a write and an apply never write one file at once. log is said the path, the
|
|
// action and the digest, never the content.
|
|
//
|
|
// **It does not refresh the runtimes' data roots.** A filled write is one file, not an apply; the placement guard
|
|
// judges it with the roots the last apply read (runtimeDataRoots), falling back to the runtimes' configuration
|
|
// files when no apply has read them yet — the runtimes themselves are asked once, at an apply's start, not once
|
|
// per file (novox/hq issue 1138).
|
|
func WriteFilled(
|
|
ctx context.Context,
|
|
sys system.System,
|
|
d *declaration.Declaration,
|
|
known store.State,
|
|
path string,
|
|
content []byte,
|
|
run Runner,
|
|
log func(string),
|
|
keep Keep,
|
|
windows *Windows,
|
|
) (report Report, _ store.State, _ error) {
|
|
if log == nil {
|
|
log = func(string) {}
|
|
}
|
|
if !d.Fills(path) {
|
|
return report, known, &Error{Resource: path, Err: fmt.Errorf("this node's declaration does not name %s "+
|
|
"as a file the provisioner fills, so nothing was written", path)}
|
|
}
|
|
var declared []*declaration.File
|
|
for _, r := range d.Resources {
|
|
if f, ok := r.(*declaration.File); ok && f.Path == path {
|
|
declared = append(declared, f)
|
|
}
|
|
}
|
|
if len(declared) != 1 {
|
|
// The parser refuses a filled path no file declares; two at one path would need the request to say whose
|
|
// keys or block it carries, and it carries the content only.
|
|
return report, known, &Error{Resource: path, Err: fmt.Errorf("%d resources declare %s, and a filled file "+
|
|
"is written for exactly one, so nothing was written", len(declared), path)}
|
|
}
|
|
file := filledAs(declared[0], content)
|
|
if held, isHeld := known.HeldAt(file.ID); isHeld {
|
|
return report, known, &Error{Resource: file.ID, Err: fmt.Errorf("%s is held as found for a module not "+
|
|
"yet taken (%s), so nothing was written", path, held.Why)}
|
|
}
|
|
|
|
in := inputs{declares: map[string]string{}, known: &known, windows: windows, away: newStoreAway(windows, log),
|
|
groups: groupsWanted(d.Resources), handed: Handed{}}
|
|
in.refused = refusedPlaces(d.Resources)
|
|
in.agentHomes = rootNeverHomes(d)
|
|
in.dirsBefore = map[string]bool{}
|
|
in.files = declaredFiles(d.Resources)
|
|
// A container that reads the file and mounts a library whose disk or mount is not there is held, as a full apply
|
|
// holds it: not created over the missing disk (novox/hq ADR 0328 §9).
|
|
in.held = heldLibraries(ctx, d.Libraries)
|
|
for _, r := range d.Resources {
|
|
in.declares[r.Identity()] = declaredDigest(r)
|
|
}
|
|
changed := map[string]bool{}
|
|
|
|
// The file, as the apply loop applies it.
|
|
was, _ := known.Find(file.ID)
|
|
var keepFound Keep
|
|
if (was.ID == "" || was.Target != file.Path) && !known.Recorded(string(declaration.TypeFile), file.Path) {
|
|
keepFound = keep
|
|
}
|
|
outcome, err := applyOne(ctx, sys, file, run, changed, in, was, nil, keepFound)
|
|
if err != nil {
|
|
log(fmt.Sprintf(" failed %s (%s): %v", file.ID, file.Path, err))
|
|
return report, known, &Error{Resource: file.ID, Err: err, Done: report}
|
|
}
|
|
recordKeeping(&known, file, outcome, was)
|
|
report.Outcomes = append(report.Outcomes, outcome)
|
|
if !moved(outcome) {
|
|
return report, known, nil
|
|
}
|
|
changed[file.ID] = true
|
|
line := fmt.Sprintf(" %s %s (%s), filled: %s", outcome.Action, file.ID, file.Path, outcome.wrote)
|
|
if outcome.Detail != "" {
|
|
line += ": " + outcome.Detail
|
|
}
|
|
log(line)
|
|
|
|
// What reads it, in the apply's order, each moving what names it in turn.
|
|
var failures []*Error
|
|
for _, r := range afterWhatTheyRead(d.Resources) {
|
|
if r.Identity() == file.ID || !readsChange(r, path, changed) {
|
|
continue
|
|
}
|
|
if _, isHeld := known.HeldAt(r.Identity()); isHeld {
|
|
log(fmt.Sprintf(" held %s (%s): held as found for a module not yet taken, so it was not restarted "+
|
|
"for %s", r.Identity(), r.Target(), file.ID))
|
|
continue
|
|
}
|
|
was, _ := known.Find(r.Identity())
|
|
o, err := applyOne(ctx, sys, r, run, changed, in, was, nil, nil)
|
|
if err != nil {
|
|
if o.found != nil {
|
|
known.KeepFound(r.Identity(), store.OriginDeclared, *o.found)
|
|
}
|
|
failures = append(failures, &Error{Resource: r.Identity(), Err: err})
|
|
log(fmt.Sprintf(" failed %s (%s): %v", r.Identity(), o.Target, err))
|
|
continue
|
|
}
|
|
if o.Action == "waiting" {
|
|
if o.found != nil {
|
|
known.KeepFound(r.Identity(), store.OriginDeclared, *o.found)
|
|
}
|
|
report.Outcomes = append(report.Outcomes, o)
|
|
log(fmt.Sprintf(" waiting %s (%s): %s", o.ID, o.Target, o.Detail))
|
|
continue
|
|
}
|
|
recordKeeping(&known, r, o, was)
|
|
report.Outcomes = append(report.Outcomes, o)
|
|
if o.busy != nil {
|
|
report.Busy = append(report.Busy, *o.busy)
|
|
}
|
|
if moved(o) {
|
|
changed[r.Identity()] = true
|
|
line := fmt.Sprintf(" %s %s (%s)", o.Action, o.ID, o.Target)
|
|
if o.Detail != "" {
|
|
line += ": " + o.Detail
|
|
}
|
|
log(line)
|
|
} else if o.Action != "unchanged" {
|
|
log(fmt.Sprintf(" %s %s (%s): %s", o.Action, o.ID, o.Target, o.Detail))
|
|
}
|
|
}
|
|
if len(failures) > 0 {
|
|
first := failures[0]
|
|
first.Done = report
|
|
first.Others = len(failures) - 1
|
|
return report, known, first
|
|
}
|
|
return report, known, nil
|
|
}
|
|
|
|
// filledAs is the declared file with the filled content in place of the declaration's.
|
|
func filledAs(declared *declaration.File, content []byte) *declaration.File {
|
|
f := *declared
|
|
f.Content, f.Sealed, f.VaultSealed, f.Bytes, f.Secrets = string(content), "", nil, "", nil
|
|
if f.Mode == "" && (declared.Secret() || len(declared.Secrets) > 0) {
|
|
// Written as the declaration would have written it: a file carrying a credential is not world-readable.
|
|
f.Mode = "0600"
|
|
}
|
|
return &f
|
|
}
|
|
|
|
// readsChange reports whether applying r is what makes it take a change of what changed: a service's restart-on or
|
|
// reload-on, a process's or container's restart-on, or a running container that reads path at creation.
|
|
func readsChange(r declaration.Resource, path string, changed map[string]bool) bool {
|
|
switch res := r.(type) {
|
|
case *declaration.Service:
|
|
return len(restartedBy(res.RestartOn, changed)) > 0 || len(restartedBy(res.ReloadOn, changed)) > 0
|
|
case *declaration.Process:
|
|
return len(restartedBy(res.RestartOn, changed)) > 0
|
|
case *declaration.Container:
|
|
if len(restartedBy(res.RestartOn, changed)) > 0 {
|
|
return true
|
|
}
|
|
if res.RunOnce || res.Schedule != "" {
|
|
return false
|
|
}
|
|
if slices.Contains(res.EnvFile, path) {
|
|
return true
|
|
}
|
|
for _, v := range res.Volumes {
|
|
if mountSource(v) == path {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// recordKeeping records r as applied, carrying where the original it replaced was kept from its record when this
|
|
// write kept none — for as long as the resource is recorded at the same target, as the apply loop carries it.
|
|
func recordKeeping(known *store.State, r declaration.Resource, o Outcome, was store.Applied) {
|
|
kept, keptMode, keptOwner := o.kept, o.keptMode, o.keptOwner
|
|
if kept == "" && was.Target == o.Target {
|
|
kept, keptMode, keptOwner = was.Kept, was.KeptMode, was.KeptOwner
|
|
}
|
|
recordApplied(known, store.OriginDeclared, r, o, kept, keptMode, keptOwner)
|
|
}
|
|
|
|
// leftToTheClient is a filled file as an apply leaves it (novox/hq ADR 0349 §5, design 57 P7): untouched, and said
|
|
// so. A file the mesh wrote at the same path under another id, going in this apply, is handed to it as an apply hands
|
|
// a whole file (novox/hq ADR 0223): its record — what the mesh wrote there, and the original it kept — is the filled
|
|
// file's now, so the verb judges what is there against it, and the file is neither removed nor written.
|
|
func leftToTheClient(f *declaration.File, known *store.State, handedFiles map[string]store.Applied, report *Report,
|
|
log func(string)) Outcome {
|
|
if from, handed := handedFiles[f.ID]; handed {
|
|
if _, recorded := known.Find(f.ID); !recorded {
|
|
rec := from
|
|
rec.ID = f.ID
|
|
known.Record(rec)
|
|
}
|
|
delete(handedFiles, f.ID)
|
|
known.Forget(from.ID)
|
|
detail := "no longer declared; " + f.ID + " declares the file now, filled by the provisioner's client, so " +
|
|
"it was handed to it, not removed"
|
|
report.Outcomes = append(report.Outcomes, Outcome{ID: from.ID, Type: from.Type, Target: from.Target,
|
|
Action: "forgotten", Detail: detail})
|
|
log(fmt.Sprintf(" forgotten %s (%s): %s", from.ID, from.Target, detail))
|
|
}
|
|
return Outcome{ID: f.ID, Type: string(declaration.TypeFile), Target: f.Path, Action: "unchanged",
|
|
Detail: "filled by the provisioner's client, which alone has it written"}
|
|
}
|