Files
mesh-host/internal/system/arch.go
T
jschoubben b4d2e851a3 Name a stale package index, rather than reporting a failed install
novox/hq 04-ISSUES/002, which was recorded against HAL and is present here: a
machine asking the mirrors for a version they have already replaced gets a 404
from every one of them. The package exists and the declaration is correct — it
is the machine's view that is old — and reported as a generic install failure
it sends somebody to check the manifest, which is the one thing that is right.

It is deliberately not fixed by syncing. `pacman -Sy <pkg>` installs a package
built against libraries the machine does not have: a partial upgrade, which
this distribution does not support and which surfaces much later as something
apparently unrelated. The remedy is a full upgrade, which is a decision about
the whole machine rather than something a host does silently while applying one
resource. So this says which of the two it is looking at, and leaves the
decision where it belongs.

Every mirror, not one: a single mirror timing out is transient and retrying is
the answer.

And the package manager's own words were being discarded entirely — the output
was read into `_`. Whatever it said is now part of the failure, which is the
rule everywhere else here and was not being followed in the one place the
reason only exists in the output.
2026-08-31 12:59:53 +02:00

222 lines
8.6 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")
var load, active 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
}
}
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)
}
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
}