Deploy¶
Where the services live, how to keep them up, and the parts that cost an evening each to diagnose.
The shape of a deployment¶
| Runs where | Why there | |
|---|---|---|
| collector | One always-on machine | Everything long-polls it, so it has to be up |
| host executor | Wherever devices are physically attached | It drives phones over adb; a cable is required |
| iOS executor | A Mac with full Xcode | simctl and devicectl ship with Xcode, not the Command Line Tools |
| machine agent | Any laptop or desktop | It is a fleet device in its own right |
| runner apps | The phones | They claim their own work |
The first three are roles of one command now. fleet up --role brain,executor
runs a collector and a host executor on one machine; fleet up on its own runs
brain,agent. They are still three programs in three processes -- fleet
wraps them and does not reimplement them, so each still reads its own FLEET_*
variables and still runs perfectly well started by hand.
The reference deployment runs the collector on a spare 2016 MacBook Pro that
does nothing else, and it is deliberately sudo-free: Node is a user-local
tarball and the service is a LaunchAgent in ~/Library/LaunchAgents. The whole
stack can be rebuilt over SSH with nobody at the keyboard.
There is no native code anywhere in the collector. It used to need
better-sqlite3 and a prebuild for the running architecture; the database is
Node's own node:sqlite now, so npm install compiles nothing and an install
cannot fail on a toolchain the machine does not have. That is also what lets the
collector run on Windows, which it never had, and what makes one
docker build --platform linux/arm64 from an x86 laptop produce a working Pi
image.
Keeping it up: fleet service¶
fleet service install # brain,agent, from ~/.fleet/config.json
fleet service install --role brain,executor
fleet service status
fleet service logs
fleet service stop | start | uninstall
That writes one unit running fleet up, and starts it. Which kind of unit
depends on the machine:
| What it writes | Where | |
|---|---|---|
| macOS | a LaunchAgent plist, label com.addisdev.fleet |
~/Library/LaunchAgents/com.addisdev.fleet.plist |
| Linux | a systemd user unit | ~/.config/systemd/user/fleet.service |
| Windows | a schtasks task that runs at logon |
the task scheduler |
fleet service install has never been run, on any platform
Not launchd, not systemd, not the Windows task. Every path it writes is
resolved absolutely -- launchd expands no ~ and reads no login PATH,
systemd is the same and Windows is worse -- and none of it has been watched
start at login. The code is written and reviewed; that is all.
What has been run, on macOS, are the older per-component plists in
collector/deploy/ and runner-machine/deploy/. Those are what the
reference deployment actually uses today, and they are documented in
headless hosts. They are deprecated -- they ship one more
release and are then removed in favour of fleet service -- but they are
the ones with mileage on them.
One unit, not five. The old deployment had a plist each for the collector,
the executor, the iOS executor, the tunnel and the agent. One unit is the
difference between switching the brain off being a config edit
(fleet config set roles agent) and being a launchctl invocation somebody has
to look up.
It also means the crash-loop detection in the supervisor applies. KeepAlive
and Restart=always have no equivalent: they restart a component that crashes
on startup every ten seconds forever, writing a stack trace into a log file
nobody rotates, and from outside that is indistinguishable from a fleet that
works. fleet up backs off, rotates, and after five failures in quick
succession stops and says which log to read. launchd still supervises the
supervisor, which is the right division of labour.
The things that will still catch you¶
- Stop it through
fleet service stop, orlaunchctl bootout-- not by killing the process. launchd starts it straight back. - Do not run
fleet upby hand while the service is loaded. The port is taken, and the second copy exits withEADDRINUSEwhile looking, for a moment, like it worked. - A LaunchAgent starts at login, not at boot. A Mac mini that reboots
unattended needs automatic login, or the same job as a root-owned
LaunchDaemon. A systemd user unit has the same problem and the same class of
answer:
loginctl enable-linger. - On macOS, a service that has to reach the LAN hits the local-network gate. This is the one that costs an evening. See networking.
Binding and exposure¶
There is no authentication, by design. Anyone who can reach the collector
can enqueue a job. FLEET_DASH_TOKEN guards the dashboard's mutations but
POST /jobs stays open so curl and CI keep working. Do not put this on the
internet.
The network is the access control, and FLEET_BIND is what decides which
network that is. It takes a comma-separated list of addresses to answer on and
defaults to every interface -- and says so in the log when it does.
or FLEET_BIND=127.0.0.1,100.x.y.z, which wins over the file. Loopback plus the
host's own tailnet address is the configuration most people want: reachable from
your own devices anywhere, and from nothing else. No port forward, no hotel
wifi, no guest network.
That matters more than it looks, because the LAN-only assumption stops being true the moment one of your agents is a laptop that leaves the house.
mDNS advertising is off by default¶
fleet config set collector.discovery true makes a collector announce itself as
_fleet._tcp, which is what fleet join --discover looks for. It is off by
default because a collector should not start announcing itself on somebody's
office network because they upgraded.
It does not widen anything. mDNS is link-local by construction, so a collector bound to a tailnet address is not advertised across it, and the posture above is unchanged.
The tailnet allowlist¶
This is not authentication and it does not make the collector safe to expose. Everything above still holds. What it addresses is narrower: once the collector answers on its tailnet address so a roaming laptop can claim work, "the network I chose" includes every node on the tailnet -- including one somebody added with a share link, and including a phone handed round at a conference.
So the network is still the boundary. It is just a network you can enumerate.
| Peer registering | What happens |
|---|---|
| loopback | admitted |
| a LAN address | admitted, under the posture above |
| a tailnet address (100.64.0.0/10) | must resolve, via tailscale whois, to a node in the list |
| anything, with the list unset | admitted -- nothing is checked, which is the default |
Three deliberate choices:
- It fences the tailnet, not the house. An allowlist that also fenced the LAN would mean enabling it broke every phone on the shelf.
- A tailnet address that cannot be identified is refused. If an unavailable
tailscalebinary made the lookup fail open, the allowlist would disable itself exactly when it was needed -- so the collector needs the CLI on its PATH, which a launchd agent does not get for free. - Entries are names, not patterns. No globs. An allowlist that can be got wrong quietly is worse than one that has to be typed out.
A refusal is a 403 on POST /devices/register and a warning in the log naming
the node and the reason.
CI runners as devices, for four minutes¶
Rather than opening the collector to GitHub, the runner comes to the collector:
it joins the tailnet, registers with a ttl_s, claims the build job for its
own commit, publishes the artifact and disappears. Everything after that runs on
real hardware at home, and report_to.github_status closes the loop back to the
pull request.
collector/ci/ephemeral-runner.yml is a worked example, and an example on
purpose -- a workflow that tries to reach a collector it cannot see fails on
every push.
More than one brain¶
Two people with two collectors can share a shelf, and a laptop can be a device on the fleet at home and on the one at work. Collectors do not share a database, forward jobs, replicate results, or elect anything: a job enqueued on brain A is A's job and lands in A's database, whatever device ran it.
GET /api/peers lists other brains and /api/peers/:id/* proxies one peer's
read API server to server, which the dashboard's brain switcher uses. The proxy
rather than CORS is a security decision: there is no authentication, so opening
the read API cross-origin would let any website in the operator's browser read
the whole fleet from any tab. Peers are addressed by stable id rather than URL,
so a link cannot be turned into a request to an arbitrary host by editing the
address bar; only an allow-list of endpoints is proxied; and nothing that mutates
is proxied at all.
The rule the device side enforces is the one that matters for numbers: a device runs one job at a time, whoever asked. Two benchmarks at once produce two numbers that are both wrong. See adding devices.
The host executor¶
or FLEET_URL=http://fleet-host.local:8788 npm run executor from collector/.
Everything it needs is user-local, keeping the host sudo-free:
~/.local/platform-tools/adb |
Android Debug Bridge, from Google's zip |
~/.local/jdk |
Temurin 17, needed only because Maestro is JVM-based |
~/.maestro/bin/maestro |
Maestro |
fleet doctor prints which of those it found, and a reason for each one it did
not.
iOS host work is not possible on a machine without full Xcode, by design.
Its xcrun is the Command Line Tools stub and cannot find simctl. See the
iOS executor.
Devices must be physically attached to the executor for it to drive them, with
one exception: collector/src/drivers/roku.ts reaches Rokus over the LAN by
SSDP rather than over a cable. With nothing attached, a host job is claimed and
fails cleanly with no android targets attached, which is the correct answer
rather than a hang.
Device state is journalled, not assumed¶
Anything that changes a phone and must put it back -- network shaping, display settings -- writes its intent to a journal before touching the device, so a crash between the two is recoverable. Both restore every attached device at executor startup, before the first claim.
That makes the journals operator-visible signals. A device still listed in
~/.fleet/network-shape.json or ~/.fleet/device-state.json after a run needs
manual attention: a phone left offline, or left in Arabic at the largest
dynamic type, looks broken rather than configured, and nothing else will tell
you which.
Restoration is asymmetric on purpose. Wifi is re-enabled unconditionally, since a fleet device off the network is broken by definition. Cellular data is only re-enabled when the journal says the executor disabled it, because turning data back on for a device deliberately kept off a metered SIM costs real money.
Schedules¶
Nightly and weekly runs live in
scripts/seed-schedules.ts,
not only in the database. npm run seed:schedules upserts them; it is
idempotent and preserves the on/off state of anything already there, so
re-running can never quietly switch off a run somebody turned on. New
schedules always arrive disabled.
Target them with targets.match rather than pools -- see
Targeting for the case that proves why.
Energy¶
power.json maps a pool to a smart plug; Tasmota, Shelly and Home Assistant all
speak the shape. A pool that also declares read_url, watts_path and
energy_method is sampled, and a job's energy is integrated over its claim
window.
Three things are deliberate and worth knowing before you trust a figure:
energy_methodis declared, never inferred. A pool that omits it gets no energy figure at all.plugmeans the plug feeds exactly one device;plug-sharedmeans several sit behind it and the figure is the pool's. It must not be divided by the device count -- the devices are not identical, they are not all busy, and a per-device number arrived at by division would be indistinguishable in storage from a measured one.- Gaps are counted, never bridged. The integration refuses to extrapolate past its first and last in-window sample, skips gaps over five minutes, and yields null rather than zero below two samples. An unreachable plug must not become fabricated draw.
- The number includes charging, and says so. Subtracting a measured idle baseline removes the charger brick's standing draw, but nothing can remove battery charging current from a wall measurement. The reported quantity is therefore stated everywhere it appears: watt-hours at the wall, above the pool's idle baseline, over the job's claim window, including any charging during it.
Also here¶
- Headless hosts -- the per-component launchd plists and systemd units that are actually deployed today, the tunnel, and what to do when nobody is at the keyboard.
- Networking -- the SSH tunnel, why it exists, and network shaping.
- The iOS executor -- standing one up on a Mac with Xcode.
- Alerts -- the rules, and getting a notification somewhere you will see it.