Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fce1dae3 | ||
|
|
3e11d720b4 | ||
|
|
429672b7ff | ||
|
|
d5c365cb2b | ||
|
|
84540e709a | ||
|
|
6431848342 | ||
|
|
8470dbd8e8 | ||
|
|
64b421b6e1 | ||
|
|
f2eda240ec | ||
|
|
2a5f4c8270 | ||
|
|
5d5dccdd55 | ||
|
|
27fb22fddb |
@@ -1487,6 +1487,11 @@ func applyAndKeep(ctx context.Context, opts options, raw []byte, signed *store.D
|
|||||||
if change.Action == "held" {
|
if change.Action == "held" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Nor is a unit waiting for its account's manager that this machine never applied (novox/hq
|
||||||
|
// ADR 0177); one applied before and waiting now is still the machine's, recorded as it was.
|
||||||
|
if _, recorded := updated.Find(change.ID); change.Action == "waiting" && !recorded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
report.Applied = append(report.Applied, change.ID)
|
report.Applied = append(report.Applied, change.ID)
|
||||||
}
|
}
|
||||||
// Kept whichever way it went, so a node that is disconnected next minute still knows what it
|
// Kept whichever way it went, so a node that is disconnected next minute still knows what it
|
||||||
|
|||||||
+319
-39
@@ -54,12 +54,23 @@ type Outcome struct {
|
|||||||
wrote string
|
wrote string
|
||||||
// into is what a file written into held before the mesh's keys (novox/hq ADR 0102).
|
// into is what a file written into held before the mesh's keys (novox/hq ADR 0102).
|
||||||
into *store.Into
|
into *store.Into
|
||||||
// kept is where this apply kept the original of a file it wrote over (novox/hq ADR 0100).
|
// kept is where this apply kept the original of a file it wrote over (novox/hq ADR 0100), and
|
||||||
kept string
|
// keptMode and keptOwner how the original was found, in the form a hold records them.
|
||||||
|
kept, keptMode, keptOwner string
|
||||||
// stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
|
// stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
|
||||||
stateless bool
|
stateless bool
|
||||||
|
// scope and user are, for a service, whose manager it was applied through (novox/hq ADR 0177).
|
||||||
|
scope, user string
|
||||||
// found is, for a service, its unit as the host first found it (novox/hq ADR 0118).
|
// found is, for a service, its unit as the host first found it (novox/hq ADR 0118).
|
||||||
found *store.FoundUnit
|
found *store.FoundUnit
|
||||||
|
// shell is, for a user, the login shell it was found with and the one the mesh set (novox/hq
|
||||||
|
// ADR 0176 §2, issue 228).
|
||||||
|
shell *store.LoginShell
|
||||||
|
// linger is, for a user, whether it lingered before the mesh changed that, and what the mesh
|
||||||
|
// set (novox/hq ADR 0177).
|
||||||
|
linger *store.Lingering
|
||||||
|
// unpacked is, for an archive, what it put on the machine (novox/hq issue 162).
|
||||||
|
unpacked *store.Unpacked
|
||||||
// reads is, for a container, the digest of each file it was created reading, by path — so
|
// reads is, for a container, the digest of each file it was created reading, by path — so
|
||||||
// the next apply can say which one changed (novox/hq 04-ISSUES/103).
|
// the next apply can say which one changed (novox/hq 04-ISSUES/103).
|
||||||
reads map[string]string
|
reads map[string]string
|
||||||
@@ -81,8 +92,9 @@ type Report struct {
|
|||||||
// nothing is the ordinary steady state, and saying so is not the same as saying it failed.
|
// nothing is the ordinary steady state, and saying so is not the same as saying it failed.
|
||||||
func (r Report) Changed() bool {
|
func (r Report) Changed() bool {
|
||||||
for _, o := range r.Outcomes {
|
for _, o := range r.Outcomes {
|
||||||
// Holding is keeping the machine as it was found, which is not moving it.
|
// Holding is keeping the machine as it was found, which is not moving it; waiting is a unit
|
||||||
if o.Action != "unchanged" && o.Action != "held" {
|
// whose account's manager is not running, which nothing moved either (novox/hq ADR 0177).
|
||||||
|
if o.Action != "unchanged" && o.Action != "held" && o.Action != "waiting" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,12 +218,13 @@ func ApplyKeeping(
|
|||||||
}
|
}
|
||||||
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
|
if errors.Is(err, errNoRemoval) && store.IsFormer(orphan.ID) {
|
||||||
// **A former target of a kind the host cannot remove is left in place and forgotten,
|
// **A former target of a kind the host cannot remove is left in place and forgotten,
|
||||||
// never fatal.** The host's own archive is the case: every version it delivers itself
|
// never fatal.** The host's own archive was the case (novox/hq issue 194): every version
|
||||||
// has a new target, so the one before is a former target on the first apply of the new
|
// it delivers itself has a new target, so the one before is a former target on the first
|
||||||
// host — and a removal that refused there stopped every machine applying anything, the
|
// apply of the new host — and a removal that refused there stopped every machine applying
|
||||||
// moment the host that carried former targets (novox/hq ADR 0163, rule 5) first
|
// anything, the moment the host that carried former targets (novox/hq ADR 0163, rule 5)
|
||||||
// replaced itself. What was written stays where it is, said, and the record no longer
|
// first replaced itself. What was written stays where it is, said, and the record no
|
||||||
// names it; whether an archive gets a removal is issue 162's question, not this apply's.
|
// longer names it. An archive answers this itself since issue 162 (removeArchive); this
|
||||||
|
// stays for any kind that still has no removal.
|
||||||
known.Forget(orphan.ID)
|
known.Forget(orphan.ID)
|
||||||
report.Outcomes = append(report.Outcomes, Outcome{
|
report.Outcomes = append(report.Outcomes, Outcome{
|
||||||
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||||
@@ -220,6 +233,15 @@ func ApplyKeeping(
|
|||||||
log(fmt.Sprintf(" forgotten %s (%s): a former target left in place: %v", orphan.ID, orphan.Target, err))
|
log(fmt.Sprintf(" forgotten %s (%s): a former target left in place: %v", orphan.ID, orphan.Target, err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, errRemovalWaits) {
|
||||||
|
// Kept recorded and said, never fatal: tried again by the next apply (novox/hq ADR 0177).
|
||||||
|
report.Outcomes = append(report.Outcomes, Outcome{
|
||||||
|
ID: orphan.ID, Type: orphan.Type, Target: orphan.Target,
|
||||||
|
Action: "waiting", Detail: err.Error(),
|
||||||
|
})
|
||||||
|
log(fmt.Sprintf(" waiting %s (%s): %v", orphan.ID, orphan.Target, err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &Error{Resource: orphan.ID, Err: err, Done: report}
|
return &Error{Resource: orphan.ID, Err: err, Done: report}
|
||||||
}
|
}
|
||||||
@@ -484,7 +506,8 @@ func ApplyKeeping(
|
|||||||
// A file this host has no record of, under any id, is the machine's until the mesh
|
// A file this host has no record of, under any id, is the machine's until the mesh
|
||||||
// writes over it — on any node, adopted or not: its original is kept first.
|
// writes over it — on any node, adopted or not: its original is kept first.
|
||||||
var keepFound Keep
|
var keepFound Keep
|
||||||
if f, isFile := resource.(*declaration.File); isFile && was.ID == "" &&
|
// A file whose path moved is a file this host has no record of at its new path.
|
||||||
|
if f, isFile := resource.(*declaration.File); isFile && (was.ID == "" || was.Target != f.Path) &&
|
||||||
!known.Recorded(string(declaration.TypeFile), f.Path) {
|
!known.Recorded(string(declaration.TypeFile), f.Path) {
|
||||||
keepFound = keep
|
keepFound = keep
|
||||||
}
|
}
|
||||||
@@ -566,15 +589,31 @@ func ApplyKeeping(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **Waiting is not applied** (novox/hq ADR 0177): a user-scoped unit whose account's manager
|
||||||
|
// is not running was not touched, so its record — if it has one — stays exactly as it was,
|
||||||
|
// what was found included, and none is written for one never applied. What one whose
|
||||||
|
// manager stopped part way found is kept as a failure's is, for the apply that finishes it.
|
||||||
|
if outcome.Action == "waiting" {
|
||||||
|
if outcome.found != nil {
|
||||||
|
known.KeepFound(resource.Identity(), origin, *outcome.found)
|
||||||
|
}
|
||||||
|
report.Outcomes = append(report.Outcomes, outcome)
|
||||||
|
log(fmt.Sprintf(" waiting %s (%s): %s", outcome.ID, outcome.Target, outcome.Detail))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Where the original of what this file replaced was kept, carried for as long as the
|
// Where the original of what this file replaced was kept, carried for as long as the
|
||||||
// resource is recorded: kept by this apply, by a hold its module's cutover ends, or before.
|
// resource is recorded: kept by this apply, by a hold its module's cutover ends, or before.
|
||||||
|
// Carried with how the original was found, and only for the path it was kept from: a file
|
||||||
|
// whose path moved leaves its original with the record of the old path (a former target),
|
||||||
|
// and must never be given another path's original when it goes (novox/hq ADR 0118).
|
||||||
held, wasHeld := known.HeldAt(resource.Identity())
|
held, wasHeld := known.HeldAt(resource.Identity())
|
||||||
kept := outcome.kept
|
kept, keptMode, keptOwner := outcome.kept, outcome.keptMode, outcome.keptOwner
|
||||||
if kept == "" && wasHeld {
|
if kept == "" && wasHeld && held.Target == outcome.Target {
|
||||||
kept = held.Kept
|
kept, keptMode, keptOwner = held.Kept, held.Mode, held.Owner
|
||||||
}
|
}
|
||||||
if kept == "" {
|
if kept == "" && was.Target == outcome.Target {
|
||||||
kept = was.Kept
|
kept, keptMode, keptOwner = was.Kept, was.KeptMode, was.KeptOwner
|
||||||
}
|
}
|
||||||
// Only now. The record follows the fact, never leads it.
|
// Only now. The record follows the fact, never leads it.
|
||||||
known.Record(store.Applied{
|
known.Record(store.Applied{
|
||||||
@@ -584,9 +623,16 @@ func ApplyKeeping(
|
|||||||
Wrote: outcome.wrote,
|
Wrote: outcome.wrote,
|
||||||
Into: outcome.into,
|
Into: outcome.into,
|
||||||
Kept: kept,
|
Kept: kept,
|
||||||
|
KeptMode: keptMode,
|
||||||
|
KeptOwner: keptOwner,
|
||||||
Reads: outcome.reads,
|
Reads: outcome.reads,
|
||||||
Stateless: outcome.stateless,
|
Stateless: outcome.stateless,
|
||||||
|
Scope: outcome.scope,
|
||||||
|
User: outcome.user,
|
||||||
Found: outcome.found,
|
Found: outcome.found,
|
||||||
|
Shell: outcome.shell,
|
||||||
|
Linger: outcome.linger,
|
||||||
|
Unpacked: outcome.unpacked,
|
||||||
Holds: holds(resource),
|
Holds: holds(resource),
|
||||||
})
|
})
|
||||||
if outcome.found != nil {
|
if outcome.found != nil {
|
||||||
@@ -716,7 +762,7 @@ func applyOne(ctx context.Context, sys system.System, r declaration.Resource, ru
|
|||||||
case *declaration.Container:
|
case *declaration.Container:
|
||||||
return applyContainer(ctx, res, run, changed, in, previous)
|
return applyContainer(ctx, res, run, changed, in, previous)
|
||||||
case *declaration.User:
|
case *declaration.User:
|
||||||
return applyUser(ctx, sys, res, run)
|
return applyUser(ctx, sys, res, run, previous)
|
||||||
case *declaration.Archive:
|
case *declaration.Archive:
|
||||||
return applyArchive(ctx, res, previous)
|
return applyArchive(ctx, res, previous)
|
||||||
case *declaration.Process:
|
case *declaration.Process:
|
||||||
@@ -768,7 +814,7 @@ func applyDirectory(r *declaration.Directory) (Outcome, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !existed {
|
if !existed {
|
||||||
if err := os.MkdirAll(r.Path, mode); err != nil {
|
if err := makeDirs(r.Path, mode, r.Owner); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -941,9 +987,13 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
|||||||
}
|
}
|
||||||
|
|
||||||
var beforeMode os.FileMode
|
var beforeMode os.FileMode
|
||||||
|
beforeOwner := ""
|
||||||
if existed {
|
if existed {
|
||||||
if info, err := os.Stat(r.Path); err == nil {
|
if info, err := os.Stat(r.Path); err == nil {
|
||||||
beforeMode = info.Mode().Perm()
|
beforeMode = info.Mode().Perm()
|
||||||
|
if uid, gid, ok := ownerOf(info); ok {
|
||||||
|
beforeOwner = fmt.Sprintf("%d:%d", uid, gid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,7 +1014,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
|||||||
return out, fmt.Errorf("keeping the original of %s before writing over it: %w", r.Path, err)
|
return out, fmt.Errorf("keeping the original of %s before writing over it: %w", r.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := writeAtomically(r.Path, []byte(content), mode); err != nil {
|
if err := writeAtomically(r.Path, []byte(content), mode); err != nil {
|
||||||
@@ -1029,6 +1079,7 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal, keepF
|
|||||||
}
|
}
|
||||||
out.kept = kept
|
out.kept = kept
|
||||||
if kept != "" {
|
if kept != "" {
|
||||||
|
out.keptMode, out.keptOwner = fmt.Sprintf("%04o", beforeMode), beforeOwner
|
||||||
if out.Detail != "" {
|
if out.Detail != "" {
|
||||||
out.Detail += "; "
|
out.Detail += "; "
|
||||||
}
|
}
|
||||||
@@ -1118,12 +1169,61 @@ func stayedRunning(ctx context.Context, sys system.System, run Runner, unit stri
|
|||||||
return sys.ServiceState(ctx, run, unit)
|
return sys.ServiceState(ctx, run, unit)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyService puts a unit into its declared state, in the manager it belongs to.
|
||||||
|
//
|
||||||
|
// **A user-scoped unit waits for its account's manager** (novox/hq ADR 0177). That manager runs
|
||||||
|
// from the account's first login to its last logout, or always while the account lingers; with
|
||||||
|
// neither, there is nothing to start the unit in, and asking would log the account in for the
|
||||||
|
// length of the question (see UserManagerRunning). A unit that fails every apply until somebody logs
|
||||||
|
// in is a node reported broken for being switched on, so it is said — "waiting", recorded as it
|
||||||
|
// was, nothing claimed — and the first apply after the manager starts applies it. One the manager
|
||||||
|
// itself starts at login needs nothing from the mesh: enabled is enabled in the account's own
|
||||||
|
// manager, and that starts what is enabled when it starts.
|
||||||
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||||
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||||
|
if !r.UserScoped() {
|
||||||
|
return applyServiceIn(ctx, sys, r, run, changed, previous)
|
||||||
|
}
|
||||||
|
away, err := managerAway(ctx, sys, r.Scope, r.User, run)
|
||||||
|
if err != nil {
|
||||||
|
return begin(r), err
|
||||||
|
}
|
||||||
|
if away != "" {
|
||||||
|
return waitingFor(r, away), nil
|
||||||
|
}
|
||||||
|
out, err := applyServiceIn(ctx, sys, r, run, changed, previous)
|
||||||
|
if err != nil {
|
||||||
|
// A manager that stopped while the apply was at it — the person logged out — is the same
|
||||||
|
// absence found a moment later, and is said the same way rather than failed.
|
||||||
|
// What was found before it went is carried, as a failure's is (novox/hq ADR 0118).
|
||||||
|
if away, _ := managerAway(ctx, sys, r.Scope, r.User, run); away != "" {
|
||||||
|
waiting := waitingFor(r, away+", having stopped during this apply ("+err.Error()+")")
|
||||||
|
waiting.found = out.found
|
||||||
|
return waiting, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitingFor is a user-scoped unit whose account's manager is not running (novox/hq ADR 0177).
|
||||||
|
func waitingFor(r *declaration.Service, away string) Outcome {
|
||||||
|
out := begin(r)
|
||||||
|
out.scope, out.user = r.Scope, r.User
|
||||||
|
out.Action = "waiting"
|
||||||
|
out.Detail = away + "; applied by the first apply after it starts — a login, or the account " +
|
||||||
|
"declared to linger"
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyServiceIn is applyService, in the manager the unit belongs to; raw reaches the machine's.
|
||||||
|
func applyServiceIn(ctx context.Context, sys system.System, r *declaration.Service, raw Runner,
|
||||||
|
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||||
|
run := managerFor(r.Scope, r.User, raw)
|
||||||
if r.Stateless() {
|
if r.Stateless() {
|
||||||
return reflectOnly(ctx, sys, r, run, changed)
|
return reflectOnly(ctx, sys, r, run, changed)
|
||||||
}
|
}
|
||||||
out := begin(r)
|
out := begin(r)
|
||||||
|
out.scope, out.user = r.Scope, r.User
|
||||||
var changes []string
|
var changes []string
|
||||||
|
|
||||||
// A file the service reflects changed, and it may be the unit's own file or a drop-in: the
|
// A file the service reflects changed, and it may be the unit's own file or a drop-in: the
|
||||||
@@ -1143,7 +1243,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
|
|||||||
// running, from the mesh's packet filter, stopped until the mesh started it and to be stopped
|
// running, from the mesh's packet filter, stopped until the mesh started it and to be stopped
|
||||||
// again. Read after the reload above, never before it: a unit whose file this same apply wrote
|
// again. Read after the reload above, never before it: a unit whose file this same apply wrote
|
||||||
// is not a unit the service manager knows until then, and reading it first would find nothing.
|
// is not a unit the service manager knows until then, and reading it first would find nothing.
|
||||||
found, gaveBack, err := foundAs(ctx, sys, r, run, previous)
|
found, gaveBack, err := foundAs(ctx, sys, r, raw, previous)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1367,7 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
|
|||||||
func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||||
changed map[string]bool) (Outcome, error) {
|
changed map[string]bool) (Outcome, error) {
|
||||||
out := begin(r)
|
out := begin(r)
|
||||||
|
out.scope, out.user = r.Scope, r.User
|
||||||
out.stateless = true
|
out.stateless = true
|
||||||
restart := reflected(r, changed)
|
restart := reflected(r, changed)
|
||||||
reload := restartedBy(r.ReloadOn, changed)
|
reload := restartedBy(r.ReloadOn, changed)
|
||||||
@@ -1378,16 +1479,10 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
|||||||
if a.Into != nil {
|
if a.Into != nil {
|
||||||
return removeInto(a)
|
return removeInto(a)
|
||||||
}
|
}
|
||||||
if err := os.RemoveAll(a.Target); err != nil {
|
return removeWhole(a)
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(a.Target); !errors.Is(err, os.ErrNotExist) {
|
|
||||||
return "", "", fmt.Errorf("%s is still there after removing it", a.Target)
|
|
||||||
}
|
|
||||||
return "removed", "no longer declared", nil
|
|
||||||
|
|
||||||
case declaration.TypeService:
|
case declaration.TypeService:
|
||||||
return removeService(ctx, sys, a, run, made[a.Target])
|
return removeService(ctx, sys, a, run, made[unitKey(a.Scope, a.User, a.Target)])
|
||||||
|
|
||||||
case declaration.TypeProcess:
|
case declaration.TypeProcess:
|
||||||
// The other side of the same line: a process's unit is the host's own — it wrote the unit
|
// The other side of the same line: a process's unit is the host's own — it wrote the unit
|
||||||
@@ -1430,6 +1525,10 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
|||||||
// would be the data loss ADR 0030 exists to prevent, on a directory the mesh never made.
|
// would be the data loss ADR 0030 exists to prevent, on a directory the mesh never made.
|
||||||
return "forgotten", "an operator-owned path is never the host's to remove", nil
|
return "forgotten", "an operator-owned path is never the host's to remove", nil
|
||||||
|
|
||||||
|
case declaration.TypeUser:
|
||||||
|
// Kept, with its shell given back when that is safe (novox/hq ADR 0176 §2, issue 228).
|
||||||
|
return removeUser(ctx, sys, a, run)
|
||||||
|
|
||||||
case declaration.TypeNetwork:
|
case declaration.TypeNetwork:
|
||||||
// **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in
|
// **The reason this is a shape at all** (novox/hq ADR 0029). Orphans are removed in
|
||||||
// reverse declaration order, so a network written before the containers that join it is
|
// reverse declaration order, so a network written before the containers that join it is
|
||||||
@@ -1448,13 +1547,18 @@ func remove(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
|||||||
}
|
}
|
||||||
return "removed", "no longer declared", nil
|
return "removed", "no longer declared", nil
|
||||||
|
|
||||||
|
case declaration.TypeArchive:
|
||||||
|
// Exactly what it unpacked, and the directories the host made for it once they are empty
|
||||||
|
// (novox/hq issue 162).
|
||||||
|
return removeArchive(a)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
|
return "", "", fmt.Errorf("%w: a %q", errNoRemoval, a.Type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// errNoRemoval is remove's answer for a kind the host has no removal for (novox/hq issue 162): an
|
// errNoRemoval is remove's answer for a kind the host has no removal for (novox/hq issue 162; an
|
||||||
// archive, among others. Fatal for an orphan the declaration dropped, so an unassignment nothing can
|
// archive has one since). Fatal for an orphan the declaration dropped, so an unassignment nothing can
|
||||||
// undo is never reported as done; not fatal for a former target, which was never dropped by anyone.
|
// undo is never reported as done; not fatal for a former target, which was never dropped by anyone.
|
||||||
var errNoRemoval = errors.New("no way to remove")
|
var errNoRemoval = errors.New("no way to remove")
|
||||||
|
|
||||||
@@ -2355,6 +2459,48 @@ func removeService(ctx context.Context, sys system.System, a store.Applied, run
|
|||||||
// the link the operator would use to do it.
|
// the link the operator would use to do it.
|
||||||
return "forgotten", "recorded before the host kept what it found; left as it is", nil
|
return "forgotten", "recorded before the host kept what it found; left as it is", nil
|
||||||
}
|
}
|
||||||
|
if a.Scope == declaration.ScopeUser {
|
||||||
|
return removeUserUnit(ctx, sys, a, run, made)
|
||||||
|
}
|
||||||
|
return giveUnitBack(ctx, sys, a, run, made)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errRemovalWaits is a removal that cannot happen yet and is not a failure: the record stays, the
|
||||||
|
// outcome says why, and the next apply tries again (novox/hq ADR 0177).
|
||||||
|
var errRemovalWaits = errors.New("not removed yet")
|
||||||
|
|
||||||
|
// removeUserUnit gives back a unit in an account's own manager (novox/hq ADR 0177).
|
||||||
|
//
|
||||||
|
// **Never fatal.** A removal that fails stops the apply, and its record is there to fail the same
|
||||||
|
// way on the next one — every machine wedged on one undeclared thing, which is what issues 162 and
|
||||||
|
// 228 each ended for their own shape. An account's manager is absent for an ordinary reason, the
|
||||||
|
// person logged out, so its unit would be the commonest such wedge there is. With no manager the
|
||||||
|
// unit is kept recorded and said to wait; the first apply that finds the manager running gives it
|
||||||
|
// back. An account that is gone took its manager and its units with it, and is forgotten.
|
||||||
|
func removeUserUnit(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||||
|
made bool) (string, string, error) {
|
||||||
|
away, err := managerAway(ctx, sys, a.Scope, a.User, run)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errNoAccount):
|
||||||
|
return "forgotten", "the account " + a.User + " is no longer on this machine, and its manager with it", nil
|
||||||
|
case err != nil:
|
||||||
|
return "", "", fmt.Errorf("%w: %v", errRemovalWaits, err)
|
||||||
|
case away != "":
|
||||||
|
return "", "", fmt.Errorf("%w: %s; given back by the first apply after it starts", errRemovalWaits, away)
|
||||||
|
}
|
||||||
|
action, detail, err := giveUnitBack(ctx, sys, a, managerFor(a.Scope, a.User, run), made)
|
||||||
|
if err != nil {
|
||||||
|
if away, _ := managerAway(ctx, sys, a.Scope, a.User, run); away != "" {
|
||||||
|
return "", "", fmt.Errorf("%w: %s, having stopped during this apply (%v)", errRemovalWaits, away, err)
|
||||||
|
}
|
||||||
|
return "", "", fmt.Errorf("%w: in %s's own manager: %v", errRemovalWaits, a.User, err)
|
||||||
|
}
|
||||||
|
return action, detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveUnitBack is removeService's giving back, in whichever manager run reaches.
|
||||||
|
func giveUnitBack(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||||
|
made bool) (string, string, error) {
|
||||||
stop := made || a.Found.State == "stopped"
|
stop := made || a.Found.State == "stopped"
|
||||||
disable := made || a.Found.Boot == "disabled"
|
disable := made || a.Found.Boot == "disabled"
|
||||||
|
|
||||||
@@ -2443,30 +2589,43 @@ func removeService(ctx context.Context, sys system.System, a store.Applied, run
|
|||||||
// which the mesh never starts or stops, or of another unit altogether. What was found about one
|
// which the mesh never starts or stops, or of another unit altogether. What was found about one
|
||||||
// unit says nothing about another, so a service moved to a new unit gives the old one back, just as
|
// unit says nothing about another, so a service moved to a new unit gives the old one back, just as
|
||||||
// if it had been undeclared, and is read afresh for the new one; gave says what that gave back.
|
// if it had been undeclared, and is read afresh for the new one; gave says what that gave back.
|
||||||
|
//
|
||||||
|
// A unit of the same name in another manager is another unit (novox/hq ADR 0177): a service moved
|
||||||
|
// from the machine's manager to an account's, or between accounts, gives the old one back through
|
||||||
|
// the manager it was in. run reaches the machine's manager; each side is routed from it.
|
||||||
func foundAs(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
func foundAs(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||||
previous store.Applied) (found *store.FoundUnit, gave string, err error) {
|
previous store.Applied) (found *store.FoundUnit, gave string, err error) {
|
||||||
|
// What a record says about its manager, only where there is a record: what an unfinished apply
|
||||||
|
// found is kept by identity alone, and was read in the manager the declaration names.
|
||||||
|
sameManager := previous.ID == "" || unitKey(previous.Scope, previous.User, "") == unitKey(r.Scope, r.User, "")
|
||||||
if f := previous.Found; f != nil {
|
if f := previous.Found; f != nil {
|
||||||
unit := f.Unit
|
unit := f.Unit
|
||||||
if unit == "" {
|
if unit == "" {
|
||||||
unit = previous.Target
|
unit = previous.Target
|
||||||
}
|
}
|
||||||
if unit == "" || unit == r.Unit {
|
if (unit == "" || unit == r.Unit) && sameManager {
|
||||||
return f, "", nil
|
return f, "", nil
|
||||||
}
|
}
|
||||||
// Given back as if undeclared, never as the mesh's own: whether the mesh wrote the old
|
// Given back as if undeclared, never as the mesh's own: whether the mesh wrote the old
|
||||||
// unit's file is known to the removal of orphans, and that file's own record goes with it.
|
// unit's file is known to the removal of orphans, and that file's own record goes with it.
|
||||||
action, detail, err := removeService(ctx, sys,
|
action, detail, err := removeService(ctx, sys,
|
||||||
store.Applied{Type: string(declaration.TypeService), Target: unit, Found: f}, run, false)
|
store.Applied{Type: string(declaration.TypeService), Target: unit, Found: f,
|
||||||
if err != nil {
|
Scope: previous.Scope, User: previous.User}, run, false)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, errRemovalWaits):
|
||||||
|
// The old unit's account manager is not there to give it back to; the new unit is not
|
||||||
|
// held up for it, and the giving back is said rather than silently dropped.
|
||||||
|
gave = unit + " not given back: " + err.Error()
|
||||||
|
case err != nil:
|
||||||
return nil, "", fmt.Errorf("giving %s back as the host found it, now that %s is declared instead: %w",
|
return nil, "", fmt.Errorf("giving %s back as the host found it, now that %s is declared instead: %w",
|
||||||
unit, r.Unit, err)
|
unit, r.Unit, err)
|
||||||
}
|
case action == "restored":
|
||||||
if action == "restored" {
|
|
||||||
gave = unit + " " + detail
|
gave = unit + " " + detail
|
||||||
}
|
}
|
||||||
} else if previous.ID != "" && !previous.Stateless && previous.Target == r.Unit {
|
} else if previous.ID != "" && !previous.Stateless && previous.Target == r.Unit && sameManager {
|
||||||
return nil, "", nil
|
return nil, "", nil
|
||||||
}
|
}
|
||||||
|
run = managerFor(r.Scope, r.User, run)
|
||||||
state, err := sys.ServiceState(ctx, run, r.Unit)
|
state, err := sys.ServiceState(ctx, run, r.Unit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, gave, err
|
return nil, gave, err
|
||||||
@@ -2480,23 +2639,77 @@ func foundAs(ctx context.Context, sys system.System, r *declaration.Service, run
|
|||||||
// in. Such a unit is the mesh's, whatever was found (novox/hq ADR 0118); see removeService.
|
// in. Such a unit is the mesh's, whatever was found (novox/hq ADR 0118); see removeService.
|
||||||
//
|
//
|
||||||
// A drop-in is not the unit's own file, and a file the host wrote over is the machine's unit with
|
// A drop-in is not the unit's own file, and a file the host wrote over is the machine's unit with
|
||||||
// the mesh's text in it: its original is kept, and put back when the file's record goes.
|
// the mesh's text in it: its original is kept, and put back when the file's record goes — unless
|
||||||
|
// the file was changed on the machine since the mesh last wrote it, or the kept copy cannot be
|
||||||
|
// read, and then it is left as it stands and the outcome says so (removeWhole, novox/hq ADR 0118).
|
||||||
|
//
|
||||||
|
// **Keyed by manager and name** (novox/hq ADR 0177): an account's unit and the machine's of the same
|
||||||
|
// name are two units, and the mesh writing one says nothing about the other. An account's manager
|
||||||
|
// loads an administrator's units from the account's own ~/.config/systemd/user, and from
|
||||||
|
// /etc/systemd/user for every account; a unit there is the mesh's for each user-scoped record of it.
|
||||||
func meshMadeUnits(known store.State) map[string]bool {
|
func meshMadeUnits(known store.State) map[string]bool {
|
||||||
made := map[string]bool{}
|
made := map[string]bool{}
|
||||||
dirs := map[string]bool{"/etc/systemd/system": true, "/run/systemd/system": true,
|
dirs := map[string]bool{"/etc/systemd/system": true, "/run/systemd/system": true,
|
||||||
filepath.Clean(unitDir): true}
|
filepath.Clean(unitDir): true}
|
||||||
for _, r := range known.Resources {
|
for _, r := range known.Resources {
|
||||||
if r.Type != string(declaration.TypeFile) || r.Kept != "" || r.Into != nil {
|
if !wroteWhole(r) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
path := filepath.Clean(r.Target)
|
path := filepath.Clean(r.Target)
|
||||||
if dirs[filepath.Dir(path)] {
|
if dirs[filepath.Dir(path)] {
|
||||||
made[filepath.Base(path)] = true
|
made[unitKey(declaration.ScopeSystem, "", filepath.Base(path))] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range known.Resources {
|
||||||
|
if r.Type != string(declaration.TypeService) || r.Scope != declaration.ScopeUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, path := range userUnitFiles(r.User, r.Target) {
|
||||||
|
if meshWroteWhole(known, path) {
|
||||||
|
made[unitKey(r.Scope, r.User, r.Target)] = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return made
|
return made
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wroteWhole is a file record of a file the host wrote whole where there was none.
|
||||||
|
func wroteWhole(r store.Applied) bool {
|
||||||
|
return r.Type == string(declaration.TypeFile) && r.Kept == "" && r.Into == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// meshWroteWhole is whether this host wrote the file at path whole, where there was none.
|
||||||
|
func meshWroteWhole(known store.State, path string) bool {
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
for _, r := range known.Resources {
|
||||||
|
if wroteWhole(r) && filepath.Clean(r.Target) == path {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// userUnitFiles is where an account's manager loads an administrator's unit from: the one every
|
||||||
|
// account's manager reads, and the account's own. The account's is left out when its home cannot be
|
||||||
|
// read, which only makes fewer files the mesh's.
|
||||||
|
func userUnitFiles(account, unit string) []string {
|
||||||
|
paths := []string{filepath.Join("/etc/systemd/user", unit)}
|
||||||
|
if home, err := homeOf(account); err == nil && home != "" {
|
||||||
|
paths = append(paths, filepath.Join(home, ".config", "systemd", "user", unit))
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitKey names a unit by the manager it is in and its name (novox/hq ADR 0177): the machine's
|
||||||
|
// manager, or one account's. A system unit is the same key whether its record says "system" or
|
||||||
|
// nothing, as every record before the scope existed does.
|
||||||
|
func unitKey(scope, user, unit string) string {
|
||||||
|
if scope == declaration.ScopeUser {
|
||||||
|
return "user:" + user + "/" + unit
|
||||||
|
}
|
||||||
|
return "system/" + unit
|
||||||
|
}
|
||||||
|
|
||||||
// moduleOf is the module a declared resource belongs to.
|
// moduleOf is the module a declared resource belongs to.
|
||||||
//
|
//
|
||||||
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may
|
// The mesh composes a module's resource ids as `<module>.<its own id>`, and **a module's name may
|
||||||
@@ -2617,3 +2830,70 @@ func appliedIDs(applied []store.Applied) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(ids, ", ")
|
return strings.Join(ids, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// managerFor routes a unit's commands to the manager it belongs to (novox/hq ADR 0177). A system
|
||||||
|
// unit's go to the machine's manager as they always did. A user-scoped unit's go to the account's
|
||||||
|
// own: `systemctl --user --machine=<account>@`, which reaches that manager from the host's own
|
||||||
|
// process without an environment to forge or a user to switch to — and which only answers while
|
||||||
|
// the account's manager runs (a login, or lingering enabled for the account). Done on the runner
|
||||||
|
// rather than in each system: every system's reading of a unit already goes through `systemctl`,
|
||||||
|
// so this is one place instead of one per system and one per method.
|
||||||
|
//
|
||||||
|
// **The host's environment is not what reaches the account** (point 4 of the review). The
|
||||||
|
// `--machine` transport does not read XDG_RUNTIME_DIR or DBUS_SESSION_BUS_ADDRESS: it asks the
|
||||||
|
// machine's manager, over the system bus, to run `systemd-stdio-bridge --user` as the account in
|
||||||
|
// a login of its own, whose runtime directory and bus are the account's. So the host, root under
|
||||||
|
// the machine's manager with neither variable set, needs only the system bus and `systemd-run` on
|
||||||
|
// its path — both of which a systemd machine has. Only the account itself would be reached through
|
||||||
|
// its own environment instead (systemctl short-cuts to the local bus when the caller is the
|
||||||
|
// account), and the host is never the account. Being root is what lets it ask: anyone else is
|
||||||
|
// refused by the machine's manager, and the refusal is the error the unit fails with.
|
||||||
|
func managerFor(scope, user string, run Runner) Runner {
|
||||||
|
if scope != declaration.ScopeUser || user == "" {
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
return func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
if name != "systemctl" {
|
||||||
|
return run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
scoped := append([]string{"--user", "--machine=" + user + "@"}, args...)
|
||||||
|
return run(ctx, name, scoped...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// userManagers is a service manager that runs a manager per account (novox/hq ADR 0177).
|
||||||
|
type userManagers interface {
|
||||||
|
UserManagerRunning(ctx context.Context, run system.Runner, account string) (running, exists bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errNoAccount is a user-scoped unit naming an account the machine does not have.
|
||||||
|
var errNoAccount = errors.New("no such account on this machine")
|
||||||
|
|
||||||
|
// managerAway is why the manager a unit is in cannot be reached now, or "" when it can — always
|
||||||
|
// for a system unit (novox/hq ADR 0177). Asked of the machine's manager through run, never of the
|
||||||
|
// account's, which a question would start (system.UserManagerRunning says why).
|
||||||
|
//
|
||||||
|
// Refused outright: an account the machine does not have, as ADR 0177 §1 requires, and a machine
|
||||||
|
// whose service manager has no manager per account — where a user-scoped unit would otherwise be
|
||||||
|
// applied as the machine's unit of the same name.
|
||||||
|
func managerAway(ctx context.Context, sys system.System, scope, user string, run Runner) (string, error) {
|
||||||
|
if scope != declaration.ScopeUser {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
um, ok := sys.(userManagers)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("a unit in %s's own manager, and this machine's service manager (%s) has "+
|
||||||
|
"no manager per account (novox/hq ADR 0177)", user, sys.Name())
|
||||||
|
}
|
||||||
|
running, exists, err := um.UserManagerRunning(ctx, system.Runner(run), user)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
return "", err
|
||||||
|
case !exists:
|
||||||
|
return "", fmt.Errorf("%w: %q, whose own manager a user-scoped unit lives in (novox/hq ADR 0177)",
|
||||||
|
errNoAccount, user)
|
||||||
|
case !running:
|
||||||
|
return user + "'s own service manager is not running: the account is not logged in and does not linger", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|||||||
+80
-24
@@ -56,20 +56,28 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
|
|||||||
out.wrote = got
|
out.wrote = got
|
||||||
|
|
||||||
// Already what it should be. The digest is the whole identity of an archive, so a matching
|
// Already what it should be. The digest is the whole identity of an archive, so a matching
|
||||||
// record means the unpacked tree came from these exact bytes.
|
// record means the unpacked tree came from these exact bytes — at this path: a record of the
|
||||||
if previous.Wrote == got {
|
// same bytes somewhere else says nothing about what is here.
|
||||||
|
if previous.Wrote == got && previous.Target == r.Path {
|
||||||
if _, err := os.Stat(r.Path); err == nil {
|
if _, err := os.Stat(r.Path); err == nil {
|
||||||
owned, err := ownedBy(r.Path, r.Owner)
|
owned, err := ownedBy(ownershipProbe(r.Path, previous.Unpacked), r.Owner)
|
||||||
if err == nil && owned {
|
if err == nil && owned {
|
||||||
|
// What it unpacked is carried, or — on a record from before the host kept it — read
|
||||||
|
// from the archive now, so the record can say it from here on (novox/hq issue 162).
|
||||||
|
out.unpacked, err = stillUnpacked(body, r.Path, previous.Unpacked)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
written, err := replaceWith(body, r.Path, r.Owner)
|
unpacked, written, err := replaceWith(body, r.Path, r.Owner, oursFrom(previous, r.Path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
out.unpacked = &unpacked
|
||||||
out.Action = "updated"
|
out.Action = "updated"
|
||||||
if previous.Wrote == "" {
|
if previous.Wrote == "" {
|
||||||
out.Action = "created"
|
out.Action = "created"
|
||||||
@@ -78,44 +86,90 @@ func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Ap
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceWith makes the directory exactly the archive (novox/hq issue 220).
|
// replaceWith makes the directory exactly the archive (novox/hq issue 220), and says what it put
|
||||||
|
// there (novox/hq issue 162).
|
||||||
//
|
//
|
||||||
// **The tree on disk is the archive and nothing else.** The digest is the whole identity of what
|
// **The tree on disk is the archive and nothing else — of what the mesh put there.** The digest is
|
||||||
// is unpacked here, so a file the previous archive had and this one does not must go. Unpacked over
|
// the whole identity of what is unpacked here, so a file the previous archive had and this one does
|
||||||
// the old tree, it stayed: a bundle rebuilt as one file per entrypoint kept the package directory
|
// not must go. Unpacked over the old tree, it stayed: a bundle rebuilt as one file per entrypoint
|
||||||
// of the version before, which code could still import, and a fix that removed a file worked on a
|
// kept the package directory of the version before, which code could still import, and a fix that
|
||||||
// fresh machine only. So the archive is unpacked into a fresh directory beside the old one, owned,
|
// removed a file worked on a fresh machine only. So the archive is unpacked into a fresh directory
|
||||||
// and swapped in by rename. A running process keeps the files it has open, and the old tree is
|
// beside the old one, owned, and swapped in by rename. A running process keeps the files it has open,
|
||||||
// removed only once the new one is in place. A failed unpack leaves the old tree untouched.
|
// and the old tree is removed only once the new one is in place. A failed unpack leaves the old tree
|
||||||
func replaceWith(body []byte, path, owner string) (int, error) {
|
// untouched.
|
||||||
|
//
|
||||||
|
// **What the mesh did not put there is never swapped away** (novox/hq issue 162, ADR 0030). The
|
||||||
|
// swap is for a directory that is the host's own: one it made, holding nothing but what the mesh
|
||||||
|
// put there. A directory that was there before the archive, or that something else has written
|
||||||
|
// into since, is the machine's: the archive's files are moved into it one by one, what the previous
|
||||||
|
// archive placed and this one does not is taken out, and everything else is left as it is. A file
|
||||||
|
// the archive would write over that the mesh did not put there refuses the archive before anything
|
||||||
|
// is moved — unless it already holds exactly the archive's bytes.
|
||||||
|
func replaceWith(body []byte, path, owner string, o ours) (store.Unpacked, int, error) {
|
||||||
parent := filepath.Dir(path)
|
parent := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
madeParents, err := makeDirsSaying(parent, 0o755, owner)
|
||||||
return 0, err
|
if err != nil {
|
||||||
|
return store.Unpacked{}, 0, err
|
||||||
}
|
}
|
||||||
|
parents := joinParents(madeParents, o.parents)
|
||||||
fresh := path + ".unpacking"
|
fresh := path + ".unpacking"
|
||||||
replaced := path + ".replaced"
|
replaced := path + ".replaced"
|
||||||
// What an interrupted earlier attempt left beside the directory.
|
// What an interrupted earlier attempt — or removal — left beside the directory.
|
||||||
for _, leftover := range []string{fresh, replaced} {
|
for _, leftover := range []string{fresh, replaced, path + ".removing"} {
|
||||||
if err := os.RemoveAll(leftover); err != nil {
|
if err := os.RemoveAll(leftover); err != nil {
|
||||||
return 0, err
|
return store.Unpacked{}, 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := os.Mkdir(fresh, 0o755); err != nil {
|
if err := os.Mkdir(fresh, 0o755); err != nil {
|
||||||
return 0, err
|
return store.Unpacked{}, 0, err
|
||||||
}
|
}
|
||||||
written, err := unpack(body, fresh)
|
written, err := unpack(body, fresh)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ownAll(fresh, owner)
|
err = ownAll(fresh, owner)
|
||||||
}
|
}
|
||||||
|
var files, dirs []string
|
||||||
|
if err == nil {
|
||||||
|
files, dirs, err = treeOf(fresh)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
os.RemoveAll(fresh)
|
os.RemoveAll(fresh)
|
||||||
return written, err
|
return store.Unpacked{}, written, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
existed := err == nil
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
os.RemoveAll(fresh)
|
||||||
|
return store.Unpacked{}, written, err
|
||||||
|
}
|
||||||
|
if existed && !info.IsDir() {
|
||||||
|
// Swapped, it would be deleted: a file at the path is nothing an archive put there.
|
||||||
|
os.RemoveAll(fresh)
|
||||||
|
return store.Unpacked{}, written, fmt.Errorf(
|
||||||
|
"%s is there and is not a directory, and the mesh did not put it there; nothing was unpacked", path)
|
||||||
|
}
|
||||||
|
foreign := 0
|
||||||
|
if existed && !o.all {
|
||||||
|
if foreign, err = foreignIn(path, o.paths); err != nil {
|
||||||
|
os.RemoveAll(fresh)
|
||||||
|
return store.Unpacked{}, written, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if existed && (foreign > 0 || !(o.made || o.all)) {
|
||||||
|
u, err := mergeInto(fresh, path, owner, files, dirs, o)
|
||||||
|
os.RemoveAll(fresh)
|
||||||
|
if err != nil {
|
||||||
|
return store.Unpacked{}, written, err
|
||||||
|
}
|
||||||
|
u.Parents = parents
|
||||||
|
return u, written, nil
|
||||||
|
}
|
||||||
|
|
||||||
hadOne := true
|
hadOne := true
|
||||||
if err := os.Rename(path, replaced); err != nil {
|
if err := os.Rename(path, replaced); err != nil {
|
||||||
if !os.IsNotExist(err) {
|
if !os.IsNotExist(err) {
|
||||||
os.RemoveAll(fresh)
|
os.RemoveAll(fresh)
|
||||||
return written, err
|
return store.Unpacked{}, written, err
|
||||||
}
|
}
|
||||||
hadOne = false
|
hadOne = false
|
||||||
}
|
}
|
||||||
@@ -125,14 +179,16 @@ func replaceWith(body []byte, path, owner string) (int, error) {
|
|||||||
os.Rename(replaced, path)
|
os.Rename(replaced, path)
|
||||||
}
|
}
|
||||||
os.RemoveAll(fresh)
|
os.RemoveAll(fresh)
|
||||||
return written, err
|
return store.Unpacked{}, written, err
|
||||||
}
|
}
|
||||||
|
// The directory is the host's own: it made it, now or before, and nothing else is in it.
|
||||||
|
u := store.Unpacked{Files: files, Dirs: dirs, Made: true, Parents: parents}
|
||||||
if hadOne {
|
if hadOne {
|
||||||
if err := os.RemoveAll(replaced); err != nil {
|
if err := os.RemoveAll(replaced); err != nil {
|
||||||
return written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
|
return u, written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return written, nil
|
return u, written, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetch(ctx context.Context, source string) ([]byte, error) {
|
func fetch(ctx context.Context, source string) ([]byte, error) {
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq issue 162: an archive can be undeclared. What it unpacked is gone, anything that
|
||||||
|
// was in its directory beforehand is still there, and the apply that removed it applied everything
|
||||||
|
// else in the same declaration.
|
||||||
|
|
||||||
|
func exists(path string) bool {
|
||||||
|
_, err := os.Lstat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveDecl(t *testing.T, id, path string, files map[string]string, more string) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
body, digest := anArchive(t, files)
|
||||||
|
return `{"id":"` + id + `","type":"archive","source":"` + serving(t, body) + `","digest":"` + digest +
|
||||||
|
`","path":"` + path + `"}` + more, digest
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnassigningAnArchiveRemovesExactlyWhatItUnpacked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "bundles", "notes", "tools")
|
||||||
|
archive, _ := archiveDecl(t, "notes.tools", target,
|
||||||
|
map[string]string{"index.js": "x", "lib/one.js": "1", "lib/two.js": "2"},
|
||||||
|
`,{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
|
||||||
|
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec, _ := state.Find("notes.tools"); rec.Unpacked == nil || !rec.Unpacked.Made ||
|
||||||
|
len(rec.Unpacked.Files) != 3 || len(rec.Unpacked.Parents) != 2 {
|
||||||
|
t.Fatalf("what the archive unpacked was not recorded: %+v", rec.Unpacked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigned, and the same push declares something new: both happen.
|
||||||
|
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"},
|
||||||
|
{"id":"other.conf","type":"file","path":"`+dir+`/other.conf","content":"b"}`)
|
||||||
|
report, state, err := Apply(context.Background(), archHost(t), d, state, store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("undeclaring an archive stopped the apply: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "notes.tools"); o.Action != "removed" {
|
||||||
|
t.Fatalf("the archive: %+v", o)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "other.conf"); o.Action != "created" {
|
||||||
|
t.Fatalf("the rest of the declaration: %+v", o)
|
||||||
|
}
|
||||||
|
if _, still := state.Find("notes.tools"); still {
|
||||||
|
t.Fatal("the archive is still on record")
|
||||||
|
}
|
||||||
|
// The directory it was unpacked into and the parents the host made to reach it, all gone.
|
||||||
|
if exists(filepath.Join(dir, "bundles")) {
|
||||||
|
t.Fatal("what the host made for the archive is still there")
|
||||||
|
}
|
||||||
|
if !exists(filepath.Join(dir, "notes.conf")) {
|
||||||
|
t.Fatal("a file the archive did not place was removed")
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(dir); len(entries) != 2 {
|
||||||
|
t.Fatalf("%d entries left in the directory", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADirectoryFoundBeforeTheArchiveKeepsWhatWasInIt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "powerlevel10k")
|
||||||
|
if err := os.MkdirAll(filepath.Join(target, "lib"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.WriteFile(filepath.Join(target, "mine.zsh"), []byte("somebody's"), 0o644)
|
||||||
|
os.WriteFile(filepath.Join(target, "lib", "mine.zsh"), []byte("somebody's"), 0o644)
|
||||||
|
|
||||||
|
archive, _ := archiveDecl(t, "shell.theme", target,
|
||||||
|
map[string]string{"p10k.zsh": "theme", "lib/theme.zsh": "lib", "gitstatus/gs": "gs"}, "")
|
||||||
|
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Applying over a directory that was there keeps what was in it.
|
||||||
|
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
|
||||||
|
t.Fatalf("%s after the apply: %q", kept, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(target, "lib", "theme.zsh")); string(got) != "lib" {
|
||||||
|
t.Fatalf("lib/theme.zsh is %q", got)
|
||||||
|
}
|
||||||
|
rec, _ := state.Find("shell.theme")
|
||||||
|
if rec.Unpacked == nil || rec.Unpacked.Made || strings.Join(rec.Unpacked.Dirs, ",") != "gitstatus" {
|
||||||
|
t.Fatalf("recorded as %+v", rec.Unpacked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A new version without one of its files: that file goes, nothing else does.
|
||||||
|
archive, _ = archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme 2"}, "")
|
||||||
|
if _, state, err = Apply(context.Background(), archHost(t), declare(t, archive), state,
|
||||||
|
store.OriginDeclared, noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exists(filepath.Join(target, "lib", "theme.zsh")) || exists(filepath.Join(target, "gitstatus")) {
|
||||||
|
t.Fatal("the previous version's files are still there")
|
||||||
|
}
|
||||||
|
if !exists(filepath.Join(target, "lib", "mine.zsh")) {
|
||||||
|
t.Fatal("a file the mesh did not put there went with the previous version")
|
||||||
|
}
|
||||||
|
|
||||||
|
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.theme"); o.Action != "removed" || !strings.Contains(o.Detail, "there before") {
|
||||||
|
t.Fatalf("the archive: %+v", o)
|
||||||
|
}
|
||||||
|
if exists(filepath.Join(target, "p10k.zsh")) {
|
||||||
|
t.Fatal("the archive's file is still there")
|
||||||
|
}
|
||||||
|
for _, kept := range []string{"mine.zsh", "lib/mine.zsh"} {
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(target, kept)); string(got) != "somebody's" {
|
||||||
|
t.Fatalf("%s after the removal: %q", kept, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file the archive would write over that the mesh did not put there refuses the archive, and the
|
||||||
|
// directory is left exactly as it was.
|
||||||
|
func TestAnArchiveDoesNotWriteOverAFileItFound(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "theme")
|
||||||
|
os.MkdirAll(target, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(target, "p10k.zsh"), []byte("somebody's"), 0o644)
|
||||||
|
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "theme", "x": "x"}, "")
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
|
||||||
|
store.OriginDeclared, noServices, nil, nil); err == nil || !strings.Contains(err.Error(), "did not put there") {
|
||||||
|
t.Fatalf("written over: %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(target, "p10k.zsh")); string(got) != "somebody's" {
|
||||||
|
t.Fatalf("p10k.zsh is %q", got)
|
||||||
|
}
|
||||||
|
if exists(filepath.Join(target, "x")) {
|
||||||
|
t.Fatal("part of a refused archive was moved in")
|
||||||
|
}
|
||||||
|
if entries, _ := os.ReadDir(dir); len(entries) != 1 {
|
||||||
|
t.Fatalf("%d entries beside the directory", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADirectoryTheHostMadeGoesOnlyWhenEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "theme")
|
||||||
|
archive, _ := archiveDecl(t, "shell.theme", target, map[string]string{"p10k.zsh": "t", "lib/a.zsh": "a"}, "")
|
||||||
|
_, state, err := Apply(context.Background(), archHost(t), declare(t, archive), store.State{},
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Somebody writes into the directory the host made.
|
||||||
|
os.WriteFile(filepath.Join(target, "lib", "local.zsh"), []byte("mine"), 0o644)
|
||||||
|
|
||||||
|
report, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o := outcomeOf(report, "shell.theme")
|
||||||
|
if o.Action != "removed" || !strings.Contains(o.Detail, "did not put there") {
|
||||||
|
t.Fatalf("the archive: %+v", o)
|
||||||
|
}
|
||||||
|
if exists(filepath.Join(target, "p10k.zsh")) || exists(filepath.Join(target, "lib", "a.zsh")) {
|
||||||
|
t.Fatal("the archive's files are still there")
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(filepath.Join(target, "lib", "local.zsh")); string(got) != "mine" {
|
||||||
|
t.Fatalf("a file the archive did not place: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An archive recorded before the host kept what it unpacked cannot be told from anything else in
|
||||||
|
// its directory: it is left in place, said and forgotten, and the apply goes on.
|
||||||
|
func TestAnArchiveRecordedBeforeItsFilesWereKeptIsLeftAndForgotten(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "tools")
|
||||||
|
os.MkdirAll(target, 0o755)
|
||||||
|
os.WriteFile(filepath.Join(target, "index.js"), []byte("x"), 0o644)
|
||||||
|
known := store.State{Resources: []store.Applied{
|
||||||
|
{ID: "notes.tools", Type: "archive", Target: target, Wrote: "sha256:old", Origin: store.OriginDeclared},
|
||||||
|
}}
|
||||||
|
d := declare(t, `{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"a"}`)
|
||||||
|
report, state, err := Apply(context.Background(), archHost(t), d, known, store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an archive from before stopped the apply: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "notes.tools"); o.Action != "forgotten" || !strings.Contains(o.Detail, "left in place") {
|
||||||
|
t.Fatalf("the archive: %+v", o)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "notes.conf"); o.Action != "created" {
|
||||||
|
t.Fatalf("the rest of the declaration: %+v", o)
|
||||||
|
}
|
||||||
|
if _, still := state.Find("notes.tools"); still {
|
||||||
|
t.Fatal("still on record")
|
||||||
|
}
|
||||||
|
if !exists(filepath.Join(target, "index.js")) {
|
||||||
|
t.Fatal("removed without a record of what it unpacked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One recorded before, still declared and unchanged, is read from its own bytes on the next apply,
|
||||||
|
// so it can be undeclared from then on: the whole directory when it holds exactly the archive, only
|
||||||
|
// the archive's files when it holds anything else.
|
||||||
|
func TestAnArchiveRecordedBeforeLearnsWhatItUnpacked(t *testing.T) {
|
||||||
|
for _, extra := range []bool{false, true} {
|
||||||
|
dir := t.TempDir()
|
||||||
|
target := filepath.Join(dir, "tools")
|
||||||
|
files := map[string]string{"index.js": "x", "lib/a.js": "a"}
|
||||||
|
archive, digest := archiveDecl(t, "notes.tools", target, files, "")
|
||||||
|
for name, body := range files {
|
||||||
|
os.MkdirAll(filepath.Dir(filepath.Join(target, name)), 0o755)
|
||||||
|
os.WriteFile(filepath.Join(target, name), []byte(body), 0o644)
|
||||||
|
}
|
||||||
|
if extra {
|
||||||
|
os.WriteFile(filepath.Join(target, "lib", "local.js"), []byte("mine"), 0o644)
|
||||||
|
}
|
||||||
|
known := store.State{Resources: []store.Applied{
|
||||||
|
{ID: "notes.tools", Type: "archive", Target: target, Wrote: digest, Origin: store.OriginDeclared},
|
||||||
|
}}
|
||||||
|
report, state, err := Apply(context.Background(), archHost(t), declare(t, archive), known,
|
||||||
|
store.OriginDeclared, noServices, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "notes.tools"); o.Action != "unchanged" {
|
||||||
|
t.Fatalf("an unchanged archive was %s", o.Action)
|
||||||
|
}
|
||||||
|
rec, _ := state.Find("notes.tools")
|
||||||
|
if rec.Unpacked == nil || len(rec.Unpacked.Files) != 2 || rec.Unpacked.Made == extra {
|
||||||
|
t.Fatalf("extra=%v: learned %+v", extra, rec.Unpacked)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), declare(t, `{"id":"other","type":"file","path":"`+dir+`/other","content":"o"}`), state,
|
||||||
|
store.OriginDeclared, noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if exists(filepath.Join(target, "index.js")) || exists(filepath.Join(target, "lib", "a.js")) {
|
||||||
|
t.Fatalf("extra=%v: the archive's files are still there", extra)
|
||||||
|
}
|
||||||
|
if exists(target) != extra {
|
||||||
|
t.Fatalf("extra=%v: the directory is there: %v", extra, exists(target))
|
||||||
|
}
|
||||||
|
if extra && !exists(filepath.Join(target, "lib", "local.js")) {
|
||||||
|
t.Fatal("a file the archive did not place was removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What an archive put on the machine, and taking exactly that away (novox/hq issue 162).
|
||||||
|
//
|
||||||
|
// An archive unpacks many files into a directory the mesh did not necessarily make, so undeclaring
|
||||||
|
// one has a real question in it: remove what the archive put there, or remove the directory? The
|
||||||
|
// second deletes whatever else lives there — for the host's own versions directory, every other
|
||||||
|
// delivered version. So the host records what each archive unpacked and whether it made the
|
||||||
|
// directory, and removal takes away exactly that: the files the archive placed, then the
|
||||||
|
// directories the host made for them once they are empty. Never a file the archive did not place,
|
||||||
|
// never a directory that was there before, never one that still holds anything else. It is the
|
||||||
|
// rule every other kind follows: the mesh gives back what it found (ADR 0118), and data outlives
|
||||||
|
// the mesh that declared it (ADR 0030).
|
||||||
|
|
||||||
|
// ours is what of the tree at an archive's path the record says is the mesh's.
|
||||||
|
type ours struct {
|
||||||
|
// all is a record from before the host kept what an archive unpacked: since the swap of issue
|
||||||
|
// 220 the tree at the path was the archive and nothing else, so the whole of it is taken for
|
||||||
|
// the mesh's, as the swap that follows has always taken it.
|
||||||
|
all bool
|
||||||
|
// paths are the files and directories the previous archive put there, relative to the path.
|
||||||
|
paths map[string]bool
|
||||||
|
files []string
|
||||||
|
dirs []string
|
||||||
|
made bool
|
||||||
|
// parents are the directories above the path the host made to reach it, deepest first.
|
||||||
|
parents []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// oursFrom reads the record of the archive before this apply, for this path only: a record of the
|
||||||
|
// same archive at a path it has moved from says nothing about what is at the new one.
|
||||||
|
func oursFrom(previous store.Applied, path string) ours {
|
||||||
|
if previous.Wrote == "" || previous.Target != path {
|
||||||
|
return ours{}
|
||||||
|
}
|
||||||
|
u := previous.Unpacked
|
||||||
|
if u == nil {
|
||||||
|
return ours{all: true, made: true}
|
||||||
|
}
|
||||||
|
o := ours{paths: map[string]bool{}, files: u.Files, dirs: u.Dirs, made: u.Made, parents: u.Parents}
|
||||||
|
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
|
||||||
|
o.paths[rel] = true
|
||||||
|
}
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownershipProbe is what says whether an archive is still its owner's. The directory, when the host
|
||||||
|
// made it; one of the archive's own files when the directory was there before — that one is held as
|
||||||
|
// found (ADR 0182), so its owner is never the archive's to judge.
|
||||||
|
func ownershipProbe(path string, u *store.Unpacked) string {
|
||||||
|
if u != nil && !u.Made && len(u.Files) > 0 {
|
||||||
|
return filepath.Join(path, u.Files[0])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// stillUnpacked is what an unchanged archive has on the machine. The record's, when it has one; on
|
||||||
|
// a record from before the host kept it, read from the archive's own bytes now — and the directory
|
||||||
|
// is taken for the host's only when it holds exactly the archive and nothing else, which is what the
|
||||||
|
// swap of issue 220 leaves. Otherwise the directory is kept for somebody's, and only the archive's
|
||||||
|
// files are recorded as its.
|
||||||
|
func stillUnpacked(body []byte, path string, recorded *store.Unpacked) (*store.Unpacked, error) {
|
||||||
|
if recorded != nil {
|
||||||
|
kept := *recorded
|
||||||
|
return &kept, nil
|
||||||
|
}
|
||||||
|
files, dirs, err := listArchive(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u := &store.Unpacked{Files: pathsOf(files)}
|
||||||
|
if exactly, err := holdsExactly(path, files, dirs); err == nil && exactly {
|
||||||
|
u.Dirs = pathsOf(dirs)
|
||||||
|
u.Made = true
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listArchive reads what an archive holds without unpacking it: each file's digest by its path, and
|
||||||
|
// every directory, named or implied, relative to where it unpacks. Refused on the same terms as
|
||||||
|
// unpack, so a listing never names a path an unpack would not write.
|
||||||
|
func listArchive(body []byte) (map[string]string, map[string]bool, error) {
|
||||||
|
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("this is not a gzipped tar: %w", err)
|
||||||
|
}
|
||||||
|
defer zipped.Close()
|
||||||
|
files, dirs := map[string]string{}, map[string]bool{}
|
||||||
|
reader := tar.NewReader(zipped)
|
||||||
|
for {
|
||||||
|
header, err := reader.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
return files, dirs, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
rel := filepath.Clean(header.Name)
|
||||||
|
if rel == "." {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !insideRel(rel) {
|
||||||
|
return nil, nil, fmt.Errorf("%s names a path outside the archive", header.Name)
|
||||||
|
}
|
||||||
|
for d := filepath.Dir(rel); d != "."; d = filepath.Dir(d) {
|
||||||
|
dirs[filepath.ToSlash(d)] = true
|
||||||
|
}
|
||||||
|
switch header.Typeflag {
|
||||||
|
case tar.TypeDir:
|
||||||
|
dirs[filepath.ToSlash(rel)] = true
|
||||||
|
case tar.TypeReg:
|
||||||
|
sum := sha256.New()
|
||||||
|
if _, err := io.Copy(sum, io.LimitReader(reader, maxArchive)); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
files[filepath.ToSlash(rel)] = hex.EncodeToString(sum.Sum(nil))
|
||||||
|
default:
|
||||||
|
return nil, nil, fmt.Errorf("%s is a %c, and this host unpacks only files and directories",
|
||||||
|
header.Name, header.Typeflag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsExactly is whether a directory holds the archive's files with the archive's bytes, its
|
||||||
|
// directories, and nothing else.
|
||||||
|
func holdsExactly(root string, files map[string]string, dirs map[string]bool) (bool, error) {
|
||||||
|
seen := 0
|
||||||
|
exact := true
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
switch {
|
||||||
|
case d.IsDir():
|
||||||
|
if !dirs[rel] {
|
||||||
|
exact = false
|
||||||
|
return filepath.SkipAll
|
||||||
|
}
|
||||||
|
case d.Type().IsRegular():
|
||||||
|
want, ok := files[rel]
|
||||||
|
if !ok || digestOfFile(path) != want {
|
||||||
|
exact = false
|
||||||
|
return filepath.SkipAll
|
||||||
|
}
|
||||||
|
seen++
|
||||||
|
default:
|
||||||
|
exact = false
|
||||||
|
return filepath.SkipAll
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return exact && seen == len(files), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func digestOfFile(path string) string {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
sum := sha256.New()
|
||||||
|
if _, err := io.Copy(sum, file); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(sum.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// treeOf is every file and directory under root, relative to it, slash-separated and sorted.
|
||||||
|
func treeOf(root string) (files, dirs []string, err error) {
|
||||||
|
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil || rel == "." {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
dirs = append(dirs, filepath.ToSlash(rel))
|
||||||
|
} else {
|
||||||
|
files = append(files, filepath.ToSlash(rel))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
sort.Strings(files)
|
||||||
|
sort.Strings(dirs)
|
||||||
|
if files == nil {
|
||||||
|
files = []string{}
|
||||||
|
}
|
||||||
|
return files, dirs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// foreignIn counts what under root the mesh did not put there. A directory that is not the mesh's
|
||||||
|
// counts once, with everything in it.
|
||||||
|
func foreignIn(root string, mine map[string]bool) (int, error) {
|
||||||
|
count := 0
|
||||||
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil || rel == "." {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !mine[filepath.ToSlash(rel)] {
|
||||||
|
count++
|
||||||
|
if d.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeInto moves a freshly unpacked archive into a directory that is not only the mesh's, one entry
|
||||||
|
// at a time, and takes out what the previous archive placed that this one does not. Everything the
|
||||||
|
// mesh did not put there stays as it is. Collisions are looked for before anything is moved, so a
|
||||||
|
// refused archive leaves the directory exactly as it was.
|
||||||
|
func mergeInto(fresh, path, owner string, files, dirs []string, o ours) (store.Unpacked, error) {
|
||||||
|
var collisions []string
|
||||||
|
for _, rel := range dirs {
|
||||||
|
info, err := os.Lstat(filepath.Join(path, filepath.FromSlash(rel)))
|
||||||
|
if err == nil && !info.IsDir() && !o.paths[rel] {
|
||||||
|
collisions = append(collisions, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rel := range files {
|
||||||
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
||||||
|
info, err := os.Lstat(dest)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
case o.paths[rel] && !info.IsDir():
|
||||||
|
case info.IsDir():
|
||||||
|
if !o.paths[rel] {
|
||||||
|
collisions = append(collisions, rel)
|
||||||
|
} else if n, err := foreignIn(dest, o.paths); err != nil || n > 0 {
|
||||||
|
collisions = append(collisions, rel)
|
||||||
|
}
|
||||||
|
case !info.Mode().IsRegular() ||
|
||||||
|
digestOfFile(dest) != digestOfFile(filepath.Join(fresh, filepath.FromSlash(rel))):
|
||||||
|
// Already holding exactly the archive's bytes is not a collision: it is what an
|
||||||
|
// interrupted earlier apply of this same archive left, or the same file either way.
|
||||||
|
collisions = append(collisions, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(collisions) > 0 {
|
||||||
|
shown := collisions
|
||||||
|
if len(shown) > 5 {
|
||||||
|
shown = shown[:5]
|
||||||
|
}
|
||||||
|
return store.Unpacked{}, fmt.Errorf("%s already holds %d path(s) the archive would write over "+
|
||||||
|
"and the mesh did not put there (%s); nothing was unpacked, and what is there is left as it "+
|
||||||
|
"is (novox/hq issue 162)", path, len(collisions), strings.Join(shown, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
var made []string
|
||||||
|
for _, rel := range dirs {
|
||||||
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
||||||
|
info, err := os.Lstat(dest)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
if o.paths[rel] {
|
||||||
|
made = append(made, rel)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
// The previous archive's file where this one has a directory.
|
||||||
|
if err := os.Remove(dest); err != nil {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mode := os.FileMode(0o755)
|
||||||
|
if from, err := os.Stat(filepath.Join(fresh, filepath.FromSlash(rel))); err == nil {
|
||||||
|
mode = from.Mode().Perm()
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(dest, mode); err != nil {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
if err := own(dest, owner); err != nil {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
made = append(made, rel)
|
||||||
|
}
|
||||||
|
for _, rel := range files {
|
||||||
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
||||||
|
if info, err := os.Lstat(dest); err == nil && info.IsDir() {
|
||||||
|
// The previous archive's directory where this one has a file, holding nothing else.
|
||||||
|
if err := os.RemoveAll(dest); err != nil {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Rename(filepath.Join(fresh, filepath.FromSlash(rel)), dest); err != nil {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the previous archive placed and this one does not.
|
||||||
|
now := map[string]bool{}
|
||||||
|
for _, rel := range append(append([]string{}, files...), dirs...) {
|
||||||
|
now[rel] = true
|
||||||
|
}
|
||||||
|
for _, rel := range o.files {
|
||||||
|
if now[rel] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Remove(filepath.Join(path, filepath.FromSlash(rel))); err != nil && !os.IsNotExist(err) {
|
||||||
|
return store.Unpacked{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rel := range deepestFirst(o.dirs) {
|
||||||
|
if now[rel] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dest := filepath.Join(path, filepath.FromSlash(rel))
|
||||||
|
if err := os.Remove(dest); err != nil && !os.IsNotExist(err) {
|
||||||
|
// Still holding something the mesh did not put there: kept, and still the host's to
|
||||||
|
// take away once it is empty.
|
||||||
|
made = append(made, rel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(made)
|
||||||
|
return store.Unpacked{Files: files, Dirs: made, Made: o.made}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeArchive is what undeclaring an archive does (novox/hq issue 162): exactly the files it
|
||||||
|
// unpacked, then the directories the host made for them once they are empty.
|
||||||
|
//
|
||||||
|
// **Never fatal.** An archive that could not be removed stopped the whole apply, on every apply
|
||||||
|
// after, until it was declared again — so every module with tools was un-unassignable, and a race
|
||||||
|
// between two pushes froze a machine against every other change. Whatever cannot be taken away is
|
||||||
|
// said, left in place, and forgotten, as a former target is (issue 194).
|
||||||
|
//
|
||||||
|
// **Removed whole when it is the host's own, and in one step.** A directory the host made that
|
||||||
|
// holds nothing but the archive is renamed aside and then removed: a reader — the runtime serving a
|
||||||
|
// module's tools from its bundle — sees the whole tree or none of it, never half, and a file it has
|
||||||
|
// open stays readable until it closes it. The runtime is told the module went by its own membership,
|
||||||
|
// not by the files disappearing.
|
||||||
|
func removeArchive(a store.Applied) (string, string, error) {
|
||||||
|
if store.IsFormer(a.ID) {
|
||||||
|
// The version before is what a rollback starts (ADR 0141) and what a reader may still have
|
||||||
|
// open; the launcher retires the host's own versions, not the apply (issue 194).
|
||||||
|
return "forgotten", "a former target left in place: only an archive the declaration dropped is " +
|
||||||
|
"taken away (novox/hq issues 162, 194)", nil
|
||||||
|
}
|
||||||
|
u := a.Unpacked
|
||||||
|
if u == nil {
|
||||||
|
return "forgotten", "left in place: recorded before the host kept what an archive unpacked, so " +
|
||||||
|
"its files cannot be told from anything else there (novox/hq issue 162)", nil
|
||||||
|
}
|
||||||
|
root := filepath.Clean(a.Target)
|
||||||
|
mine := map[string]bool{}
|
||||||
|
for _, rel := range append(append([]string{}, u.Files...), u.Dirs...) {
|
||||||
|
if !insideRel(filepath.FromSlash(rel)) {
|
||||||
|
return "forgotten", fmt.Sprintf("left in place: its record names %q, which is not inside %s",
|
||||||
|
rel, root), nil
|
||||||
|
}
|
||||||
|
mine[rel] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(root)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
removeParents(root, u.Parents)
|
||||||
|
return "forgotten", "no longer there", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "forgotten", fmt.Sprintf("left in place: %v", err), nil
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return "forgotten", "left in place: no longer a directory, so not what the archive was unpacked into", nil
|
||||||
|
}
|
||||||
|
foreign, err := foreignIn(root, mine)
|
||||||
|
if err != nil {
|
||||||
|
return "forgotten", fmt.Sprintf("left in place: cannot read what is in it: %v", err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.Made && foreign == 0 {
|
||||||
|
aside := root + ".removing"
|
||||||
|
if err := os.RemoveAll(aside); err == nil {
|
||||||
|
if err := os.Rename(root, aside); err == nil {
|
||||||
|
if err := os.RemoveAll(aside); err != nil {
|
||||||
|
return "forgotten", fmt.Sprintf("taken out of place, and what it unpacked could not be "+
|
||||||
|
"removed from %s: %v — remove it by hand", aside, err), nil
|
||||||
|
}
|
||||||
|
removeParents(root, u.Parents)
|
||||||
|
return "removed", fmt.Sprintf("no longer declared; the %d file(s) it unpacked, and the "+
|
||||||
|
"directory the host made for them", len(u.Files)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A rename that could not be made is taken file by file instead.
|
||||||
|
}
|
||||||
|
|
||||||
|
removed := 0
|
||||||
|
var failed []string
|
||||||
|
for _, rel := range u.Files {
|
||||||
|
err := os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
removed++
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
default:
|
||||||
|
failed = append(failed, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, rel := range deepestFirst(u.Dirs) {
|
||||||
|
// Only once empty: what is still inside is somebody's.
|
||||||
|
_ = os.Remove(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
|
}
|
||||||
|
detail := fmt.Sprintf("no longer declared; %d file(s) it unpacked removed", removed)
|
||||||
|
switch {
|
||||||
|
case u.Made && os.Remove(root) == nil:
|
||||||
|
removeParents(root, u.Parents)
|
||||||
|
detail += ", and the directory the host made for them"
|
||||||
|
case u.Made:
|
||||||
|
left, _ := os.ReadDir(root)
|
||||||
|
detail += fmt.Sprintf("; the directory is kept: %d item(s) inside that the mesh did not put there",
|
||||||
|
len(left))
|
||||||
|
default:
|
||||||
|
detail += "; the directory is kept: it was there before the archive"
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
// Said and not fatal: fatal, the record would stay and fail the same way on every apply
|
||||||
|
// after — the very wedge this removal exists to end.
|
||||||
|
return "forgotten", detail + "; could not remove, and left in place: " + strings.Join(failed, "; "), nil
|
||||||
|
}
|
||||||
|
return "removed", detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeParents takes away the directories above an archive the host made to reach it, deepest
|
||||||
|
// first, each only once it is empty and only if it is above the archive's directory.
|
||||||
|
func removeParents(root string, parents []string) {
|
||||||
|
for _, p := range parents {
|
||||||
|
clean := filepath.Clean(p)
|
||||||
|
if !filepath.IsAbs(clean) || !strings.HasPrefix(root, clean+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.Remove(clean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// joinParents is the parents made now and those recorded before, deepest first, once each.
|
||||||
|
func joinParents(now, before []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var out []string
|
||||||
|
for _, p := range append(append([]string{}, now...), before...) {
|
||||||
|
if !seen[p] {
|
||||||
|
seen[p] = true
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// insideRel is whether a relative path stays inside the directory it is relative to.
|
||||||
|
func insideRel(rel string) bool {
|
||||||
|
clean := filepath.Clean(rel)
|
||||||
|
return clean != "." && !filepath.IsAbs(clean) && clean != ".." &&
|
||||||
|
!strings.HasPrefix(clean, ".."+string(os.PathSeparator))
|
||||||
|
}
|
||||||
|
|
||||||
|
func deepestFirst(rels []string) []string {
|
||||||
|
out := append([]string{}, rels...)
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return strings.Count(out[i], "/") > strings.Count(out[j], "/") ||
|
||||||
|
(strings.Count(out[i], "/") == strings.Count(out[j], "/") && out[i] > out[j])
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathsOf[V any](m map[string]V) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -183,7 +183,7 @@ func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
|
|||||||
} else if mode, err = modeOf(r.Mode, mode); err != nil {
|
} else if mode, err = modeOf(r.Mode, mode); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(real), 0o755); err != nil {
|
if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := writeAtomically(real, []byte(next), mode); err != nil {
|
if err := writeAtomically(real, []byte(next), mode); err != nil {
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
|
|
||||||
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
|
// The host's own former archive stops nothing (novox/hq issue 194). A new host's first apply finds
|
||||||
// the version before it as a former target of the archive that delivered it; an archive has no
|
// the version before it as a former target of the archive that delivered it; an archive has no
|
||||||
// removal (issue 162), and the refusal stopped every machine applying anything. A former target of
|
// removal then (issue 162), and the refusal stopped every machine applying anything. A former
|
||||||
// such a kind is left in place, said, and forgotten. An archive the declaration dropped still fails,
|
// target of an archive is left in place, said, and forgotten: the version before is what a rollback
|
||||||
// as 162 has it.
|
// starts (ADR 0141).
|
||||||
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
|
func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
|
||||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
if name == "docker" && args[0] == "info" {
|
if name == "docker" && args[0] == "info" {
|
||||||
@@ -62,14 +62,7 @@ func TestTheHostsOwnFormerArchiveIsLeftInPlaceNotFatal(t *testing.T) {
|
|||||||
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
|
t.Fatalf("the rest of the declaration was not applied: %+v", report.Outcomes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An archive the declaration dropped is a different matter: nothing can undo it, and saying
|
// An archive the declaration dropped is taken away since issue 162; one recorded before the
|
||||||
// it was would report an effect the host declined to have (issue 162).
|
// host kept what it unpacked is left in place and forgotten, never fatal — that case is
|
||||||
dropped := store.State{Resources: []store.Applied{
|
// archive_removal_test.go's.
|
||||||
{ID: "tool.next", Type: "archive", Target: "/usr/lib/tool/versions/old", Origin: store.OriginDeclared},
|
|
||||||
}}
|
|
||||||
only := parse(t, `{"declaration":1,"resources":[{"id":"notes.conf","type":"file","path":"`+dir+`/notes.conf","content":"x"}]}`)
|
|
||||||
if _, _, err := Apply(context.Background(), archHost(t), only, dropped, store.OriginDeclared, run, nil, nil); err == nil ||
|
|
||||||
!strings.Contains(err.Error(), "no way to remove") {
|
|
||||||
t.Fatalf("a dropped archive was passed over: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ func TestOnlyAUnitsOwnFileTheMeshCreatedMakesItTheMeshs(t *testing.T) {
|
|||||||
made := meshMadeUnits(known)
|
made := meshMadeUnits(known)
|
||||||
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
|
for unit, want := range map[string]bool{"made.service": true, "runtime.service": true, "kept.service": false,
|
||||||
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
|
"into.service": false, "mesh.conf": false, "docker.service.d": false, "elsewhere.service": false, "dir.service": false} {
|
||||||
if made[unit] != want {
|
if made[unitKey("", "", unit)] != want {
|
||||||
t.Errorf("%s: made %v, want %v", unit, made[unit], want)
|
t.Errorf("%s: made %v, want %v", unit, made[unitKey("", "", unit)], want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-9
@@ -109,9 +109,30 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case *declaration.Service:
|
case *declaration.Service:
|
||||||
if res.Stateless() || known.Recorded(string(declaration.TypeService), res.Unit) {
|
if res.Stateless() || recordedUnit(known, res) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
key := "unit:" + unitKey(res.Scope, res.User, res.Unit)
|
||||||
|
run := run
|
||||||
|
if res.UserScoped() {
|
||||||
|
// In the account's own manager (novox/hq ADR 0177), and only asked while it runs. With
|
||||||
|
// it not running, what can be known is whether somebody put the unit's file where that
|
||||||
|
// manager loads an administrator's units from — and the mesh's own file there is not
|
||||||
|
// somebody's. An account the machine does not have has no unit to find.
|
||||||
|
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if away != "" {
|
||||||
|
for _, path := range userUnitFiles(res.User, res.Unit) {
|
||||||
|
if present(path) && !meshWroteWhole(known, path) {
|
||||||
|
seen.is[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
run = managerFor(res.Scope, res.User, run)
|
||||||
|
}
|
||||||
// **Found is a unit somebody put on this machine, or one the machine uses.**
|
// **Found is a unit somebody put on this machine, or one the machine uses.**
|
||||||
//
|
//
|
||||||
// Where it comes from first: a unit the service manager loads from outside /usr —
|
// Where it comes from first: a unit the service manager loads from outside /usr —
|
||||||
@@ -129,14 +150,14 @@ func lookBefore(ctx context.Context, sys system.System, d *declaration.Declarati
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if from, ok := sys.(unitFiles); ok {
|
if from, ok := sys.(unitFiles); ok {
|
||||||
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(path) {
|
if path, err := from.ServiceUnitFile(ctx, run, res.Unit); err == nil && installedByHand(known, path) {
|
||||||
seen.is["unit:"+res.Unit] = true
|
seen.is[key] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
boot, _ := sys.ServiceBoot(ctx, run, res.Unit)
|
boot, _ := sys.ServiceBoot(ctx, run, res.Unit)
|
||||||
if state == "running" || boot == "enabled" {
|
if state == "running" || boot == "enabled" {
|
||||||
seen.is["unit:"+res.Unit] = true
|
seen.is[key] = true
|
||||||
}
|
}
|
||||||
case *declaration.Container:
|
case *declaration.Container:
|
||||||
if known.Recorded(string(declaration.TypeContainer), res.Name) {
|
if known.Recorded(string(declaration.TypeContainer), res.Name) {
|
||||||
@@ -177,12 +198,26 @@ type unitFiles interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// installedByHand is whether a unit file is one somebody put on this machine rather than one a
|
// installedByHand is whether a unit file is one somebody put on this machine rather than one a
|
||||||
// package ships: anywhere but /usr, where distributions keep what they install.
|
// package ships: anywhere but /usr, where distributions keep what they install — and not one this
|
||||||
func installedByHand(path string) bool {
|
// host wrote whole. That covers an account's units (novox/hq ADR 0177): one under the account's
|
||||||
|
// ~/.config/systemd/user or /etc/systemd/user is somebody's, unless the mesh wrote it there.
|
||||||
|
func installedByHand(known store.State, path string) bool {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return !strings.HasPrefix(filepath.Clean(path), "/usr/")
|
return !strings.HasPrefix(filepath.Clean(path), "/usr/") && !meshWroteWhole(known, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordedUnit is whether this host has a record of a service in the same manager as res, under the
|
||||||
|
// same name (novox/hq ADR 0177): an account's unit and the machine's of one name are two units.
|
||||||
|
func recordedUnit(known store.State, res *declaration.Service) bool {
|
||||||
|
want := unitKey(res.Scope, res.User, res.Unit)
|
||||||
|
for _, r := range known.Resources {
|
||||||
|
if r.Type == string(declaration.TypeService) && unitKey(r.Scope, r.User, r.Target) == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func present(path string) bool {
|
func present(path string) bool {
|
||||||
@@ -374,7 +409,7 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
|
|||||||
case *declaration.User:
|
case *declaration.User:
|
||||||
isFound = before.has("user:" + res.Name)
|
isFound = before.has("user:" + res.Name)
|
||||||
case *declaration.Service:
|
case *declaration.Service:
|
||||||
isFound = before.has("unit:" + res.Unit)
|
isFound = before.has("unit:" + unitKey(res.Scope, res.User, res.Unit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !isFound {
|
if !isFound {
|
||||||
@@ -388,7 +423,11 @@ func holdOnAdopted(ctx context.Context, sys system.System, r declaration.Resourc
|
|||||||
// A held service is not started, stopped, enabled or restarted — but a reload stops nothing,
|
// A held service is not started, stopped, enabled or restarted — but a reload stops nothing,
|
||||||
// so one the module names still happens (novox/hq ADR 0102, ADR 0103).
|
// so one the module names still happens (novox/hq ADR 0102, ADR 0103).
|
||||||
if svc, ok := r.(*declaration.Service); ok && svc.State == "running" {
|
if svc, ok := r.(*declaration.Service); ok && svc.State == "running" {
|
||||||
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 {
|
// In the unit's own manager, and nothing to reload in one that is not running (novox/hq ADR
|
||||||
|
// 0177): the state read below then fails, and the reload is not attempted.
|
||||||
|
away, awayErr := managerAway(ctx, sys, svc.Scope, svc.User, run)
|
||||||
|
run := managerFor(svc.Scope, svc.User, run)
|
||||||
|
if which := restartedBy(svc.ReloadOn, changed); len(which) > 0 && awayErr == nil && away == "" {
|
||||||
if state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" {
|
if state, err := sys.ServiceState(ctx, run, svc.Unit); err == nil && state == "running" {
|
||||||
reloader, can := sys.(serviceReloader)
|
reloader, can := sys.(serviceReloader)
|
||||||
if !can {
|
if !can {
|
||||||
@@ -711,6 +750,20 @@ func hold(ctx context.Context, sys system.System, r declaration.Resource, module
|
|||||||
}
|
}
|
||||||
detail = "the user was found on the machine; its shell and groups are kept until " + module + " is taken"
|
detail = "the user was found on the machine; its shell and groups are kept until " + module + " is taken"
|
||||||
case *declaration.Service:
|
case *declaration.Service:
|
||||||
|
if res.UserScoped() {
|
||||||
|
// Read in the account's own manager, and not at all while it is not running (novox/hq
|
||||||
|
// ADR 0177): held as found, with nothing to compare until it runs.
|
||||||
|
away, err := managerAway(ctx, sys, res.Scope, res.User, run)
|
||||||
|
if err != nil {
|
||||||
|
return out, h, err
|
||||||
|
}
|
||||||
|
if away != "" {
|
||||||
|
detail = "its unit was found in " + res.User + "'s own manager, which is not running; " +
|
||||||
|
"kept as found until " + module + " is taken"
|
||||||
|
break
|
||||||
|
}
|
||||||
|
run = managerFor(res.Scope, res.User, run)
|
||||||
|
}
|
||||||
state, err := sys.ServiceState(ctx, run, res.Unit)
|
state, err := sys.ServiceState(ctx, run, res.Unit)
|
||||||
switch {
|
switch {
|
||||||
case err != nil && !already:
|
case err != nil && !already:
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
osuser "os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0182 and to-be 41: a parent the host makes inside an owner's home is the
|
||||||
|
// owner's, one that was there is held as found, and one outside the home is made as before.
|
||||||
|
|
||||||
|
// aHome gives the account running the test a home in a directory the test owns, and writes down
|
||||||
|
// every directory the host gives to whom. The account's own name, so what the host chowns resolves
|
||||||
|
// without being root; the record, so what was given is told apart from what was merely made.
|
||||||
|
func aHome(t *testing.T) (home, owner string, given map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
me, err := osuser.Current()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no current user to own anything")
|
||||||
|
}
|
||||||
|
home = t.TempDir()
|
||||||
|
given = map[string]string{}
|
||||||
|
wasHome, wasOwn := homeOf, ownMade
|
||||||
|
homeOf = func(name string) (string, error) {
|
||||||
|
if name == me.Username {
|
||||||
|
return home, nil
|
||||||
|
}
|
||||||
|
return wasHome(name)
|
||||||
|
}
|
||||||
|
ownMade = func(path, owner string) error {
|
||||||
|
given[path] = owner
|
||||||
|
return wasOwn(path, owner)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { homeOf, ownMade = wasHome, wasOwn })
|
||||||
|
return home, me.Username, given
|
||||||
|
}
|
||||||
|
|
||||||
|
func givenPaths(given map[string]string) []string {
|
||||||
|
var paths []string
|
||||||
|
for p := range given {
|
||||||
|
paths = append(paths, p)
|
||||||
|
}
|
||||||
|
sort.Strings(paths)
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
|
||||||
|
home, owner, given := aHome(t)
|
||||||
|
target := filepath.Join(home, ".config", "mesh", "environment.sh")
|
||||||
|
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
|
||||||
|
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
|
||||||
|
noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{filepath.Join(home, ".config"), filepath.Join(home, ".config", "mesh")}
|
||||||
|
if got := givenPaths(given); strings.Join(got, ",") != strings.Join(want, ",") {
|
||||||
|
t.Errorf("given to the owner: %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for _, p := range want {
|
||||||
|
if given[p] != owner {
|
||||||
|
t.Errorf("%s given to %q", p, given[p])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnArchiveUnderAHomeGivesTheParentsItMadeToItsOwner(t *testing.T) {
|
||||||
|
home, owner, given := aHome(t)
|
||||||
|
body, digest := anArchive(t, map[string]string{"p10k.zsh": "theme"})
|
||||||
|
target := filepath.Join(home, ".local", "share", "powerlevel10k")
|
||||||
|
d := declare(t, `{"id":"shell.theme","type":"archive","source":"`+serving(t, body)+
|
||||||
|
`","digest":"`+digest+`","path":"`+target+`","owner":"`+owner+`"}`)
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
|
||||||
|
noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, p := range []string{filepath.Join(home, ".local"), filepath.Join(home, ".local", "share")} {
|
||||||
|
if given[p] != owner {
|
||||||
|
t.Errorf("%s, made by the host, was not given to the owner: %v", p, givenPaths(given))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAParentThatWasThereIsHeldAsFound(t *testing.T) {
|
||||||
|
home, owner, given := aHome(t)
|
||||||
|
config := filepath.Join(home, ".config")
|
||||||
|
if err := os.Mkdir(config, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := filepath.Join(config, "mesh", "environment.sh")
|
||||||
|
d := parse(t, `{"declaration":1,"resources":[{"id":"shell.env","type":"file","path":"`+target+
|
||||||
|
`","content":"export A=1\n","owner":"`+owner+`"}]}`)
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
|
||||||
|
noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, touched := given[config]; touched {
|
||||||
|
t.Error("a parent that was already there was given to the owner")
|
||||||
|
}
|
||||||
|
if info, _ := os.Stat(config); info.Mode().Perm() != 0o700 {
|
||||||
|
t.Errorf("a parent that was already there changed mode: %o", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
if given[filepath.Join(config, "mesh")] != owner {
|
||||||
|
t.Errorf("the parent the host made was not given to the owner: %v", givenPaths(given))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAParentOutsideTheHomeIsMadeAsBefore(t *testing.T) {
|
||||||
|
_, owner, given := aHome(t)
|
||||||
|
target := filepath.Join(t.TempDir(), "var", "lib", "module", "settings.conf")
|
||||||
|
d := parse(t, `{"declaration":1,"resources":[{"id":"module.conf","type":"file","path":"`+target+
|
||||||
|
`","content":"a=1\n","owner":"`+owner+`"}]}`)
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginDeclared,
|
||||||
|
noServices, nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(given) != 0 {
|
||||||
|
t.Errorf("parents outside the owner's home were given to it: %v", givenPaths(given))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ func applyInto(r *declaration.File, previous store.Applied) (Outcome, error) {
|
|||||||
mode = m
|
mode = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(filepath.Dir(r.Path), 0o755); err != nil {
|
if err := makeDirs(filepath.Dir(r.Path), 0o755, r.Owner); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
if err := writeAtomically(r.Path, want, mode); err != nil {
|
if err := writeAtomically(r.Path, want, mode); err != nil {
|
||||||
|
|||||||
+12
-1
@@ -90,6 +90,17 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
|||||||
switch {
|
switch {
|
||||||
case orphan.Stateless:
|
case orphan.Stateless:
|
||||||
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
|
step.Verb, step.Why = "forget", "no longer declared; its unit's state was never the mesh's and is left as it is"
|
||||||
|
case orphan.Type == string(declaration.TypeArchive) && store.IsFormer(orphan.ID):
|
||||||
|
// In removeArchive's words (novox/hq issues 162, 194).
|
||||||
|
step.Verb, step.Why = "forget", "a former target; left in place, since the version before is what a rollback starts"
|
||||||
|
case orphan.Type == string(declaration.TypeArchive) && orphan.Unpacked == nil:
|
||||||
|
step.Verb, step.Why = "forget", "no longer declared; recorded before the host kept what it unpacked, so it is left in place"
|
||||||
|
case orphan.Type == string(declaration.TypeArchive):
|
||||||
|
step.Why = "no longer declared; the files it unpacked go, and the directories the host made for them once empty"
|
||||||
|
case orphan.Type == string(declaration.TypeFile) && orphan.Into == nil && orphan.Kept != "":
|
||||||
|
// In removeWhole's words (novox/hq ADR 0118): written over, so given back, not deleted.
|
||||||
|
step.Verb, step.Why = "restore", "no longer declared; the original the mesh wrote over goes back "+
|
||||||
|
"from "+orphan.Kept+", unless the file was changed since the mesh last wrote it"
|
||||||
case orphan.Type == string(declaration.TypeService):
|
case orphan.Type == string(declaration.TypeService):
|
||||||
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
|
// What removal will do, said before it does it (novox/hq ADR 0118), in removeService's
|
||||||
// words. "restore" only where it may stop or disable something — the record cannot say
|
// words. "restore" only where it may stop or disable something — the record cannot say
|
||||||
@@ -98,7 +109,7 @@ func Plan(d *declaration.Declaration, known store.State, origin string) []Step {
|
|||||||
// stopped whatever was found.
|
// stopped whatever was found.
|
||||||
f := orphan.Found
|
f := orphan.Found
|
||||||
switch {
|
switch {
|
||||||
case made[orphan.Target]:
|
case made[unitKey(orphan.Scope, orphan.User, orphan.Target)]:
|
||||||
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
|
step.Verb, step.Why = "remove", "no longer declared; the mesh wrote its unit file, so it is "+
|
||||||
"stopped and disabled at boot before that file goes"
|
"stopped and disabled at boot before that file goes"
|
||||||
case f == nil:
|
case f == nil:
|
||||||
|
|||||||
+292
-18
@@ -2,6 +2,7 @@ package apply
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
osuser "os/user"
|
osuser "os/user"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/novox/mesh-host/internal/declaration"
|
"github.com/novox/mesh-host/internal/declaration"
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
"github.com/novox/mesh-host/internal/system"
|
"github.com/novox/mesh-host/internal/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,14 +25,39 @@ import (
|
|||||||
//
|
//
|
||||||
// Reconciling, like everything else here: it is not told whether the user is new. Creating,
|
// Reconciling, like everything else here: it is not told whether the user is new. Creating,
|
||||||
// setting a shell and adding groups are each done only when the machine does not already agree.
|
// setting a shell and adding groups are each done only when the machine does not already agree.
|
||||||
func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner) (Outcome, error) {
|
//
|
||||||
|
// previous is this resource's record, which carries the shell the account had before the mesh
|
||||||
|
// first changed it, so removal can give it back (novox/hq ADR 0176 §2, issue 228).
|
||||||
|
func applyUser(ctx context.Context, sys system.System, r *declaration.User, run Runner,
|
||||||
|
previous store.Applied) (Outcome, error) {
|
||||||
out := begin(r)
|
out := begin(r)
|
||||||
out.Action = "unchanged"
|
out.Action = "unchanged"
|
||||||
|
// What was found is carried from the record for as long as the resource is recorded — for this
|
||||||
|
// account only: a declaration that renamed its user says nothing about the new one's shell.
|
||||||
|
if previous.Shell != nil && previous.Target == r.Name {
|
||||||
|
kept := *previous.Shell
|
||||||
|
out.shell = &kept
|
||||||
|
}
|
||||||
|
if previous.Linger != nil && previous.Target == r.Name {
|
||||||
|
kept := *previous.Linger
|
||||||
|
out.linger = &kept
|
||||||
|
}
|
||||||
|
|
||||||
login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name)
|
login, exists, err := system.LookUpUser(ctx, system.Runner(run), r.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// **A shell is refused before anything is touched** (novox/hq issue 228). Refused after the
|
||||||
|
// account was created or its groups changed, the account would be half the declaration's; a
|
||||||
|
// refusal fails this resource and leaves the account exactly as it was.
|
||||||
|
if r.Shell != "" && (!exists || login.Shell != r.Shell) {
|
||||||
|
if err := system.UsableShell(r.Shell); err != nil {
|
||||||
|
return out, fmt.Errorf("%q's shell was not set, and the account was left as it is: %w",
|
||||||
|
r.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
if err := sys.CreateUser(ctx, system.Runner(run), r.Name, r.Home, r.Shell); err != nil {
|
if err := sys.CreateUser(ctx, system.Runner(run), r.Name, r.Home, r.Shell); err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
@@ -46,26 +73,15 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run
|
|||||||
r.Name)
|
r.Name)
|
||||||
}
|
}
|
||||||
out.Action = "created"
|
out.Action = "created"
|
||||||
}
|
if r.Shell != "" {
|
||||||
|
// No shell from before to give back: the account had none until the mesh made it.
|
||||||
// The shell, only when it differs. Absent means the host asserts nothing — a field that
|
out.shell = &store.LoginShell{Set: login.Shell, Created: true}
|
||||||
// always asserts cannot express "leave it alone", which is the difference between managing a
|
|
||||||
// machine and taking it over.
|
|
||||||
if r.Shell != "" && login.Shell != r.Shell {
|
|
||||||
if err := sys.SetUserShell(ctx, system.Runner(run), r.Name, r.Shell); err != nil {
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
if back, _, err := system.LookUpUser(ctx, system.Runner(run), r.Name); err != nil {
|
|
||||||
return out, err
|
|
||||||
} else if back.Shell != r.Shell {
|
|
||||||
return out, fmt.Errorf("set %q's shell to %q and the user database says %q",
|
|
||||||
r.Name, r.Shell, back.Shell)
|
|
||||||
}
|
|
||||||
if out.Action == "unchanged" {
|
|
||||||
out.Action = "updated"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Groups before the shell, so that a failure here comes before the shell is changed: a record
|
||||||
|
// is written only for an apply that worked, and a shell changed by a failed one would be read
|
||||||
|
// next time as the account's own, and the one it replaced lost.
|
||||||
if len(r.Groups) > 0 {
|
if len(r.Groups) > 0 {
|
||||||
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
|
in, err := system.GroupsOf(ctx, system.Runner(run), r.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -87,9 +103,198 @@ func applyUser(ctx context.Context, sys system.System, r *declaration.User, run
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The shell, only when it differs. Absent means the host asserts nothing — a field that
|
||||||
|
// always asserts cannot express "leave it alone", which is the difference between managing a
|
||||||
|
// machine and taking it over.
|
||||||
|
if r.Shell != "" && login.Shell != r.Shell {
|
||||||
|
if err := sys.SetUserShell(ctx, system.Runner(run), r.Name, r.Shell); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if back, _, err := system.LookUpUser(ctx, system.Runner(run), r.Name); err != nil {
|
||||||
|
return out, err
|
||||||
|
} else if back.Shell != r.Shell {
|
||||||
|
return out, fmt.Errorf("set %q's shell to %q and the user database says %q",
|
||||||
|
r.Name, r.Shell, back.Shell)
|
||||||
|
}
|
||||||
|
// **What was found is recorded once** (novox/hq ADR 0176 §2). A later change keeps it: what
|
||||||
|
// is given back is the shell from before the mesh, never the mesh's own earlier choice.
|
||||||
|
if out.shell == nil {
|
||||||
|
out.shell = &store.LoginShell{Found: login.Shell}
|
||||||
|
}
|
||||||
|
out.shell.Set = r.Shell
|
||||||
|
if out.Action == "unchanged" {
|
||||||
|
out.Action = "updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lingering last, and only when declared and different: the one change here that starts or
|
||||||
|
// stops something — the account's manager and every unit in it (novox/hq ADR 0177).
|
||||||
|
if r.Linger != nil {
|
||||||
|
changed, err := applyLinger(ctx, sys, r.Name, *r.Linger, run, &out)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if changed && out.Action == "unchanged" {
|
||||||
|
out.Action = "updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lingerer is a service manager that runs a manager per account, and can keep one running with
|
||||||
|
// nobody logged in (novox/hq ADR 0177).
|
||||||
|
type lingerer interface {
|
||||||
|
Lingering(ctx context.Context, run system.Runner, name string) (bool, error)
|
||||||
|
SetLingering(ctx context.Context, run system.Runner, name string, on bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyLinger makes whether an account lingers what was declared, read back from the machine, and
|
||||||
|
// keeps what it found the first time it changed it so removal can give that back.
|
||||||
|
func applyLinger(ctx context.Context, sys system.System, name string, want bool, run Runner,
|
||||||
|
out *Outcome) (bool, error) {
|
||||||
|
l, ok := sys.(lingerer)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%q is declared to linger, and this machine's service manager has no "+
|
||||||
|
"manager per account to keep running (novox/hq ADR 0177)", name)
|
||||||
|
}
|
||||||
|
now, err := l.Lingering(ctx, system.Runner(run), name)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if now == want {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err := l.SetLingering(ctx, system.Runner(run), name, want); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if back, err := l.Lingering(ctx, system.Runner(run), name); err != nil {
|
||||||
|
return false, err
|
||||||
|
} else if back != want {
|
||||||
|
return false, fmt.Errorf("asked logind to make %q linger %s, and it reads %s",
|
||||||
|
name, onOff(want), onOff(back))
|
||||||
|
}
|
||||||
|
// Found once, as the shell's is: what goes back is what was there before the mesh.
|
||||||
|
if out.linger == nil {
|
||||||
|
out.linger = &store.Lingering{Found: now}
|
||||||
|
}
|
||||||
|
out.linger.Set = want
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOff(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUser is what undeclaring a login does: never deleting the account, and giving back the
|
||||||
|
// shell the mesh replaced when that is still safe (novox/hq ADR 0176 §2, issue 228) — and whether
|
||||||
|
// it lingered, on the same rule (novox/hq ADR 0177).
|
||||||
|
//
|
||||||
|
// **The account is never deleted, whether or not the mesh created it.** An account owns a home,
|
||||||
|
// files, a crontab, a mailbox — what a person did with it is not the mesh's to know, and deleting
|
||||||
|
// it is the data loss ADR 0030 exists to prevent. It is the package's rule, on a login: the
|
||||||
|
// mesh no longer requires it, which is not the same as "remove it".
|
||||||
|
//
|
||||||
|
// The shell goes back only while the account still has the one the mesh set — one a person chose
|
||||||
|
// since is theirs — and only to a shell that is still usable: giving back a shell that has been
|
||||||
|
// uninstalled since would break the very logins the giving back is for. Otherwise it is left, and
|
||||||
|
// the outcome says why. Never errNoRemoval: an orphaned login that failed removal stopped the
|
||||||
|
// whole apply, on every apply after.
|
||||||
|
func removeUser(ctx context.Context, sys system.System, a store.Applied, run Runner) (string, string, error) {
|
||||||
|
const kept = "the account is kept; the host never deletes a login"
|
||||||
|
login, exists, err := system.LookUpUser(ctx, system.Runner(run), a.Target)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return "forgotten", "no longer there", nil
|
||||||
|
}
|
||||||
|
action, detail, err := giveShellBack(ctx, sys, a, login, run, kept)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if gave, said := giveLingerBack(ctx, sys, a, run); said != "" {
|
||||||
|
detail += "; " + said
|
||||||
|
if gave {
|
||||||
|
action = "restored"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return action, detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveShellBack is removeUser's shell: given back only while the account still has the one the
|
||||||
|
// mesh set, and only to one still usable.
|
||||||
|
func giveShellBack(ctx context.Context, sys system.System, a store.Applied, login system.Login,
|
||||||
|
run Runner, kept string) (string, string, error) {
|
||||||
|
found := a.Shell
|
||||||
|
switch {
|
||||||
|
case found == nil:
|
||||||
|
return "forgotten", kept + ", and its shell was never changed by the mesh", nil
|
||||||
|
case found.Created:
|
||||||
|
return "forgotten", kept + "; the mesh created it, so there is no shell from before to give back", nil
|
||||||
|
case login.Shell != found.Set:
|
||||||
|
return "forgotten", fmt.Sprintf("%s, and its shell %s left as it is: changed since the mesh set %s",
|
||||||
|
kept, login.Shell, found.Set), nil
|
||||||
|
case found.Found == "":
|
||||||
|
return "forgotten", kept + ", and its shell left as it is: it had none before the mesh set one", nil
|
||||||
|
}
|
||||||
|
if err := system.UsableShell(found.Found); err != nil {
|
||||||
|
return "forgotten", fmt.Sprintf("%s, and its shell %s left as it is: the one it had before "+
|
||||||
|
"cannot be given back: %v", kept, login.Shell, err), nil
|
||||||
|
}
|
||||||
|
// A give-back that fails is said and not fatal: fatal, the record would stay and fail the same
|
||||||
|
// way on every apply after — the very wedge this removal exists to end.
|
||||||
|
if err := sys.SetUserShell(ctx, system.Runner(run), a.Target, found.Found); err != nil {
|
||||||
|
return "forgotten", fmt.Sprintf("%s, and the shell it had before the mesh, %s, could not be "+
|
||||||
|
"given back: %v", kept, found.Found, err), nil
|
||||||
|
}
|
||||||
|
if back, _, err := system.LookUpUser(ctx, system.Runner(run), a.Target); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
} else if back.Shell != found.Found {
|
||||||
|
return "forgotten", fmt.Sprintf("%s; gave back the shell %s and the user database says %s",
|
||||||
|
kept, found.Found, back.Shell), nil
|
||||||
|
}
|
||||||
|
return "restored", fmt.Sprintf("%s; the shell it had before the mesh, %s, given back", kept, found.Found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveLingerBack is removeUser's lingering (novox/hq ADR 0177), on the shell's rule: whether the
|
||||||
|
// account lingered before the mesh changed it goes back, and only while the account still has what
|
||||||
|
// the mesh set — one the operator changed since with loginctl is theirs. Never fatal, for the
|
||||||
|
// shell's reason. Empty when the mesh never changed it, so a removal says nothing about it.
|
||||||
|
//
|
||||||
|
// Giving back "not lingering" stops the account's manager if nobody is logged in, and every unit
|
||||||
|
// in it: that is what the account had before the mesh, and its user units go with its declaration.
|
||||||
|
func giveLingerBack(ctx context.Context, sys system.System, a store.Applied, run Runner) (bool, string) {
|
||||||
|
found := a.Linger
|
||||||
|
if found == nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
if found.Found == found.Set {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
l, ok := sys.(lingerer)
|
||||||
|
if !ok {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
now, err := l.Lingering(ctx, system.Runner(run), a.Target)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Sprintf("whether it lingered before the mesh could not be given back: %v", err)
|
||||||
|
}
|
||||||
|
if now != found.Set {
|
||||||
|
return false, fmt.Sprintf("lingering left %s: changed since the mesh set it %s", onOff(now), onOff(found.Set))
|
||||||
|
}
|
||||||
|
if err := l.SetLingering(ctx, system.Runner(run), a.Target, found.Found); err != nil {
|
||||||
|
return false, fmt.Sprintf("lingering, %s before the mesh, could not be given back: %v", onOff(found.Found), err)
|
||||||
|
}
|
||||||
|
if back, err := l.Lingering(ctx, system.Runner(run), a.Target); err != nil || back != found.Found {
|
||||||
|
return false, fmt.Sprintf("gave back lingering %s and logind does not read it so", onOff(found.Found))
|
||||||
|
}
|
||||||
|
return true, fmt.Sprintf("lingering %s again, as before the mesh", onOff(found.Found))
|
||||||
|
}
|
||||||
|
|
||||||
// own sets a path's owner, when one was declared.
|
// own sets a path's owner, when one was declared.
|
||||||
//
|
//
|
||||||
// Looked up by name every time rather than cached: a user's numeric id is not stable across
|
// Looked up by name every time rather than cached: a user's numeric id is not stable across
|
||||||
@@ -170,6 +375,75 @@ func ownedBy(path, owner string) (bool, error) {
|
|||||||
return uid == wantUID && gid == wantGID, nil
|
return uid == wantUID && gid == wantGID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeDirs makes a directory and any parent of it that is missing, as MkdirAll does — and gives
|
||||||
|
// each one it made inside the owner's home to the owner (novox/hq ADR 0182, to-be 41).
|
||||||
|
//
|
||||||
|
// **A parent made as root inside a home is a home the person cannot use.** A module writing
|
||||||
|
// ~/.config/mesh/environment.sh, or unpacking into ~/.local/share/powerlevel10k, on a fresh account
|
||||||
|
// made ~/.config and ~/.local/share owned by root: the file was the person's, the directory every
|
||||||
|
// program of theirs writes into was not. So what the host creates between the home and the target
|
||||||
|
// is the owner's, as the target is.
|
||||||
|
//
|
||||||
|
// **Only what the host created.** A parent that was already there is never chowned or chmodded:
|
||||||
|
// what a person or another program made is held as found (ADR 0182). And only inside the owner's
|
||||||
|
// home, read from the user database, not guessed from a prefix on /home: a module's directory under
|
||||||
|
// /var/lib is made exactly as before, whoever its files belong to.
|
||||||
|
func makeDirs(dir string, mode os.FileMode, owner string) error {
|
||||||
|
_, err := makeDirsSaying(dir, mode, owner)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeDirsSaying is makeDirs, and says which directories it made, deepest first — so an archive
|
||||||
|
// can take away on removal the parents it made to reach its directory (novox/hq issue 162).
|
||||||
|
func makeDirsSaying(dir string, mode os.FileMode, owner string) ([]string, error) {
|
||||||
|
var made []string
|
||||||
|
for d := filepath.Clean(dir); ; d = filepath.Dir(d) {
|
||||||
|
if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
made = append(made, d)
|
||||||
|
if filepath.Dir(d) == d {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, mode); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if owner == "" || len(made) == 0 {
|
||||||
|
return made, nil
|
||||||
|
}
|
||||||
|
home, err := homeOf(owner)
|
||||||
|
if err != nil || home == "" {
|
||||||
|
// A numeric owner — a container's user — has no home, and a name the machine does not
|
||||||
|
// know fails where the target is given to it. Either way nothing here is a home's.
|
||||||
|
return made, nil
|
||||||
|
}
|
||||||
|
home = filepath.Clean(home)
|
||||||
|
for _, d := range made {
|
||||||
|
if d != home && !strings.HasPrefix(d, home+string(os.PathSeparator)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := ownMade(d, owner); err != nil {
|
||||||
|
return made, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return made, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeOf is an owner's home from the user database, and ownMade gives a directory the host made to
|
||||||
|
// its owner. Variables so a test can give an owner a home it owns, and see what was given to whom
|
||||||
|
// without being root.
|
||||||
|
var (
|
||||||
|
homeOf = func(owner string) (string, error) {
|
||||||
|
found, err := osuser.Lookup(owner)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return found.HomeDir, nil
|
||||||
|
}
|
||||||
|
ownMade = own
|
||||||
|
)
|
||||||
|
|
||||||
// ownAll gives a whole tree to a user, for an archive that was unpacked into it.
|
// ownAll gives a whole tree to a user, for an archive that was unpacked into it.
|
||||||
func ownAll(root, owner string) error {
|
func ownAll(root, owner string) error {
|
||||||
if owner == "" {
|
if owner == "" {
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
"github.com/novox/mesh-host/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0176 §2 and issue 228: a login the mesh set is given back when its holding
|
||||||
|
// moves, undeclaring one never stops the node applying, and a shell is checked before it is set.
|
||||||
|
|
||||||
|
// logins is a fake user database: each account's shell by name, and every command it was asked.
|
||||||
|
type logins struct {
|
||||||
|
shells map[string]string
|
||||||
|
asked []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *logins) run(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
l.asked = append(l.asked, name+" "+strings.Join(args, " "))
|
||||||
|
who := args[len(args)-1]
|
||||||
|
switch name {
|
||||||
|
case "getent":
|
||||||
|
if shell, ok := l.shells[who]; ok {
|
||||||
|
return who + ":x:1500:1500::/home/" + who + ":" + shell + "\n", nil
|
||||||
|
}
|
||||||
|
return "", errors.New("getent exited 2: ") // the host's runner's words for "no such key"
|
||||||
|
case "useradd":
|
||||||
|
shell := ""
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "--shell" {
|
||||||
|
shell = args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.shells[who] = shell
|
||||||
|
case "usermod":
|
||||||
|
if args[0] == "--shell" {
|
||||||
|
l.shells[who] = args[1]
|
||||||
|
}
|
||||||
|
case "userdel":
|
||||||
|
delete(l.shells, who)
|
||||||
|
case "id":
|
||||||
|
return "\n", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *logins) did(prefix string) bool {
|
||||||
|
for _, a := range l.asked {
|
||||||
|
if strings.HasPrefix(a, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellsOn makes a machine's shells in a directory a test owns: each name an executable file,
|
||||||
|
// listed or not in the machine's list of shells as said, which this test's apply then reads.
|
||||||
|
func shellsOn(t *testing.T, listed []string, unlisted ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
var list strings.Builder
|
||||||
|
list.WriteString("# Pathnames of valid login shells.\n")
|
||||||
|
for _, name := range append(append([]string{}, listed...), unlisted...) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range listed {
|
||||||
|
list.WriteString(filepath.Join(dir, name) + "\n")
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "shells"), []byte(list.String()), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(system.ShellsIn(filepath.Join(dir, "shells")))
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUsers(t *testing.T, l *logins, known store.State, resources string) (Report, store.State, error) {
|
||||||
|
t.Helper()
|
||||||
|
if resources == "" {
|
||||||
|
// Undeclared: something else stays, since a declaration with nothing in it is refused.
|
||||||
|
resources = `{"id":"other.dir","type":"directory","path":"` + t.TempDir() + `/other"}`
|
||||||
|
}
|
||||||
|
return Apply(context.Background(), archHost(t), parse(t, `{"declaration":1,"resources":[`+resources+`]}`),
|
||||||
|
known, store.OriginDeclared, l.run, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func userWith(shell string) string {
|
||||||
|
return `{"id":"shell.login","type":"user","name":"operator","shell":"` + shell + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUndeclaredUserNoLongerStopsTheApply(t *testing.T) {
|
||||||
|
// Before issue 228 the host had no removal for a user, the orphan failed with "no way to
|
||||||
|
// remove", and an orphan's failure aborts the apply before its first resource — on every
|
||||||
|
// apply after, since the record stayed.
|
||||||
|
dir := shellsOn(t, []string{"bash", "zsh"})
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
_, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
page := filepath.Join(t.TempDir(), "page")
|
||||||
|
report, state, err := applyUsers(t, l, state,
|
||||||
|
`{"id":"web.page","type":"file","path":"`+page+`","content":"hello\n"}`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("an undeclared user stopped the apply: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(page); err != nil {
|
||||||
|
t.Errorf("a file in the same declaration was not written: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); o.Action == "" {
|
||||||
|
t.Errorf("the user's removal was not reported: %+v", report.Outcomes)
|
||||||
|
}
|
||||||
|
if _, still := state.Find("shell.login"); still {
|
||||||
|
t.Error("the user is still recorded, so the next apply would meet it again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheShellFoundIsGivenBackWhenTheUserIsUndeclared(t *testing.T) {
|
||||||
|
dir := shellsOn(t, []string{"bash", "zsh"})
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
_, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l.shells["operator"] != dir+"/zsh" {
|
||||||
|
t.Fatalf("the declared shell was not set: %q", l.shells["operator"])
|
||||||
|
}
|
||||||
|
if r, _ := state.Find("shell.login"); r.Shell == nil || r.Shell.Found != dir+"/bash" {
|
||||||
|
t.Fatalf("the shell the account had was not recorded: %+v", r.Shell)
|
||||||
|
}
|
||||||
|
|
||||||
|
report, _, err := applyUsers(t, l, state, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l.shells["operator"] != dir+"/bash" {
|
||||||
|
t.Errorf("the shell the account had was not given back: %q", l.shells["operator"])
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); o.Action != "restored" {
|
||||||
|
t.Errorf("the give-back was not said: %+v", o)
|
||||||
|
}
|
||||||
|
if l.did("userdel") {
|
||||||
|
t.Error("the account was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShellAPersonChangedSinceIsLeftAlone(t *testing.T) {
|
||||||
|
dir := shellsOn(t, []string{"bash", "zsh", "fish"})
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
_, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l.shells["operator"] = dir + "/fish" // chsh, by the person whose login it is
|
||||||
|
l.asked = nil
|
||||||
|
|
||||||
|
report, _, err := applyUsers(t, l, state, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l.did("usermod") || l.shells["operator"] != dir+"/fish" {
|
||||||
|
t.Errorf("a shell a person chose was taken from them: %q, %v", l.shells["operator"], l.asked)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); o.Action != "forgotten" || !strings.Contains(o.Detail, "changed since") {
|
||||||
|
t.Errorf("the outcome does not say why the shell was left: %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFoundShellThatIsGoneIsNotGivenBack(t *testing.T) {
|
||||||
|
// Giving back a shell uninstalled since would break the logins the giving back is for.
|
||||||
|
dir := shellsOn(t, []string{"bash", "zsh"})
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
_, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(dir + "/bash"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l.asked = nil
|
||||||
|
report, _, err := applyUsers(t, l, state, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l.did("usermod") || l.shells["operator"] != dir+"/zsh" {
|
||||||
|
t.Errorf("a shell no longer on the machine was given back: %q", l.shells["operator"])
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); !strings.Contains(o.Detail, "cannot be given back") {
|
||||||
|
t.Errorf("the outcome does not say why the shell was left: %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAShellThatIsMissingOrUnlistedIsRefusedBeforeItIsSet(t *testing.T) {
|
||||||
|
dir := shellsOn(t, []string{"bash"}, "unlisted")
|
||||||
|
for name, shell := range map[string]string{
|
||||||
|
"missing": dir + "/zsh",
|
||||||
|
"unlisted": dir + "/unlisted",
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
page := filepath.Join(t.TempDir(), "page")
|
||||||
|
report, state, err := applyUsers(t, l, store.State{}, userWith(shell)+`,
|
||||||
|
{"id":"web.page","type":"file","path":"`+page+`","content":"hello\n"}`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("the refused shell did not fail its resource")
|
||||||
|
}
|
||||||
|
if l.did("usermod") || l.shells["operator"] != dir+"/bash" {
|
||||||
|
t.Errorf("the account was changed: %q, %v", l.shells["operator"], l.asked)
|
||||||
|
}
|
||||||
|
if _, recorded := state.Find("shell.login"); recorded {
|
||||||
|
t.Error("a refused user was recorded")
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "web.page"); o.Action != "created" {
|
||||||
|
t.Errorf("the refusal stopped the rest of the declaration: %+v", report.Outcomes)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("an account not yet made", func(t *testing.T) {
|
||||||
|
l := &logins{shells: map[string]string{}}
|
||||||
|
if _, _, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh")); err == nil {
|
||||||
|
t.Fatal("the missing shell was not refused")
|
||||||
|
}
|
||||||
|
if l.did("useradd") {
|
||||||
|
t.Errorf("the account was made with a shell that is not there: %v", l.asked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAccountThatRefusesLoginsNeedNotBeListed(t *testing.T) {
|
||||||
|
// A service's account has nologin, which no distribution lists among its shells; refusing it
|
||||||
|
// would refuse the controller's own account.
|
||||||
|
dir := shellsOn(t, []string{"bash"}, "nologin")
|
||||||
|
l := &logins{shells: map[string]string{}}
|
||||||
|
if _, _, err := applyUsers(t, l, store.State{}, userWith(dir+"/nologin")); err != nil {
|
||||||
|
t.Fatalf("a service account was refused: %v", err)
|
||||||
|
}
|
||||||
|
if l.shells["operator"] != dir+"/nologin" {
|
||||||
|
t.Errorf("the account was not made: %v", l.asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACreatedAccountSurvivesItsRemoval(t *testing.T) {
|
||||||
|
dir := shellsOn(t, []string{"zsh"})
|
||||||
|
l := &logins{shells: map[string]string{}}
|
||||||
|
report, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); o.Action != "created" {
|
||||||
|
t.Fatalf("the account was not created: %+v", o)
|
||||||
|
}
|
||||||
|
l.asked = nil
|
||||||
|
report, _, err = applyUsers(t, l, state, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, still := l.shells["operator"]; !still || l.did("userdel") || l.did("usermod") {
|
||||||
|
t.Errorf("a created account was not left as it is: %v", l.asked)
|
||||||
|
}
|
||||||
|
if o := outcomeOf(report, "shell.login"); !strings.Contains(o.Detail, "account is kept") {
|
||||||
|
t.Errorf("the outcome does not say the account was kept: %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFoundShellIsNotOverwrittenByASecondChange(t *testing.T) {
|
||||||
|
// The holding moves from one shell module to another: what is given back in the end is the
|
||||||
|
// shell from before the mesh, not the first module's.
|
||||||
|
dir := shellsOn(t, []string{"bash", "zsh", "fish"})
|
||||||
|
l := &logins{shells: map[string]string{"operator": dir + "/bash"}}
|
||||||
|
_, state, err := applyUsers(t, l, store.State{}, userWith(dir+"/zsh"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, state, err = applyUsers(t, l, state, userWith(dir+"/fish"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r, _ := state.Find("shell.login"); r.Shell == nil || r.Shell.Found != dir+"/bash" || r.Shell.Set != dir+"/fish" {
|
||||||
|
t.Fatalf("the record is not the shell found and the one set last: %+v", r.Shell)
|
||||||
|
}
|
||||||
|
if _, _, err := applyUsers(t, l, state, ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l.shells["operator"] != dir+"/bash" {
|
||||||
|
t.Errorf("given back %q, not the shell from before the mesh", l.shells["operator"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
"github.com/novox/mesh-host/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0177: a unit in the operator account's own service manager is applied
|
||||||
|
// through that manager — `systemctl --user --machine=<account>@` — and never as a system unit of
|
||||||
|
// the same name; its record remembers the scope so removal goes the same way. The account's
|
||||||
|
// manager runs only while somebody is logged in or the account lingers: with it away a unit waits
|
||||||
|
// rather than fails, a removal is never fatal, and the manager is never started by asking it.
|
||||||
|
|
||||||
|
// account is a machine with one account whose own manager runs or does not, and the units in it.
|
||||||
|
type account struct {
|
||||||
|
name, uid, home string
|
||||||
|
// up is whether the account's manager runs: a login, or lingering.
|
||||||
|
up bool
|
||||||
|
// lingers is logind's record, kept as files in a directory a test owns.
|
||||||
|
lingerDir string
|
||||||
|
// units are the account's units by name; system are the machine's.
|
||||||
|
units, system map[string]*fakeUnit
|
||||||
|
// busDown is a manager that says it runs while its bus does not answer — it stopped between
|
||||||
|
// the question and the command.
|
||||||
|
busDown bool
|
||||||
|
asked []string
|
||||||
|
// strays are system-scope commands that reached a unit, which no test here expects unless it
|
||||||
|
// declares a system unit.
|
||||||
|
strays []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAccount(t *testing.T, up bool) *account {
|
||||||
|
t.Helper()
|
||||||
|
was := serviceSettle
|
||||||
|
serviceSettle = 0
|
||||||
|
dir := t.TempDir()
|
||||||
|
restore := system.LingerIn(dir)
|
||||||
|
t.Cleanup(func() { serviceSettle = was; restore() })
|
||||||
|
return &account{name: "ops", uid: "1001", home: "/home/ops", up: up, lingerDir: dir,
|
||||||
|
units: map[string]*fakeUnit{"i3-reload-watcher.service": {active: "inactive", enabled: "disabled"}},
|
||||||
|
system: map[string]*fakeUnit{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *account) did(prefix string) bool {
|
||||||
|
for _, c := range a.asked {
|
||||||
|
if strings.HasPrefix(c, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *account) run(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
line := name + " " + strings.Join(args, " ")
|
||||||
|
a.asked = append(a.asked, line)
|
||||||
|
switch name {
|
||||||
|
case "getent":
|
||||||
|
if args[len(args)-1] == a.name {
|
||||||
|
return a.name + ":x:" + a.uid + ":" + a.uid + "::" + a.home + ":/bin/bash\n", nil
|
||||||
|
}
|
||||||
|
return "", errors.New("getent exited 2: ")
|
||||||
|
case "loginctl":
|
||||||
|
path := filepath.Join(a.lingerDir, args[1])
|
||||||
|
switch args[0] {
|
||||||
|
case "enable-linger":
|
||||||
|
a.up = true
|
||||||
|
return "", os.WriteFile(path, nil, 0o644)
|
||||||
|
case "disable-linger":
|
||||||
|
a.up = false
|
||||||
|
return "", os.Remove(path)
|
||||||
|
}
|
||||||
|
case "systemctl":
|
||||||
|
if line == "systemctl is-active user@"+a.uid+".service" {
|
||||||
|
if a.up {
|
||||||
|
return "active\n", nil
|
||||||
|
}
|
||||||
|
return "inactive\n", errors.New("systemctl exited 3: ")
|
||||||
|
}
|
||||||
|
prefix := "--machine=" + a.name + "@"
|
||||||
|
if len(args) > 1 && args[0] == "--user" && args[1] == prefix {
|
||||||
|
if !a.up || a.busDown {
|
||||||
|
return "", errors.New("systemctl exited 1: Failed to connect to user scope bus via " +
|
||||||
|
"machine transport: No such file or directory")
|
||||||
|
}
|
||||||
|
return unitCommand(a.units, args[2:])
|
||||||
|
}
|
||||||
|
a.strays = append(a.strays, line)
|
||||||
|
return unitCommand(a.system, args)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unitCommand is one systemctl verb against a set of units.
|
||||||
|
func unitCommand(units map[string]*fakeUnit, args []string) (string, error) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if args[0] == "daemon-reload" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
u, ok := units[args[1]]
|
||||||
|
switch args[0] {
|
||||||
|
case "show":
|
||||||
|
if !ok {
|
||||||
|
return "LoadState=not-found\nActiveState=inactive", nil
|
||||||
|
}
|
||||||
|
return "LoadState=loaded\nActiveState=" + u.active, nil
|
||||||
|
case "is-enabled":
|
||||||
|
if !ok {
|
||||||
|
return "", errors.New("systemctl exited 1: ")
|
||||||
|
}
|
||||||
|
return u.enabled + "\n", nil
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("systemctl exited 5: Unit %s not found", args[1])
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "start", "restart":
|
||||||
|
u.active = "active"
|
||||||
|
case "stop":
|
||||||
|
u.active = "inactive"
|
||||||
|
case "enable":
|
||||||
|
u.enabled = "enabled"
|
||||||
|
case "disable":
|
||||||
|
u.enabled = "disabled"
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = `{"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"}`
|
||||||
|
|
||||||
|
func declaring(resources ...string) string {
|
||||||
|
return `{"declaration":1,"resources":[` + strings.Join(resources, ",") + `]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAccount(t *testing.T, raw string, known store.State, a *account) (Report, store.State, error) {
|
||||||
|
t.Helper()
|
||||||
|
return Apply(context.Background(), archHost(t), parse(t, raw), known, store.OriginDeclared, a.run, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func outcomeFor(r Report, id string) Outcome {
|
||||||
|
for _, o := range r.Outcomes {
|
||||||
|
if o.ID == id {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUserScopedUnitIsAppliedThroughTheAccountsManager(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
report, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply: %v\n%s", err, strings.Join(a.asked, "\n"))
|
||||||
|
}
|
||||||
|
if !report.Changed() {
|
||||||
|
t.Fatal("a unit that was stopped and is now running changed nothing")
|
||||||
|
}
|
||||||
|
if !a.did("systemctl --user --machine=ops@ start i3-reload-watcher.service") ||
|
||||||
|
!a.did("systemctl --user --machine=ops@ enable i3-reload-watcher.service") {
|
||||||
|
t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(a.asked, "\n"))
|
||||||
|
}
|
||||||
|
if len(a.strays) != 0 {
|
||||||
|
t.Fatalf("a user-scoped unit reached the machine's manager: %v", a.strays)
|
||||||
|
}
|
||||||
|
recorded, ok := known.At("service", "i3-reload-watcher.service")
|
||||||
|
if !ok || recorded.Scope != "user" || recorded.User != "ops" || recorded.Found == nil {
|
||||||
|
t.Fatalf("the record does not say whose manager the unit is in, or what was found: %+v", recorded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASystemUnitIsUntouchedByTheScope(t *testing.T) {
|
||||||
|
var commands []string
|
||||||
|
decl := `{"declaration":1,"resources":[
|
||||||
|
{"id":"x.daemon","type":"service","unit":"sshd.service","state":"running","boot":"enabled"}
|
||||||
|
]}`
|
||||||
|
if _, _, err := Apply(context.Background(), archHost(t), parse(t, decl),
|
||||||
|
store.State{}, store.OriginDeclared, unitIn(true, &commands), nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, c := range commands {
|
||||||
|
if strings.Contains(c, "--user") || strings.Contains(c, "--machine") || strings.Contains(c, "user@") {
|
||||||
|
t.Fatalf("a system unit was addressed to an account's manager: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// With nobody logged in and no lingering, the unit waits: not a failure, nothing recorded, and the
|
||||||
|
// account's manager never asked — asking it would log the account in.
|
||||||
|
func TestAUserUnitWaitsForItsAccountsManagerAndIsAppliedWhenItRuns(t *testing.T) {
|
||||||
|
a := newAccount(t, false)
|
||||||
|
report, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a unit whose account is not logged in failed the apply: %v", err)
|
||||||
|
}
|
||||||
|
o := outcomeFor(report, "i3.watcher")
|
||||||
|
if o.Action != "waiting" || !strings.Contains(o.Detail, "not running") {
|
||||||
|
t.Fatalf("the outcome does not say the unit waits for its manager: %+v", o)
|
||||||
|
}
|
||||||
|
if report.Changed() {
|
||||||
|
t.Error("a unit that waited is reported as a change")
|
||||||
|
}
|
||||||
|
if a.did("systemctl --user") {
|
||||||
|
t.Fatalf("the account's manager was asked while it was not running:\n%s", strings.Join(a.asked, "\n"))
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); ok {
|
||||||
|
t.Fatal("a unit never applied was recorded")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The person logs in.
|
||||||
|
a.up = true
|
||||||
|
report, known, err = applyAccount(t, declaring(watcher), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "i3.watcher"); o.Action == "waiting" || a.units["i3-reload-watcher.service"].active != "active" {
|
||||||
|
t.Fatalf("the unit was not applied once its manager ran: %+v", o)
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); !ok {
|
||||||
|
t.Fatal("the unit was applied and not recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A unit applied before, its account since logged out: the record stays exactly as it was, what
|
||||||
|
// was found included, so a later removal still gives back what was there before the mesh.
|
||||||
|
func TestAWaitingUnitKeepsItsRecord(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
_, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before, _ := known.Find("i3.watcher")
|
||||||
|
a.up = false
|
||||||
|
_, known, err = applyAccount(t, declaring(watcher), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, ok := known.Find("i3.watcher")
|
||||||
|
if !ok || after.Found == nil || *after.Found != *before.Found || after.Scope != "user" {
|
||||||
|
t.Fatalf("waiting changed the record: before %+v, after %+v", before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manager that says it runs and then does not answer — the person logged out mid-apply — is the
|
||||||
|
// same absence, and is said the same way.
|
||||||
|
func TestAManagerThatStopsDuringTheApplyIsWaitedFor(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
a.busDown = true
|
||||||
|
calls := 0
|
||||||
|
run := func(ctx context.Context, name string, args ...string) (string, error) {
|
||||||
|
if name == "systemctl" && len(args) == 2 && args[0] == "is-active" {
|
||||||
|
calls++
|
||||||
|
if calls > 1 {
|
||||||
|
a.up = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a.run(ctx, name, args...)
|
||||||
|
}
|
||||||
|
report, _, err := Apply(context.Background(), archHost(t), parse(t, declaring(watcher)), store.State{},
|
||||||
|
store.OriginDeclared, run, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a manager that went away mid-apply failed it: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" {
|
||||||
|
t.Fatalf("not waiting: %+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUserUnitOfAnAccountTheMachineDoesNotHaveIsRefused(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
a.name = "someone-else"
|
||||||
|
_, _, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `"ops"`) {
|
||||||
|
t.Fatalf("a unit of an account that is not here was not refused naming it: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a manager per account — OpenRC — a user-scoped unit would otherwise be applied as the
|
||||||
|
// machine's service of the same name. Refused instead.
|
||||||
|
func TestAUserUnitIsRefusedWhereTheServiceManagerHasNoAccounts(t *testing.T) {
|
||||||
|
alpine, err := system.For("alpine")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newAccount(t, true)
|
||||||
|
_, _, err = Apply(context.Background(), alpine, parse(t, declaring(watcher)), store.State{},
|
||||||
|
store.OriginDeclared, a.run, nil, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no manager per account") {
|
||||||
|
t.Fatalf("not refused: %v", err)
|
||||||
|
}
|
||||||
|
if a.did("rc-service") {
|
||||||
|
t.Fatalf("a user-scoped unit reached the machine's services: %v", a.asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Removal is never fatal** (the issue 162/228 wedge): with the manager away the record stays and
|
||||||
|
// the outcome says it waits; the first apply that finds the manager gives the unit back.
|
||||||
|
func TestRemovingAUserUnitWithItsManagerAwayWaitsAndIsNeverFatal(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
_, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.up = false
|
||||||
|
report, known, err := applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("undeclaring a unit whose account is logged out failed the apply: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" || !strings.Contains(o.Detail, "not running") {
|
||||||
|
t.Fatalf("the removal does not say it waits: %+v", o)
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); !ok {
|
||||||
|
t.Fatal("a unit not given back was forgotten")
|
||||||
|
}
|
||||||
|
|
||||||
|
a.up = true
|
||||||
|
report, known, err = applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u := a.units["i3-reload-watcher.service"]
|
||||||
|
if u.active != "inactive" || u.enabled != "disabled" {
|
||||||
|
t.Fatalf("the unit was not given back as found: %+v (%+v)", u, outcomeFor(report, "i3.watcher"))
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); ok {
|
||||||
|
t.Fatal("a unit given back is still recorded")
|
||||||
|
}
|
||||||
|
if len(a.strays) != 0 {
|
||||||
|
t.Fatalf("the removal reached the machine's manager: %v", a.strays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Failed to connect to bus" from a manager that said it ran is said and retried, never fatal.
|
||||||
|
func TestRemovingAUserUnitWhoseBusDoesNotAnswerIsNotFatal(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
_, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.busDown = true
|
||||||
|
report, known, err := applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a bus that did not answer made the removal fatal: %v", err)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "i3.watcher"); o.Action != "waiting" || !strings.Contains(o.Detail, "Failed to connect") {
|
||||||
|
t.Fatalf("the removal does not say what stopped it: %+v", o)
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); !ok {
|
||||||
|
t.Fatal("a unit not given back was forgotten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemovingAUserUnitOfAnAccountThatIsGoneForgetsIt(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
_, known, err := applyAccount(t, declaring(watcher), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.name = "renamed"
|
||||||
|
report, known, err := applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "i3.watcher"); o.Action != "forgotten" || !strings.Contains(o.Detail, "no longer on this machine") {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
if _, ok := known.Find("i3.watcher"); ok {
|
||||||
|
t.Fatal("still recorded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A unit file the mesh wrote under the account's own unit directory makes the unit the mesh's — and
|
||||||
|
// only the account's unit of that name, never the machine's.
|
||||||
|
func TestAUserUnitsFileTheMeshWroteMakesThatUnitAndNoOtherTheMeshs(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
was := homeOf
|
||||||
|
homeOf = func(name string) (string, error) {
|
||||||
|
if name == "ops" {
|
||||||
|
return home, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("no such account")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { homeOf = was })
|
||||||
|
|
||||||
|
known := store.State{Resources: []store.Applied{
|
||||||
|
{ID: "f", Type: "file", Target: filepath.Join(home, ".config/systemd/user/watcher.service")},
|
||||||
|
{ID: "g", Type: "file", Target: "/etc/systemd/user/shared.service"},
|
||||||
|
{ID: "h", Type: "file", Target: filepath.Join(home, ".config/systemd/user/kept.service"), Kept: "/x"},
|
||||||
|
{ID: "s1", Type: "service", Target: "watcher.service", Scope: "user", User: "ops"},
|
||||||
|
{ID: "s2", Type: "service", Target: "shared.service", Scope: "user", User: "ops"},
|
||||||
|
{ID: "s3", Type: "service", Target: "kept.service", Scope: "user", User: "ops"},
|
||||||
|
{ID: "s4", Type: "service", Target: "watcher.service"},
|
||||||
|
}}
|
||||||
|
made := meshMadeUnits(known)
|
||||||
|
for key, want := range map[string]bool{
|
||||||
|
unitKey("user", "ops", "watcher.service"): true,
|
||||||
|
unitKey("user", "ops", "shared.service"): true,
|
||||||
|
unitKey("user", "ops", "kept.service"): false,
|
||||||
|
unitKey("", "", "watcher.service"): false,
|
||||||
|
unitKey("system", "", "shared.service"): false,
|
||||||
|
} {
|
||||||
|
if made[key] != want {
|
||||||
|
t.Errorf("%s: made %v, want %v", key, made[key], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if installedByHand(known, filepath.Join(home, ".config/systemd/user/watcher.service")) {
|
||||||
|
t.Error("a user unit file the mesh wrote reads as installed by hand")
|
||||||
|
}
|
||||||
|
if !installedByHand(known, filepath.Join(home, ".config/systemd/user/other.service")) {
|
||||||
|
t.Error("a user unit file somebody else put there reads as not installed by hand")
|
||||||
|
}
|
||||||
|
if installedByHand(known, "/usr/lib/systemd/user/pipewire.service") {
|
||||||
|
t.Error("a packaged user unit reads as installed by hand")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undeclared together, the account's unit whose file the mesh wrote is stopped and disabled in the
|
||||||
|
// account's manager — whatever was found — and a system unit of the same name is not touched.
|
||||||
|
func TestAMeshMadeUserUnitIsStoppedInItsAccountAndTheMachinesNamesakeIsNot(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
home := t.TempDir()
|
||||||
|
was := homeOf
|
||||||
|
homeOf = func(string) (string, error) { return home, nil }
|
||||||
|
t.Cleanup(func() { homeOf = was })
|
||||||
|
unitFile := filepath.Join(home, ".config/systemd/user/i3-reload-watcher.service")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(unitFile), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(unitFile, []byte("[Service]\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.units["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"}
|
||||||
|
a.system["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"}
|
||||||
|
known := store.State{Resources: []store.Applied{
|
||||||
|
{ID: "i3.unit", Type: "file", Target: unitFile, Origin: store.OriginDeclared},
|
||||||
|
{ID: "i3.watcher", Type: "service", Target: "i3-reload-watcher.service", Scope: "user", User: "ops",
|
||||||
|
Origin: store.OriginDeclared, Found: &store.FoundUnit{State: "running", Boot: "enabled"}},
|
||||||
|
}}
|
||||||
|
if _, _, err := applyAccount(t, nothingButA(t), known, a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u := a.units["i3-reload-watcher.service"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||||
|
t.Fatalf("the mesh's own user unit was not stopped and disabled: %+v", u)
|
||||||
|
}
|
||||||
|
if u := a.system["i3-reload-watcher.service"]; u.active != "active" || u.enabled != "enabled" {
|
||||||
|
t.Fatalf("the machine's unit of the same name was touched: %+v %v", u, a.strays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A service moved from the machine's manager into an account's is two units: the machine's is given
|
||||||
|
// back as it was found, through the machine's manager, and the account's is read afresh.
|
||||||
|
func TestAServiceMovedIntoAnAccountGivesTheMachinesUnitBack(t *testing.T) {
|
||||||
|
a := newAccount(t, true)
|
||||||
|
a.system["i3-reload-watcher.service"] = &fakeUnit{active: "active", enabled: "enabled"}
|
||||||
|
known := store.State{Resources: []store.Applied{
|
||||||
|
{ID: "i3.watcher", Type: "service", Target: "i3-reload-watcher.service", Origin: store.OriginDeclared,
|
||||||
|
Found: &store.FoundUnit{Unit: "i3-reload-watcher.service", State: "stopped", Boot: "disabled"}},
|
||||||
|
}}
|
||||||
|
_, known, err := applyAccount(t, declaring(watcher), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u := a.system["i3-reload-watcher.service"]; u.active != "inactive" || u.enabled != "disabled" {
|
||||||
|
t.Fatalf("the machine's unit was not given back as found: %+v", u)
|
||||||
|
}
|
||||||
|
if u := a.units["i3-reload-watcher.service"]; u.active != "active" {
|
||||||
|
t.Fatalf("the account's unit was not started: %+v", u)
|
||||||
|
}
|
||||||
|
r, _ := known.Find("i3.watcher")
|
||||||
|
if r.Found == nil || r.Found.State != "stopped" || r.Scope != "user" {
|
||||||
|
t.Fatalf("what was found is not the account's unit's: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lingering is the account's (novox/hq ADR 0177): declared, set and read back; recorded with what
|
||||||
|
// was found; given back on removal while the account still has what the mesh set.
|
||||||
|
func TestLingeringIsDeclaredOnTheAccountAndGivenBack(t *testing.T) {
|
||||||
|
a := newAccount(t, false)
|
||||||
|
user := `{"id":"ops.login","type":"user","name":"ops","linger":true}`
|
||||||
|
report, known, err := applyAccount(t, declaring(user), store.State{}, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !a.did("loginctl enable-linger ops") || outcomeFor(report, "ops.login").Action != "updated" {
|
||||||
|
t.Fatalf("lingering was not enabled: %v", a.asked)
|
||||||
|
}
|
||||||
|
r, _ := known.Find("ops.login")
|
||||||
|
if r.Linger == nil || r.Linger.Found || !r.Linger.Set {
|
||||||
|
t.Fatalf("the record does not say what was found and set: %+v", r.Linger)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.asked = nil
|
||||||
|
report, known, err = applyAccount(t, declaring(user), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.did("loginctl") || outcomeFor(report, "ops.login").Action != "unchanged" {
|
||||||
|
t.Fatalf("a second apply changed lingering: %v", a.asked)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a user-scoped unit of the account now applies with nobody logged in.
|
||||||
|
if _, known, err = applyAccount(t, declaring(user, watcher), known, a); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.units["i3-reload-watcher.service"].active != "active" {
|
||||||
|
t.Fatal("a lingering account's unit was not started")
|
||||||
|
}
|
||||||
|
|
||||||
|
report, _, err = applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !a.did("loginctl disable-linger ops") {
|
||||||
|
t.Fatalf("lingering was not given back: %v", a.asked)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "ops.login"); o.Action != "restored" || !strings.Contains(o.Detail, "lingering off") {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLingeringTheOperatorChangedSinceIsLeft(t *testing.T) {
|
||||||
|
a := newAccount(t, false)
|
||||||
|
known := store.State{Resources: []store.Applied{{ID: "ops.login", Type: "user", Target: "ops",
|
||||||
|
Origin: store.OriginDeclared, Linger: &store.Lingering{Found: false, Set: true}}}}
|
||||||
|
// Not lingering now: somebody ran disable-linger since the mesh set it.
|
||||||
|
report, _, err := applyAccount(t, nothingButA(t), known, a)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if a.did("loginctl") {
|
||||||
|
t.Fatalf("lingering the operator changed was changed back: %v", a.asked)
|
||||||
|
}
|
||||||
|
if o := outcomeFor(report, "ops.login"); !strings.Contains(o.Detail, "changed since") {
|
||||||
|
t.Fatalf("%+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLingeringIsRefusedWhereTheServiceManagerHasNoAccounts(t *testing.T) {
|
||||||
|
alpine, err := system.For("alpine")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a := newAccount(t, false)
|
||||||
|
_, _, err = Apply(context.Background(), alpine, parse(t, declaring(
|
||||||
|
`{"id":"ops.login","type":"user","name":"ops","linger":true}`)), store.State{},
|
||||||
|
store.OriginDeclared, a.run, nil, nil)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "linger") {
|
||||||
|
t.Fatalf("not refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A file written whole, undeclared (novox/hq ADR 0118, ADR 0102).
|
||||||
|
//
|
||||||
|
// **What the mesh made goes; what it wrote over is given back.** Before the host writes a file over
|
||||||
|
// one it has no record of making, it keeps the original first (ADR 0102: "whatever the host writes
|
||||||
|
// over without a record of it, it keeps first"). Undeclaring gives a thing back the state it was
|
||||||
|
// found in (ADR 0118), so a file with a kept original is not deleted when its record goes: the
|
||||||
|
// original is put back, with the mode and owner it was found with. Deleting it was the failure —
|
||||||
|
// a module that writes the package manager's configuration whole, unassigned, left the machine with
|
||||||
|
// no configuration at all.
|
||||||
|
//
|
||||||
|
// The cases, decided once and in this order:
|
||||||
|
//
|
||||||
|
// - **No kept original** — the mesh made the file where there was none (or the record is from
|
||||||
|
// before the host kept originals, which it cannot tell apart): removed, as before.
|
||||||
|
// - **The file is gone** — somebody removed it: nothing is put back, since bringing back a file a
|
||||||
|
// person deleted is not giving back the state the mesh found; the original stays kept.
|
||||||
|
// - **The file was changed since the mesh last wrote it** — it is somebody's again, as a block or
|
||||||
|
// a JSON file the mesh wrote into stays somebody's: left exactly as it stands, never clobbered,
|
||||||
|
// and the outcome names where the original is so a person can choose.
|
||||||
|
// - **The kept copy cannot be read** — the mesh's file is left in place rather than deleted, and
|
||||||
|
// the outcome says the original is missing.
|
||||||
|
// - Otherwise the original is written back atomically, and the outcome is "restored".
|
||||||
|
//
|
||||||
|
// **Never fatal.** Each case that leaves the file says so and lets the record go; none stops the
|
||||||
|
// rest of an unassignment. The kept copy itself is never deleted (novox/hq ADR 0100).
|
||||||
|
func removeWhole(a store.Applied) (string, string, error) {
|
||||||
|
if a.Kept == "" {
|
||||||
|
if err := os.RemoveAll(a.Target); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(a.Target); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", "", fmt.Errorf("%s is still there after removing it", a.Target)
|
||||||
|
}
|
||||||
|
return "removed", "no longer declared", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := os.ReadFile(a.Target)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "forgotten", "no longer there; the original the mesh wrote over stays kept at " + a.Kept, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "kept", fmt.Sprintf("no longer declared, and it cannot be read (%v), so it was left as it "+
|
||||||
|
"is; the original the mesh wrote over is kept at %s", err, a.Kept), nil
|
||||||
|
}
|
||||||
|
if a.Wrote == "" || digestOf(string(current)) != a.Wrote {
|
||||||
|
return "kept", "no longer declared, and changed on the machine since the mesh last wrote it, so " +
|
||||||
|
"it was left as it is; the original the mesh wrote over is kept at " + a.Kept, nil
|
||||||
|
}
|
||||||
|
original, err := os.ReadFile(a.Kept)
|
||||||
|
if err != nil {
|
||||||
|
return "kept", fmt.Sprintf("no longer declared, but the original it was written over cannot be "+
|
||||||
|
"read at %s (%v), so the mesh's file was left in place", a.Kept, err), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(a.Target)
|
||||||
|
if err != nil {
|
||||||
|
return "kept", fmt.Sprintf("no longer declared, and it cannot be seen (%v), so it was left as it "+
|
||||||
|
"is; the original the mesh wrote over is kept at %s", err, a.Kept), nil
|
||||||
|
}
|
||||||
|
mode := info.Mode().Perm()
|
||||||
|
if a.KeptMode != "" {
|
||||||
|
if m, err := strconv.ParseUint(a.KeptMode, 8, 32); err == nil {
|
||||||
|
mode = os.FileMode(m).Perm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeAtomically(a.Target, original, mode); err != nil {
|
||||||
|
return "kept", fmt.Sprintf("no longer declared, and the original kept at %s could not be put "+
|
||||||
|
"back (%v), so the mesh's file was left in place", a.Kept, err), nil
|
||||||
|
}
|
||||||
|
detail := "no longer declared; the original the mesh wrote over was put back from " + a.Kept
|
||||||
|
if err := giveOwnerBack(a.Target, a.KeptOwner, info); err != nil {
|
||||||
|
detail += "; " + err.Error()
|
||||||
|
}
|
||||||
|
if back, err := os.ReadFile(a.Target); err != nil || string(back) != string(original) {
|
||||||
|
return "kept", "no longer declared; putting back the original kept at " + a.Kept +
|
||||||
|
" did not leave it there — check the file by hand", nil
|
||||||
|
}
|
||||||
|
return "restored", detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// giveOwnerBack gives a file put back the owner its original was found with — "uid:gid" as a hold
|
||||||
|
// records it — or, on a record from before the host kept that, the owner of what it replaced.
|
||||||
|
func giveOwnerBack(path, owner string, was os.FileInfo) error {
|
||||||
|
if owner == "" {
|
||||||
|
return keepOwner(path, was)
|
||||||
|
}
|
||||||
|
uid, gid, err := idsOf(owner)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("its owner %q could not be read: %w", owner, err)
|
||||||
|
}
|
||||||
|
now, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if u, g, ok := ownerOf(now); ok && u == uid && g == gid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Chown(path, uid, gid); err != nil {
|
||||||
|
return fmt.Errorf("its owner %s could not be given back: %w", owner, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defends novox/hq ADR 0118 with ADR 0102: a file the host wrote whole over one it found is given
|
||||||
|
// its kept original back when it is undeclared — not deleted, which left a machine whose package
|
||||||
|
// manager's configuration a module wrote with no configuration at all once that module was
|
||||||
|
// unassigned.
|
||||||
|
|
||||||
|
const pacmanFound = "[options]\nArchitecture = auto\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n"
|
||||||
|
const pacmanMesh = "# written by the mesh\n[options]\nArchitecture = auto\nParallelDownloads = 5\n"
|
||||||
|
|
||||||
|
// writtenOver is a file found at path with content and mode, then written whole by the mesh.
|
||||||
|
func writtenOver(t *testing.T, content string, mode os.FileMode) (path string, state store.State) {
|
||||||
|
t.Helper()
|
||||||
|
path = filepath.Join(t.TempDir(), "pacman.conf")
|
||||||
|
if err := os.WriteFile(path, []byte(content), mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, mode); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, state = applyKeepingIn(t, wholeDecl(path, pacmanMesh), store.State{}, t.TempDir())
|
||||||
|
if got := readText(t, path); got != pacmanMesh {
|
||||||
|
t.Fatalf("the mesh's file was not written: %q", got)
|
||||||
|
}
|
||||||
|
return path, state
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileWrittenOverGetsItsKeptOriginalBackWhenUndeclared(t *testing.T) {
|
||||||
|
path, state := writtenOver(t, pacmanFound, 0o640)
|
||||||
|
rec, _ := state.Find(namesID)
|
||||||
|
if rec.Kept == "" || rec.KeptMode != "0640" {
|
||||||
|
t.Fatalf("the original and how it was found were not recorded: kept %q, mode %q", rec.Kept, rec.KeptMode)
|
||||||
|
}
|
||||||
|
if steps := Plan(somethingElse(t), state, store.OriginDeclared); !strings.Contains(verbs(steps), "restore "+namesID) {
|
||||||
|
t.Errorf("the plan did not say the original goes back: %s", verbs(steps))
|
||||||
|
}
|
||||||
|
report, after := undeclare(t, state)
|
||||||
|
if got := readText(t, path); got != pacmanFound {
|
||||||
|
t.Fatalf("undeclared, the machine did not get its original back: %q", got)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.Mode().Perm() != 0o640 {
|
||||||
|
t.Errorf("the original came back with mode %o, it was found 640", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
o := outcomeOf(report, namesID)
|
||||||
|
if o.Action != "restored" || !strings.Contains(o.Detail, rec.Kept) {
|
||||||
|
t.Errorf("the give-back was reported as %q: %s", o.Action, o.Detail)
|
||||||
|
}
|
||||||
|
if _, still := after.Find(namesID); still {
|
||||||
|
t.Error("the record outlived its declaration")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(rec.Kept); err != nil {
|
||||||
|
t.Errorf("the kept copy went with the give-back: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileTheMeshMadeIsRemovedWhenUndeclared(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "pacman.conf")
|
||||||
|
_, state := applyKeepingIn(t, wholeDecl(path, pacmanMesh), store.State{}, t.TempDir())
|
||||||
|
if rec, _ := state.Find(namesID); rec.Kept != "" {
|
||||||
|
t.Fatalf("a file that was not there recorded an original at %s", rec.Kept)
|
||||||
|
}
|
||||||
|
report, _ := undeclare(t, state)
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("a file the mesh made outlived its declaration: %v", err)
|
||||||
|
}
|
||||||
|
if got := outcomeOf(report, namesID).Action; got != "removed" {
|
||||||
|
t.Errorf("removal was reported as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileWrittenOverAndChangedSinceIsLeftAsItStands(t *testing.T) {
|
||||||
|
path, state := writtenOver(t, pacmanFound, 0o644)
|
||||||
|
edited := pacmanMesh + "IgnorePkg = linux\n"
|
||||||
|
if err := os.WriteFile(path, []byte(edited), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, _ := undeclare(t, state)
|
||||||
|
if got := readText(t, path); got != edited {
|
||||||
|
t.Fatalf("the operator's change was clobbered: %q", got)
|
||||||
|
}
|
||||||
|
rec, _ := state.Find(namesID)
|
||||||
|
o := outcomeOf(report, namesID)
|
||||||
|
if o.Action != "kept" || !strings.Contains(o.Detail, "changed on the machine") || !strings.Contains(o.Detail, rec.Kept) {
|
||||||
|
t.Errorf("leaving it was reported as %q: %s", o.Action, o.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileWhoseKeptOriginalIsMissingIsLeftAndSaysSo(t *testing.T) {
|
||||||
|
path, state := writtenOver(t, pacmanFound, 0o644)
|
||||||
|
rec, _ := state.Find(namesID)
|
||||||
|
if err := os.Remove(rec.Kept); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, _ := undeclare(t, state)
|
||||||
|
if got := readText(t, path); got != pacmanMesh {
|
||||||
|
t.Fatalf("with no original to put back, the file became %q", got)
|
||||||
|
}
|
||||||
|
o := outcomeOf(report, namesID)
|
||||||
|
if o.Action != "kept" || !strings.Contains(o.Detail, "cannot be read at "+rec.Kept) {
|
||||||
|
t.Errorf("leaving it was reported as %q: %s", o.Action, o.Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileWrittenOverAndDeletedSinceIsNotBroughtBack(t *testing.T) {
|
||||||
|
path, state := writtenOver(t, pacmanFound, 0o644)
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, _ := undeclare(t, state)
|
||||||
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("a file somebody deleted was brought back: %v", err)
|
||||||
|
}
|
||||||
|
if got := outcomeOf(report, namesID).Action; got != "forgotten" {
|
||||||
|
t.Errorf("reported as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFileWhosePathMovedIsNeverGivenTheOldPathsOriginal(t *testing.T) {
|
||||||
|
// The old path's original stays with the old path's record; the new path keeps its own.
|
||||||
|
oldPath, state := writtenOver(t, pacmanFound, 0o644)
|
||||||
|
newPath := filepath.Join(filepath.Dir(oldPath), "pacman.d.conf")
|
||||||
|
newFound := "# the machine's own at the new path\n"
|
||||||
|
if err := os.WriteFile(newPath, []byte(newFound), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, state = applyKeepingIn(t, wholeDecl(newPath, pacmanMesh), state, t.TempDir())
|
||||||
|
rec, _ := state.Find(namesID)
|
||||||
|
if rec.Kept == "" {
|
||||||
|
t.Fatal("the original at the new path was written over without being kept")
|
||||||
|
}
|
||||||
|
if kept := readText(t, rec.Kept); kept != newFound {
|
||||||
|
t.Fatalf("the new path's record names the wrong original: %q", kept)
|
||||||
|
}
|
||||||
|
// The old path is a former target, given back by the next apply; the new one by undeclaring.
|
||||||
|
undeclare(t, state)
|
||||||
|
if got := readText(t, newPath); got != newFound {
|
||||||
|
t.Errorf("undeclared, the new path holds %q", got)
|
||||||
|
}
|
||||||
|
if got := readText(t, oldPath); got != pacmanFound {
|
||||||
|
t.Errorf("the old path did not get its own original back: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARecordFromBeforeTheModeWasKeptPutsTheOriginalBackAsTheFileStands(t *testing.T) {
|
||||||
|
path, state := writtenOver(t, pacmanFound, 0o644)
|
||||||
|
for i := range state.Resources {
|
||||||
|
state.Resources[i].KeptMode, state.Resources[i].KeptOwner = "", ""
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
report, _ := undeclare(t, state)
|
||||||
|
if got := readText(t, path); got != pacmanFound {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
info, _ := os.Stat(path)
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Errorf("mode %o, the file stood at 600", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
if got := outcomeOf(report, namesID).Action; got != "restored" {
|
||||||
|
t.Errorf("reported as %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -374,6 +374,15 @@ type User struct {
|
|||||||
// Home directory. Absent means the system's default for a new user, and is not changed for
|
// Home directory. Absent means the system's default for a new user, and is not changed for
|
||||||
// one that exists — moving somebody's home is not something a declaration should do quietly.
|
// one that exists — moving somebody's home is not something a declaration should do quietly.
|
||||||
Home string `json:"home,omitempty"`
|
Home string `json:"home,omitempty"`
|
||||||
|
|
||||||
|
// Linger is whether the account's own service manager runs with nobody logged in (novox/hq ADR
|
||||||
|
// 0177). A user-scoped unit lives in that manager, and the manager runs only from the account's
|
||||||
|
// first login to its last logout — so a server's user unit, where nobody ever logs in, never
|
||||||
|
// runs without it, and a workstation's runs only while its person is there, which on a desktop
|
||||||
|
// is what is wanted. Absent asserts nothing, as Shell's does: true has the account linger, false
|
||||||
|
// has it not. On the account rather than on the unit, because it is the account's: two units of
|
||||||
|
// one account cannot disagree about it, and undeclaring one of them must not stop the other.
|
||||||
|
Linger *bool `json:"linger,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Network is a named network on this machine.
|
// Network is a named network on this machine.
|
||||||
@@ -710,6 +719,16 @@ type Service struct {
|
|||||||
// declaration that reports success and stops being true at the next power cut.
|
// declaration that reports success and stops being true at the next power cut.
|
||||||
Boot string `json:"boot,omitempty"`
|
Boot string `json:"boot,omitempty"`
|
||||||
|
|
||||||
|
// Scope is whose service manager the unit belongs to: "system" (absent means system), or
|
||||||
|
// "user" — the operator account's own manager (novox/hq ADR 0177). A workstation's per-user
|
||||||
|
// daemons — a window manager's reload watcher, an audio mask, a memory guard — are units in
|
||||||
|
// that manager, and until this they had no form the mesh could send. A user-scoped unit names
|
||||||
|
// its User; the host talks to that account's manager and never starts a system unit by the
|
||||||
|
// same name.
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
// User is the account whose manager a user-scoped unit lives in. Required with scope "user",
|
||||||
|
// refused otherwise; a module names it ${machine:account} and never the person.
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
// RestartOn names resources whose change means this service must be restarted.
|
// RestartOn names resources whose change means this service must be restarted.
|
||||||
//
|
//
|
||||||
// Because a running service does not re-read its configuration. Replace the file, find the
|
// Because a running service does not re-read its configuration. Replace the file, find the
|
||||||
@@ -755,11 +774,33 @@ func (s *Service) Identity() string { return s.ID }
|
|||||||
func (s *Service) Kind() Type { return TypeService }
|
func (s *Service) Kind() Type { return TypeService }
|
||||||
func (s *Service) Target() string { return s.Unit }
|
func (s *Service) Target() string { return s.Unit }
|
||||||
|
|
||||||
|
// ScopeSystem and ScopeUser are the two managers a unit may belong to (novox/hq ADR 0177).
|
||||||
|
const (
|
||||||
|
ScopeSystem = "system"
|
||||||
|
ScopeUser = "user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserScoped is whether this unit lives in an account's own service manager.
|
||||||
|
func (s *Service) UserScoped() bool { return s.Scope == ScopeUser }
|
||||||
|
|
||||||
func (s *Service) validate(where string, _ bool) []string {
|
func (s *Service) validate(where string, _ bool) []string {
|
||||||
var problems []string
|
var problems []string
|
||||||
if s.Unit == "" {
|
if s.Unit == "" {
|
||||||
problems = append(problems, where+": a service needs a unit")
|
problems = append(problems, where+": a service needs a unit")
|
||||||
}
|
}
|
||||||
|
switch s.Scope {
|
||||||
|
case "", ScopeSystem:
|
||||||
|
if s.User != "" {
|
||||||
|
problems = append(problems, where+": a system unit names no user; only a user-scoped unit does")
|
||||||
|
}
|
||||||
|
case ScopeUser:
|
||||||
|
if s.User == "" {
|
||||||
|
problems = append(problems, where+": a user-scoped unit names the account whose manager it lives in")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
problems = append(problems, fmt.Sprintf(
|
||||||
|
"%s: scope %q; a unit is in the \"system\" manager or the operator account's \"user\" one", where, s.Scope))
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case s.State == "running" || s.State == "stopped":
|
case s.State == "running" || s.State == "stopped":
|
||||||
case s.State != "":
|
case s.State != "":
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package declaration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0177: a unit is in the system manager or an account's own; a user-scoped one names
|
||||||
|
// the account, a system one may not, and any other word is refused.
|
||||||
|
func TestAUserScopedUnitNamesItsAccountAndASystemOneMayNot(t *testing.T) {
|
||||||
|
cases := []struct{ scope, user, wants string }{
|
||||||
|
{"user", "ops", ""},
|
||||||
|
{"", "", ""},
|
||||||
|
{"system", "", ""},
|
||||||
|
{"user", "", "names the account"},
|
||||||
|
{"", "ops", "names no user"},
|
||||||
|
{"session", "ops", "scope \"session\""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
s := &Service{ID: "m.u", Type: TypeService, Unit: "u.service", State: "running", Scope: c.scope, User: c.user}
|
||||||
|
problems := s.validate("m.u", false)
|
||||||
|
got := strings.Join(problems, "; ")
|
||||||
|
if c.wants == "" && len(problems) != 0 {
|
||||||
|
t.Fatalf("scope %q user %q refused: %s", c.scope, c.user, got)
|
||||||
|
}
|
||||||
|
if c.wants != "" && !strings.Contains(got, c.wants) {
|
||||||
|
t.Fatalf("scope %q user %q: wanted a refusal saying %q, got %q", c.scope, c.user, c.wants, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// novox/hq ADR 0177: lingering is a field of the account, absent meaning nothing asserted.
|
||||||
|
func TestAnAccountMayBeDeclaredToLinger(t *testing.T) {
|
||||||
|
d, err := Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops","linger":true}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, ok := d.Resources[0].(*User)
|
||||||
|
if !ok || u.Linger == nil || !*u.Linger {
|
||||||
|
t.Fatalf("linger not read: %+v", d.Resources[0])
|
||||||
|
}
|
||||||
|
d, err = Parse([]byte(`{"declaration":1,"resources":[{"id":"m.login","type":"user","name":"ops"}]}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u := d.Resources[0].(*User); u.Linger != nil {
|
||||||
|
t.Fatal("an account that said nothing about lingering asserts it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,11 +77,21 @@ type Applied struct {
|
|||||||
// (novox/hq issue 128) is given back its original with the mesh's region in it — and a path
|
// (novox/hq issue 128) is given back its original with the mesh's region in it — and a path
|
||||||
// said once in a log line is not a path the host can find again.
|
// said once in a log line is not a path the host can find again.
|
||||||
Kept string `json:"kept,omitempty"`
|
Kept string `json:"kept,omitempty"`
|
||||||
|
// KeptMode and KeptOwner are the original's mode ("0644") and numeric owner ("0:0") as found,
|
||||||
|
// so undeclaring the file puts the original back as the machine had it (novox/hq ADR 0118).
|
||||||
|
// Absent on a record from before the host kept them; the file's mode and owner as it stands
|
||||||
|
// are used then.
|
||||||
|
KeptMode string `json:"kept_mode,omitempty"`
|
||||||
|
KeptOwner string `json:"kept_owner,omitempty"`
|
||||||
|
|
||||||
// Stateless is, for a service, that its unit's lifecycle was never the mesh's (novox/hq ADR
|
// Stateless is, for a service, that its unit's lifecycle was never the mesh's (novox/hq ADR
|
||||||
// 0117) — kept here because removal happens once the declaration that said so is gone, and a
|
// 0117) — kept here because removal happens once the declaration that said so is gone, and a
|
||||||
// service removed as if it had a state is stopped: the machine's network manager, for one.
|
// service removed as if it had a state is stopped: the machine's network manager, for one.
|
||||||
Stateless bool `json:"stateless,omitempty"`
|
Stateless bool `json:"stateless,omitempty"`
|
||||||
|
// Scope and User are, for a service in an account's own manager (novox/hq ADR 0177), which
|
||||||
|
// manager — so removal gives the unit back through the same one it was applied through.
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
User string `json:"user,omitempty"`
|
||||||
|
|
||||||
// Found is, for a service, the state its unit was in when this host first applied it — before
|
// Found is, for a service, the state its unit was in when this host first applied it — before
|
||||||
// the mesh started, stopped, enabled or disabled anything. Removal gives that back and nothing
|
// the mesh started, stopped, enabled or disabled anything. Removal gives that back and nothing
|
||||||
@@ -95,6 +105,25 @@ type Applied struct {
|
|||||||
// file itself was — so undeclaring it gives the machine back exactly what it had.
|
// file itself was — so undeclaring it gives the machine back exactly what it had.
|
||||||
Into *Into `json:"into,omitempty"`
|
Into *Into `json:"into,omitempty"`
|
||||||
|
|
||||||
|
// Shell is, for a user, the login shell the account had before the mesh first set one, and
|
||||||
|
// the shell the mesh set last (novox/hq ADR 0176 §2, issue 228). Removal gives the found shell
|
||||||
|
// back, and only while the account still has the one the mesh set: a shell a person chose since
|
||||||
|
// is theirs. Absent when the mesh never changed the shell, and on a record written before the
|
||||||
|
// host kept it — then the shell is left exactly as it is.
|
||||||
|
Shell *LoginShell `json:"shell,omitempty"`
|
||||||
|
|
||||||
|
// Linger is, for a user, whether the account lingered before the mesh first changed it, and
|
||||||
|
// what the mesh set (novox/hq ADR 0177). Removal gives the found one back while the account
|
||||||
|
// still has the mesh's; absent when the mesh never changed it.
|
||||||
|
Linger *Lingering `json:"linger,omitempty"`
|
||||||
|
|
||||||
|
// Unpacked is, for an archive, what it put on the machine (novox/hq issue 162): the files and
|
||||||
|
// directories it unpacked, and whether the directory it was unpacked into and the parents above
|
||||||
|
// it were made by the host. Removal takes away exactly that and nothing else. Absent on a
|
||||||
|
// record written before the host kept it — then removal cannot tell the archive's files from
|
||||||
|
// anything else in the directory, and leaves it in place.
|
||||||
|
Unpacked *Unpacked `json:"unpacked,omitempty"`
|
||||||
|
|
||||||
// Reads is, for a container, the digest of each file it was created reading — its env-files
|
// Reads is, for a container, the digest of each file it was created reading — its env-files
|
||||||
// and the files mounted into it — by path (novox/hq 04-ISSUES/103).
|
// and the files mounted into it — by path (novox/hq 04-ISSUES/103).
|
||||||
//
|
//
|
||||||
@@ -597,6 +626,46 @@ type FoundUnit struct {
|
|||||||
Boot string `json:"boot,omitempty"`
|
Boot string `json:"boot,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginShell is what the host knows about an account's login shell, to give it back.
|
||||||
|
type LoginShell struct {
|
||||||
|
// Found is the shell the account had when the mesh first changed it. Never overwritten by a
|
||||||
|
// later change: what is given back is what was there before the mesh, not the mesh's own
|
||||||
|
// previous choice. Empty for an account the mesh created, which had no shell before it.
|
||||||
|
Found string `json:"found,omitempty"`
|
||||||
|
// Set is the shell the mesh set last — what removal compares the account against, since the
|
||||||
|
// declaration that said so is gone by then.
|
||||||
|
Set string `json:"set"`
|
||||||
|
// Created is an account the mesh made. Kept only so removal can say why there is nothing to
|
||||||
|
// give back; the account itself is never deleted.
|
||||||
|
Created bool `json:"created,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lingering is what the host knows about whether an account's manager runs with nobody logged in,
|
||||||
|
// to give it back (novox/hq ADR 0177).
|
||||||
|
type Lingering struct {
|
||||||
|
// Found is whether it lingered when the mesh first changed it. Never overwritten by a later
|
||||||
|
// change, as LoginShell.Found is not.
|
||||||
|
Found bool `json:"found"`
|
||||||
|
// Set is what the mesh set last.
|
||||||
|
Set bool `json:"set"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpacked is what an archive put under its directory, so undeclaring it takes away exactly that
|
||||||
|
// (novox/hq issue 162).
|
||||||
|
type Unpacked struct {
|
||||||
|
// Files are the files the archive placed, relative to its directory, slash-separated.
|
||||||
|
Files []string `json:"files"`
|
||||||
|
// Dirs are the directories inside it the host made for the archive — never one that was
|
||||||
|
// there before, so removal never takes a directory somebody else made, even an empty one.
|
||||||
|
Dirs []string `json:"dirs,omitempty"`
|
||||||
|
// Made is that the host made the directory itself: it was not there before the archive. One
|
||||||
|
// that was there before is never removed, empty or not.
|
||||||
|
Made bool `json:"made,omitempty"`
|
||||||
|
// Parents are the directories above it the host made to reach it, deepest first; each is
|
||||||
|
// removed on the way out only once it is empty.
|
||||||
|
Parents []string `json:"parents,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// PendingFound is a unit as found by an apply of its service that has not yet been recorded, and
|
// PendingFound is a unit as found by an apply of its service that has not yet been recorded, and
|
||||||
// who asked for that apply — so only a declaration from the same origin can say it is gone.
|
// who asked for that apply — so only a declaration from the same origin can say it is gone.
|
||||||
type PendingFound struct {
|
type PendingFound struct {
|
||||||
|
|||||||
+86
-1
@@ -2,6 +2,7 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -186,7 +187,7 @@ func describeAge(when, now time.Time) string {
|
|||||||
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
||||||
// would have had to drop.
|
// would have had to drop.
|
||||||
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
||||||
out, _ := run(ctx, "systemctl", "show", unit,
|
out, asked := run(ctx, "systemctl", "show", unit,
|
||||||
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
||||||
"--property=RemainAfterExit", "--property=ExecMainStatus")
|
"--property=RemainAfterExit", "--property=ExecMainStatus")
|
||||||
|
|
||||||
@@ -212,6 +213,11 @@ func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string,
|
|||||||
|
|
||||||
switch load {
|
switch load {
|
||||||
case "":
|
case "":
|
||||||
|
// With why, where it said why: an account's manager that could not be reached says so
|
||||||
|
// here, and nowhere else (novox/hq ADR 0177).
|
||||||
|
if asked != nil {
|
||||||
|
return "", fmt.Errorf("the service manager said nothing about %s: %w", unit, asked)
|
||||||
|
}
|
||||||
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
||||||
case "not-found":
|
case "not-found":
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
@@ -364,3 +370,82 @@ func (arch) ReloadService(ctx context.Context, run Runner, unit string) error {
|
|||||||
_, err := run(ctx, "systemctl", "reload", unit)
|
_, err := run(ctx, "systemctl", "reload", unit)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An account's own service manager (novox/hq ADR 0177).
|
||||||
|
//
|
||||||
|
// systemd runs one manager per account beside the machine's, as user@<uid>.service, from the
|
||||||
|
// account's first login until its last logout — or for as long as the machine runs, when the
|
||||||
|
// account lingers. A user-scoped unit lives in that manager, so it can be acted on only while the
|
||||||
|
// manager runs, and lingering is what makes it run with nobody logged in.
|
||||||
|
|
||||||
|
// lingerDir is where systemd-logind keeps which accounts linger: one empty file per account. A
|
||||||
|
// variable so a test can give it a directory of its own; LingerIn is how.
|
||||||
|
var lingerDir = "/var/lib/systemd/linger"
|
||||||
|
|
||||||
|
// LingerIn points where the machine keeps lingering at a directory a test owns, until the returned
|
||||||
|
// function puts it back. Nothing outside a test calls it.
|
||||||
|
func LingerIn(dir string) (restore func()) {
|
||||||
|
was := lingerDir
|
||||||
|
lingerDir = dir
|
||||||
|
return func() { lingerDir = was }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lingering is whether an account's manager is kept running with nobody logged in. Read from
|
||||||
|
// logind's own record rather than from `loginctl show-user`, which answers only for an account
|
||||||
|
// that is logged in or already lingers — absence there is an error, and absence here is the answer.
|
||||||
|
func (arch) Lingering(_ context.Context, _ Runner, name string) (bool, error) {
|
||||||
|
_, err := os.Stat(filepath.Join(lingerDir, name))
|
||||||
|
if err == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("whether %q lingers could not be read: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLingering has logind keep an account's manager running with nobody logged in, or stop doing
|
||||||
|
// so. Disabling it stops the manager of an account that is not logged in, and every unit in it.
|
||||||
|
func (arch) SetLingering(ctx context.Context, run Runner, name string, on bool) error {
|
||||||
|
verb := "enable-linger"
|
||||||
|
if !on {
|
||||||
|
verb = "disable-linger"
|
||||||
|
}
|
||||||
|
if _, err := run(ctx, "loginctl", verb, name); err != nil {
|
||||||
|
return fmt.Errorf("cannot %s for %q: %w", verb, name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserManagerRunning is whether an account's own manager runs now, asked of the machine's manager
|
||||||
|
// — never of the account's.
|
||||||
|
//
|
||||||
|
// **Asking the account's manager would start it.** `systemctl --user --machine=<account>@` reaches
|
||||||
|
// it through `systemd-run --machine=<account>@.host -p PAMName=login systemd-stdio-bridge`: a login,
|
||||||
|
// which starts the account's manager if none runs, for as long as that one command takes. The host
|
||||||
|
// would then act on a manager that ends a moment later and take the account's whole session with it
|
||||||
|
// — a unit started into it stops again, and the next apply starts it again. So whether there is a
|
||||||
|
// manager is read from user@<uid>.service in the machine's own, which a query does not start.
|
||||||
|
//
|
||||||
|
// exists is false for an account the machine does not have.
|
||||||
|
func (arch) UserManagerRunning(ctx context.Context, run Runner, account string) (running, exists bool, err error) {
|
||||||
|
login, exists, err := LookUpUser(ctx, run, account)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return false, exists, err
|
||||||
|
}
|
||||||
|
if login.UID == "" {
|
||||||
|
return false, true, fmt.Errorf("the user database gave %q no number", account)
|
||||||
|
}
|
||||||
|
// is-active exits non-zero for every answer but "active", so the words are the answer and the
|
||||||
|
// exit is not; no words at all is a manager that did not answer.
|
||||||
|
out, err := run(ctx, "systemctl", "is-active", "user@"+login.UID+".service")
|
||||||
|
state := strings.TrimSpace(out)
|
||||||
|
if state == "" {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New("no answer")
|
||||||
|
}
|
||||||
|
return false, true, fmt.Errorf("the service manager did not say whether %q's own manager runs: %w",
|
||||||
|
account, err)
|
||||||
|
}
|
||||||
|
return state == "active", true, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -82,6 +84,9 @@ type System interface {
|
|||||||
type Login struct {
|
type Login struct {
|
||||||
Home string
|
Home string
|
||||||
Shell string
|
Shell string
|
||||||
|
// UID is the account's number, as the user database gives it: what names the account's own
|
||||||
|
// service manager to the machine's (user@<uid>.service, novox/hq ADR 0177).
|
||||||
|
UID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookUpUser reads a login from the user database.
|
// LookUpUser reads a login from the user database.
|
||||||
@@ -113,7 +118,7 @@ func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, erro
|
|||||||
return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
|
return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
|
||||||
strings.TrimSpace(out), name)
|
strings.TrimSpace(out), name)
|
||||||
}
|
}
|
||||||
return Login{Home: fields[5], Shell: fields[6]}, true, nil
|
return Login{Home: fields[5], Shell: fields[6], UID: fields[2]}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GroupsOf is every group a login is in.
|
// GroupsOf is every group a login is in.
|
||||||
@@ -125,6 +130,60 @@ func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) {
|
|||||||
return strings.Fields(out), nil
|
return strings.Fields(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shells is where the machine lists the shells a login may have (shells(5)). A variable so a test
|
||||||
|
// can point it at a list of its own; ShellsIn is how.
|
||||||
|
var shells = "/etc/shells"
|
||||||
|
|
||||||
|
// ShellsIn points where the machine's shells are listed at a file a test owns, until the returned
|
||||||
|
// function puts it back. Nothing outside a test calls it.
|
||||||
|
func ShellsIn(list string) (restore func()) {
|
||||||
|
was := shells
|
||||||
|
shells = list
|
||||||
|
return func() { shells = was }
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsableShell says why a path cannot be an account's login shell, or nil when it can.
|
||||||
|
//
|
||||||
|
// **Asked before a shell is set, because nothing after it would say.** `usermod --shell` only
|
||||||
|
// warns about a shell that is missing or not executable, and succeeds; the host's read-back
|
||||||
|
// compares the user database's string, which then matches. So an account could be pointed at a
|
||||||
|
// shell that is not there, and console, ssh and display-manager logins all fail — after a
|
||||||
|
// failed package install, say, which does not stop the resources after it (novox/hq issue 228).
|
||||||
|
//
|
||||||
|
// Listed among the machine's shells as well as executable, because that list is what login
|
||||||
|
// services check: an unlisted shell is one ssh and the display manager may refuse.
|
||||||
|
//
|
||||||
|
// **Except a shell that refuses a login.** nologin and false are how a service's account says it
|
||||||
|
// is not a login at all, and no distribution lists them — the controller's own account has one.
|
||||||
|
// Requiring them listed would refuse every service account; they are still required to exist.
|
||||||
|
func UsableShell(path string) error {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("the shell %q is not an absolute path", path)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("the shell %s is not on this machine: %w", path, err)
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() || info.Mode().Perm()&0o111 == 0 {
|
||||||
|
return fmt.Errorf("the shell %s is not an executable file", path)
|
||||||
|
}
|
||||||
|
if base := filepath.Base(path); base == "nologin" || base == "false" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(shells)
|
||||||
|
if err != nil {
|
||||||
|
// Unreadable is not "not listed": the two are told apart, as the user database's are.
|
||||||
|
return fmt.Errorf("the machine's shells (%s) could not be read, so %s cannot be checked: %w",
|
||||||
|
shells, path, err)
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
if line = strings.TrimSpace(line); line == path {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("the shell %s is not listed in %s, so logins may refuse it", path, shells)
|
||||||
|
}
|
||||||
|
|
||||||
// Supports reports whether this host can apply a shape.
|
// Supports reports whether this host can apply a shape.
|
||||||
func Supports(s System, t declaration.Type) bool {
|
func Supports(s System, t declaration.Type) bool {
|
||||||
for _, shape := range s.Shapes() {
|
for _, shape := range s.Shapes() {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// novox/hq ADR 0177: whether an account's own manager runs is asked of the machine's manager, by
|
||||||
|
// the account's number — never of the account's, which the question would start.
|
||||||
|
func TestAnAccountsManagerIsAskedOfTheMachinesManager(t *testing.T) {
|
||||||
|
var asked []string
|
||||||
|
up := false
|
||||||
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
line := name + " " + strings.Join(args, " ")
|
||||||
|
asked = append(asked, line)
|
||||||
|
switch {
|
||||||
|
case line == "getent passwd ops":
|
||||||
|
return "ops:x:1001:1001::/home/ops:/bin/bash\n", nil
|
||||||
|
case name == "getent":
|
||||||
|
return "", errors.New("getent exited 2: ")
|
||||||
|
case line == "systemctl is-active user@1001.service":
|
||||||
|
if up {
|
||||||
|
return "active\n", nil
|
||||||
|
}
|
||||||
|
return "inactive\n", errors.New("systemctl exited 3: ")
|
||||||
|
}
|
||||||
|
t.Fatalf("asked %q", line)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
a := arch{}
|
||||||
|
for _, want := range []bool{false, true} {
|
||||||
|
up = want
|
||||||
|
running, exists, err := a.UserManagerRunning(context.Background(), run, "ops")
|
||||||
|
if err != nil || !exists || running != want {
|
||||||
|
t.Fatalf("up %v: running %v exists %v err %v", want, running, exists, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, exists, err := a.UserManagerRunning(context.Background(), run, "nobody-here"); err != nil || exists {
|
||||||
|
t.Fatalf("an account the machine does not have: exists %v err %v", exists, err)
|
||||||
|
}
|
||||||
|
for _, c := range asked {
|
||||||
|
if strings.Contains(c, "--user") || strings.Contains(c, "--machine") {
|
||||||
|
t.Fatalf("the account's own manager was asked: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLingeringIsReadFromLogindsRecordAndSetThroughLoginctl(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
defer LingerIn(dir)()
|
||||||
|
a := arch{}
|
||||||
|
if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || on {
|
||||||
|
t.Fatalf("no record read as lingering: %v %v", on, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "ops"), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if on, err := a.Lingering(context.Background(), nil, "ops"); err != nil || !on {
|
||||||
|
t.Fatalf("logind's record not read as lingering: %v %v", on, err)
|
||||||
|
}
|
||||||
|
var asked []string
|
||||||
|
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
asked = append(asked, name+" "+strings.Join(args, " "))
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
_ = a.SetLingering(context.Background(), run, "ops", true)
|
||||||
|
_ = a.SetLingering(context.Background(), run, "ops", false)
|
||||||
|
if strings.Join(asked, "; ") != "loginctl enable-linger ops; loginctl disable-linger ops" {
|
||||||
|
t.Fatalf("%v", asked)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user