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{} 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) }