Files
mesh-host/internal/system/android.go
T
jschoubben 02f1fcc865 Three hosts: arch, alpine and android
ADR 0060, built. `make hosts` produces mesh-host-arch, mesh-host-alpine and
mesh-host-android, each pinned to its system at link time.

The claim that "almost all of it is shared" held up. All 36 existing apply
tests pass unchanged -- the only edit was naming which system they run against,
which was previously implicit. What moved into internal/system is two appliers'
worth of code and the probes that go with them.

Each system's differences are real and needed re-deriving rather than
translating:

apk reports absence by EMPTY OUTPUT and exits zero either way, where pacman
exits non-zero. Reading apk's exit code the way pacman's is read reports every
package as installed. That is the single most dangerous difference between the
two and it is invisible until it bites.

OpenRC has no LoadState, so "the service does not exist" is read from its prose
rather than a field. Same distinction, different evidence -- and this is exactly
what an interface spanning both would have had to drop, which is why 0060
rejected one.

OpenRC has no is-enabled either. Boot state comes from the runlevel listing:
"does it start at boot" becomes "does it appear in rc-update show default".

Android is a partial host and that is the point. It implements file, directory
and action -- the shapes needing only a filesystem and a way to run something --
and refuses the other three by name, before anything is applied. Its unreachable
appliers return ErrUnsupported rather than a zero value, so "unreachable" fails
loudly if it stops being true.

A host also confirms it is on the machine it was built for, once, at the start.
The alpine host on this Arch machine says "this machine is not Alpine" instead
of failing later inside a package manager that is not there. And a host built
without -X main.builtFor refuses everything, naming the hosts that exist.

Two test problems found by injecting faults. One injection did not compile, so
the check now reports that separately from a pass. The other passed with the
behaviour removed: the missing-service assertion matched "does not exist", which
the FALL-THROUGH error also contains because it echoes the raw output. It now
asserts the diagnosis, which only the correct branch produces.

Verified with the real binaries: android refuses a package naming what it does
support; alpine on Arch refuses the machine; arch applies and is idempotent; a
system-less build refuses everything.
2026-08-28 01:08:11 +02:00

75 lines
3.3 KiB
Go

package system
import (
"context"
"fmt"
"github.com/novox/mesh-host/internal/declaration"
)
// android is a partial host, and being partial is the point.
//
// It implements `file`, `directory` and `action` — the shapes that need only a filesystem and a
// way to run something — and refuses the other three. That is not a broken host: a declaration
// naming a shape this host does not implement is refused whole, the same treatment an unknown
// type gets, and the profile tells the control plane which shapes exist so it never sends one
// it cannot do (novox/hq ADR 0060).
//
// What it cannot do, and why:
//
// - **package** — there is no package manager an ordinary app may drive. Installing software
// on Android means the framework installing an APK, which is not something a process asks
// for on its own behalf.
// - **service** — Android's init reads .rc files from the system partition, which needs root
// and an unlocked bootloader. On a normal device nothing can register with it.
// - **container** — no container runtime, and no kernel access to give one.
//
// **Being STARTED on Android is not solved by this file, and it is the real gap.** Everywhere
// else an init runs the launcher at boot. Here the equivalent is the app framework — a
// foreground service, or something under Termux — both of which the system may kill when it
// wants memory. That is a different mechanism from every other node rather than a variant of
// one, and nothing here designs it.
type android struct{}
func (android) Name() string { return "android" }
func (android) Shapes() []declaration.Type { return portableShapes() }
func (android) Confirm(ctx context.Context, run Runner) error {
// Ask the property service, which exists on every Android and nowhere else. A file path
// check would pass inside a chroot; this asks something only Android answers.
if _, err := run(ctx, "getprop", "ro.build.version.sdk"); err != nil {
return fmt.Errorf(
"this is the android host and the property service does not answer here. Either "+
"this is not Android, or it is a container without it: %w", err)
}
return nil
}
// The four below are unreachable through the ordinary path: Check refuses a declaration naming
// these shapes before anything is applied. They are here so that "unreachable" fails loudly if
// it ever stops being true, rather than a nil applier being called.
func (a android) PackageInstalled(context.Context, Runner, string) (bool, error) {
return false, fmt.Errorf("%w: package (there is no package manager an app may drive)", ErrUnsupported)
}
func (a android) InstallPackage(context.Context, Runner, string) error {
return fmt.Errorf("%w: package", ErrUnsupported)
}
func (a android) ServiceState(context.Context, Runner, string) (string, error) {
return "", fmt.Errorf("%w: service (init is not reachable without root)", ErrUnsupported)
}
func (a android) SetServiceState(context.Context, Runner, string, string) error {
return fmt.Errorf("%w: service", ErrUnsupported)
}
func (a android) ServiceBoot(context.Context, Runner, string) (string, error) {
return "", fmt.Errorf("%w: service", ErrUnsupported)
}
func (a android) SetServiceBoot(context.Context, Runner, string, string) error {
return fmt.Errorf("%w: service", ErrUnsupported)
}