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.
206 lines
8.2 KiB
Go
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{}}
|
|
}
|