box

package
v0.0.93 Latest Latest
Warning

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

Go to latest
Published: Oct 12, 2026 License: MIT Imports: 32 Imported by: 0

Documentation

Overview

Package box is what runs ON a server: the probes that describe a machine, and the report they produce.

It is imported by three programs that are not the same program and are not upgraded together. komizo-box runs on a server as root and WRITES a report. The komizo CLI runs on a laptop and READS one. The komizo service receives them from every box it knows about, each running whatever version it was last given. They share this package so the schema is a Go type checked by the compiler rather than a protocol three codebases agree about in prose.

PUBLIC, not internal/, for that third reader: the service is a different module. A schema that only one module can express is a schema the other two have to restate, and a restated wire format is two definitions waiting to disagree. See Version for what that costs and what the rule is.

Nothing in here may import internal/app. The CLI is allowed to know about the box; the box is not allowed to know about what reads it.

Index

Constants

View Source
const (
	// EtcDir holds the agent's credential.
	//
	// Under /etc, NOT under StateDir. The state directory is 0750 root:root
	// because apps/<app>.env names every deploy account, so nothing
	// unprivileged can traverse into it -- a credential placed there would be
	// unreadable by the one process that needs it. That is exactly what
	// happened to report.json, found by the first run on a real Alpine box, and
	// it is worth not repeating in a file that would fail silently instead of
	// loudly.
	//
	// /etc is world-traversable; /etc/komizo is not. The GROUP is the boundary:
	// root writes it, the agent reads it, nothing else can see it exists.
	EtcDir = "/etc/komizo"

	// AgentConfPath is the whole of what enrolment leaves behind.
	//
	// One file rather than a token beside a URL. They are issued together, they
	// are replaced together, and two files are two things that can disagree
	// about which server this is.
	AgentConfPath = EtcDir + "/agent.json"
)
View Source
const (
	MaxArgNameBytes  = 64
	MaxArgValueBytes = 256
)

MaxArgNameBytes and MaxArgValueBytes bound one argument.

These were two literals inside VerifyCommand, and they are named for the same reason KnownOp exists: since komizo-be#180 the SIGNER is the service, and a signer that guesses these produces envelopes the box silently refuses. The symptom of that guess being wrong is a button that does nothing, which is the hardest kind of wrong to find.

The app has its own copies (MAX_ARG_NAME_BYTES in app/src/lib/device.ts) and always will -- it is a browser and cannot import Go. Those are checked against these by CI rather than trusted to stay in step.

View Source
const (
	OpAppStart   = "app.start"
	OpAppStop    = "app.stop"
	OpAppRestart = "app.restart"

	// OpAppAdd sets an app up.
	//
	// NOT "or updates one", which is what this said and could not do. `pubkey`
	// is mandatory and alpine.sh WRITES authorized_keys, so pointing this at an
	// app that already exists rotates its deploy key and breaks that repository's
	// CI -- where `komizo add` reads the existing values back off the box first.
	// Updating over this transport is a separate op with its own arguments, not
	// this one used twice.
	//
	// The op that mints nothing. `komizo add` on the command line generates a
	// deploy keypair and prints the private half; a command carries only the
	// PUBLIC half, because a result file lives where the account that talks to
	// the internet can read it and report.go says never, in anything it can
	// read, "registry tokens, private keys". The app generates the pair and
	// shows the private half once -- app-only.md §5a.
	OpAppAdd = "app.add"

	// OpAppRotate replaces one app's deploy key and changes nothing else.
	//
	// nicodes/komizo-be#112. The moment a deploy key is discovered leaked is the
	// moment most likely to happen away from a laptop, and it was the one
	// capability with time pressure that the app could not reach.
	//
	// SEPARATE FROM app.add RATHER THAN app.add WITH FEWER ARGUMENTS. app.add
	// carries every setting an app has -- config image, deploy account, app
	// directory, hostnames -- and a caller that had to supply those to rotate a
	// key would be a caller that could CHANGE them while rotating one. The
	// screen doing it knows the app's name and nothing else, and it should not
	// have to know more to revoke a credential.
	//
	// So the box reads the rest off its own record. Every setting comes from
	// /var/lib/komizo/apps/<app>.env, which root wrote, and the only thing the
	// envelope decides is which app and which key.
	//
	// AND THE APP MUST ALREADY EXIST. app.add creates one; this refuses a name
	// it has no record of rather than provisioning it, because "rotate the key
	// of an app that is not here" is a typo and creating an app in response to
	// one is the worst available reading of it.
	OpAppRotate = "app.rotate"

	// OpLogsRead is a READ, and the only op here that nothing applies.
	//
	// It exists because app-only.md §5 asks for logs to be authorised by the
	// DEVICE rather than by the registry: a read token names a server and an
	// expiry and no user, so the ownership check is the service's to make, and
	// whoever holds its signing key could otherwise mint one for any box. That
	// is already true of the report and the history -- registry.md §6 decided it
	// -- and §5 refuses to put the most sensitive bytes on the machine behind
	// the same single environment variable.
	//
	// The serving account verifies this itself, which grants it nothing: the
	// keys are public, and verifying with a public key is not signing with one.
	//
	// It is NOT an op /v1/commands accepts -- see ApplyOps. A comment here once
	// claimed rootd would refuse it, and rootd did: it took the file, verified
	// it, wrote a claim and wrote a failure. Refusing it costs a public-key
	// operation and two writes; refusing it at the route costs nothing.
	OpLogsRead = "logs.read"

	// OpReportRead and OpHistoryRead are the same shape, for the same reason.
	//
	// komizo-be#58: the report and the history were behind the registry token
	// alone, so whoever held the service's signing key could read every enrolled
	// box -- which is the sentence komizo.dev is built on, being true of two
	// routes out of four. §5 had already refused that trade for logs; this is the
	// same refusal applied consistently rather than a new argument.
	//
	// Neither is applied. See ApplyOps.
	OpReportRead  = "report.read"
	OpHistoryRead = "history.read"

	// OpMetricsRead is what the proxy's access log says about an app: requests
	// and failures per minute, per service.
	//
	// komizo#80. It was computed only by `komizo-box poll` and `monitor`, which
	// the CLI runs over SSH, so the TUI could draw a sparkline and the app could
	// not -- the one column of the interface with no path to a device. A READ,
	// like the three above, and not applied.
	//
	// SEPARATE FROM history.read RATHER THAN FOLDED INTO IT. Samples are written
	// on rootd's timer; metrics are computed from a log over an arbitrary
	// window. Serving them together would force one cadence on two things that
	// do not share one, and would grow every stored sample to carry counts it
	// was not measured with.
	OpMetricsRead = "metrics.read"
)

The ops a box knows, which is the same closed set komizo-box implements.

Named here so that VerifyCommand can refuse an unknown one BEFORE anything dispatches on it, and so that adding a verb is one edit rather than two that can disagree.

View Source
const (
	StateDir = "/var/lib/komizo"
	AppsDir  = StateDir + "/apps"

	// RunDir is the one directory anything unprivileged may enter, and
	// ReportPath is the one file in it.
	//
	// NOT under StateDir, which is 0750 root:root because apps/<app>.env names
	// every app's deploy account and directory. A world-readable file inside a
	// directory nothing may traverse is world-readable in name only -- which is
	// what this was, and the mode on the file said otherwise.
	//
	// /run because that is what /run is for: state describing the system since
	// it was booted. It is tmpfs, so a reboot clears it and the report is
	// rewritten on rootd's first tick seconds later. That is the RIGHT
	// behaviour rather than a cost: a report is a claim about now, and one that
	// outlives the boot it describes is a lie waiting to be read.
	RunDir     = "/run/komizo"
	ReportPath = RunDir + "/report.json"
	// DeployFloorsPath is operator-written host-wide byte floors that
	// deploy-APP reads as root. Komizo never creates this file. Empty or
	// missing keys mean no floor: deploys fail-open with a warning rather
	// than inventing a production SLO in code.
	DeployFloorsPath = "/etc/komizo/deploy-floors"
	// ServedDir is what root writes for somebody else to read.
	//
	// Its own directory, and for the reason APISocketDir got one. StateDir is
	// 0750 root:root because apps/<app>.env names every app's deploy account and
	// directory -- so the account that serves this box cannot traverse it, and
	// the history that was kept in there was unreadable by the one process whose
	// job is to hand it out.
	//
	// That failed SILENTLY, which is why it survived a release: ReadSamples
	// treats an unreadable file the same as an absent one, because a box that
	// has never sampled is a new box rather than a broken one. So GET /v1/history
	// answered "no readings" on every box, forever, and nothing anywhere said
	// permission.
	//
	// The fix is the smaller thing to lend, the same as it was for the socket: a
	// directory containing only what a reader was going to be given anyway.
	// Under StateDir still, because this has to SURVIVE a reboot -- which is the
	// one property that separates it from the report.
	ServedDir = StateDir + "/served"

	// HistoryPath is one JSON reading per line, oldest first.
	//
	// Append-only and trimmed by bytes. Reading it is a tail, because the
	// question is always about recent minutes.
	//
	// Written by root and read by the account with nothing, which is the same
	// boundary the report is on. It moved here from StateDir when the box became
	// the store rather than a courier -- komizo-be design/registry.md.
	HistoryPath = ServedDir + "/history.jsonl"

	// MetricsPath is what the proxy's access log said, computed by rootd and
	// left where the serving account can read it.
	//
	// komizo#80, and the location is decided by a permission rather than a
	// preference: /srv/_proxy/logs is 0750 root:root, and the API runs as
	// komizo_monitor. It cannot read the access log at all. Loosening that
	// directory would be the cheap fix and the wrong one -- it holds every
	// hostname and path this box serves, and the split exists so that a
	// compromise of the network-facing process does not read the box.
	//
	// So the measurement happens where root already is, and the answer lands
	// beside report.json and history.jsonl, which are there for the same reason.
	MetricsPath = ServedDir + "/metrics.json"
	VersionPath = StateDir + "/version"

	// APISocketDir holds the socket the box answers on, and it is its OWN
	// directory for two reasons that arrived a day apart.
	//
	// The first is that RunDir is 0755 root:root, so the account that opens the
	// socket cannot create a file in it. That is the fourth time this shape has
	// bitten -- the report, the state directory, the credential, and now this --
	// and every time it was a process that could READ where it needed to WRITE.
	//
	// The second is what comes next: the box's proxy has to reach this socket,
	// which means a bind mount, and mounting RunDir would hand the one
	// internet-facing container on the box a writable path next to report.json.
	// A directory with nothing in it but a socket is the smaller thing to lend.
	APISocketDir = RunDir + "/api"

	// APISocketPath is where the box answers for itself.
	//
	// A socket rather than a port, so nothing new listens on the network -- see
	// cmd/komizo-box/serve.go.
	APISocketPath = APISocketDir + "/api.sock"
	// PendingDir is where signed requests land for rootd to apply. Nothing in v0
	// writes here; the directory is created so the shape is visible on a box and
	// the permissions are decided once, by the thing that owns them.
	PendingDir = StateDir + "/pending"

	SrvDir    = "/srv"
	ProxyDir  = SrvDir + "/_proxy"
	AccessLog = ProxyDir + "/logs/access.log"
	Caddyfile = ProxyDir + "/Caddyfile"

	OSRelease = "/etc/os-release"
)

Where komizo keeps things on a server.

Constants rather than values threaded through every call: these are decided by the provisioning scripts, not by a caller, and a path passed as an argument is a path some future caller can pass wrongly. The tests that need to point somewhere else take a root prefix instead -- see probe.Root.

View Source
const (
	PreviewDBContainer   = "komizo-previews"
	PreviewDBSuperuser   = "postgres"
	PreviewDBMaintenance = "postgres"
)

PreviewDBContainer is komizo's OWN postgres, one per box, and the only database a preview ever touches.

It used to create its role and database inside the APP's postgres -- superuser DDL against the server holding production data, on every PR opened and every PR closed. The containment was careful and the blast radius was still production's database. komizo is supposed to be non-invasive: it owns what it created and nothing else, which is why a stop is recorded in the app's own record and why sweep pins its argv to make "never touches a volume" provable. The preview database was the one place that reached inside.

Owning the server also removes an accidental requirement. The old path had to FIND the app's postgres, so a product without one -- a gate-only static site -- could not have a preview at all, failing with "no postgres container is running". Nothing about a static site needs a database, and now nothing asks it for one.

Parity is kept by pinning the same image every product pins, so a preview runs the engine production runs.

View Source
const (
	PreviewDomainDefault    = "preview.gdam.dev"
	PreviewTTLDefault       = 72 * time.Hour
	PreviewMaxDefault       = 5
	PreviewMemLimitDefault  = "512m"
	PreviewCPULimitDefault  = "0.75"
	PreviewAskPortDefault   = 8487
	PreviewPortRangeDefault = "20000-21000"
)

Preview defaults.

View Source
const (
	ProblemAliasClash           = "alias_clash"
	ProblemProxyStopped         = "proxy_stopped"
	ProblemDetached             = "detached"
	ProblemOrphanDir            = "orphan_dir"
	ProblemNoTLSGate            = "no_tls_gate"
	ProblemAppDown              = "app_down"
	ProblemDeploymentIncomplete = "deployment_incomplete"
	// An app running under a record that says it was stopped. komizo#57.
	//
	// The one problem here that is about the REPORT rather than about the box:
	// every other kind names something wrong with the machine, and this names a
	// disagreement between the machine and what komizo believes about it. It is
	// worth a kind of its own precisely because of what the disagreement costs --
	// app_down keys on the marker, so an app in this state cannot page.
	ProblemStoppedButRunning = "stopped_but_running"
)

The problem kinds. Named constants because an alert rule matches on these, and a typo in a string literal produces a rule that silently never fires.

View Source
const (
	// HistoryMax is roughly a week at a handful of containers.
	HistoryMax = 16 << 20
	// HistoryKeep is how many readings survive a trim.
	HistoryKeep = 20000
)
View Source
const (
	SweepMinAgeDefault   = 7 * 24 * time.Hour
	SweepIntervalDefault = 24 * time.Hour
)

SweepMinAgeDefault and SweepIntervalDefault are the conservative knobs when the operator has written none.

View Source
const APISocketDirMode = os.ModeSetgid | 0o750

APISocketDirMode is 0750 AND SETGID, and the setgid bit is the whole point.

The box's proxy has to connect to the socket in here, and it runs with every capability dropped but CAP_NET_BIND_SERVICE -- komizo's own hardening, in alpine-proxy.sh. So its root is NOT exempt from permission checks the way root usually is: it cannot traverse a directory it does not own, and it cannot connect to a socket it has no write bit on.

A socket is created by whoever binds it, which is the agent account, and it would inherit that account's group. Setgid on the directory makes it inherit the DIRECTORY's group instead -- root -- which the proxy is in. Nothing else on the box is.

The alternative was 0666 on the socket. That works, and it means any local process can open the one door on this machine that answers questions about it. A valid token would still be required; being able to knock is not nothing.

View Source
const APIVersion = 1

APIVersion is the schema of the documents this serves.

Its own number, not box.Version. The report's schema and this API's shape change for different reasons -- a new field in the report does not alter what these routes are -- and one number covering both would force a version bump on readers that were not affected.

View Source
const AgentUser = "komizo_monitor"

AgentUser is the account the agent runs as. The GROUP of the same name is what makes the credential readable to it and to nothing else.

View Source
const CommandPrefix = "kmz_cmd_"

CommandPrefix marks the signed form, so a string in a log or a bug report can be identified without being decoded.

View Source
const CommandVersion = 1

CommandVersion is the envelope's schema. Its own number, not APIVersion and not Version: a new route does not change what a command is, and a new report field does not either.

View Source
const DetailMax = 4 << 10

WriteResult records the outcome, atomically.

Atomically because the serving account reads these on its own schedule and the two are not coordinated -- a reader that caught a half-written document would get a JSON error, and the honest reading of a JSON error from a box is "this is broken", which would be a lie told every time.

0640 AND THE GROUP, both, because the mode alone is not the boundary.

Root writes these and the serving account reads them. A file root creates is born root:root, so 0640 on its own is a result only root can open -- which is the third time this shape has bitten and the second time it shipped. On a real box every result was -rw-r----- root:root, the serving account is komizo_monitor and in no other group, and GET /v1/commands/{id} therefore answered "no result yet" for every command, forever: the operator watched eleven minutes of compose work and sixteen of app.add succeed on the machine and be reported as a box that never said what happened.

TWO MECHANISMS, DELIBERATELY. The directory is setgid to the serving account -- see PrepareResultsDir -- so the file is born in the right group; and it is chgrped afterwards anyway, the same as the credential in WriteAgentConf. The setgid bit is one chmod away from being cleared by anything that touches the directory, and that is precisely how it was cleared here. The chown is a no-op on a box where the bit survived, and is the difference between a result and a silence on one where it did not. DetailMax bounds what a result may carry, IN BYTES, at the writer.

komizo#68. Every bound on Detail used to be at a producer -- runProvision's lastLines, composeOut's copy of the same argument -- so the rule was real, written down twice, and upheld by convention. `applyOne` sets `res.Detail = err.Error()` for ANY error a verb returns, so the next op that returns a long error wrote an unbounded result file, which rootd puts under ServedDir and the agent then posts. Nothing in the type, the writer or the tests said otherwise.

BOUNDED WHERE IT IS WRITTEN, so a producer that forgets is corrected rather than trusted. The producers keep their own trims: those are about choosing WHICH lines are worth having, which is a judgement this cannot make, and this is about how much may leave the box. Two different questions that happen to share a number.

View Source
const DeviceKeyPrefix = "kmz_dev_"

DeviceKeyPrefix marks a device's public key.

Public, so this is not the leak-detection argument the token prefixes make. It is so that a string somebody has pasted into an issue, a script or the wrong flag can be identified as what it is without being decoded first.

View Source
const DeviceKeyUsage = "a device that may command this box, from the app (repeatable)"

DeviceKeyUsage is the one wording for the flag, so the two commands that take it cannot describe it differently.

View Source
const InboxDir = RunDir + "/inbox"

Where a signed command lands, and where its result comes back.

komizo-be design/app-only.md §4: the account that SERVES the box is not the account that ACTS. The write route runs as komizo_monitor -- no shell, no docker group, no doas -- and it does not apply anything. It drops a signed blob here, and rootd verifies and applies it. The serving process cannot forge one because it holds no key, which makes this a dropbox rather than a privilege.

TWO DIRECTIONS, TWO DIRECTORIES, and each is owned by whoever writes into it:

InboxDir    monitor writes, root reads     a command arriving
ServedDir   root writes, monitor reads     the result going back

NOT PendingDir. That has existed empty on every box since the beginning and it is 0750 root:root inside a StateDir that is also 0750 root:root, so the serving account cannot traverse to it, let alone create a file in it -- and both serving processes describe themselves as having "nothing under the state directory". Reusing it would have been the sixth time this shape bit: a process handed a path it can read where it needs to write. It was very nearly the design, because an empty directory named "pending" reads like an invitation.

View Source
const InboxDirMode = os.ModeSetgid | 0o750

InboxDirMode is 0750 and setgid, the mirror of APISocketDirMode.

There the account creates a socket and root's group has to reach it; here the account creates a file and root has to read it. Setgid so a command the account drops is born in the directory's group rather than the account's -- which is not what lets root read it (root reads anything) but is what stops anything ELSE that shares the account's group from doing so.

No world bits. A command in flight names an app on this machine.

View Source
const InboxFull = 128

InboxFull is how many commands may be waiting before this route says no.

Below the sweep rootd applies, so the answer to a full inbox is "try again" rather than a box quietly discarding work. Bounded because the directory is tmpfs, and tmpfs is memory.

View Source
const LogKeyPrefix = "kmz_log_"

LogKeyPrefix marks an account's log key.

Its own prefix, for the reason DeviceKeyPrefix has one and then some: there are now four opaque base64-ish blobs in this system -- a registry key, an agent token, a device key and this -- and a person moving one between two windows cannot tell them apart by looking. Planting the wrong one as a log key is planting an authority nobody meant to grant.

View Source
const LogKeyUsage = "an account that may read this box's logs (repeatable)"

LogKeyUsage is the one wording, so the commands that take it cannot describe it differently.

View Source
const LogsDir = ServedDir + "/logs"

LogsDir holds one file per app, and NOT the shared proxy's.

It said "plus the shared proxy's" while the two guards fourteen lines below it exist to make sure that never happens -- which is the same re-add-the-leak instruction the design doc was corrected for, sitting in the code. Caddy's error logger writes full requests to stdout; see collect.go.

Under ServedDir, which is root-writes-agent-reads and setgid, so a file root puts here is born in a group the serving account can open.

View Source
const LogsMax = 256 << 10

LogsMax bounds one app's file.

A quarter of a megabyte is a few thousand lines, which is far more than the tail anybody reads and small enough that a box running twenty apps spends five megabytes on this. Bounded at all because it is written every interval forever, on a disk that is somebody else's.

View Source
const MaxCommandBytes = 8 << 10

MaxCommandBytes bounds one command.

Small on purpose. A command is an op and a handful of short arguments; this is reachable from a route on the internet, and an unbounded read is an unbounded allocation before anything has been verified.

View Source
const MaxCommandTTL = 5 * time.Minute

MaxCommandTTL is the longest a command may be valid for.

The threat is not a stranger -- it is a service that ROUTED the request and chose not to deliver it, holding a valid signature to spend later. A short life is what makes that captured signature nearly worthless, so the box refuses one dated further out rather than trusting the signer's judgement.

View Source
const MaxSubjectBytes = 64

MaxSubjectBytes bounds the account name in an envelope.

Generous next to what fills it -- komizo signs a PocketBase record id, which is fifteen characters -- and deliberately not sized to that. The box does not get to decide what a service calls its accounts, and a limit tuned to today's id would be a limit that breaks the day the service changes one.

View Source
const MetricsWindow = 60 * 60

MetricsWindow is how much of the access log rootd keeps computed.

An hour, because the app's sparkline asks for half of one and a reader who pulls to refresh should not find the window has moved under them. Longer would cost nothing to compute -- Metrics reads a bounded tail either way -- and would cost a larger file for readings nothing asks for.

View Source
const PreviewKnobPath = "/etc/komizo/preview"

PreviewKnobPath is the operator's tuning file. Komizo never creates it; the defaults below are the conservative answer.

View Source
const PreviewsDir = StateDir + "/previews"

PreviewsDir is where preview state lives: one directory per preview, beside the app records and equally root-owned.

View Source
const ReadTokenPrefix = "kmz_rd_"

ReadTokenPrefix marks a token as this kind, so a leaked string is identifiable at a glance -- in a log, a bug report, or a secret scanner.

View Source
const ResultKept = 24 * time.Hour

ResultKept is how long a result stays readable.

Long enough that somebody who pressed a button, locked their phone and came back can still see what happened; short enough that a box does not accumulate a file per command forever. It also bounds the replay record, which is the reason this is not simply "until the disk fills": a command's own expiry is minutes, so an id can be safely forgotten long before this.

View Source
const ResultVersion = 1

ResultVersion is this document's schema, stated for the reason every other document here states one: the box and the app are separate releases.

View Source
const ResultsDir = ServedDir + "/results"

ResultsDir is the default. Passed explicitly to everything below rather than read from here, so a test can point at a temp directory -- the same seam Probe.Root and AppDir already are, and the reason the replay check below is testable at all.

ResultsDir is where they live. Under ServedDir because the reader is the serving account, and that directory is already the one root writes into for it -- see PrepareServedDir.

View Source
const ServedDirMode = os.ModeSetgid | 0o750

ServedDirMode is 0750 and setgid, and it is setgid for the mirror image of the reason APISocketDirMode is.

Here root creates the files and the agent account reads them, so a file would be born group root and unreadable by the one process that serves it. Setgid on the directory gives it the DIRECTORY's group instead -- the agent's -- and AppendSample's 0640 then means what it says.

No world bits. Every other account on the box, including every app's deploy account, has no business reading what this machine measured about itself.

View Source
const StateDirMode = os.FileMode(0o750)

StateDirMode is 0750, and the GROUP is the whole of it.

/var/lib/komizo is closed because apps/<app>.env names every app's deploy account and directory. But ServedDir lives inside it, and the account that serves this box has to reach that -- so the directory has to be TRAVERSABLE by that account while its contents stay closed.

It was root:root, so the agent could not enter at all and every mode set on anything beneath it was decorative. That is the same confusion paths.go records about report.json: "a world-readable file inside a directory nothing may traverse is world-readable in name only". This is the other direction of it, and it survived review because nothing walked the chain -- the installer caught it on the first real box.

Group r-x, which is traverse and list. Listing shows four directory names and no contents; apps/ and pending/ are 0750 root:root and stay unreachable. The same boundary EtcDir already uses, in its own words: "/etc is world-traversable; /etc/komizo is not. The GROUP is the boundary."

View Source
const StoppedByCLI = "cli"

StoppedByCLI is who stopped an app over SSH.

The ROUTE, not a person, and that is the honest answer rather than a lazy one. What actually reaches the box on this path is an SSH session that already authenticated as root by key; the box never learns which human sat behind it. A laptop could send its local username, but that is a string the sender chose about itself -- unverified, and recorded as though it were established fact.

What it must NOT be is anything about where the connection came from. This value is written into a report that is served over the network to the registry and rendered in an app, so an address or a hostname here is a piece of somebody's infrastructure travelling further than they asked it to. The box knows the route; the route is what it says.

View Source
const SweepKnobPath = "/etc/komizo/disk-sweep"

SweepKnobPath is where the operator tunes the sweep. Komizo never creates it, like the deploy floors: defaults above are the conservative answer, and the file only ever makes the sweep GENTLER in cadence or stricter in age -- there is no key that widens what may be removed.

View Source
const TempPrefix = ".tmp-"

TempPrefix marks a command that is still being written.

A DOT, and that is the contract with rootd rather than a convention: it reads this directory on a timer and must not pick up a file mid-write. It did, and the result was a command applied and then removed, so the route's rename failed and the caller was told 503 for work that had already happened.

Exported so the reader uses this and not a literal of its own. Two spellings of "still being written" is one spelling that stops matching.

View Source
const Version = 1

Version is the report schema, and the rule for changing it.

ADD fields. Never rename one, never repurpose one, never change what a unit means. An old CLI reading a new report ignores what it does not know, and a new CLI reading an old report sees a zero -- both of which are survivable. A field that changed MEANING is not: it decodes perfectly and is wrong, on a screen somebody is using to decide whether their server is healthy.

This is the cost of moving the probe from shell to a binary. The shell was shipped fresh from the laptop on every poll, so producer and consumer were always the same release and the wire format could change freely. It cannot now.

Bumped only when a reader must refuse the whole document, which no addition warrants. Nothing does yet.

Variables

ApplyOps is the subset /v1/commands accepts, which is every op that CHANGES something. app.add is one of them: it provisions, which is work for root.

Two sets because they answer different questions, and one set answering both is how a read envelope became something root would pick up, parse and write two files about. apply.go asserts its own dispatch against this, so adding an op to one and not the other is a refusal rather than a silent success.

View Source
var ErrCommandRefused = fmt.Errorf("this box will not act on that")

ErrCommandRefused is every reason a command was not accepted.

One error, deliberately. "Not signed by a key I hold", "meant for another box", "expired" and "malformed" are one answer as far as a caller is concerned, and telling them apart out loud is free reconnaissance about which keys a box trusts. rootd logs the detail locally, where the operator is.

View Source
var ErrUnauthorized = errors.New("missing or invalid read token")

ErrUnauthorized covers every reason a token was not accepted.

Which reason is deliberately not reported. "Expired", "forged" and "meant for another box" are one answer as far as a caller is concerned, and telling them apart out loud is free reconnaissance.

Functions

func APIHostFor added in v0.0.16

func APIHostFor(sshHost, override string) (string, error)

APIHostFor picks the endpoint from what is already known.

The override wins. Otherwise the SSH target IS the answer whenever it is a name: reaching a box by one means a record already points at it, and https://<that name> reaches the Caddy on it. Asking a question whose answer is on the command line is how a setup step becomes something people skip.

An IP target returns "" -- no endpoint, no error. That is a supported state, not a failure, and the caller is expected to say so out loud.

func AppDir added in v0.0.16

func AppDir(root, name string) (string, error)

AppDir is where one app lives, from komizo's own record of it.

Read from AppsDir/<name>.env rather than assumed to be SrvDir/<name>. An app placed elsewhere with --app-dir is invisible to that assumption, and the failure it produces is the worst kind: a lifecycle command that finds no compose file, does nothing, and has no reason to say so. The same argument appStates makes about globbing /srv.

root prefixes the path, for the same reason Probe.Root does -- so this is testable without a box.

func AppSettings added in v0.0.17

func AppSettings(root, name string) (map[string]string, error)

AppSettings is one app's record, as komizo wrote it.

EVERY VALUE ROOT DECIDED, handed back so a re-provision can preserve them. nicodes/komizo-be#112: app.rotate replaces a deploy key and must change nothing else, and the only way to be sure of that is to re-run the provisioning script with the values already on the box rather than with values a caller supplied. A caller that had to supply them is a caller that could change them while rotating a key.

A missing record is an error rather than an empty map: "rotate the key of an app this box has never heard of" is a typo, and provisioning one in response to it is the worst available reading.

func AppendSample

func AppendSample(path string, s Sample, max int64, keep int) error

AppendSample adds one reading to the history log and trims it if it has grown past max bytes.

Trimmed only when it has grown, rather than rewritten every time: the copy costs the whole file, and paying that once every few days is the difference between a background job nobody notices and one that shows up in iowait.

Trimmed by BYTES, not by age. Age would make retention depend on how many containers the box runs -- twenty containers would keep a fifth as long as four, for the same disk -- and the thing actually worth bounding is the disk.

func Applied added in v0.0.16

func Applied(dir, id string) bool

Applied reports whether this command has already been done.

This is the replay check, and it is a file rather than a set in memory because a box restarts. A signed command is valid for minutes; a process that forgot every id it had seen would accept every one of them again for the rest of that window.

func Applies added in v0.0.16

func Applies(op string) bool

Applies reports whether this op is one the command route takes.

func ApplyPreviewRoute added in v0.0.42

func ApplyPreviewRoute(ctx context.Context, run previewRun, proxy, routesDir, file, content string) error

ApplyPreviewRoute writes a route with the same discipline as every other route change on this box: write, validate the COMBINED config inside the proxy container, restore the previous file set on failure, reload on success. The previous content of the file (if any) is held for the restore.

func ClearStopped added in v0.0.16

func ClearStopped(root, app string) error

ClearStopped takes the marker off, so the app can page again.

Every key, not just STOPPED. Leaving STOPPED_BY and STOPPED_AT behind would leave a running app carrying the record of a stop that is over, and the next person to read that file would have no way to tell it from a live one.

func ClearStoppedIfUnchanged added in v0.0.75

func ClearStoppedIfUnchanged(root, app, expected string) (bool, error)

ClearStoppedIfUnchanged cannot erase a newer owner request or provisioning change. It shares the mandatory record lock with every stop-marker writer.

func Decode

func Decode[T Document](b []byte) (T, error)

Decode parses a document and refuses one from a future it cannot read.

The version check is the reason this exists rather than a bare Unmarshal at each call site. A box running a newer binary than the CLI is a NORMAL state -- boxes are updated by hand, one at a time -- and the failure has to be a message about versions rather than a screen of plausible zeroes.

A document with no version at all reads as v0, which no komizo-box has ever written, so it fails the same way: this is not a document komizo produced.

func Fingerprint added in v0.0.16

func Fingerprint(key string) string

Fingerprint is a device key in the form somebody compares by eye.

The first and last eight characters of the key ITSELF, not a hash of it. A hash would be a second thing to compute in two places and get wrong once, and what a person actually does is look at a screen and look at a terminal and see whether they match. The app renders exactly this form, and the two ends agreeing is the whole of the check.

It is not a security boundary on its own. The key is carried in the command the operator pastes, so this catches a truncated paste and a key that went somewhere unexpected -- not a browser that was served a dishonest bundle, which would show a matching fingerprint for a key that was never yours.

func FormatDeviceKey added in v0.0.16

func FormatDeviceKey(pub ed25519.PublicKey) string

FormatDeviceKey renders a public key as it is carried and stored.

func FormatLog added in v0.0.17

func FormatLog(lines []LogLine) string

FormatLog writes lines in the stored form.

func FormatLogKey added in v0.0.21

func FormatLogKey(pub ed25519.PublicKey) string

FormatLogKey renders an account log key as it is carried and stored.

func Handler added in v0.0.16

func Handler(cfg APIConfig) http.Handler

Handler is the box's read API.

Every route requires a token signed by the registry and named for THIS box. There is no unauthenticated route, not even a health check: a box that would tell a stranger it is a komizo box is a box that has said something about somebody's infrastructure to whoever asked.

func IsIPAddress added in v0.0.16

func IsIPAddress(s string) bool

IsIPAddress is how the endpoint rule is decided, and it is deliberately not a guess about what the string looks like.

net.ParseIP handles both families and the forms that only look like names -- "::1", "2001:db8::1". A regexp over dots and digits would call an IPv6 address a hostname.

func IsStopped added in v0.0.16

func IsStopped(root, app string) (bool, error)

IsStopped answers whether an app's own record says it is down on purpose.

A FIFTH READER, added reluctantly and deliberately narrow. The comment at the top of this file counts four -- probe.go, diagnose.go, report_cmd.go and komizo-be's appsFromReport -- and adding one more is a place for them to disagree. It exists because komizo#66 needs the answer BEFORE anything runs, and every other reader gets it from a Probe that has already walked the box.

SAME RULES AS EVERY OTHER READER, and they are the ones the deploy script spells out at length in alpine.sh:

FIRST-WINS. readStateFile takes the first occurrence of a key, which is
what alpine.sh's `head -n 1` does and what probe.go inherits. A reader that
took the last would disagree with all of them about the same bytes.

"1" AND NOTHING ELSE. probe.go tests `== "1"`, so anything else -- "true",
"yes", an empty value -- is not a stop. Being lenient here would mean an
app this refuses to start is one probe.go still reports as running.

A MISSING FILE IS NOT A STOP, and that matters more than it looks. An app with no record does not appear in the report at all, so app_down cannot fire for it and there is nothing to protect by refusing -- the same reasoning alpine.sh gives for carrying on when the record is absent. The error return is for a file that exists and cannot be read, which is a broken box rather than a missing app.

func IsTemporary added in v0.0.16

func IsTemporary(name string) bool

IsTemporary reports whether a name in the inbox is one of those.

func KnownOp added in v0.0.20

func KnownOp(op string) bool

KnownOp reports whether this box would recognise the name at all.

EXPORTED FOR THE SIGNER, which since komizo-be#180 is the service rather than a device. Something has to decide what it is willing to put a signature on, and the answer is "an op a box could act on" -- so the service asks this rather than keeping a list of its own. Two lists of what may be signed are two chances to disagree about it, and the one that is wrong is whichever the reviewer did not read; operator.go makes the same argument about device-key parsers, in the same words.

A FUNCTION RATHER THAN THE SLICE. `commandOps` stays unexported because an exported slice is a mutable global: any package that can see it can append to it, and what it would be appending to is the set of instructions a box acts on. ApplyOps above is exported and predates this, which makes it the exception to fix rather than the precedent to follow.

This is NOT an authorisation check and must not be read as one. It says a name is spellable, nothing more. Who may spend it is settled by ownership at the service and by the signature at the box.

func LastSegment added in v0.0.16

func LastSegment(s string) string

LastSegment is the part of a registry reference a TAG would be in.

THE RULE: a colon AFTER the last slash is a tag; a colon before it is a registry port. `reg.example.com:5000/a/web` has no tag; `a/web:1` does. That sentence lived in internal/app and in alpine.sh and not in the function that now decides it for both, which is how a rule goes missing while three copies of the code that implements it survive.

Agrees with `${VAR##*/}`, which is what scripts/alpine.sh uses and therefore what actually decides what gets pulled -- see segment_test.go, which pins this against a real shell rather than against a second Go opinion.

One function because there were two, and they disagreed. The CLI took everything after the last "/" and the box used path.Base, which differ on a trailing slash: for "ghcr.io/a/web:1/" the first yields "" -- no colon, no tag, accepted -- and the second yields "web:1", refused. So there was a value `komizo add` took and a signed app.add would not, which is the app able to do less than the CLI.

Not exploitable, since both refuse the shapes that matter. Fixed by removing the second opinion rather than by picking a winner: two ways of finding the last segment is how this happened, and it is how it would happen again.

func LockDeployment added in v0.0.71

func LockDeployment(ctx context.Context, root, app string) (func(), error)

LockDeployment shares the deploy script's order: app, then host. The worker runs in rootd, so client termination cannot release these locks mid-effect.

func LockImageMetadata added in v0.0.76

func LockImageMetadata(ctx context.Context, root string) (func(), error)

LockImageMetadata serializes the bounded root-owned immutable digest cache.

func LogPath added in v0.0.16

func LogPath(dir, name string) (string, error)

func LogServices added in v0.0.17

func LogServices(lines []LogLine) []string

LogServices is every service the lines name, in the order they first appear.

First-appearance order rather than sorted: it is the order the stack started in, which is the order somebody reading a log already has in their head.

func MarkStopped added in v0.0.16

func MarkStopped(root, app, by string, at time.Time) error

MarkStopped records a deliberate stop in the app's own record.

at is passed in rather than read from the clock here so the caller decides the reading, which is the same seam Probe.Now is.

func NewCommandID added in v0.0.16

func NewCommandID() (string, error)

NewCommandID is 128 random bits, hex-free and URL-safe.

func Now

func Now() time.Time

Now is the clock the box writes with, in UTC.

UTC because these timestamps are compared against a laptop in another zone, and a duration computed across two zones is wrong by hours in a way that looks like a plausible uptime.

func ParseDeviceKey added in v0.0.16

func ParseDeviceKey(s string) (ed25519.PublicKey, error)

ParseDeviceKey reads one, and is strict about the prefix.

Strict because the failure it prevents is planting the WRONG STRING as an authority. A registry key, an agent token and a device key are all opaque base64-ish blobs to a person moving them between two windows, and a box that quietly accepted any of them would be a box trusting something nobody meant to trust.

func ParseLogKey added in v0.0.21

func ParseLogKey(s string) (ed25519.PublicKey, error)

ParseLogKey reads one, and is strict about the prefix.

STRICT ABOUT THIS ONE IN PARTICULAR. The failure it prevents is planting the REGISTRY key here -- which is the one string that would silently undo komizo-be#187 entirely, because it is exactly what a service would send if it wanted to read your logs, and it is the same 32 bytes the box already holds and trusts for everything else. A prefix will not stop a determined service; it stops the accident, and the accident is the likely one.

func ParsePublicKey added in v0.0.16

func ParsePublicKey(s string) (ed25519.PublicKey, error)

ParsePublicKey reads the key enrolment planted, as it is stored on the box.

Raw base64 of the 32 bytes, one line, no armour. There is no format to negotiate and no algorithm field to lie about: it is an ed25519 key or the file is wrong.

func PrepareAPISocketDir added in v0.0.16

func PrepareAPISocketDir(path string) error

PrepareAPISocketDir makes the socket directory reachable by the two things that need it and nothing else.

Owned by the agent account, so it can create the socket. Grouped to root, so the proxy can reach it. Called by rootd, which runs as root -- the agent cannot chgrp to a group it is not in, and root is not one of its groups.

func PrepareInboxDir added in v0.0.16

func PrepareInboxDir(path string) error

PrepareInboxDir makes the directory the write route drops commands into.

Owned by the AGENT account, because that is what creates files here, and grouped to root. Called by rootd, which runs as root: the agent cannot chgrp to a group it is not in, and root is not one of its groups.

Called on every start as well as by the installer, because RunDir is tmpfs and this is simply gone after a reboot -- the same reason the report's directory is recreated there. The CALLER must have made RunDir first, and rootd does. That ordering is load-bearing rather than incidental: the chown and chmod below are unconditional, so a directory the agent had pre-created -- or a symlink it had left in its place -- would be handed root's blessing. RunDir is 0755 root:root and made before this runs, so there is no window in which the agent could put anything at this path.

func PrepareResultsDir added in v0.0.16

func PrepareResultsDir(path string) error

PrepareResultsDir makes the directory rootd writes outcomes into.

PrepareServedDir, because this IS a served directory: root writes it and the account that serves the box reads it, which is the only property either end of that relationship needs. The logs directory is prepared the same way, and for the same reason.

It was its own two lines -- MkdirAll and a chmod to 0750 -- and that chmod is the whole bug. The installer creates this directory 2750 root:komizo_monitor, correctly; rootd then ran this on every start and cleared the setgid bit, leaving a directory the serving account could list and files born in root's group that it could not open. So every result was root:root 0640 and every GET /v1/commands/{id} answered "no result yet" until the file was swept.

A separate implementation of "root writes here, the agent reads here" is a second opinion about a boundary that has now been got wrong five times. There is one.

func PrepareServedDir added in v0.0.16

func PrepareServedDir(path string) error

PrepareServedDir makes the directory root writes into and the agent reads from.

Owned by ROOT, grouped to the agent: the opposite of the socket directory, because the direction is the opposite. Called by rootd, which runs as root -- nothing else on the box can hand a directory to another account.

Idempotent, and called on every start rather than only at install: StateDir survives a reboot but an operator, an upgrade or a restored backup can leave this directory owned by root with no group at all, and the failure that produces is an empty chart rather than an error.

EVERY directory on this side of the boundary goes through here -- the served directory itself, the logs, and the results. Each of them is "root writes, the agent reads", and the one that had its own two lines instead is the one that shipped with the setgid bit cleared; see PrepareResultsDir.

func PrepareStateDir added in v0.0.16

func PrepareStateDir(path string) error

PrepareStateDir makes the state directory reachable by the account that serves the box, without opening anything in it.

Called before PrepareServedDir, because a directory whose parent cannot be entered is a directory nothing can reach.

func PreviewAskAllow added in v0.0.42

func PreviewAskAllow(host, domain string) bool

previewAskPattern is what the on-demand-TLS gate approves: pr-<digits> and pr-<digits>-api under the preview domain, and nothing else. The #132 guard -- routes that need on-demand issuance with no ask module -- is untouched; this decides, hostname by hostname, who may cause a certificate to exist.

func PreviewAskHandler added in v0.0.42

func PreviewAskHandler(knob PreviewKnob) http.Handler

PreviewAskHandler answers the ask endpoint. Caddy passes the hostname it was asked for as ?domain=. 200 is "issue it"; anything else is a refusal, and a refusal is the whole point.

func PreviewCheckFloors added in v0.0.42

func PreviewCheckFloors(f PreviewFloors, present bool, report []byte) (refuse string, warns []string, err error)

PreviewCheckFloors applies the floors to report.json the way the deploy wrapper does: refuse below the floor, warn below twice it. An empty floors file means no floor -- fail-open, because that is the wrapper's rule and a preview must not be stricter than a deploy.

func PreviewDBName added in v0.0.42

func PreviewDBName(app string, pr int) string

PreviewDBName is the per-preview database, <app>_pr_<N>, inside the app's postgres container. Charset-checked against postgres's unquoted-identifier rule: an app name with a hyphen becomes an underscore here, so the name is always a bare identifier and never needs quoting -- a quoted identifier in generated SQL is an injection vector invited.

func PreviewDown added in v0.0.42

func PreviewDown(ctx context.Context, run previewRun, cfg PreviewUpConfig, rec PreviewRecord) error

PreviewDown takes a preview down, exactly: project down, ITS database dropped, ITS route removed with the validate discipline, ITS state directory gone. Nothing else is named.

func PreviewHost added in v0.0.42

func PreviewHost(pr int, domain string) string

PreviewHost is the web route, pr-<N>.<domain>; the API is <host>-api.

func PreviewProject added in v0.0.42

func PreviewProject(app string, pr int) string

PreviewProject is the compose project name: <app>-pr-<N>. Also the state directory name, because one name per preview is how nothing collides.

func PreviewReapPath added in v0.0.42

func PreviewReapPath() string

PreviewReapPath is where the record lives.

func PruneLogs added in v0.0.16

func PruneLogs(dir string, keep []string) error

PruneLogs removes the files of apps that are no longer here.

An app that was removed leaves its log behind otherwise, and a log for something that does not exist is a thing somebody can open and be confused by -- as well as bytes nobody will ever collect.

func PruneResults added in v0.0.16

func PruneResults(dir string, now time.Time) error

PruneResults removes what is older than ResultKept.

Swept rather than expired on read, because nothing would ever read the ones that matter here: an id nobody asks about again is exactly the one that accumulates.

func ReadLog added in v0.0.16

func ReadLog(dir, name string, n int) (string, error)

ReadLog returns the last n lines of what was written.

The bound is applied HERE as well as by whoever wrote the file, because the caller asks for a tail and the file holds whatever the last interval produced.

func RecordLockName added in v0.0.16

func RecordLockName(app string) string

RecordLockName is the per-app lock file, and it is exported so the agreement with alpine.sh can be asserted from ONE definition rather than from two literals that happen to look alike. The directory was already pinned that way; the name was not, and a rename on either side would have left the two processes locking different files -- which excludes nothing, quietly, while every test still passes.

func RefuseScopedStart added in v0.0.42

func RefuseScopedStart(root, app string) error

RefuseScopedStart stops start and restart for fields-postgres-v2 unless the current generation still matches the id recorded at provision and the nonsecret provenance marker written only by v2 provision. Env files are not opened. A withdrawn fields-postgres-v1 record is not treated as ready. Other apps are untouched. Marker bytes are not included in errors. A lock that cannot be taken is a refusal, not a start beside a deploy.

func RemovePreviewRoute added in v0.0.42

func RemovePreviewRoute(ctx context.Context, run previewRun, proxy, routesDir, file string) error

RemovePreviewRoute takes a route out with the same discipline: remove, validate, restore on failure, reload on success.

func SignReadToken added in v0.0.16

func SignReadToken(priv ed25519.PrivateKey, serverID string, expires time.Time) (string, error)

SignReadToken mints a token for one server. The registry calls this; a box never does.

Exported from this package because the wire format has to have exactly one definition. The service reimplementing it from a specification is two implementations that can disagree about a security boundary, which is the failure this package exists to prevent for the report already.

func StateRevision added in v0.0.75

func StateRevision(root, app string) (string, error)

StateRevision is a bounded fingerprint, never the contents of an app record.

func StoppedByCommand added in v0.0.20

func StoppedByCommand(c Command, pub ed25519.PublicKey) string

StoppedByCommand is who stopped an app, given the whole verified envelope.

THE SUBJECT WINS WHEN THERE IS ONE, and the reason is that after komizo-be#180 the signing key stopped being an identity. Every command the app sends is signed by the registry key, so a fingerprint of the verified key is the same eight characters on every box for every account komizo has -- technically accurate, completely useless, and worse than useless because it LOOKS like an answer. The subject is the account the service signed for, and it is inside the signature; see Command.Sub.

FALLS BACK TO THE DEVICE, which is not a degraded case. An operator's device key carries no subject because it does not need one, and that path is unchanged: a fingerprint there names one real device out of however many were planted.

Both forms are PREFIXED with what kind of thing they name. "account" and "device" are different sorts of claim -- one this box verified, one this box was told -- and a reader who cannot tell them apart cannot weigh either.

func StoppedByDevice added in v0.0.16

func StoppedByDevice(pub ed25519.PublicKey) string

StoppedByDevice is who stopped an app through a signed command.

The VERIFIED key, which on this path is the one identity the box can assert rather than accept. VerifyCommand already returns which key matched, and says why in its own words: "a result should be able to say who asked and because revoking one device later means knowing that". A stop is exactly that kind of act -- somebody will one day want to know which device took an app down, and the answer has to survive the process that applied it.

A fingerprint rather than the whole key, and Fingerprint's own reason applies: this is the form the app renders and the form a person compares by eye. It is public key material either way -- holding it grants nothing, and it is not sensitive in a report the same way an address would be.

func SweepPath added in v0.0.42

func SweepPath() string

SweepPath is where the record lives, beside the history and metrics the serving account already reads.

func ValidCommandID added in v0.0.16

func ValidCommandID(id string) error

ValidCommandID is what an id may be.

The alphabet NewCommandID produces and nothing else, because this string is joined into a path to name a result -- and the id is chosen by whoever signed, which is not the same as being safe. A device that was planted is still a device somebody can lose.

Checked in the ENVELOPE rather than only where a file is opened. That was the bug: the one place that looked at an id was resultPath, so an id it refused made the box unable to record the command and unable to notice a replay of it, which is worse than refusing the command outright.

func ValidateAPI added in v0.0.15

func ValidateAPI(raw string) (string, error)

ValidateAPI refuses a service URL the agent should not be sending a credential to.

HTTPS, or nothing. The token is a bearer credential and this runs unattended on somebody's server; sending it in the clear because a URL was typed without the s is not a mistake the agent should be able to make.

The one exception is a loopback host, which is how this is tested and how somebody would run the service alongside the agent while developing. Nothing leaves the machine, so there is nothing to intercept.

func ValidateAPIHost added in v0.0.16

func ValidateAPIHost(s string) error

ValidateAPIHost refuses a name that could not carry a public certificate.

Checked here rather than at the moment Caddy fails, because the failure it prevents is asymmetric: a bad endpoint is a route on the SHARED proxy, and a config the proxy will not load takes every app on the box down with it.

func VerifyReadToken added in v0.0.16

func VerifyReadToken(pub ed25519.PublicKey, token, serverID string, now time.Time) error

VerifyReadToken decides whether a caller may read THIS box.

serverID is the box's own id, from the credential enrolment left behind. A box that has not enrolled has no id, cannot be named by any token, and therefore refuses everything -- which is the correct answer for a machine no registry has ever heard of.

func WriteAgentConf added in v0.0.15

func WriteAgentConf(path string, c AgentConf) error

WriteAgentConf stores the credential, readable by the agent's group and nobody else.

Mode AND GROUP, both set explicitly. 0640 alone is not the boundary: a file written by root is owned by root:root, so a 0640 credential in a directory the agent can traverse is one the agent still cannot read -- which is exactly what happened, and is the third time this shape has bitten. The mode says who may read it; the group says who they are.

A missing group is not fatal. This is how it behaves in a test, and on a box where the account exists the lookup succeeds -- but failing the write would turn "the agent cannot read this" into "enrolment does not complete", which is a worse outcome for the same misconfiguration.

func WriteLog added in v0.0.16

func WriteLog(dir, name string, body []byte) error

WriteLog replaces one subject's log, atomically and bounded.

Replaced whole rather than appended, because this is a TAIL: what it holds is the last of what docker has, and docker is already the thing keeping history. Appending would make this a second copy of a log that rotates underneath it.

Atomically, because the serving account reads on its own schedule and the two are not coordinated -- a reader that caught a half-written file would show somebody a truncated last line and call it their app's output.

func WriteMetrics added in v0.0.17

func WriteMetrics(path string, m Metrics) error

WriteMetrics replaces the served metrics, atomically.

REPLACED WHOLE, like report.json and unlike history.jsonl. Metrics are recomputed from the access log's tail on every tick, so each write is the whole truth about the window rather than one more reading to append -- and appending recomputed buckets would double-count every minute that is still inside the window.

func WritePreviewReap added in v0.0.42

func WritePreviewReap(path string, r PreviewReap) error

WritePreviewReap leaves the gc record where the probe reads it.

func WriteReport

func WriteReport(path string, r Report) error

WriteReport replaces report.json atomically.

Atomically because the agent reads this file on its own schedule and the two are not coordinated. A reader that caught a half-written document would get a JSON error, and the honest reading of a JSON error from a box is "this server is broken" -- which would be a lie told every interval.

0644 by design. This is exactly the boundary that lets the reporting account have no privileges at all: root writes, an unprivileged process reads. See design/appify.md §3. Nothing secret may ever go in it -- see Report.

func WriteResult added in v0.0.16

func WriteResult(dir string, r Result) error

func WriteSweepRecord added in v0.0.42

func WriteSweepRecord(path string, r SweepRecord) error

WriteSweepRecord leaves the record where the probe and the serving account read it.

Types

type APIConfig added in v0.0.16

type APIConfig struct {
	// ServerID is this box's id, from the credential enrolment wrote. A box
	// with none refuses every request: no token can name it, which is the right
	// answer for a machine no registry has heard of.
	ServerID string
	// RegistryKey verifies read tokens. Planted at enrolment; see token.go.
	RegistryKey ed25519.PublicKey

	// OperatorKeys are locally planted public keys for root commands. Registry
	// enrollment never populates this set. Empty means root commands refuse.
	OperatorKeys []ed25519.PublicKey

	// LogKeys is local operator keys plus separately planted account log keys.
	// Registry keys cannot access logs, even though they authenticate reports.
	LogKeys []ed25519.PublicKey

	// ReportPath and HistoryPath are the files rootd writes.
	ReportPath  string
	HistoryPath string
	MetricsPath string

	// InboxDir is where a signed command is left for rootd, and ResultsDir is
	// where rootd leaves what happened. Two directions, two directories, each
	// owned by whoever writes into it -- see inbox.go.
	InboxDir   string
	ResultsDir string
	// LogsDir is where rootd leaves each app's recent output. Root writes it
	// because the account serving this has no docker group -- see logs.go.
	LogsDir string

	// Now is injectable so expiry is testable without sleeping.
	Now func() time.Time
}

APIConfig is what the handler needs to answer.

type AddSpec added in v0.0.16

type AddSpec struct {
	App       string
	Config    string
	PubKey    string
	User      string
	AppDir    string
	KnownAs   []string
	HardenSSH bool
}

AddSpec is app.add's arguments, checked.

A separate step from verifying the envelope, and a separate one again from running anything: the signature says a device this box trusts sent it, and says nothing about whether "web; rm -rf /" is an app name.

type AgentConf added in v0.0.15

type AgentConf struct {
	// API is the base URL of the komizo service, without a trailing slash.
	API string `json:"api"`
	// ServerID is what the service calls this box. Issued by the service, never
	// chosen by the box -- see design/enrolment.md §4.
	ServerID string `json:"server_id"`
	// Token authenticates every report. It may report as this one server and
	// nothing else, which is what makes a leak worth fixing rather than
	// disclosing.
	Token string `json:"token"`
	// RegistryKey verifies the read tokens the service signs, so this box can
	// decide who may read it WITHOUT asking anybody -- see box/token.go. Raw
	// base64 of an ed25519 public key.
	//
	// In this file rather than beside it for the reason the file exists at all:
	// these values are issued together and replaced together, and two files are
	// two things that can disagree about which registry this box trusts.
	//
	// Empty on a box enrolled against a service that does not offer one, which
	// is every box enrolled before this existed. Such a box reports exactly as
	// it did and serves nothing, because there is no key to verify against.
	RegistryKey string `json:"registry_key,omitempty"`

	// OperatorKeys are the devices this box will take orders from -- see
	// operator.go for what they are and why the service does not choose them.
	//
	// Carried here by the person setting the box up rather than returned by the
	// exchange above, which is the whole of the difference between komizo being
	// able to command your machines and not. Everything else in this file is
	// issued BY the service; this one is issued TO it.
	//
	// Empty is the ordinary state: the box reports, serves reads, and accepts
	// no commands.
	OperatorKeys []string `json:"operator_keys,omitempty"`

	// LogKeys are the ACCOUNTS that may read this box's logs -- komizo-be#187.
	//
	// One per account rather than one per device, and that difference is the
	// whole reason this exists. app-only.md §5 wanted logs behind a key the
	// service does not hold, and got there with device keys; komizo-be#180
	// removed those from the required path because a per-device key means a root
	// session per browser, which is the step that broke the product.
	//
	// An account key is PORTABLE. Its private half is derived from a passphrase
	// the person knows, so it works on any device they sign in from -- the
	// property a device key could never have -- while komizo stores only the
	// encrypted form and cannot read a log.
	//
	// PLANTED WITH ROOT, AND ONLY WITH ROOT. There is deliberately no op that
	// writes this: if the service could push a log key to a box it could push
	// its own, and everything above would be theatre. See commandOps, which is
	// asserted to contain nothing that reaches here.
	//
	// Changing the passphrase does NOT change this value -- it re-encrypts the
	// same private half -- so the ordinary case touches no box. Only a lost
	// passphrase needs a new keypair and a visit with root, which is the bargain
	// app-only.md §6 already struck for losing every device.
	LogKeys []string `json:"log_keys,omitempty"`
}

AgentConf is what enrolment issued.

func ReadAgentConf added in v0.0.15

func ReadAgentConf(path string) (AgentConf, error)

ReadAgentConf loads the credential.

A missing file is not an error: it is a box that has not enrolled, which is the normal state for every box until somebody decides otherwise.

func (AgentConf) CanCommand added in v0.0.16

func (c AgentConf) CanCommand() bool

CanCommand requires local authority and an audience. SSH management remains available independently of registry enrollment or signed API commands.

func (AgentConf) CanReadLogs added in v0.0.21

func (c AgentConf) CanReadLogs() bool

CanReadLogs reports whether anybody may read this box's logs.

Separate from CanCommand, and the answers genuinely differ: an ordinary box today takes orders from its owner's account and refuses every log, because commanding needs the registry key and reading a log needs one komizo does not have. That is not a broken state and must not be reported as one.

func (AgentConf) CanServe added in v0.0.16

func (c AgentConf) CanServe() bool

CanServe reports whether this box can open its socket at all.

Both halves are required and neither is optional: the key is what verifies a caller, and the server id is what a token has to name. A box missing either could only refuse every request, so it does not open a socket at all.

NOT THE SAME AS ANSWERING ANYTHING. Since komizo-be#72 took away the reads that took only the registry's token, every route also needs an envelope signed by a device -- so CanServe() && !CanCommand() is a box that listens and refuses everything. Callers that treat this as "can be read" are wrong, and two of them were: `komizo-box serve` and `komizo-box enrol` both told the operator such a box was working.

func (AgentConf) EnrolURL added in v0.0.15

func (c AgentConf) EnrolURL() string

func (AgentConf) Enrolled added in v0.0.15

func (c AgentConf) Enrolled() bool

Enrolled reports whether this box has been through enrolment.

A box that has not is not broken. komizo works entirely without the service -- appify.md §10 requires that -- so the agent's job when there is no config is to say so once and exit, not to retry.

func (AgentConf) LogTrustedKeys added in v0.0.21

func (c AgentConf) LogTrustedKeys() ([]ed25519.PublicKey, error)

LogTrustedKeys is what this box will verify a LOG read against.

Operator keys plus account log keys, AND NOT THE REGISTRY KEY. That absence is the whole of komizo-be#187 and it is the one thing about this function worth checking: TrustedKeys above deliberately includes the registry key so that any signed-in device can command this box, and using it here would let whoever holds komizo's signing key read every log on every enrolled box -- which app-only.md §5 refused, and which komizo-be#180 accidentally allowed.

BUILT SEPARATELY RATHER THAN FILTERED. Deriving this from TrustedKeys by removing the registry key would be shorter and would fail OPEN the day somebody refactors the derivation -- and failing open here is silent, because a log that can be read looks exactly like a log that should be readable.

Operator keys are still in it. They were §5's original answer, the CLI's over-SSH path still uses them, and an operator who planted one has root on the box anyway -- so this grants them nothing they did not already have.

ALL OR NOTHING, for the reason TrustedKeys gives: a set that silently trusts fewer keys than the operator believes is discovered months later with nothing to read.

func (AgentConf) ReportURL added in v0.0.15

func (c AgentConf) ReportURL() string

ReportURL and EnrolURL are the two endpoints, built from the base.

func (AgentConf) TrustedKeys added in v0.0.16

func (c AgentConf) TrustedKeys() ([]ed25519.PublicKey, error)

TrustedKeys verifies commands only against public keys planted locally by the operator. The registry key can authenticate reads, never root actions. An invalid local key fails the entire set rather than silently skipping it.

type App

type App struct {
	RuntimeState string `json:"runtime_state,omitempty"`
	Name         string `json:"name"`
	// User is the account CI deploys as -- komizo-<name>.
	User string `json:"user,omitempty"`
	Dir  string `json:"dir,omitempty"`
	// Version is what is deployed, from APP_VERSION in the app's .env. "none"
	// when nothing has been deployed yet.
	Version     string `json:"version,omitempty"`
	ConfigImage string `json:"config_image,omitempty"`
	// KnownAs are the names CI dials this app by, recorded when it was added.
	// The keys belong to the machine; these names belong to the repo.
	KnownAs []string `json:"known_as,omitempty"`

	// Stopped is a deliberate stop, held on the box.
	//
	// On the box, and never read from a service, because the deploy path must
	// not consult komizo -- see design/appify.md §2. It is also what keeps a
	// stopped app from paging anyone: down is an incident, stopped is a
	// decision, and nothing outside the box can tell them apart.
	Stopped   bool      `json:"stopped,omitempty"`
	StoppedBy string    `json:"stopped_by,omitempty"`
	StoppedAt time.Time `json:"stopped_at,omitzero"`

	// Deployment is root-owned durable activation state, including failures.
	Deployment *Deployment    `json:"deployment,omitempty"`
	Static     *StaticServing `json:"static,omitempty"`
	Ingress    *Network       `json:"ingress,omitempty"`
	Containers []Container    `json:"containers,omitempty"`
	// Hosts is every name this app answers on, with the container the app said
	// serves it. The service is empty when the app did not say -- which is the
	// honest default, since nothing else on this machine could work it out.
	Hosts []Host `json:"hosts,omitempty"`
}

App is one deployed application.

func (App) Routes

func (a App) Routes() []string

Routes is every hostname the app publishes, in the order it declared them.

func (App) Running

func (a App) Running() int

Running is how many of the app's containers are up.

type CPU

type CPU struct {
	Total uint64 `json:"total"`
	Idle  uint64 `json:"idle"`
}

CPU is cumulative processor jiffies since boot.

type Command added in v0.0.16

type Command struct {
	// V is the envelope version, so a box can say "I do not speak this" rather
	// than misreading a field that moved.
	V int `json:"v"`

	// ID names this command and the result written for it. Random, not a
	// counter: a counter is guessable, and the id is what makes a REPLAY
	// refusable -- a second arrival of an id already applied is the same
	// command, not a new one.
	ID string `json:"id"`

	// Srv is the audience, and it is not optional.
	//
	// registry.md §6 had to learn this for reads: "a token signed for box A must
	// be useless at box B, or the registry becomes a skeleton key for every box
	// it knows about". A command without it is worse than a read token without
	// it, because it is a signed INSTRUCTION spendable on every box the operator
	// owns.
	Srv string `json:"srv"`

	// Exp is unix seconds. See MaxCommandTTL.
	Exp int64 `json:"exp"`

	// Op is a NAME, never a command line.
	//
	// The box maps this to its own call and builds the arguments itself. A
	// signed command containing a shell string would be remote code execution by
	// design, with the signature as the thing that authorised it.
	Op string `json:"op"`

	// Sub is WHO this was signed for, and it is only meaningful because it is
	// inside the signature.
	//
	// komizo-be#180 made the registry key an authority to command, which solved
	// the product flow and cost the box the one identity it used to be able to
	// assert: with device keys, the key that verified WAS the person. Now every
	// command from the app verifies against the same registry key, so "which
	// device stopped this app" has exactly one answer for every account komizo
	// serves, which is no answer at all.
	//
	// So the service names the account it signed for. The box cannot check that
	// claim -- it has no idea who komizo's users are, and asking would be the
	// network dependency registry.md §6 exists to remove -- but it does not need
	// to. SETTING THIS REQUIRES SIGNING. A caller who could put an arbitrary
	// subject here could put an arbitrary op here, and the signature is what
	// stops both. It is exactly as trustworthy as the key that signed it, which
	// is the correct amount for a field whose whole job is attribution.
	//
	// OPTIONAL, and stays optional. A command signed by an operator's device key
	// carries no subject and does not need one: the key itself is the identity,
	// and that path is unchanged. An empty value means "the signer did not say",
	// which stopped.go renders as the device rather than as nobody.
	Sub string `json:"sub,omitempty"`

	// Args are flat strings, deliberately.
	//
	// No nesting, no lists, no numbers. Every value here is destined to become
	// an argument to something on this machine, and a nested structure is a
	// second parser with its own opinions about what a value is. An op that one
	// day genuinely needs structure is a version bump, which is a cost worth
	// paying to keep this one shallow.
	Args map[string]string `json:"args,omitempty"`
}

Command is one instruction, and the whole of what is signed.

func VerifyCommand added in v0.0.16

func VerifyCommand(keys []ed25519.PublicKey, raw []byte, serverID string, now time.Time) (Command, ed25519.PublicKey, error)

VerifyCommand decides whether this box will act on what it was sent.

The order is deliberate and is the mitigation architecture.md §9 asked for when it leaned towards waking root on a file arriving: THE SIGNATURE IS CHECKED FIRST, so anything unsigned costs one public-key operation and a delete rather than any parsing of what it claims to be.

keys is the operator set from AgentConf.TrustedKeys. An empty set refuses everything, which is the right answer for a box nobody has given a device to -- and is the state of every box that exists today.

Which key matched is returned, because a result should be able to say who asked and because revoking one device later means knowing that.

func (Command) AddOf added in v0.0.16

func (c Command) AddOf() (AddSpec, error)

func (Command) AppOf added in v0.0.16

func (c Command) AppOf() (string, error)

AppOf is the app an op names, checked as an app name rather than trusted.

Checked HERE and not only where it is used, because this is the value that crosses from a signed document into an argument on this machine, and the rule that makes it safe should live beside the thing that decides a command is legitimate.

func (Command) RotateOf added in v0.0.17

func (c Command) RotateOf() (app, pubkey string, err error)

AddOf reads app.add's arguments, refusing anything that is not what it claims. RotateOf is which app and which key, and nothing else.

The public half only. A private key is not carried, generated or returned here for the reason OpAppAdd gives: a result file lives where the account that talks to the internet can read it.

The key rules are AddOf's, deliberately shared rather than restated -- a second copy is a second chance for one of them to relax, and the one that matters is that komizo authorises ed25519 and nothing else, whoever signed for it.

type Container

type Container struct {
	Service string `json:"service"`
	Name    string `json:"name"`
	// State is docker's machine-readable word (running, exited, created);
	// Status is docker's own prose ("Up 3 hours"), which is what a person
	// actually wants to read.
	State  string `json:"state"`
	Health string `json:"health,omitempty"`
	Status string `json:"status,omitempty"`
	Image  string `json:"image,omitempty"`

	// Timestamps rather than prose. "Up 3 hours" and "Exited (1) 2 minutes ago"
	// cannot be compared, added up, or rendered in one format, and every row on
	// the page shows a duration.
	StartedAt  time.Time `json:"started_at,omitzero"`
	FinishedAt time.Time `json:"finished_at,omitzero"`
	ExitCode   int       `json:"exit_code,omitempty"`

	// Ports is what this container is LISTENING on, read from its network
	// namespace rather than declared anywhere. Empty for a stopped container,
	// which has no namespace to read.
	//
	// Observed, not declared: EXPOSE is inherited from base images, so a Caddy
	// gate "exposes" 443 and 2019 it never binds.
	Ports []int `json:"ports,omitempty"`
}

Container is one container belonging to an app.

Service is the name in compose.yml and is the one to lead with: it is what you wrote, what the shared proxy is pointed at, and what stays stable across restarts. Name is docker's, derived from it.

type ContainerStat

type ContainerStat struct {
	App     string `json:"app"`
	Service string `json:"service"`
	// CPUUsec is microseconds, normalised here. cgroup v1 reports nanoseconds
	// and v2 microseconds; converting at the source means no reader ever has to
	// know which kind of box it is talking to.
	CPUUsec             *uint64   `json:"cpu_usec,omitempty"`
	CPUPeriods          *uint64   `json:"cpu_periods,omitempty"`
	CPUThrottledPeriods *uint64   `json:"cpu_throttled_periods,omitempty"`
	CPUThrottledUsec    *uint64   `json:"cpu_throttled_usec,omitempty"`
	MemoryCurrent       *uint64   `json:"memory_current,omitempty"`
	MemoryMaxEvents     *uint64   `json:"memory_max_events,omitempty"`
	OOMKills            *uint64   `json:"oom_kills,omitempty"`
	Swap                *uint64   `json:"swap,omitempty"`
	Pressure            *Pressure `json:"pressure,omitempty"`
	// Mem excludes inactive page cache, the convention `docker stats` follows
	// and the honest one: cache is memory the container is BORROWING and the
	// kernel will take straight back under pressure. Counting it makes anything
	// that has ever read a large file look permanently swollen.
	Mem *uint64 `json:"mem,omitempty"`
	// Limit is the container's memory cap, absent when it is not capped.
	Limit *uint64 `json:"limit,omitempty"`
}

ContainerStat is one container's cumulative processor time and memory now.

The pointers are the point: a cgroup that could not be read is UNKNOWN, and a zero would be a measurement -- one saying the container is using nothing at all. Blanks travel as blanks.

type Deployment added in v0.0.61

type Deployment struct {
	ID        string    `json:"operation_id,omitempty"`
	StartedAt time.Time `json:"started_at,omitzero"`
	Deadline  time.Time `json:"deadline,omitzero"`
	Version   int       `json:"version"`
	App       string    `json:"app"`
	Candidate string    `json:"candidate"`
	Previous  string    `json:"previous,omitempty"`
	Phase     string    `json:"phase"`
	At        time.Time `json:"at"`
}

Deployment is the durable host-side activation journal.

type DeviceKeyList added in v0.0.16

type DeviceKeyList []string

DeviceKeyList collects a repeatable --device-key on any command line.

ONE definition, used by the CLI on the laptop and by komizo-box on the server. There were two, briefly, with the same TrimSpace-parse-dedupe body in both -- and scripts/embed.go already records why that is wrong in this codebase's own words: "The app package had its own copy; it now calls this one, so there is a single definition of the rule."

It matters more here than for a rule about shell quoting. Two parsers for what counts as an authority are two chances to disagree about it, and the one that is wrong is whichever the reviewer did not read.

func (*DeviceKeyList) Set added in v0.0.16

func (d *DeviceKeyList) Set(v string) error

Set validates as it goes, so an error names the value that is wrong.

These are copied between two windows, which is where a truncated base64 string comes from -- and a truncated authority that failed later, on the box, would fail after the connection and in the middle of somebody's setup.

func (*DeviceKeyList) String added in v0.0.16

func (d *DeviceKeyList) String() string

type Disk

type Disk struct {
	Mount string `json:"mount"`
	Dev   string `json:"dev,omitempty"`
	Used  uint64 `json:"used"`
	Size  uint64 `json:"size"`
	// Available is Bavail * Bsize, what an ordinary process may still write.
	// Not Blocks-Used and not Bfree: those include space reserved for root.
	Available uint64 `json:"available"`
	// Inodes is present when the filesystem reports a non-zero inode table.
	Inodes *Inodes `json:"inodes,omitempty"`
}

Disk is one filesystem's used and total bytes.

Used and available rather than the filesystem's raw size, because df computes its own percentage that way -- excluding the blocks reserved for root -- and a disk figure that disagrees with df on the very same box is one nobody will believe, however defensible the arithmetic behind it.

type Document

type Document interface {
	Report | Poll | Monitor
	Schema() int
}

Document is anything komizo-box emits. Every one of them states the schema it was written against, so no reader has to know which document it is holding to find out whether it can be trusted.

type GroupStat added in v0.0.70

type GroupStat struct {
	Name string        `json:"name"`
	Stat ContainerStat `json:"stat"`
}

GroupStat exposes the fixed host envelopes with the same cumulative counters as containers. Missing measurements stay absent, including on cgroup v1.

type HistoryResponse added in v0.0.16

type HistoryResponse struct {
	V       int      `json:"v"`
	From    int64    `json:"from"`
	To      int64    `json:"to"`
	Samples []Sample `json:"samples"`
}

HistoryResponse is what the box was, over a window.

The samples are as they were recorded -- cumulative counters, not rates. box/system.go explains why and it holds here too: a rate needs two readings, and the reader owning the subtraction keeps the interval explicit rather than assumed. Serving pre-computed rates would bake in an interval that a late poll makes wrong, and it cannot be recovered afterwards.

func (HistoryResponse) Schema added in v0.0.16

func (h HistoryResponse) Schema() int

type Host

type Host struct {
	Name string `json:"name"`
	// Service is what the app said serves this name, from the arrow in its
	// hostnames file. It is the only thing on the machine that knows which
	// container a hostname reaches; without it every name lands on the gate,
	// which is true of the first hop and useless as an answer.
	Service string `json:"service,omitempty"`
}

Host is one name an app answers on.

type HostKey

type HostKey struct {
	Type string `json:"type"`
	Key  string `json:"key"`
}

type Inodes added in v0.0.41

type Inodes struct {
	Used uint64 `json:"used"`
	Free uint64 `json:"free"`
}

Inodes is one filesystem's inode used and free counts.

type KomizoInstall

type KomizoInstall struct {
	Installed bool `json:"installed"`
	// Version is the komizo release that provisioned the box; Stamp is the
	// content hash of what it wrote. Two different questions: which komizo set
	// this up, and would running the update change anything.
	Version string `json:"version,omitempty"`
	Stamp   string `json:"stamp,omitempty"`
	// Agent is the version of the binary that produced this report, which is not
	// necessarily the one that provisioned the box -- that is the whole point of
	// reporting it.
	Agent string `json:"agent,omitempty"`
}

KomizoInstall is the stamp komizo wrote when it last set this box up.

type LogKeyList added in v0.0.21

type LogKeyList []string

LogKeyList collects a repeatable --log-key, the way DeviceKeyList does.

func (*LogKeyList) Set added in v0.0.21

func (d *LogKeyList) Set(v string) error

func (*LogKeyList) String added in v0.0.21

func (d *LogKeyList) String() string

type LogLine added in v0.0.17

type LogLine struct {
	// Service is empty when the line predates this format, or came from a
	// source that has no services. Empty is "not recorded", never "the service
	// with no name" -- see ParseLog.
	Service string
	Text    string
}

LogLine is one collected line: which service said it, and what it said.

func FilterLog added in v0.0.17

func FilterLog(lines []LogLine, service string) []LogLine

FilterLog keeps the lines one service said.

An UNRECORDED service is not a match for anything: a caller asking for "web" must not be handed lines from before the service was recorded, because it cannot tell whether they are web's. Silence is the honest answer and the route says so separately.

func ParseLog added in v0.0.17

func ParseLog(s string) []LogLine

ParseLog reads the stored form, and reads the OLD form too.

BOTH, DELIBERATELY. A box collects on a fifteen-second timer and komizo is updated by a person, so between the two there is a window in which the file holds lines written by the previous version -- and the tail a reader asks for spans it. A parser that assumed the new shape would render the old lines as a service called "blog-web-1 | started" with no message.

A line with no separator is a line whose service was not recorded. That is reported as empty rather than guessed at from a container name, because guessing is what this format exists to stop.

type LogsResponse added in v0.0.16

type LogsResponse struct {
	V     int    `json:"v"`
	App   string `json:"app"`
	Lines string `json:"lines"`
	// Service is what was asked for, echoed back. Empty means the whole app.
	Service string `json:"service,omitempty"`
	// Services is every service the tail names, so a reader can offer the
	// choice without parsing the lines itself -- and so a box that has not
	// recorded any can say so by sending none.
	//
	// Absent and empty are the same here on purpose: JSON omits it either way,
	// and "no services in this tail" is the only meaning either could have.
	Services []string `json:"services,omitempty"`
}

LogsResponse is one subject's recent output.

func (LogsResponse) Schema added in v0.0.16

func (l LogsResponse) Schema() int

type Mem

type Mem struct {
	Total uint64 `json:"total"`
	Used  uint64 `json:"used"`
	// Available is MemAvailable, never MemFree. Used is Total minus this.
	Available uint64 `json:"available"`
	// Swap is present only when the box has swap. Nested so total and used
	// travel together; a zero used with a non-zero total is a measurement.
	Swap *Swap `json:"swap,omitempty"`
}

Mem is memory as of now, in bytes.

type Metric

type Metric struct {
	// Minute is unix seconds truncated to the minute.
	Minute int64  `json:"minute"`
	App    string `json:"app"`
	// Service is the container the app said serves the hostname these counts
	// came from, or empty when it said nothing. Summing over it gives the app;
	// filtering on it gives the container.
	Service string `json:"service,omitempty"`
	C2      int    `json:"c2,omitempty"`
	C3      int    `json:"c3,omitempty"`
	C4      int    `json:"c4,omitempty"`
	C5      int    `json:"c5,omitempty"`
}

Metric is one minute of one app's traffic, split by status class.

Split rather than totalled because the two questions are different: "is anything reaching this" and "is any of it failing". A single number answers neither on its own.

func (Metric) Total

func (m Metric) Total() int

type Metrics

type Metrics struct {
	// Span is what the log itself covers, which is not the same as the range
	// asked for. Without it every minute before the log started charts as zero
	// -- a confident claim that nothing was served, drawn over a stretch nobody
	// recorded.
	Span *Span    `json:"span,omitempty"`
	Rows []Metric `json:"rows"`
}

Metrics is request counts over a window, read off the shared proxy's access log and attributed to apps.

The proxy is the one place on the box that sees every request, and it is komizo's own component rather than anything an app ships -- so this needs nothing from apps and works the same for an app running nginx as for one running Caddy. The join is the hostname: every app declares the names it answers on, and the deploy script records them at /srv/<app>/hostnames.

COUNTS, NEVER LINES. The box does the totalling and reports one record per minute per app; raw log lines -- which carry client IPs and request paths -- never leave the machine. This is a promise, not an optimisation, and it is why this type has no field that could hold one.

func ReadMetrics added in v0.0.17

func ReadMetrics(path string, from, to int64) (Metrics, error)

ReadMetrics returns what rootd last computed, narrowed to a window.

A BOX WITH NO METRICS FILE IS NOT A BROKEN BOX. Nothing has been computed yet, or no app on it publishes a hostname -- in which case the proxy wrote no route for it, no route means no access log, and there is genuinely nothing to count. An empty result is the honest answer and the caller says so; an error would put a fault on screen about a box that is working.

type MetricsResponse added in v0.0.17

type MetricsResponse struct {
	V       int     `json:"v"`
	From    int64   `json:"from"`
	To      int64   `json:"to"`
	Metrics Metrics `json:"metrics"`
}

ReportResponse is the current state, whole.

type Monitor

type Monitor struct {
	V       int      `json:"v"`
	Metrics Metrics  `json:"metrics"`
	History []Sample `json:"history"`
	// Volumes is measured for ONE app, and only when asked. It costs a walk of
	// every volume that app mounts, which is the only number on that screen
	// that cannot be read from a counter.
	Volumes []Volume `json:"volumes,omitempty"`
}

Monitor is what the charts are drawn from, for one range.

func (Monitor) Schema

func (m Monitor) Schema() int

type Network

type Network struct {
	Name    string          `json:"name"`
	Driver  string          `json:"driver,omitempty"`
	Subnet  string          `json:"subnet,omitempty"`
	Members []NetworkMember `json:"members,omitempty"`
}

Network is the shared docker network and -- the point of reporting it at all -- who is attached and under what alias. Caddy reaches an app by alias, so a container missing here, or one sharing an alias with another app, is the whole explanation for a 502 that nothing else on the box reveals.

type NetworkMember

type NetworkMember struct {
	Container string   `json:"container"`
	Aliases   []string `json:"aliases,omitempty"`
}

type Poll

type Poll struct {
	V       int     `json:"v"`
	Report  Report  `json:"report"`
	Metrics Metrics `json:"metrics"`
}

Poll is what the app list redraws from, every few seconds.

func (Poll) Schema

func (p Poll) Schema() int

type Pressure added in v0.0.70

type Pressure struct {
	CPU    *PressureResource `json:"cpu,omitempty"`
	Memory *PressureResource `json:"memory,omitempty"`
	IO     *PressureResource `json:"io,omitempty"`
}

type PressureLine added in v0.0.70

type PressureLine struct {
	Avg10     float64 `json:"avg10"`
	Avg60     float64 `json:"avg60"`
	Avg300    float64 `json:"avg300"`
	TotalUsec uint64  `json:"total_usec"`
}

type PressureResource added in v0.0.70

type PressureResource struct {
	Some *PressureLine `json:"some,omitempty"`
	Full *PressureLine `json:"full,omitempty"`
}

type PreviewFloors added in v0.0.42

type PreviewFloors struct {
	DiskBytes int64
	MemBytes  int64
}

PreviewFloors is the parsed /etc/komizo/deploy-floors.

func ParsePreviewFloors added in v0.0.42

func ParsePreviewFloors(body string) (PreviewFloors, bool, error)

ParsePreviewFloors parses the floors file. An unreadable file or a non-numeric value is an error -- fail-LOUD, exactly as the deploy wrapper does: a floor that cannot be read is not a floor.

type PreviewHostResources added in v0.0.67

type PreviewHostResources struct {
	MemoryBytes, MilliCPUs, ProductionReserveBytes, DatabaseReserveBytes, PhysicalMemoryBytes int64
}

PreviewHostResources is the capacity view supplied by the root CLI after it validates the complete private host policy. The box package owns lifecycle primitives and does not depend on the higher-level workload policy package.

type PreviewKnob added in v0.0.42

type PreviewKnob struct {

	// SharedDomain opts this host into flat, app-qualified preview names.
	SharedDomain      string
	Domain            string
	TTL               time.Duration
	Max               int
	MemLimit          string
	CPULimit          string
	ProductionReserve int64
	DatabaseReserve   int64
	MemoryBudget      int64
	InvalidBudget     bool
	AskPort           int
	PortRange         string
	// contains filtered or unexported fields
}

PreviewKnob is the parsed knob file.

func ParsePreviewKnob added in v0.0.42

func ParsePreviewKnob(body string) (PreviewKnob, string)

ParsePreviewKnob reads the key=value knob. Missing keys are defaults; a non-numeric value is that key's default with the value named in the note.

func ReadPreviewKnob added in v0.0.42

func ReadPreviewKnob(path string) (PreviewKnob, string)

ReadPreviewKnob reads the knob file; absent is the defaults.

func (PreviewKnob) APIHostFor added in v0.0.52

func (k PreviewKnob) APIHostFor(app string, pr int) string

func (PreviewKnob) DomainFor added in v0.0.42

func (k PreviewKnob) DomainFor(app string) string

DomainFor is the per-app resolution chain, exactly: SHARED_DOMAIN.<app> / SHARED_DOMAIN take priority when enabled; otherwise get("DOMAIN."+app) → get("DOMAIN") → PreviewDomainDefault. An empty DOMAIN.<app>= falls through to the bare DOMAIN; an empty DOMAIN= falls through to the compiled default. An unknown app is NEVER refused -- a product without its own key previews on the default, exactly as before per-app keys existed.

func (PreviewKnob) Domains added in v0.0.42

func (k PreviewKnob) Domains() []string

Domains preserves legacy TLS authorization during migration: the default and nonempty DOMAIN.<app> values, deduped and sorted after the default. Shared-domain names are authorized separately by SharedAskAllow.

func (PreviewKnob) HostFor added in v0.0.52

func (k PreviewKnob) HostFor(app string, pr int) string

HostFor keeps each app/PR in one DNS label so one wildcard covers the host. Without SHARED_DOMAIN, existing per-app domains and URLs are unchanged.

func (PreviewKnob) PathAskAllow added in v0.0.53

func (k PreviewKnob) PathAskAllow(host string) bool

Path ask authorization is restricted to explicit per-app opt-ins.

func (PreviewKnob) PathDomainFor added in v0.0.53

func (k PreviewKnob) PathDomainFor(app string) string

PathDomainFor opts an app into stable hostnames and unique numeric PR paths.

func (PreviewKnob) SharedAskAllow added in v0.0.52

func (k PreviewKnob) SharedAskAllow(host string) bool

func (PreviewKnob) SharedDomainFor added in v0.0.52

func (k PreviewKnob) SharedDomainFor(app string) string

Per-app opt-in permits a canary before changing the rest of a host.

type PreviewReap added in v0.0.42

type PreviewReap struct {
	V      int       `json:"v"`
	At     time.Time `json:"at"`
	Reaped []string  `json:"reaped"`
	Kept   int       `json:"kept"`
	Note   string    `json:"note,omitempty"`
}

PreviewReap is what a gc pass did, written to the served directory so the report -- and komizo ui -- can show it. Same shape as the disk sweep's record, for the same reason: a reaper that says nothing is indistinguishable from one that never ran.

func PreviewGC added in v0.0.42

func PreviewGC(ctx context.Context, run previewRun, cfg PreviewUpConfig, now time.Time) (PreviewReap, error)

PreviewGC applies the TTL and the max-N ceiling, reaping ONLY previews it has state records for. It never looks at anything else: no app, no container, no image, no route that is not derived from a record in the previews directory. That is the reaper's refusal, and the contract test pins it.

func ReadPreviewReap added in v0.0.42

func ReadPreviewReap(path string) (PreviewReap, bool)

ReadPreviewReap reads it back; absent is a box that has never reaped.

type PreviewRecord added in v0.0.42

type PreviewRecord struct {
	V        int    `json:"v"`
	App      string `json:"app"`
	PR       int    `json:"pr"`
	Project  string `json:"project"`
	Host     string `json:"host,omitempty"`
	APIHost  string `json:"api_host"`
	BasePath string `json:"base_path,omitempty"`
	DBName   string `json:"db_name"`
	// DBPassword is the per-preview owner role's password, recorded 600-root
	// and injected into the preview's compose environment. The preview
	// connects as its own role, which can touch only its own database.
	//
	// json:"-" -- it is a credential, and credentials are never marshalled:
	// `preview up` and `preview ls` print this record as JSON, and a
	// credential on stdout is a credential in somebody's scrollback. The
	// state file keeps it, 600 and root-owned, which is the only place it
	// may exist.
	DBPassword          string    `json:"-"`
	GatePort            int       `json:"gate_port"`
	Images              []string  `json:"images"`
	CreatedAt           time.Time `json:"created_at"`
	LastUsed            time.Time `json:"last_used"`
	RouteFile           string    `json:"route_file"`
	MemoryReservedBytes int64     `json:"memory_reserved_bytes,omitempty"`
}

PreviewRecord is one preview's state file, key=value like the app records.

func ListPreviews added in v0.0.42

func ListPreviews(root string) ([]PreviewRecord, error)

ListPreviews reads every record under the previews root, oldest last-use first -- the reaper's and ls's shared reading.

func PreviewUp added in v0.0.42

func PreviewUp(ctx context.Context, run previewRun, cfg PreviewUpConfig, app string, pr int, images []string, now time.Time) (result PreviewRecord, resultErr error)

func (PreviewRecord) PublicAPIHost added in v0.0.52

func (r PreviewRecord) PublicAPIHost(k PreviewKnob) string

func (PreviewRecord) WebHost added in v0.0.52

func (r PreviewRecord) WebHost(k PreviewKnob) string

Persisted names describe the running preview even if the host knob changes.

type PreviewTarget added in v0.0.52

type PreviewTarget struct {
	App      string `json:"app"`
	PR       int    `json:"pr"`
	Domain   string `json:"domain"`
	Host     string `json:"host"`
	APIHost  string `json:"api_host"`
	BasePath string `json:"base_path,omitempty"`
}

PreviewTarget is a read-only answer used before building or writing auth settings. It never creates resources or claims that a preview is running.

func ResolvePreview added in v0.0.52

func ResolvePreview(k PreviewKnob, app string, pr int, hasAPI bool) (PreviewTarget, error)

type PreviewUpConfig added in v0.0.42

type PreviewUpConfig struct {
	Knob               PreviewKnob
	Root               string // previews state root ("" = the box's)
	RoutesDir          string
	Proxy              string
	Network            string
	FloorsBody         string
	ReportJSON         []byte
	ReportPath         string
	HostResources      *PreviewHostResources
	ManageIdleDatabase bool // root host lifecycle; never provisions or deletes a volume
}

PreviewUpConfig is what an up needs. Run is the docker runner; everything else is paths and the knob, so a test drives the whole lifecycle with fakes and a temp root.

type Probe

type Probe struct {
	// Root is prefixed to every absolute path this reads. Empty in production.
	Root string
	// Docker runs a docker command and returns its stdout. Nil means the real
	// one. A docker that is absent or not running is not an error here -- it is
	// the "bare" and "docker-stopped" server states, which are things to show.
	Docker func(ctx context.Context, args ...string) (string, error)
	// Now is the clock, for tests that need a fixed report time.
	Now func() time.Time
	// Agent is the version of the binary doing the probing, reported so the
	// interface can say a box is running an old one.
	Agent string
}

Probe reads a machine.

A struct with two seams rather than a package of free functions, and both seams exist for the tests. Root prefixes every filesystem path, so a test builds a fake /proc and /var/lib/komizo in a temp directory. Docker runs docker, so a test hands back canned output instead of needing a daemon.

The zero value probes the real machine, which is what komizo-box uses.

func (*Probe) Metrics

func (p *Probe) Metrics(from, to int64) Metrics

Metrics totals the requests in a range, per app, per minute.

func (*Probe) Report

func (p *Probe) Report(ctx context.Context) Report

Report produces one complete reading.

Two docker calls, whatever is on the box. Everything below reads the index they produce -- see docker.go, where that is the property being defended.

func (*Probe) System

func (p *Probe) System(inv dockerInventory) System

System is one reading of what the box and its containers are spending.

func (*Probe) Volumes

func (p *Probe) Volumes(ctx context.Context, only string) []Volume

Volumes measures every app's docker volumes, or one app's.

The expensive one. Everything else in this package is a file read; this walks whole directory trees, which is why the timer runs it on a slower cadence than the rest and why it is a separate call rather than part of Report.

Builds its own index: it is asked for perhaps once every fifteen minutes, and two docker calls to answer it is a better trade than making the common path carry an argument it does not need.

type Problem

type Problem struct {
	Kind   string `json:"kind"`
	Detail string `json:"detail"`
	// App is which app it concerns, empty when it is the box's own.
	App string `json:"app,omitempty"`
}

Problem is one thing wrong with the box, named and explained.

Kind is stable and machine-readable -- an alert rule matches on it. Detail is prose for a person and may be reworded freely; nothing should ever parse it.

func Diagnose

func Diagnose(r Report) []Problem

Diagnose is what is wrong with a box, worked out from one reading of it.

This is most of the product. Every condition below is invisible from outside the machine -- the box is up, the proxy answers, the certificate is valid, and one hostname 502s. Shipping the diagnosis rather than the raw facts is the difference between a page of numbers and something worth being woken by.

A pure function of a Report, deliberately. It is therefore testable without a machine, it can be re-run over a stored report to see what would have fired, and there is exactly one definition of "broken" for the CLI, the monitor, and whatever alerts later.

Ordered most severe first, so a reader that shows one shows the worst.

type Proxy

type Proxy struct {
	State   string `json:"state"` // running | stopped
	Network string `json:"network,omitempty"`
	Image   string `json:"image,omitempty"`
	Status  string `json:"status,omitempty"`
	// TLSAsk is the on-demand TLS ask endpoint. A wildcard hostname needs one,
	// and its absence is the whole explanation for a wildcard deploy that fails.
	TLSAsk     string    `json:"tls_ask,omitempty"`
	StartedAt  time.Time `json:"started_at,omitzero"`
	FinishedAt time.Time `json:"finished_at,omitzero"`
}

Proxy is the shared reverse proxy. Not an app: no deploy account, no config image, nothing from CI ever touches it.

func (Proxy) Running

func (p Proxy) Running() bool

type Report

type Report struct {
	// V is the schema Version of the producer. Present so a reader can say
	// "this box speaks a report I do not understand" rather than silently
	// showing a screen of zeroes.
	V int `json:"v"`
	// At is when the probe ran, from the box's own clock.
	//
	// The box's, not the reader's. A report is written on a timer and read some
	// time later, and every duration on screen is measured from this. Using the
	// reader's clock would fold the staleness of the file into the age of every
	// container on it.
	At time.Time `json:"at"`

	Server Server `json:"server"`
	// Proxy and Network are absent, not empty, when there is none. A box with no
	// shared proxy is a state komizo offers to fix; a proxy record full of zeroes
	// reads as one that is installed and broken.
	Proxy   *Proxy   `json:"proxy,omitempty"`
	Network *Network `json:"network,omitempty"`

	Apps []App `json:"apps"`
	// Orphans are directories under /srv with no state file behind them --
	// usually a removal that did not finish.
	Orphans []string `json:"orphans,omitempty"`

	// System is what the box is spending right now: cumulative counters, not
	// rates. Two readings make a rate, and the reader owns the subtraction --
	// which also makes the interval explicit rather than assumed, so a poll that
	// arrived late measures the longer gap it actually covered.
	System System `json:"system"`

	// Problems is the diagnosis, computed here.
	//
	// This is most of the product and the reason the report is not just facts.
	// An alias clash, an app publishing routes while detached from the network,
	// a stopped proxy -- these are the failures that are invisible from outside,
	// where the box looks healthy and one hostname 502s.
	//
	// Computed on the box rather than by each reader because there are now
	// several readers -- the CLI, the TUI, and later a service and a phone --
	// and a diagnosis reimplemented four times is four chances to disagree about
	// whether a server is broken.
	Problems []Problem `json:"problems"`

	// Sweep is what the disk-hygiene sweep last did, when it has run: what was
	// swept, when, how much came back -- and the quiet line when the answer is
	// nothing. Absent, not zeroed, on a box that has never swept, for the same
	// reason Proxy is: a record full of zeroes reads as installed-and-broken
	// rather than never-run. See sweep.go.
	Sweep *SweepRecord `json:"sweep,omitempty"`

	// Preview is what the preview reaper last did, when it has run: what it
	// reaped, what it kept, and the quiet line when the answer is nothing.
	// Absent on a box that has never reaped -- see preview.go.
	Preview *PreviewReap `json:"preview,omitempty"`
}

Report is one complete reading of a server.

Everything here is derivable from the box by root. None of it is new data -- it is what `komizo list` and the monitor already show, produced once by the machine that knows it rather than assembled from a dozen shell records.

NEVER in this document: secret values, the contents of .env or secrets.env, registry tokens, private keys, or raw access-log lines. The first four are 600 root and this must not be the thing that copies them out; the last is the standing promise that request paths and client IPs stay on the box. See Metrics.

func ReadReport

func ReadReport(path string) (Report, error)

ReadReport loads a report written by WriteReport.

func (Report) Schema

func (r Report) Schema() int

Schema is the version this document was written against. See Decode.

func (Report) Stale

func (r Report) Stale(now time.Time, d time.Duration) bool

Stale reports whether this reading is older than d.

The check belongs here rather than in each reader: "how old is too old" is a property of how often the box writes, which is a fact this package owns.

type ReportResponse added in v0.0.16

type ReportResponse struct {
	V      int    `json:"v"`
	Report Report `json:"report"`
}

func (ReportResponse) Schema added in v0.0.16

func (r ReportResponse) Schema() int

type Result added in v0.0.16

type Result struct {
	V  int    `json:"v"`
	ID string `json:"id"`
	Op string `json:"op"`
	// At is when it finished, from the box's own clock -- the same choice the
	// report makes, and for the same reason.
	At time.Time `json:"at"`
	OK bool      `json:"ok"`
	// Detail is prose for a person and is empty when it worked. It is the
	// command's own failure, not a diagnosis of the box.
	Detail string `json:"detail,omitempty"`
}

Result is what rootd writes when it has finished with a command.

func ReadResult added in v0.0.16

func ReadResult(dir, id string) (Result, bool, error)

ReadResult returns what happened, whether there is anything to return, and whether this box could not tell.

THREE ANSWERS, NOT TWO, and the third one is the whole of this function's history. The absence of a result is not an error: a command that has arrived and not been applied yet is the normal state for the moment between the two, and it is what the app is polling to see change. A result that EXISTS and cannot be opened is nothing like that -- it is a fault on this box, and every command on it is already done.

Collapsing the two is what made both of the failures in this shape silent. paths.go records the first: ReadSamples treated an unreadable history as an empty one, so /v1/history answered "no readings" on every box forever. This is the second, one directory along -- results were written root:root, the serving account could not open one, and the route turned that into 404 "no result yet" for every command anyone sent. Neither said "permission" anywhere, because neither had anywhere to say it.

A result that is present and unparseable is a fault too. It is a record of work that HAS happened, damaged; answering "not yet" about it would leave the caller polling for a change that cannot come.

func (Result) Schema added in v0.0.16

func (r Result) Schema() int

type Sample

type Sample struct {
	At     time.Time `json:"at"`
	System System    `json:"system"`
}

Sample is one timestamped reading, as written to the history log.

The report is what the box is NOW; this is what it was. They carry the same System because they are the same measurement -- one kept, one current.

func ReadSamples

func ReadSamples(path string, from, to int64) ([]Sample, error)

ReadSamples returns the readings between from and to, inclusive.

type Server

type Server struct {
	// State is ready | docker-stopped | bare. Checked before anything else here
	// is read: an uninitialised box is a state to show, not an error.
	State  string `json:"state"`
	Docker string `json:"docker,omitempty"`
	// OS is the distribution as it names itself -- PRETTY_NAME from
	// /etc/os-release. Read rather than assumed: komizo installs Alpine but is
	// pointed at existing servers too, and a row that says "alpine" about a
	// Debian box is wrong in the one place whose job is to state facts.
	OS      string `json:"os,omitempty"`
	UptimeS int64  `json:"uptime_s,omitempty"`

	// Komizo is what komizo last installed here, read back rather than assumed.
	// The alternative is for komizo to print what it WOULD install, which is a
	// fact about the laptop rather than about the box.
	Komizo KomizoInstall `json:"komizo"`

	// HostKeys are the box's own public keys, for the known_hosts value CI pins.
	// Public by definition -- what they need is integrity, not secrecy.
	HostKeys []HostKey `json:"host_keys,omitempty"`
}

Server is the box itself, before any app is considered.

func (Server) OSName

func (s Server) OSName() string

OSName is what the box runs, or what komizo installs when nothing has said otherwise -- a box whose probe never got as far as /etc/os-release.

func (Server) Ready

func (s Server) Ready() bool

type Signed added in v0.0.16

type Signed struct {
	Payload string `json:"payload"`
	Sig     string `json:"sig"`
}

Signed is the envelope as it crosses the wire.

The payload travels as OPAQUE BYTES and the signature covers those exact bytes. Not a struct the box re-marshals before checking: JSON has no single serialisation, so re-encoding to verify means verifying something the signer never saw, and the gap between the two is where a canonicalisation bug lives.

func SignCommand added in v0.0.16

func SignCommand(priv ed25519.PrivateKey, c Command) (Signed, error)

SignCommand produces the envelope for one command.

Fills in V, and an ID if the caller left one out, so those two cannot be forgotten at a call site.

type Span

type Span struct {
	From int64 `json:"from"`
	To   int64 `json:"to"`
}

Span is the oldest and newest request the log holds.

type StaticServing added in v0.0.74

type StaticServing struct {
	Version     int    `json:"version"`
	Revision    string `json:"revision"`
	TreeSHA256  string `json:"tree_sha256"`
	RouteSHA256 string `json:"route_sha256"`
	GateImage   string `json:"gate_image"`
	Proxy       string `json:"proxy"`
	Active      bool   `json:"active"`
}

StaticServing records the root executor's accepted serving mode. It is not a container, and never contributes to Running's actual container count.

type Swap added in v0.0.41

type Swap struct {
	Total uint64 `json:"total"`
	Used  uint64 `json:"used"`
}

Swap is swap as of now, in bytes.

type SweepImage added in v0.0.42

type SweepImage struct {
	ID        string
	Repo      string
	Tag       string
	Created   time.Time
	SizeBytes int64
}

SweepImage is one image as the sweep needs to know it.

type SweepKnob added in v0.0.42

type SweepKnob struct {
	MinAge   time.Duration
	Interval time.Duration
	Disabled bool
}

SweepKnob is the parsed knob file.

func ParseSweepKnob added in v0.0.42

func ParseSweepKnob(body string) (knob SweepKnob, note string)

ParseSweepKnob reads the key=value knob. A missing file is the defaults; a non-numeric value is that KEY's default with a loud note in the record -- this is hygiene, so a bad knob must not stop the report, but it must not be quiet either.

func ReadSweepKnob added in v0.0.42

func ReadSweepKnob(path string) (SweepKnob, string)

ReadSweepKnob reads the knob file, treating a missing one as the defaults and an unreadable one as defaults-with-a-note -- a hygiene knob must never stop the report, and must never be quiet about being wrong either.

type SweepRecord added in v0.0.42

type SweepRecord struct {
	V              int       `json:"v"`
	At             time.Time `json:"at"`
	MinAgeDays     int       `json:"min_age_days"`
	Candidates     int       `json:"candidates"`
	Removed        int       `json:"removed"`
	Skipped        int       `json:"skipped"`
	ReclaimedBytes int64     `json:"reclaimed_bytes"`
	// Note is the quiet line: nothing swept, what the knob said, what a remove
	// refused. A sweep that did nothing says so, rather than being
	// indistinguishable from a sweep that never ran.
	Note string `json:"note,omitempty"`
}

SweepRecord is what a pass did, written where the serving account and the probe can read it -- the report surface, so `komizo ui` shows the sweep the same way it shows everything else the box did.

func DockerSweep added in v0.0.42

func DockerSweep(ctx context.Context, cfg SweepKnob, appsDir, srvDir string, now time.Time, run func(ctx context.Context, args ...string) (string, error)) SweepRecord

DockerSweep is the production path: one docker to ask, one to remove with. It only ever invokes "images", "ps", "image inspect" and "image rm" -- the contract test pins the argv, because "never touches a volume" is a claim about exactly this list.

func ReadSweepRecord added in v0.0.42

func ReadSweepRecord(path string) (SweepRecord, bool)

ReadSweepRecord reads it back; absent is not an error, it is a box that has never swept.

func Sweep added in v0.0.42

func Sweep(cfg SweepKnob, now time.Time, images []SweepImage, refs map[string]bool, stateNames []string, remove func(id string) error) SweepRecord

Sweep is one pass, with every effect injected so a test drives it with fakes: the images, the container references, the state names, and the remove. The DOCKER wiring below calls this; the refusals live here and nowhere else, so the contract tests pin the function that ships.

func (SweepRecord) Schema added in v0.0.42

func (r SweepRecord) Schema() int

type System

type System struct {
	// Cores is the processor count -- the divisor that turns jiffies into a
	// percentage the same way top does.
	Cores int `json:"cores,omitempty"`
	// CPU is cumulative jiffies since boot. Idle counts iowait with it: waiting
	// on a disk is not work, and counting it as busy makes a box that is merely
	// reading from a slow disk look loaded.
	CPU      *CPU        `json:"cpu,omitempty"`
	Pressure *Pressure   `json:"pressure,omitempty"`
	Groups   []GroupStat `json:"groups,omitempty"`
	// Mem is measured from MemAvailable, never MemFree. Free memory on a healthy
	// box is near zero -- the kernel spends everything spare on cache and hands
	// it back on demand -- so reporting free as used is the classic way to make
	// every server on earth look seconds from death.
	Mem *Mem `json:"mem,omitempty"`

	// Disks covers / and /var/lib/docker, because the one that fills is usually
	// not the one people watch. The device travels with the numbers so a reader
	// can fold the two when they are one filesystem -- by device, not mount
	// point, because docker setups routinely bind-mount /var/lib/docker onto
	// itself and df then reports one filesystem under two names.
	Disks []Disk `json:"disks,omitempty"`

	// Containers is per-container processor time and memory, read from the
	// cgroup -- which is where the kernel does the accounting anyway. `docker
	// stats` reads the same files and then streams, which is why it costs a
	// second per call; this is three small reads with no daemon in the path.
	//
	// Running containers only. A stopped one has no pid and no cgroup, so there
	// is nothing to read, and its ABSENCE is the honest record of that.
	Containers []ContainerStat `json:"containers,omitempty"`

	// Volumes is present only on the readings that measured it -- roughly every
	// fifteenth minute, because it costs a du over the whole tree. A reading
	// without it is not a reading of zero storage, it is a minute nobody asked.
	Volumes []Volume `json:"volumes,omitempty"`
}

System is one reading of what the box is spending.

type Volume

type Volume struct {
	App     string `json:"app"`
	Service string `json:"service"`
	Name    string `json:"name"`
	Bytes   uint64 `json:"bytes"`
}

Volume is one docker volume's size on disk, attributed to the service that mounts it.

Measured once per volume and then attributed, so a volume shared by two containers does not count twice -- and because du is far too expensive to run twice for the same answer.

Jump to

Keyboard shortcuts

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