257 lines
10 KiB
Go
257 lines
10 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// arch is pacman and systemd.
|
|
type arch struct{}
|
|
|
|
func (arch) Name() string { return "arch" }
|
|
func (arch) Shapes() []declaration.Type { return everyShape() }
|
|
|
|
func (a arch) Confirm(ctx context.Context, run Runner) error {
|
|
if _, err := run(ctx, "pacman", "-Q", "pacman"); err != nil {
|
|
return fmt.Errorf(
|
|
"this is the arch host and pacman does not answer here. Either this machine is not "+
|
|
"Arch, or its package database is broken: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageInstalled asks the package database, having first established that it answers.
|
|
//
|
|
// The two-step is the trap this file exists to remember. `pacman -Q name` exits non-zero for a
|
|
// package that is not installed AND for a database that cannot be read, so believing the first
|
|
// answer reports a broken package manager as "nothing is installed" — absence read as fact.
|
|
// Proving the tool answers about something that certainly exists separates them.
|
|
func (a arch) PackageInstalled(ctx context.Context, run Runner, name string) (bool, error) {
|
|
if err := a.Confirm(ctx, run); err != nil {
|
|
return false, fmt.Errorf("nothing can be said about %q: %w", name, err)
|
|
}
|
|
if _, err := run(ctx, "pacman", "-Q", name); err != nil {
|
|
return false, nil
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (arch) InstallPackage(ctx context.Context, run Runner, name string) error {
|
|
out, err := run(ctx, "pacman", "-S", "--noconfirm", "--needed", name)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
|
|
// **The package manager's own words, and a name for the case that looks like a bug in the
|
|
// declaration and is not.** A stale index asks the mirrors for a version they have already
|
|
// superseded and gets a 404 from every one of them — so the package exists, the declaration is
|
|
// correct, and the machine's idea of what exists is old (novox/hq 04-ISSUES/002).
|
|
//
|
|
// **It is not fixed by syncing here.** `pacman -Sy <pkg>` installs a package built against
|
|
// libraries this machine does not have: a partial upgrade, which Arch does not support and
|
|
// which breaks the machine in a way that surfaces much later as something unrelated. The
|
|
// remedy is a full upgrade, and it is a decision about the whole machine rather than
|
|
// something to do silently in the middle of applying one resource.
|
|
//
|
|
// So this says which of the two it is looking at. A declaration that is wrong and a machine
|
|
// that is out of date fail identically otherwise, and they are fixed in completely different
|
|
// places.
|
|
if staleIndex(out) {
|
|
return fmt.Errorf(
|
|
"%s could not be fetched from any mirror, which is what a stale package index looks "+
|
|
"like: this machine is asking for a version the mirrors have replaced. The "+
|
|
"package and the declaration are probably both fine. It is fixed by upgrading "+
|
|
"the machine, not by this host syncing one package — that would be a partial "+
|
|
"upgrade, which this distribution does not support.\n\n%s",
|
|
name, strings.TrimSpace(out))
|
|
}
|
|
return fmt.Errorf("%w\n\n%s", err, strings.TrimSpace(out))
|
|
}
|
|
|
|
// staleIndex reports whether a failed install looks like the machine's view being old rather than
|
|
// the package being wrong.
|
|
//
|
|
// By what the package manager said, because there is nothing else to go on: the exit code is the
|
|
// same for both.
|
|
func staleIndex(out string) bool {
|
|
said := strings.ToLower(out)
|
|
if !strings.Contains(said, "failed retrieving file") && !strings.Contains(said, "404") {
|
|
return false
|
|
}
|
|
// Every mirror, not one. A single mirror failing is an ordinary transient thing and retrying
|
|
// is the answer; every one of them saying the file is gone is the index being old.
|
|
return strings.Contains(said, "error") || strings.Count(said, "404") > 1
|
|
}
|
|
|
|
// ServiceState reads what systemd says about a unit.
|
|
//
|
|
// Two traps, and both were hit before this read what it now reads.
|
|
//
|
|
// The exit code is not the answer: `is-active` exits non-zero for every state except active.
|
|
//
|
|
// And "inactive" does not mean stopped. `systemctl is-active` says "inactive" for a unit that
|
|
// DOES NOT EXIST exactly as it does for one installed and stopped, so declaring a unit stopped
|
|
// reported success for a unit the host cannot manage at all. LoadState is what separates them,
|
|
// so LoadState is what is read — and it is the thing an interface spanning systemd and OpenRC
|
|
// would have had to drop.
|
|
func (arch) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "show", unit,
|
|
"--property=LoadState", "--property=ActiveState", "--property=Type",
|
|
"--property=RemainAfterExit", "--property=ExecMainStatus")
|
|
|
|
var load, active, kind, remains, exited string
|
|
for _, line := range strings.Split(out, "\n") {
|
|
key, value, found := strings.Cut(strings.TrimSpace(line), "=")
|
|
if !found {
|
|
continue
|
|
}
|
|
switch key {
|
|
case "LoadState":
|
|
load = value
|
|
case "ActiveState":
|
|
active = value
|
|
case "Type":
|
|
kind = value
|
|
case "RemainAfterExit":
|
|
remains = value
|
|
case "ExecMainStatus":
|
|
exited = value
|
|
}
|
|
}
|
|
|
|
switch load {
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
|
case "not-found":
|
|
return "", fmt.Errorf(
|
|
"%s does not exist on this machine. A declaration naming a unit that is not "+
|
|
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
|
"absence as success", unit)
|
|
case "masked":
|
|
return "", fmt.Errorf("%s is masked, so its state cannot be declared", unit)
|
|
case "error", "bad-setting":
|
|
return "", fmt.Errorf("%s is installed but its unit file cannot be loaded (%s)", unit, load)
|
|
}
|
|
|
|
// **A one-shot that finished is not stopped.** A unit whose whole job is to apply something
|
|
// and exit — load a rule set, set a sysctl — is reported inactive the moment it succeeds, and
|
|
// unless it is told to linger there is no state in which it is ever "active". Reading that as
|
|
// "stopped" makes such a unit permanently unsatisfiable: the host starts it, it does its work,
|
|
// it exits, the host reads back "stopped" and reports failure — for ever, on every apply,
|
|
// while the thing it configured is in place and working.
|
|
//
|
|
// That is not hypothetical. It is what the firewall did on every machine it was ever assigned
|
|
// to: rules loaded, service reported failed, the mesh reported a machine not doing what it was
|
|
// told, and the only visible symptom was a red line about a unit nobody could see anything
|
|
// wrong with.
|
|
//
|
|
// So for that shape, what "running" means is "it ran, and it worked".
|
|
if kind == "oneshot" && remains != "yes" && active == "inactive" {
|
|
if exited == "0" || exited == "" {
|
|
return "running", nil
|
|
}
|
|
return "stopped", nil
|
|
}
|
|
|
|
switch active {
|
|
case "active", "activating", "reloading":
|
|
return "running", nil
|
|
case "inactive", "failed", "deactivating":
|
|
return "stopped", nil
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q, which is neither running nor stopped", unit, active)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
|
verb := "start"
|
|
if state == "stopped" {
|
|
verb = "stop"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// ServiceBoot reads whether a unit starts at boot.
|
|
//
|
|
// `is-enabled` has more than two answers, and `static` is the one that matters: the unit has no
|
|
// install section and CANNOT be enabled. Reading it as "disabled" would have the host try, fail,
|
|
// and blame the wrong thing — the same shape as reading a missing unit as "stopped".
|
|
func (arch) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, _ := run(ctx, "systemctl", "is-enabled", unit)
|
|
switch state := strings.TrimSpace(out); state {
|
|
case "enabled", "enabled-runtime", "alias":
|
|
return "enabled", nil
|
|
case "disabled":
|
|
return "disabled", nil
|
|
case "":
|
|
return "", fmt.Errorf("the service manager said nothing about whether %s starts at boot", unit)
|
|
case "static":
|
|
return "", fmt.Errorf(
|
|
"%s is static — it has no install section, so it cannot be enabled or disabled. "+
|
|
"Something else pulls it in, and that is what a declaration should name", unit)
|
|
case "masked", "masked-runtime":
|
|
return "", fmt.Errorf("%s is masked, so its boot state cannot be declared", unit)
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q at boot, which is neither enabled nor disabled",
|
|
unit, state)
|
|
}
|
|
}
|
|
|
|
func (arch) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
|
|
verb := "enable"
|
|
if boot == "disabled" {
|
|
verb = "disable"
|
|
}
|
|
_, err := run(ctx, "systemctl", verb, unit)
|
|
return err
|
|
}
|
|
|
|
// CreateUser makes a login with useradd.
|
|
//
|
|
// `--create-home` because a user whose home does not exist is a user nothing can be delivered
|
|
// to, and delivering a shell's configuration is most of why the mesh knows about users at all.
|
|
func (arch) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
|
|
args := []string{"--create-home"}
|
|
if home != "" {
|
|
args = append(args, "--home-dir", home)
|
|
}
|
|
if shell != "" {
|
|
args = append(args, "--shell", shell)
|
|
}
|
|
if _, err := run(ctx, "useradd", append(args, name)...); err != nil {
|
|
return fmt.Errorf("cannot create the user %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (arch) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
|
|
if _, err := run(ctx, "usermod", "--shell", shell, name); err != nil {
|
|
return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddUserToGroup appends, and `--append` is the whole point: without it usermod REPLACES the
|
|
// user's supplementary groups, so a declaration naming one group would silently remove every
|
|
// other — including the ones that make a login able to use a machine at all.
|
|
func (arch) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
|
|
if _, err := run(ctx, "usermod", "--append", "--groups", group, name); err != nil {
|
|
return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReloadUnits has systemd read its unit files again. A unit file that changed on disk is otherwise
|
|
// ignored: a restart runs the unit systemd already loaded, and the new text only takes effect
|
|
// after a reload nobody asked for.
|
|
func (arch) ReloadUnits(ctx context.Context, run Runner) error {
|
|
_, err := run(ctx, "systemctl", "daemon-reload")
|
|
return err
|
|
}
|