Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95c2b6ea9 | ||
|
|
5b73e04192 | ||
|
|
f57386cdea | ||
|
|
f92dd3e286 |
@@ -58,6 +58,8 @@ type Outcome struct {
|
||||
kept string
|
||||
// stateless is a service whose unit's lifecycle is the machine's (novox/hq ADR 0117).
|
||||
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 *store.FoundUnit
|
||||
// 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,
|
||||
Reads: outcome.reads,
|
||||
Stateless: outcome.stateless,
|
||||
Scope: outcome.scope,
|
||||
User: outcome.user,
|
||||
Found: outcome.found,
|
||||
Holds: holds(resource),
|
||||
})
|
||||
@@ -1043,10 +1047,12 @@ type unitReloader interface {
|
||||
|
||||
func applyService(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||
changed map[string]bool, previous store.Applied) (Outcome, error) {
|
||||
run = managerFor(r.Scope, r.User, run)
|
||||
if r.Stateless() {
|
||||
return reflectOnly(ctx, sys, r, run, changed)
|
||||
}
|
||||
out := begin(r)
|
||||
out.scope, out.user = r.Scope, r.User
|
||||
var changes []string
|
||||
|
||||
// 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.
|
||||
func reflectOnly(ctx context.Context, sys system.System, r *declaration.Service, run Runner,
|
||||
changed map[string]bool) (Outcome, error) {
|
||||
run = managerFor(r.Scope, r.User, run)
|
||||
out := begin(r)
|
||||
out.scope, out.user = r.Scope, r.User
|
||||
out.stateless = true
|
||||
restart := reflected(r, changed)
|
||||
reload := restartedBy(r.ReloadOn, changed)
|
||||
@@ -1407,6 +1415,25 @@ func applyPackage(ctx context.Context, sys system.System, r *declaration.Package
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if r.Absent {
|
||||
// Declared absent (novox/hq ADR 0175): removed when it is here, left alone when it is not.
|
||||
if !installed {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "not installed, as declared"
|
||||
return out, nil
|
||||
}
|
||||
if err := sys.RemovePackage(ctx, run, r.Package); err != nil {
|
||||
return out, fmt.Errorf("removing %s: %w", r.Package, err)
|
||||
}
|
||||
if still, err := sys.PackageInstalled(ctx, run, r.Package); err != nil {
|
||||
return out, err
|
||||
} else if still {
|
||||
return out, fmt.Errorf("%s was removed without error and the package database still has it", r.Package)
|
||||
}
|
||||
out.Action = "removed"
|
||||
out.Detail = "declared absent; its configuration is left where the package manager leaves it"
|
||||
return out, nil
|
||||
}
|
||||
if installed {
|
||||
out.Action = "unchanged"
|
||||
out.Detail = "already installed"
|
||||
@@ -2216,6 +2243,7 @@ func declaredDigest(r declaration.Resource) string {
|
||||
// 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,
|
||||
made bool) (string, string, error) {
|
||||
run = managerFor(a.Scope, a.User, run)
|
||||
if a.Stateless {
|
||||
// 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.
|
||||
@@ -2390,3 +2418,23 @@ func moduleOf(identity string) (string, bool) {
|
||||
}
|
||||
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...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,3 +173,42 @@ func TestACapabilityReachesTheRuntimeAndTheSpec(t *testing.T) {
|
||||
t.Fatal("a capability is not part of the container's spec")
|
||||
}
|
||||
}
|
||||
|
||||
// A package may be declared absent (novox/hq ADR 0175): removed when it is installed, read back,
|
||||
// left alone when it is not.
|
||||
func TestAPackageDeclaredAbsentIsRemovedWhenPresentAndLeftWhenNot(t *testing.T) {
|
||||
installed := true
|
||||
var ran []string
|
||||
run := func(_ context.Context, name string, args ...string) (string, error) {
|
||||
ran = append(ran, name+" "+strings.Join(args, " "))
|
||||
if name != "pacman" {
|
||||
return "", nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "-Q":
|
||||
if args[1] == "pacman" || installed {
|
||||
return args[1] + " 1.0\n", nil
|
||||
}
|
||||
return "", errors.New("package not found")
|
||||
case "-R":
|
||||
installed = false
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
d := parseTrusted(t, `{"declaration":1,"resources":[{"id":"front-end","type":"package","package":"ufw","absent":true}]}`)
|
||||
report, _, err := Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Outcomes[0].Action != "removed" || !strings.Contains(strings.Join(ran, "\n"), "pacman -R --noconfirm ufw") {
|
||||
t.Fatalf("an installed package declared absent was not removed: %+v\n%v", report.Outcomes[0], ran)
|
||||
}
|
||||
ran = nil
|
||||
report, _, err = Apply(context.Background(), archHost(t), d, store.State{}, store.OriginCarried, run, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Outcomes[0].Action != "unchanged" || strings.Contains(strings.Join(ran, "\n"), "-R") {
|
||||
t.Fatalf("a package already absent was touched: %+v\n%v", report.Outcomes[0], ran)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ func retireFirewall(ctx context.Context, d *declaration.Declaration, origin stri
|
||||
if origin != store.OriginDeclared || d.Adoption != nil || rec == nil || rec.Kind != string(firewall.UFW) || !rec.WasActive {
|
||||
return "", nil
|
||||
}
|
||||
if !firewall.Installed(ctx, run) {
|
||||
// Uninstalled (novox/hq ADR 0175): retired for good, by the module that replaced it. Said
|
||||
// once, and nothing is asked of a command that is not there.
|
||||
if rec.RetiredBy != firewall.RetiredRemoved {
|
||||
rec.RetiredBy = firewall.RetiredRemoved
|
||||
log(" the found firewall (ufw) is no longer installed; the mesh's filter is what filters this machine")
|
||||
return "removed: ufw is no longer installed; the mesh's filter is what filters this machine", nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
active := firewall.Active(ctx, run)
|
||||
if !active && !(rec.Forward != nil && !rec.DisabledByMesh) {
|
||||
// Inactive, and either the mesh's doing already or nobody's recorded here: said as found,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/novox/mesh-host/internal/declaration"
|
||||
"github.com/novox/mesh-host/internal/store"
|
||||
@@ -522,3 +523,33 @@ func TestUfwIsNotRetiredUntilTheMeshsOwnFilterIsLoaded(t *testing.T) {
|
||||
t.Errorf("ufw was not retired once the mesh's filter was loaded: active %v, %+v", u.active, state.Firewall)
|
||||
}
|
||||
}
|
||||
|
||||
// A front end that is no longer installed is recorded as removed, said once, and asked nothing of
|
||||
// (novox/hq ADR 0175).
|
||||
func TestAnUninstalledFrontEndIsRetiredForGood(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
u := &ufwMachine{installed: false, ruleset: "table inet mesh\n"}
|
||||
known := store.State{Firewall: &store.FoundFirewall{Kind: "ufw", WasActive: true, DisabledByMesh: true,
|
||||
RetiredBy: "mesh", FoundAt: time.Now()}}
|
||||
converged := parse(t, `{"declaration":1,"resources":[`+withConf(dir)+`]}`)
|
||||
report, state, err := applyWith(t, converged, known, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Firewall.RetiredBy != "removed" || !strings.Contains(report.Firewall, "no longer installed") {
|
||||
t.Fatalf("record %+v, said %q", state.Firewall, report.Firewall)
|
||||
}
|
||||
u.asked = nil
|
||||
report, _, err = applyWith(t, converged, state, u.run)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if report.Firewall != "" {
|
||||
t.Errorf("said again: %q", report.Firewall)
|
||||
}
|
||||
for _, a := range u.asked {
|
||||
if strings.HasPrefix(a, "ufw") && a != "ufw status" {
|
||||
t.Errorf("asked something of a front end that is not there: %v", u.asked)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
//
|
||||
// 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) 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 {
|
||||
var problems []string
|
||||
if s.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 {
|
||||
case s.State == "running" || s.State == "stopped":
|
||||
case s.State != "":
|
||||
@@ -870,6 +902,12 @@ type Package struct {
|
||||
ID string `json:"id"`
|
||||
Type Type `json:"type"`
|
||||
Package string `json:"package"`
|
||||
// Absent declares that the package is NOT installed (novox/hq ADR 0175): the host removes it
|
||||
// when it is, and leaves a machine that never had it alone. For the one case a module replaces
|
||||
// software the machine was found with and the operator has decided it does not come back — the
|
||||
// firewall front end a converged machine's filter module retired. Nothing to undo when the
|
||||
// declaration drops it: the host does not install what a declaration stopped saying is absent.
|
||||
Absent bool `json:"absent,omitempty"`
|
||||
}
|
||||
|
||||
func (p *Package) Identity() string { return p.ID }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -319,8 +319,17 @@ func Active(ctx context.Context, run Runner) bool {
|
||||
return err == nil && statusActive(out)
|
||||
}
|
||||
|
||||
// Installed says whether ufw is on this machine at all: a command that is not there is a front end
|
||||
// that was uninstalled (novox/hq ADR 0175), not one that is silent.
|
||||
func Installed(ctx context.Context, run Runner) bool {
|
||||
_, err := run(ctx, "ufw", "status")
|
||||
return !missing(err)
|
||||
}
|
||||
|
||||
// Retirements of a found firewall, as the host records them.
|
||||
const (
|
||||
RetiredByMesh = "mesh"
|
||||
RetiredFoundSo = "found-inactive"
|
||||
// RetiredRemoved is a front end uninstalled by the module that replaced it (ADR 0175).
|
||||
RetiredRemoved = "removed"
|
||||
)
|
||||
|
||||
@@ -82,6 +82,10 @@ type Applied struct {
|
||||
// 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.
|
||||
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
|
||||
// the mesh started, stopped, enabled or disabled anything. Removal gives that back and nothing
|
||||
|
||||
@@ -46,6 +46,11 @@ func (a alpine) PackageInstalled(ctx context.Context, run Runner, name string) (
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
func (alpine) RemovePackage(ctx context.Context, run Runner, name string) error {
|
||||
_, err := run(ctx, "apk", "del", name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error {
|
||||
_, err := run(ctx, "apk", "add", "--no-cache", name)
|
||||
return err
|
||||
|
||||
@@ -65,6 +65,10 @@ func (a android) InstallPackage(context.Context, Runner, string) error {
|
||||
return fmt.Errorf("%w: package", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) RemovePackage(context.Context, Runner, string) error {
|
||||
return fmt.Errorf("%w: package", ErrUnsupported)
|
||||
}
|
||||
|
||||
func (a android) ServiceState(context.Context, Runner, string) (string, error) {
|
||||
return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,14 @@ func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bo
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemovePackage removes one package and nothing it depends on: `-R`, not `-Rs`, because what else
|
||||
// relied on a dependency is not this declaration's to know. pacman keeps a configuration file the
|
||||
// operator changed as `.pacsave`, which is what "never flushed" comes to once the front end is gone.
|
||||
func (arch) RemovePackage(ctx context.Context, run Runner, name string) error {
|
||||
_, err := run(ctx, "pacman", "-R", "--noconfirm", name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
||||
out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
||||
if err == nil {
|
||||
|
||||
@@ -51,6 +51,9 @@ type System interface {
|
||||
|
||||
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
|
||||
InstallPackage(ctx context.Context, run Runner, name string) error
|
||||
// RemovePackage uninstalls one package, leaving its dependencies and anything the operator
|
||||
// changed in its configuration where the package manager leaves them (novox/hq ADR 0175).
|
||||
RemovePackage(ctx context.Context, run Runner, name string) error
|
||||
|
||||
// ServiceState is "running" or "stopped". A unit that does not exist is an error, never
|
||||
// "stopped" — reporting absence as satisfaction is the fault this host exists to prevent.
|
||||
|
||||
Reference in New Issue
Block a user