install

package
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 13, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package install turns an enrollment bundle into a running, restart-on-boot, hardened cloop executor agent on an edge device.

Enrollment (Task 20158) ends where the real work begins. It hands the operator a `cloop executor agent --server … --token …` string and stops there, so every device costs the same manual sequence: get the binary onto the box, decide which user runs it, keep the token out of the shell history and out of `ps`, arrange for a restart after a power cut, and put the logs somewhere. Done by hand on the tenth device, at least one of those steps is done differently — and the one that gets skipped is usually the credential's file mode.

So this package makes the whole shape a single artifact. The important properties are structural rather than clever:

  • The enrollment token never appears in ExecStart. It is written to a 0600 file that only the service user can read, and the command line carries a path. Anyone on the device can read a unit file, and `systemctl show` and `ps` will both print an argv; neither may be a place where a credential is legible. See credentialsArtifact.
  • The service runs as a dedicated non-login user with an empty capability bounding set, a read-only filesystem, and no ability to gain privilege. A harness that a compromised control plane persuades to run `sudo` finds nothing there to escalate into.
  • The SPKI pin from Task 20166 is baked into the unit, so a device that reboots keeps verifying the hub's identity rather than falling back to whatever DNS says on the day.

Rendering is separated from applying on purpose: BuildPlan is pure, so --dry-run is the same code path as a real install minus the writes, and the generated unit can be tested without a systemd on the machine running the tests.

Index

Constants

View Source
const (
	DefaultServiceName = "cloop-executor"
	DefaultUser        = "cloop-executor"
	DefaultBinaryPath  = "/usr/local/bin/cloop"
	DefaultStateRoot   = "/var/lib"
	DefaultUnitDir     = "/etc/systemd/system"
	DefaultInitDir     = "/etc/init.d"
	DefaultImage       = "ghcr.io/blechschmidt/cloop:latest"

	// CredentialFileMode is the mode of the file holding enrollment
	// material. It is the single most important number in this package:
	// everything else is defence in depth around the fact that this file is
	// a bearer credential.
	CredentialFileMode os.FileMode = 0o600

	// StateDirMode keeps the credential's directory unreadable to other
	// local users, so a 0600 file cannot be reached by a directory listing
	// plus a race on a later re-write.
	StateDirMode os.FileMode = 0o700

	// SystemDirMode is for shared system directories (/etc/systemd/system,
	// /etc/init.d). They hold no secret and every other service on the box
	// traverses them, so creating one at StateDirMode would break far more
	// than it protected.
	SystemDirMode os.FileMode = 0o755

	// UnitFileMode is world-readable on purpose: systemd reads it as root,
	// operators read it to debug, and it contains no secrets — which is the
	// invariant credentialsArtifact exists to preserve.
	UnitFileMode os.FileMode = 0o644

	// ScriptFileMode is for the generated init script, which must be
	// executable by root and readable for debugging.
	ScriptFileMode os.FileMode = 0o755

	// BinaryMode is for the cloop binary an upgrade installs: executable by
	// everyone, writable only by its owner. Asserted explicitly rather than
	// inherited, because CreateTemp makes 0600 and a binary left at 0600 is a
	// service that fails to start as its own unprivileged user — the exact
	// failure mode an upgrade must not introduce.
	BinaryMode os.FileMode = 0o755
)

Defaults for an install that the operator did not otherwise specify. They are deliberately conventional: an operator debugging a device at 3am should find the unit and the state where the FHS says they will be.

Variables

View Source
var ErrNotInstalled = errors.New("install: no existing agent install found")

ErrNotInstalled is returned when there is nothing to upgrade.

A sentinel because the CLI distinguishes it: it is the one failure with a different next step (install, not retry), and a caller should not have to match on message text to find out.

Functions

func BootstrapScript

func BootstrapScript(p BootstrapParams) string

BootstrapScript renders the /install.sh body.

func ComposeFragment

func ComposeFragment(s Spec) string

ComposeFragment renders the same deployment as a compose service.

func ContainerFragment

func ContainerFragment(s Spec) string

ContainerFragment renders both the `podman run` command and the compose service, with the run command first because it is the thing an operator on a single device actually pastes.

func InitScript

func InitScript(s Spec) string

InitScript renders the POSIX supervisor script for spec s.

func PodmanRun

func PodmanRun(s Spec) string

PodmanRun renders the run command. It works unchanged under `docker run`; podman is named first because a rootless engine is the better fit for an edge device.

func SystemdUnit

func SystemdUnit(s Spec) string

SystemdUnit renders the service unit for spec s.

s is expected to have been through Normalize; BuildPlan is the supported entry point and does that for you.

Types

type Artifact

type Artifact struct {
	// Path is absolute on the device (before any Installer.Root prefix).
	Path string
	// Mode is the file's permission bits.
	Mode os.FileMode
	// Content is the file body.
	Content string
	// Secret marks content that must never be logged, printed by --dry-run,
	// or written world-readable.
	Secret bool
	// Owned marks a file that should be chowned to the service user.
	Owned bool
}

Artifact is one file the installer writes.

type BootstrapParams

type BootstrapParams struct {
	// Server is the WebSocket URL agents dial, e.g.
	// wss://cloop.example.com/api/executors/connect.
	Server string
	// Pin is the hub's SPKI fingerprint, or "" when the hub has no
	// configured certificate. An empty pin makes the script say so rather
	// than quietly install an unpinned agent.
	Pin string
	// ServiceName overrides the installed unit's name.
	ServiceName string
}

BootstrapParams are the deployment-specific values baked into the script.

type Dir

type Dir struct {
	Path string
	Mode os.FileMode
}

Dir is a directory the install must exist, with the mode it must carry.

The mode is per-directory rather than one constant because these fall into two classes with opposite requirements: a state directory holding a credential must be 0700, while /etc/systemd/system must stay traversable by everything on the box. Creating the latter at 0700 — which a single MkdirAll(…, 0700) over a missing path would do — breaks unrelated services.

type Installer

type Installer struct {
	// Root prefixes every path. Empty means the real filesystem root.
	// A non-empty Root also disables command execution entirely: a staged
	// install describes a device that is not this machine, so running
	// systemctl or useradd against this one would be wrong even if it
	// happened to work.
	Root string

	// Run executes a command. Nil uses exec.Command. Ignored when Root is
	// set.
	Run Runner

	// Logf receives progress messages. Nil discards them.
	Logf func(format string, args ...any)
}

Installer applies and reverses plans.

The zero value writes to the real filesystem and runs real commands, which is what the CLI wants; tests set Root and Run.

func (*Installer) Apply

func (in *Installer) Apply(p Plan) error

Apply materialises the plan.

func (*Installer) Uninstall

func (in *Installer) Uninstall(spec Spec, out Output, purgeState bool) error

Uninstall reverses an install. It is idempotent: running it twice, or running it on a device that was never installed, succeeds quietly.

The credential is removed first and the state directory last, so an interrupted uninstall never leaves a live token behind in a directory it then failed to delete.

func (*Installer) Upgrade

func (in *Installer) Upgrade(spec Spec, out Output, opts UpgradeOptions) (UpgradeResult, error)

Upgrade replaces the service binary and restarts the service.

It reports what it did rather than only whether it succeeded — see UpgradeResult — because "nothing needed doing" and "the binary was replaced but the service was stopped" are both successes that an operator must be able to tell apart from "the device is now running the new build".

type Output

type Output string

Output selects which supervision system the plan targets.

const (
	// OutputSystemd emits a hardened .service unit. The default, because it
	// is what an edge device running a mainstream Linux already has.
	OutputSystemd Output = "systemd"
	// OutputDocker emits a `podman run` command and an equivalent compose
	// fragment, for a device whose only supervision is a container engine.
	OutputDocker Output = "docker"
	// OutputShell emits a POSIX init script, for BusyBox/OpenRC devices and
	// anything else without systemd.
	OutputShell Output = "shell"
)

func ParseOutput

func ParseOutput(s string) (Output, error)

ParseOutput validates an --output value.

type Plan

type Plan struct {
	Output Output
	Spec   Spec

	// Dirs are created before Artifacts are written.
	Dirs []Dir

	// Artifacts are the files to write.
	Artifacts []Artifact

	// Display is the operator-facing rendering for --dry-run. It never
	// contains credential material.
	Display string

	// Next lists the follow-up steps Apply performs (or that the operator
	// must perform when the plan targets a system this machine is not).
	Next []string
}

Plan is everything an install would do, computed without touching anything.

func BuildPlan

func BuildPlan(spec Spec, out Output) (Plan, error)

BuildPlan renders everything an install needs, without writing anything.

type Runner

type Runner func(name string, args ...string) error

Runner executes a system command. Tests substitute a recorder.

type Spec

type Spec struct {
	// ServiceName names the unit, the container, the init script, the
	// system user's home and the state directory. One name so an operator
	// greps for one string.
	ServiceName string

	// User and Group own the credential file and run the process. Empty
	// defaults to ServiceName.
	User  string
	Group string

	// BinaryPath is the cloop binary on the device.
	BinaryPath string

	// StateDir holds the credential file, the agent's long-lived identity,
	// and (by default) the workload root. Empty defaults to
	// /var/lib/<ServiceName>.
	StateDir string

	// UnitDir is where the systemd unit is written. Empty defaults to
	// /etc/systemd/system.
	UnitDir string

	// InitDir is where the OutputShell init script is written. Empty defaults
	// to /etc/init.d.
	//
	// It exists for the same reason UnitDir does. Without it the shell output
	// was the one artifact in this package with no redirectable destination,
	// which made every OutputShell path untestable except against the real
	// /etc/init.d: as root a test would overwrite the host's own service
	// script, and as anyone else it failed outright.
	InitDir string

	// CredentialsFile is the 0600 file the enrollment material is written
	// to. Empty defaults to <StateDir>/enrollment.
	//
	// There is deliberately no way to put the token on the command line
	// instead: a flag that inlines a credential into ExecStart is a flag
	// that will be used, and the resulting unit is world-readable.
	CredentialsFile string

	// AgentCredential is where the agent persists the long-lived identity
	// it receives in exchange for the enrollment token. Empty defaults to
	// <StateDir>/agent.json.
	AgentCredential string

	// WorkDirRoot confines every workload on the device. Empty defaults to
	// <StateDir>/work.
	WorkDirRoot string

	// Server is the control-plane WebSocket URL the agent dials.
	Server string

	// Pin is the hub's SPKI fingerprint ("sha256:<base64>", comma-separated
	// to stage a key rotation). Empty means ordinary PKI verification, which
	// is a real downgrade — see remote.Bundle.
	Pin string

	// Bundle is the encoded enrollment bundle (server + token + pin in one
	// blob). Either this or Token must be set for a first install; both are
	// empty when re-installing over an agent that already holds a
	// credential.
	Bundle string

	// Token is a bare enrollment token, for an operator who has one without
	// a bundle.
	Token string

	// RootCAFile is a PEM bundle to trust in addition to the system store,
	// for a hub behind a private CA.
	RootCAFile string

	// MaxConcurrent bounds simultaneous workloads. Zero lets the agent
	// choose (number of CPUs).
	MaxConcurrent int

	// Labels are scheduler selectors baked into the unit.
	Labels map[string]string

	// Image is the container image for OutputDocker.
	Image string

	// NoStart installs the unit without enabling or starting it, for
	// golden-image builds where the device must not enroll until first boot.
	NoStart bool
}

Spec describes the installation to materialise.

Every field has a usable default; Normalize fills them and rejects the combinations that cannot produce a working service.

func (Spec) InitScriptPath

func (s Spec) InitScriptPath() string

InitScriptPath is where OutputShell writes its script.

func (Spec) Normalize

func (s Spec) Normalize() (Spec, error)

Normalize fills defaults and rejects a spec that cannot yield a working service. It returns a copy: callers keep their input intact so an error message can quote what was actually asked for.

func (Spec) NormalizeForRemoval

func (s Spec) NormalizeForRemoval() (Spec, error)

NormalizeForRemoval fills the same defaults but does not insist on a control-plane URL.

Uninstalling is not the inverse of installing in its inputs: it needs only the paths, and an operator removing a device does not necessarily still have the bundle it was enrolled with — often the reason they are removing it is that the hub is gone. Demanding --server to delete a file would be exactly the friction this command exists to remove.

func (Spec) UnitFileName

func (s Spec) UnitFileName() string

UnitFileName is the systemd unit's basename.

func (Spec) UnitPath

func (s Spec) UnitPath() string

UnitPath is the absolute path the unit is written to.

type UpgradeOptions

type UpgradeOptions struct {
	// Source is the new cloop binary to install. Empty means the running
	// executable, which is the common case: an operator copies a new cloop to
	// the device and runs it with --upgrade.
	Source string
	// Force replaces the binary and restarts the service even when the
	// installed binary is already byte-identical. For recovering from a
	// truncated or mis-permissioned install, where the bytes match but the
	// service is not actually running the binary on disk.
	Force bool
	// DryRun performs every check and reports what would happen without
	// writing a byte or restarting anything.
	//
	// It is a real mode rather than a courtesy, because this command bounces a
	// service on a device an operator may not be able to reach again quickly.
	// The checks it runs are the expensive ones — is anything installed, does
	// the source exist, is it even a different build — so a dry run answers
	// "will this do what I think" rather than merely echoing the flags back.
	DryRun bool
}

UpgradeOptions parameterises an in-place upgrade.

type UpgradeResult

type UpgradeResult struct {
	// Spec is the normalised spec the upgrade ran against.
	Spec Spec
	// Output is the supervision system involved.
	Output Output
	// Source is the binary that was installed from.
	Source string
	// AlreadyCurrent reports that the installed binary was byte-identical and
	// nothing was changed.
	AlreadyCurrent bool
	// BinaryReplaced reports that the service binary was rewritten.
	BinaryReplaced bool
	// Restarted reports that the service was asked to restart. False with
	// BinaryReplaced true means the service was not running, so the new binary
	// is in place and will be used whenever it is next started.
	Restarted bool
	// DryRun echoes that nothing was actually changed, so a caller cannot
	// render a result as done when it was only planned.
	DryRun bool
	// PreviousChecksum and NewChecksum are SHA-256 sums of the old and new
	// binaries, for an operator correlating a rollout across a fleet.
	//
	// "Checksum" rather than "digest" deliberately. These are content sums of a
	// public binary used to answer "are these the same bytes", not credential
	// material, so comparing them with == is correct — and the security suite's
	// constant-time scanner reads any identifier containing "digest" as a
	// secret. Naming them for what they are keeps that check meaningful instead
	// of adding an exception to it.
	PreviousChecksum string
	NewChecksum      string
}

UpgradeResult describes what an upgrade did, so the CLI can report the truth rather than a fixed success message.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL