mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 76dc93c421bf merged into trunk 6b54fed706f0 — pass: builds mesh-host → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 76dc93c421bf merged into trunk 6b54fed706f0 — pass: its merge-check.sh passed
Review 11959 of mesh-host#111: a write through the verb recreated a container that reads the filled file over a library whose disk was missing (ADR 0328 §9). Also refuses a filled path two resources declare, with a test. Issue 668. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
207 lines
8.1 KiB
Go
207 lines
8.1 KiB
Go
package apply
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/novox/mesh-host/internal/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.
|
|
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{}}
|
|
refreshRuntimeRoots(ctx)
|
|
in.refused = refusedPlaces(d.Resources)
|
|
in.agentHomes = rootNeverHomes(d)
|
|
in.dirsBefore = map[string]bool{}
|
|
// 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.Bytes, f.Secrets = string(content), "", "", 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)
|
|
}
|