An account's manager runs only while it is logged in or lingers. A user-scoped unit whose manager is not running is now "waiting" rather than failed, its record kept as it was; its removal is never fatal (kept recorded, retried) and an account that is gone is forgotten. Whether the manager runs is asked of user@<uid>.service in the machine's manager: asking the account's own, through --machine, logs it in. The user shape gains `linger`, set with loginctl, read back from logind's record, and given back on removal like the shell. Unit files the mesh writes under ~/.config/systemd/user or /etc/systemd/user make that unit the mesh's, and made, holds and found units are keyed by manager and name, so an account's unit and the machine's of one name are two units. A service moved between managers gives the old one back through the manager it was in. OpenRC refuses both.
559 lines
20 KiB
Go
559 lines
20 KiB
Go
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)
|
|
}
|
|
}
|