feat: v0 — the agent, and only the agent - #8
Merged
Merged
Conversation
v0 of design/appify.md §11: komizo-rootd probes the machine and writes report.json, and everything that used to be shell doing it is now a typed document. internal/box is shared by two programs that are not upgraded together -- the binary on the server writes a report, the CLI reads one -- so the schema is a Go type the compiler checks rather than a wire format two codebases agree about in prose. Version states the rule: add fields, never repurpose one. The shell had no such constraint because it was shipped fresh from the laptop on every poll. Ported: the inventory, the per-container cgroup stats (v1 and v2), disk, listening ports read from /proc/<pid>/net/tcp, volume sizes, and the access-log request counts. Diagnose is new as a place rather than as logic -- alias clashes and the rest were computed by each reader, and there are about to be four readers. Two properties carried across deliberately, with tests that fail if either is lost: no secret and no request detail can reach the report, and report.json is 0644 so the reporting account needs no privileges. Found on the way: reading a seeked file dropped the first line whether or not a seek had happened, which silently lost the oldest reading of every history and access log small enough to be read whole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switches every read path -- `komizo list`, the TUI poll, the monitor -- off piping shell over SSH and onto komizo-box. `komizo report` is new: one reading of a box, in one screen, with --json for anything else. init installs the agent instead of the sampler. The binary is embedded (internal/agent) rather than downloaded, for the same reason the provisioning shell is: init has to work on a box with nothing on it, and adding a release URL to that would add a network dependency and a supply-chain step to the one operation meant to work when little else does. Two connections -- the binary on its own, then the installer -- because base64 in a heredoc turns a failed transfer into a syntax error in the middle of a script running as root. The agents must exist before the CLI compiles, since //go:embed cannot invoke a compiler. `make agents` builds them and the release action runs it first; a CLI built without gets one clear message at the moment somebody installs an agent, not a broken build. Both binaries are 2.6MB, so the CLI grows by ~5MB for amd64 and arm64. komizo_monitor is created with no shell, no doas and no docker group -- appify.md §3's account, which nothing uses yet because there is no agent process to run as it. The per-minute crontab entry goes; the sampler's log stays, being the only record of what happened before the update. Fixed on the way: rootd created /var/lib/komizo even when --report pointed elsewhere, which made it unrunnable as anyone but root and untestable. It now creates only what it writes, and laying out the box is the installer's job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README described a server komizo installs almost nothing on and reads by piping shell. Half of that is still true -- provisioning is still shell -- and the reading half is not. Says the trade out loud rather than only the win: the shell arrived fresh on every poll, so a newer komizo read new things off an untouched box, and an agent has to be updated to learn anything new. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draws the line at v0. Nothing here preserves anything the old path did; what the agent reports is what komizo knows. Deleted: ~530 lines of telemetry shell (inventory, metrics, system-log, storage, sampler, sampler-install and the three probe libs), their Go builders, and the six record parsers in internal/app. About 1,300 lines of test went with them -- not because the behaviour stopped mattering, but because they asserted on a wire format that no longer exists. The tests that DID matter were fixture-bound rather than format-bound, so they now build box.Reports and go through inventoryFromReport. That is strictly better than before: the display tests exercise the one path a real box's facts actually take, instead of a hand-written tab record no box ever emitted. Two of them had drifted far enough to assert an upstream of "blog-web" when every box has always written "<app>-gate". Compat removed: the one-line version file, the history file that had to coexist with system.log, the crontab line that displaced the sampler. Fixed, a regression from the port: parseInventory scrubbed control characters at the boundary and inventoryFromReport did not, so an escape sequence in a container name or docker's status prose would have reached the terminal. Scrubbing is back, in the one place values cross. Fixed, a second one: disk folding lived in the deleted parseSystem. The agent already folds by device in Probe.disks, so the reader now trusts what it is sent -- and the rule finally has a test where it lives. And the optimisation the port had regressed: containers were found with a `compose ps` per app, then an `inspect` per container for its service and pid, then another for its mounts. Roughly eighty docker calls per report on a six-app box, on a five-second poll. Membership now comes from compose's own project.working_dir label, and aliases, mounts, pids and timestamps all come out of one `inspect` over every id at once. Five calls per report, flat, at one app and at twenty. There is a test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on gate errNoAgent matched "not found" against the captured stdout and the Go error string. Neither can hold it: the message is written to the far end's stderr, which runCapture passes through to the terminal, and the error this side is "exit status 127". So a box with no agent produced a raw exit-status error, and the one instruction that would have fixed it was unreachable. Now it matches the exit CODE. 127 is what a shell reports for a command it could not find, and ssh passes the remote status through unchanged. Stderr is captured instead of passed through, which also stops a failing agent from writing into the middle of the TUI's frame. The poll and the monitor decoded with a bare json.Unmarshal, so only the report was ever checked against the schema version -- and the two that skipped it are the ones that would have rendered a plausible screen of nothing. Every document now states its version and goes through one generic box.Decode. A document with no version at all is refused rather than read as v0: some other JSON would otherwise decode into a report full of zeroes and draw as a broken server. Report.Stale had no caller. It does now: `komizo report --cached` says when a reading is old, which is the one case where it can be, and a stale report of a healthy box is exactly what a box that died five minutes ago looks like. Also removed: `komizo-box metrics` and `history`, which `monitor` subsumes and nothing called; the awk-execution tests and helpers left behind by the deleted metrics.sh; three copies of the agent-detection logic, now one askBox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Volumes and dirBytes had no test at all, and the rules in them are the subtle kind: a volume shared by two containers is walked once and attributed twice, hard links count once, a sparse gigabyte is measured by what it occupies rather than what it claims, and a volume whose host directory is gone reports nothing rather than zero. internal/agent had none either. It now asserts that every architecture komizo claims to ship is actually embedded and is actually an ELF binary -- a release missing one would otherwise be found by whoever ran init on an arm box. lastLine was wrong for the one-line case: it searched the trimmed string and sliced the original, so a single line ending in a newline fell through untouched and would have carried the newline into a status bar. Also: a test helper reimplementing strings.Contains, and the last two comments describing code that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First run on a real Alpine box, which is what this was waiting for. report.json was 0644 inside /var/lib/komizo, which is 0750 root:root -- correctly, because apps/<app>.env names every app's deploy account and directory. Nothing unprivileged can traverse into it, so the mode on the file meant nothing: `su komizo_monitor -c "cat report.json"` is Permission denied. That is the property design/appify.md §3 is built on, and it did not hold. The test that was supposed to catch it asserted the file's mode, which is a proxy for the property rather than the property, and passed the whole time. The report moves to /run/komizo/report.json, in a 0755 directory. /run because that is what /run is for, and because a report is a claim about NOW -- tmpfs clears it on reboot and rootd rewrites it seconds later, which is right rather than a cost. The history stays under /var/lib: it has to survive a reboot, and nothing unprivileged needs it, because the agent posts each report as it is written. The installer now PROVES the boundary on the box rather than asserting it -- it reads the report as komizo_monitor and fails the install if it cannot. The Go test walks the real directory chain, and checks that the check can fail by pointing it at the old layout. Also from the same run: komizo_monitor's home field named an /home path nothing created, now /nonexistent; history.jsonl claimed 0644 inside a private directory, now 0640, because a mode that says something the directory contradicts is how this happened in the first place. Verified on alpine:3.20 with openrc: static musl binary runs, adduser flags are right, the service file is written, rc-update takes it, the account can read the report and cannot read the state directory, and a second run of the installer is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
internal/box was right when two programs in one module shared it. The service is a third reader and a different module, and a schema only one module can express is one the others have to restate -- which is two definitions of a wire format waiting to disagree, and the exact thing the version rule exists to prevent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
appify.md§11's v0, built as the only way komizo reads a server. No compatibility layer, no second path — v0, line drawn.What's here
internal/box— the report schema and the probes. Shared by the CLI and the agent so the schema is a Go type the compiler checks, not a wire format two codebases agree about in prose.Versionstates the rule (add fields, never repurpose one), which the shell never needed because it shipped fresh from the laptop on every poll.cmd/komizo-box—rootd(root, timer, writesreport.json), plusreport/poll/monitor/metrics/history.komizo report— one reading of a box in one screen,--jsonfor anything else.internal/agent— the compiled agents, embedded into the CLI.make agentsbuilds them; the release action runs it first.initinstalls the agent.komizo list, the TUI poll and the monitor all read it. ~530 lines of telemetry shell and its six record parsers are gone, along with ~1,300 lines of test that asserted on a wire format that no longer exists.Diagnoseis new as a place rather than as logic — alias clashes, detached apps, a stopped proxy were computed by each reader, and there are about to be four readers.Five docker calls, flat
The first cut of the port asked per app: a
compose psto find an app's containers, aninspectper container for its service and pid, another for its mounts. Roughly eighty invocations per report on a six-app box, on a five-second poll — worse than the shell it replaced, which had been rewritten once for exactly this reason.Membership now comes from compose's own
project.working_dirlabel, and aliases, mounts, pids and timestamps all come out of oneinspectover every id at once. Five calls per report at one app and at twenty, with a test that fails if that changes.Bugs found on the way
parseInventoryscrubbed control characters at the boundary;inventoryFromReportdid not. An escape sequence in a container name or docker's status prose would have reached the terminal — JSON carries one through perfectly well.parseSystem, so/and/var/lib/dockercould chart as two bars for one filesystem. The agent already folds by device; the reader now trusts it, and the rule has a test where it lives.rootdcreated/var/lib/komizoeven when--reportpointed elsewhere, making it unrunnable as anyone but root and untestable.blog-webwhen every box has always written<app>-gate.Properties kept, with tests that fail if they're lost
report.jsonis 0644, which is what letskomizo_monitorhave no privileges at all (appify.md §3). That account is created and does nothing yet — there's no agent process to run as it, because there's nothing to report to.stoppedtravels in the report, so a deliberate stop doesn't page anyone.Not verified
The agent has never run on a real Alpine box. Everything is tested against a fake
/proc, a fake docker and a temp filesystem; the install script is syntax-checked and rendered but not executed. First run on a real box is the remaining risk — most likely the OpenRC service or busyboxadduser's flags. There is no fallback path now, which is the point, and worth knowing before the firstinit.Size
Both agents are 2.6MB, so the CLI grows ~5MB carrying amd64 and arm64.
🤖 Generated with Claude Code