Files
mesh-host/internal/system/system.go
T
jschoubben c57087d75d A user, bytes, and an archive — because most of what people install is
not a service

A shell, a terminal, a chat client, a desktop are a package plus
configuration in somebody's home. A mesh with no notion of a user can own
/etc and nothing anybody looks at, which is most of the reason to manage
a machine at all.

Three shapes, and the vocabulary test asserts the count precisely because
widening it widens what a compromised control plane can express:

  user     a login, its shell and its groups
  archive  a set of files, fetched by digest and unpacked
  (file)   gains `bytes` for what is not text, and `owner`

`user` also makes "zsh is my login shell" declared state. chsh is a
command, the link may not carry one, and a shell settable only by hand is
a shell the mesh cannot manage.

Groups are additive and never pruned — usermod without --append REPLACES
them, which would silently remove every group that makes a login able to
use the machine. A machine's own groups are not the mesh's to know about.

The archive is the one place this host reaches out on its own; everywhere
else it holds one outbound connection and fetches nothing. So it carries
the discipline the bootstrap already uses for images: pinned by digest,
and the digest checked before a single file is written.

Two decisions in the unpacker worth naming:

- an entry naming a path outside the archive is REFUSED, not sanitised.
  Rewriting it to land inside would put a file somewhere nobody asked for
  and report success. Found by the test: the first version quietly
  relocated it.
- symlinks and device nodes are refused rather than skipped, or an
  archive that needed one arrives silently incomplete.

A partial host does archives and refuses users: an archive needs a
filesystem and a way to fetch; a user needs a user database it is allowed
to write.
2026-08-30 03:22:38 +02:00

206 lines
8.2 KiB
Go

// Package system is the part of the host that differs between operating systems.
//
// novox/hq ADR 0005. A machine has apk because it is Alpine; the package manager, the service
// manager and the packaging format arrive together as one decision somebody made at install
// time. So they are not independent knobs — they are one implementation, named after the system
// it belongs to.
//
// Everything else in the host is shared: the declaration vocabulary, the store, the apply loop,
// the read-back discipline, the refusal model, the link. What lives here is two appliers' worth
// of difference and the probes that go with them.
//
// Not abstracted behind a lowest common denominator, deliberately. `systemctl show` reports a
// LoadState that separates *not installed* from *stopped*, and OpenRC has no equivalent — an
// interface spanning both would have to drop it, and dropping it is how absence gets reported
// as success. Each system says what it can say.
package system
import (
"context"
"errors"
"fmt"
"strings"
"github.com/novox/mesh-host/internal/declaration"
)
// Runner executes a command. The real one runs a process; tests pass one that records what was
// asked for, because what is being tested is which commands each system issues.
type Runner func(ctx context.Context, name string, args ...string) (string, error)
// ErrUnsupported is what a system returns for a shape it cannot implement.
//
// Not an error in the ordinary sense — an Android host declining to install packages is
// correct, not broken. It is refused at the declaration rather than attempted and failed, so a
// control plane learns the difference from the profile instead of from a stack trace.
var ErrUnsupported = errors.New("this host does not implement that")
// System is one operating system's half of the host.
type System interface {
// Name is what this host was built for: "arch", "alpine", "android".
Name() string
// Shapes are the declaration types this host can apply. Anything else is refused whole.
Shapes() []declaration.Type
// Confirm proves this is the system the host was built for.
//
// A host installed on the wrong machine must say so, not discover it by calling a package
// manager that is not there. The failure is legible exactly once, at start.
Confirm(ctx context.Context, run Runner) error
PackageInstalled(ctx context.Context, run Runner, name string) (bool, error)
InstallPackage(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.
ServiceState(ctx context.Context, run Runner, unit string) (string, error)
SetServiceState(ctx context.Context, run Runner, unit, state string) error
// ServiceBoot is "enabled" or "disabled" — whether the unit starts at boot.
ServiceBoot(ctx context.Context, run Runner, unit string) (string, error)
SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error
// CreateUser makes a login. Home and shell may be empty, meaning the system's own defaults —
// a declaration that says nothing about them must not impose an opinion.
CreateUser(ctx context.Context, run Runner, name, home, shell string) error
// SetUserShell changes an existing login's shell, which is what makes "zsh is my shell"
// declared state rather than a command the link may not carry.
SetUserShell(ctx context.Context, run Runner, name, shell string) error
// AddUserToGroup is additive and never removes. A machine's own groups are not the mesh's to
// know about, and a declaration that pruned them would take away what somebody set by hand.
AddUserToGroup(ctx context.Context, run Runner, name, group string) error
}
// Login is what the machine's user database says about a login.
type Login struct {
Home string
Shell string
}
// LookUpUser reads a login from the user database.
//
// Shared rather than per-system: `getent passwd` gives the same seven colon-separated fields
// everywhere this host runs, and a second implementation would be a second thing to get wrong in
// the same way.
//
// **Absent is an answer, an error is not.** A user database that cannot be read must not be
// reported as "no such user" — that is absence read as fact, the exact confusion this package
// takes trouble over elsewhere. `getent` exits 2 for "not found" and other codes for failures, so
// the two are distinguished rather than collapsed.
func LookUpUser(ctx context.Context, run Runner, name string) (Login, bool, error) {
out, err := run(ctx, "getent", "passwd", name)
if err != nil {
// getent's own convention: 2 means the key was not found, which is the only failure that
// means "no such user".
if strings.Contains(err.Error(), "exit status 2") {
return Login{}, false, nil
}
return Login{}, false, fmt.Errorf(
"the user database did not answer about %q, so nothing can be said about it: %w",
name, err)
}
fields := strings.Split(strings.TrimSpace(out), ":")
if len(fields) < 7 {
return Login{}, false, fmt.Errorf("the user database gave %q for %q, which is not a passwd entry",
strings.TrimSpace(out), name)
}
return Login{Home: fields[5], Shell: fields[6]}, true, nil
}
// GroupsOf is every group a login is in.
func GroupsOf(ctx context.Context, run Runner, name string) ([]string, error) {
out, err := run(ctx, "id", "-nG", name)
if err != nil {
return nil, err
}
return strings.Fields(out), nil
}
// Supports reports whether this host can apply a shape.
func Supports(s System, t declaration.Type) bool {
for _, shape := range s.Shapes() {
if shape == t {
return true
}
}
return false
}
// Check refuses a declaration naming a shape this host cannot apply.
//
// Refused whole and before anything is applied, which is the same treatment an unknown type
// gets (novox/hq ADR 0005) — a host that applied the parts it understood would leave a machine
// that looks configured and is not. The reason differs and the outcome does not.
func Check(s System, d *declaration.Declaration) error {
var problems []string
seen := map[declaration.Type]bool{}
for _, r := range d.Resources {
t := r.Kind()
if Supports(s, t) || seen[t] {
continue
}
seen[t] = true
problems = append(problems, fmt.Sprintf(
"resource %q is a %s, and the %s host does not implement that shape. This host "+
"applies %s",
r.Identity(), t, s.Name(), shapeList(s)))
}
if len(problems) > 0 {
return &declaration.RefusalError{Problems: problems}
}
return nil
}
func shapeList(s System) string {
names := make([]string, 0, len(s.Shapes()))
for _, t := range s.Shapes() {
names = append(names, string(t))
}
return strings.Join(names, ", ")
}
// everyShape is what a host on a full operating system can apply.
func everyShape() []declaration.Type {
return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeService,
declaration.TypePackage, declaration.TypeContainer, declaration.TypeAction,
declaration.TypeUser, declaration.TypeArchive,
}
}
// portableShapes need only a filesystem and a way to run something.
//
// The floor. A host that can do nothing else can still do these, which is what makes a partial
// host a real thing rather than a broken one (novox/hq ADR 0005).
func portableShapes() []declaration.Type {
return []declaration.Type{
declaration.TypeDirectory, declaration.TypeFile, declaration.TypeAction,
// An archive is a file that arrives in a bundle rather than in the declaration. It needs
// only a filesystem and a way to fetch, so a partial host can do it; a user needs a user
// database it is allowed to write, which it does not have.
declaration.TypeArchive,
}
}
// For returns the system with this name, or an error naming the ones that exist.
func For(name string) (System, error) {
for _, s := range All() {
if s.Name() == name {
return s, nil
}
}
var names []string
for _, s := range All() {
names = append(names, s.Name())
}
return nil, fmt.Errorf(
"this host was built for %q, which is not a system it knows. Built hosts are: %s",
name, strings.Join(names, ", "))
}
// All is every system the host can be built for.
func All() []System {
return []System{arch{}, alpine{}, android{}}
}