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.
171 lines
6.4 KiB
Go
171 lines
6.4 KiB
Go
package system
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
)
|
|
|
|
// alpine is apk and OpenRC.
|
|
//
|
|
// The intended first node. Where it differs from systemd is not cosmetic, and each difference
|
|
// below is a place where the systemd implementation's care had to be re-derived rather than
|
|
// translated.
|
|
type alpine struct{}
|
|
|
|
func (alpine) Name() string { return "alpine" }
|
|
func (alpine) Shapes() []declaration.Type { return everyShape() }
|
|
|
|
func (a alpine) Confirm(ctx context.Context, run Runner) error {
|
|
// `apk info -e apk-tools` asks the installed-package database about something that is
|
|
// certainly there. `apk --version` would prove only that a binary exists, which is the
|
|
// assumption 04-ISSUES/007 records.
|
|
if _, err := run(ctx, "apk", "info", "-e", "apk-tools"); err != nil {
|
|
return fmt.Errorf(
|
|
"this is the alpine host and apk does not answer here. Either this machine is not "+
|
|
"Alpine, or its package database is broken: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PackageInstalled asks apk, having first established that apk answers.
|
|
//
|
|
// `apk info -e <name>` prints the name when installed and NOTHING when not — and exits zero
|
|
// either way. So unlike pacman, the exit code cannot be used at all here: an empty answer is
|
|
// the negative. Reading the exit code would report every package as installed.
|
|
func (a alpine) 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)
|
|
}
|
|
out, err := run(ctx, "apk", "info", "-e", name)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
func (alpine) InstallPackage(ctx context.Context, run Runner, name string) error {
|
|
_, err := run(ctx, "apk", "add", "--no-cache", name)
|
|
return err
|
|
}
|
|
|
|
// ServiceState reads what OpenRC says about a service.
|
|
//
|
|
// The same trap as systemd's, and it needs answering differently because OpenRC has no
|
|
// LoadState. `rc-service <name> status` exits 3 for a stopped service and 1 for one that does
|
|
// not exist — but the exit code reaches us wrapped, so the output is read instead: OpenRC says
|
|
// "does not exist" plainly, and that distinction is the whole reason this function is not a
|
|
// one-liner.
|
|
func (alpine) ServiceState(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, err := run(ctx, "rc-service", unit, "status")
|
|
text := strings.ToLower(out + " " + errText(err))
|
|
|
|
switch {
|
|
case strings.Contains(text, "does not exist"):
|
|
return "", fmt.Errorf(
|
|
"%s does not exist on this machine. A declaration naming a service that is not "+
|
|
"installed cannot be satisfied, and reporting it stopped would be reporting "+
|
|
"absence as success", unit)
|
|
case strings.Contains(text, "status: started"), strings.Contains(text, "status: starting"):
|
|
return "running", nil
|
|
case strings.Contains(text, "status: stopped"), strings.Contains(text, "status: stopping"):
|
|
return "stopped", nil
|
|
case strings.Contains(text, "status: crashed"):
|
|
// Crashed is not running, and it is not the same as stopped either — but a
|
|
// declaration can only ask for one of two things, and the honest mapping is that the
|
|
// service is not up. Starting it is then the right next act.
|
|
return "stopped", nil
|
|
case strings.TrimSpace(text) == "":
|
|
return "", fmt.Errorf("the service manager said nothing about %s", unit)
|
|
default:
|
|
return "", fmt.Errorf(
|
|
"the service manager reports %s as %q, which is neither running nor stopped",
|
|
unit, strings.TrimSpace(out))
|
|
}
|
|
}
|
|
|
|
func (alpine) SetServiceState(ctx context.Context, run Runner, unit, state string) error {
|
|
verb := "start"
|
|
if state == "stopped" {
|
|
verb = "stop"
|
|
}
|
|
_, err := run(ctx, "rc-service", unit, verb)
|
|
return err
|
|
}
|
|
|
|
// ServiceBoot reads whether a service is in a runlevel.
|
|
//
|
|
// OpenRC has no `is-enabled`. What it has is `rc-update show`, which lists services against the
|
|
// runlevels they are added to — so "does it start at boot" becomes "does it appear here", and
|
|
// there is no equivalent of systemd's `static` because OpenRC has no unit files without an
|
|
// install story.
|
|
func (alpine) ServiceBoot(ctx context.Context, run Runner, unit string) (string, error) {
|
|
out, err := run(ctx, "rc-update", "show", "default")
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot read which services start at boot: %w", err)
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
// A line looks like ` docker | default`. The name is the first field.
|
|
name, _, _ := strings.Cut(strings.TrimSpace(line), "|")
|
|
if strings.TrimSpace(name) == unit {
|
|
return "enabled", nil
|
|
}
|
|
}
|
|
return "disabled", nil
|
|
}
|
|
|
|
func (alpine) SetServiceBoot(ctx context.Context, run Runner, unit, boot string) error {
|
|
verb := "add"
|
|
if boot == "disabled" {
|
|
verb = "del"
|
|
}
|
|
_, err := run(ctx, "rc-update", verb, unit, "default")
|
|
return err
|
|
}
|
|
|
|
func errText(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
// CreateUser makes a login with busybox adduser, whose flags are not useradd's.
|
|
//
|
|
// `-D` is "do not ask for a password", which is what makes it usable without a terminal. A login
|
|
// created this way has no password and cannot be logged into over the network with one, which is
|
|
// correct: what the mesh manages is what a login owns, never a way to become it.
|
|
func (alpine) CreateUser(ctx context.Context, run Runner, name, home, shell string) error {
|
|
args := []string{"-D"}
|
|
if home != "" {
|
|
args = append(args, "-h", home)
|
|
}
|
|
if shell != "" {
|
|
args = append(args, "-s", shell)
|
|
}
|
|
if _, err := run(ctx, "adduser", append(args, name)...); err != nil {
|
|
return fmt.Errorf("cannot create the user %q: %w", name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (alpine) SetUserShell(ctx context.Context, run Runner, name, shell string) error {
|
|
// busybox has no usermod. `sed`-ing /etc/passwd is what the distribution's own tooling does,
|
|
// and chsh is the one command that exists for it everywhere.
|
|
if _, err := run(ctx, "chsh", "-s", shell, name); err != nil {
|
|
return fmt.Errorf("cannot set %q's shell to %q: %w", name, shell, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddUserToGroup uses addgroup, which on busybox takes the user and the group and is additive by
|
|
// construction — there is no form of it that replaces the set.
|
|
func (alpine) AddUserToGroup(ctx context.Context, run Runner, name, group string) error {
|
|
if _, err := run(ctx, "addgroup", name, group); err != nil {
|
|
return fmt.Errorf("cannot put %q in the group %q: %w", name, group, err)
|
|
}
|
|
return nil
|
|
}
|