Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95c2b6ea9 | ||
|
|
5b73e04192 |
@@ -58,6 +58,8 @@ type Outcome struct {
|
|||||||
kept string
|
kept 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
|
||||||
// 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
|
||||||
@@ -563,6 +565,8 @@ func ApplyKeeping(
|
|||||||
Kept: kept,
|
Kept: kept,
|
||||||
Reads: outcome.reads,
|
Reads: outcome.reads,
|
||||||
Stateless: outcome.stateless,
|
Stateless: outcome.stateless,
|
||||||
|
Scope: outcome.scope,
|
||||||
|
User: outcome.user,
|
||||||
Found: outcome.found,
|
Found: outcome.found,
|
||||||
Holds: holds(resource),
|
Holds: holds(resource),
|
||||||
})
|
})
|
||||||
@@ -1043,10 +1047,12 @@ type unitReloader interface {
|
|||||||
|
|
||||||
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) {
|
||||||
|
run = managerFor(r.Scope, r.User, run)
|
||||||
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
|
||||||
@@ -1186,7 +1192,9 @@ func applyService(ctx context.Context, sys system.System, r *declaration.Service
|
|||||||
// a machine that uses another — and it reads the change when whatever starts it does.
|
// a machine that uses another — and it reads the change when whatever starts it does.
|
||||||
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) {
|
||||||
|
run = managerFor(r.Scope, r.User, run)
|
||||||
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)
|
||||||
@@ -2235,6 +2243,7 @@ func declaredDigest(r declaration.Resource) string {
|
|||||||
// what was actually done — a unit already as it was found is forgotten, not "restored".
|
// what was actually done — a unit already as it was found is forgotten, not "restored".
|
||||||
func removeService(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
func removeService(ctx context.Context, sys system.System, a store.Applied, run Runner,
|
||||||
made bool) (string, string, error) {
|
made bool) (string, string, error) {
|
||||||
|
run = managerFor(a.Scope, a.User, run)
|
||||||
if a.Stateless {
|
if a.Stateless {
|
||||||
// Declared with no state (novox/hq ADR 0117): its lifecycle was never the mesh's, and the
|
// Declared with no state (novox/hq ADR 0117): its lifecycle was never the mesh's, and the
|
||||||
// declaration that said so is the operator's word to hold to, a file of the mesh's or not.
|
// declaration that said so is the operator's word to hold to, a file of the mesh's or not.
|
||||||
@@ -2409,3 +2418,23 @@ func moduleOf(identity string) (string, bool) {
|
|||||||
}
|
}
|
||||||
return identity[:at], true
|
return identity[:at], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package apply
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/novox/mesh-host/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// accountManager is a user's service manager whose one unit starts when asked, recording the
|
||||||
|
// commands and refusing any that reach it outside the account's scope.
|
||||||
|
func accountManager(account string, commands *[]string) Runner {
|
||||||
|
active, enabled := false, false
|
||||||
|
return func(_ context.Context, name string, args ...string) (string, error) {
|
||||||
|
line := name + " " + strings.Join(args, " ")
|
||||||
|
*commands = append(*commands, line)
|
||||||
|
if name == "systemctl" && !strings.HasPrefix(line, "systemctl --user --machine="+account+"@ ") {
|
||||||
|
return "", fmt.Errorf("a system-scope command reached the account's manager: %s", line)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(line, " start "):
|
||||||
|
active = true
|
||||||
|
return "", nil
|
||||||
|
case strings.Contains(line, " stop "):
|
||||||
|
active = false
|
||||||
|
return "", nil
|
||||||
|
case strings.Contains(line, " enable "):
|
||||||
|
enabled = true
|
||||||
|
return "", nil
|
||||||
|
case strings.Contains(line, " disable "):
|
||||||
|
enabled = false
|
||||||
|
return "", nil
|
||||||
|
case strings.Contains(line, "is-enabled"):
|
||||||
|
if enabled {
|
||||||
|
return "enabled", nil
|
||||||
|
}
|
||||||
|
return "disabled", nil
|
||||||
|
case strings.Contains(line, "show") && strings.Contains(line, "ActiveState"):
|
||||||
|
if active {
|
||||||
|
return "LoadState=loaded\nActiveState=active\nSubState=running", nil
|
||||||
|
}
|
||||||
|
return "LoadState=loaded\nActiveState=inactive\nSubState=dead", nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAUserScopedUnitIsAppliedThroughTheAccountsManager(t *testing.T) {
|
||||||
|
var commands []string
|
||||||
|
decl := `{"declaration":1,"resources":[
|
||||||
|
{"id":"i3.watcher","type":"service","unit":"i3-reload-watcher.service","state":"running","boot":"enabled","scope":"user","user":"ops"}
|
||||||
|
]}`
|
||||||
|
report, known, err := Apply(context.Background(), archHost(t), parse(t, decl),
|
||||||
|
store.State{}, store.OriginDeclared, accountManager("ops", &commands), nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply: %v\n%s", err, strings.Join(commands, "\n"))
|
||||||
|
}
|
||||||
|
if !report.Changed() {
|
||||||
|
t.Fatal("a unit that was stopped and is now running changed nothing")
|
||||||
|
}
|
||||||
|
var started, enabled bool
|
||||||
|
for _, c := range commands {
|
||||||
|
if c == "systemctl --user --machine=ops@ start i3-reload-watcher.service" {
|
||||||
|
started = true
|
||||||
|
}
|
||||||
|
if c == "systemctl --user --machine=ops@ enable i3-reload-watcher.service" {
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !started || !enabled {
|
||||||
|
t.Fatalf("the unit was not started and enabled in the account's manager:\n%s", strings.Join(commands, "\n"))
|
||||||
|
}
|
||||||
|
recorded, ok := known.At("service", "i3-reload-watcher.service")
|
||||||
|
if !ok || recorded.Scope != "user" || recorded.User != "ops" {
|
||||||
|
t.Fatalf("the record does not say whose manager the unit is in: %+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") {
|
||||||
|
t.Fatalf("a system unit was addressed to an account's manager: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -684,6 +684,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
|
||||||
@@ -729,11 +739,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,30 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,10 @@ type Applied struct {
|
|||||||
// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user