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
- Variables
- func APIHostFor(sshHost, override string) (string, error)
- func AppDir(root, name string) (string, error)
- func AppSettings(root, name string) (map[string]string, error)
- func AppendSample(path string, s Sample, max int64, keep int) error
- func Applied(dir, id string) bool
- func Applies(op string) bool
- func ApplyPreviewRoute(ctx context.Context, run previewRun, proxy, routesDir, file, content string) error
- func ClearStopped(root, app string) error
- func ClearStoppedIfUnchanged(root, app, expected string) (bool, error)
- func Decode[T Document](b []byte) (T, error)
- func Fingerprint(key string) string
- func FormatDeviceKey(pub ed25519.PublicKey) string
- func FormatLog(lines []LogLine) string
- func FormatLogKey(pub ed25519.PublicKey) string
- func Handler(cfg APIConfig) http.Handler
- func IsIPAddress(s string) bool
- func IsStopped(root, app string) (bool, error)
- func IsTemporary(name string) bool
- func KnownOp(op string) bool
- func LastSegment(s string) string
- func LockDeployment(ctx context.Context, root, app string) (func(), error)
- func LockImageMetadata(ctx context.Context, root string) (func(), error)
- func LogPath(dir, name string) (string, error)
- func LogServices(lines []LogLine) []string
- func MarkStopped(root, app, by string, at time.Time) error
- func NewCommandID() (string, error)
- func Now() time.Time
- func ParseDeviceKey(s string) (ed25519.PublicKey, error)
- func ParseLogKey(s string) (ed25519.PublicKey, error)
- func ParsePublicKey(s string) (ed25519.PublicKey, error)
- func PrepareAPISocketDir(path string) error
- func PrepareInboxDir(path string) error
- func PrepareResultsDir(path string) error
- func PrepareServedDir(path string) error
- func PrepareStateDir(path string) error
- func PreviewAskAllow(host, domain string) bool
- func PreviewAskHandler(knob PreviewKnob) http.Handler
- func PreviewCheckFloors(f PreviewFloors, present bool, report []byte) (refuse string, warns []string, err error)
- func PreviewDBName(app string, pr int) string
- func PreviewDown(ctx context.Context, run previewRun, cfg PreviewUpConfig, rec PreviewRecord) error
- func PreviewHost(pr int, domain string) string
- func PreviewProject(app string, pr int) string
- func PreviewReapPath() string
- func PruneLogs(dir string, keep []string) error
- func PruneResults(dir string, now time.Time) error
- func ReadLog(dir, name string, n int) (string, error)
- func RecordLockName(app string) string
- func RefuseScopedStart(root, app string) error
- func RemovePreviewRoute(ctx context.Context, run previewRun, proxy, routesDir, file string) error
- func SignReadToken(priv ed25519.PrivateKey, serverID string, expires time.Time) (string, error)
- func StateRevision(root, app string) (string, error)
- func StoppedByCommand(c Command, pub ed25519.PublicKey) string
- func StoppedByDevice(pub ed25519.PublicKey) string
- func SweepPath() string
- func ValidCommandID(id string) error
- func ValidateAPI(raw string) (string, error)
- func ValidateAPIHost(s string) error
- func VerifyReadToken(pub ed25519.PublicKey, token, serverID string, now time.Time) error
- func WriteAgentConf(path string, c AgentConf) error
- func WriteLog(dir, name string, body []byte) error
- func WriteMetrics(path string, m Metrics) error
- func WritePreviewReap(path string, r PreviewReap) error
- func WriteReport(path string, r Report) error
- func WriteResult(dir string, r Result) error
- func WriteSweepRecord(path string, r SweepRecord) error
- type APIConfig
- type AddSpec
- type AgentConf
- func (c AgentConf) CanCommand() bool
- func (c AgentConf) CanReadLogs() bool
- func (c AgentConf) CanServe() bool
- func (c AgentConf) EnrolURL() string
- func (c AgentConf) Enrolled() bool
- func (c AgentConf) LogTrustedKeys() ([]ed25519.PublicKey, error)
- func (c AgentConf) ReportURL() string
- func (c AgentConf) TrustedKeys() ([]ed25519.PublicKey, error)
- type App
- type CPU
- type Command
- type Container
- type ContainerStat
- type Deployment
- type DeviceKeyList
- type Disk
- type Document
- type GroupStat
- type HistoryResponse
- type Host
- type HostKey
- type Inodes
- type KomizoInstall
- type LogKeyList
- type LogLine
- type LogsResponse
- type Mem
- type Metric
- type Metrics
- type MetricsResponse
- type Monitor
- type Network
- type NetworkMember
- type Poll
- type Pressure
- type PressureLine
- type PressureResource
- type PreviewFloors
- type PreviewHostResources
- type PreviewKnob
- func (k PreviewKnob) APIHostFor(app string, pr int) string
- func (k PreviewKnob) DomainFor(app string) string
- func (k PreviewKnob) Domains() []string
- func (k PreviewKnob) HostFor(app string, pr int) string
- func (k PreviewKnob) PathAskAllow(host string) bool
- func (k PreviewKnob) PathDomainFor(app string) string
- func (k PreviewKnob) SharedAskAllow(host string) bool
- func (k PreviewKnob) SharedDomainFor(app string) string
- type PreviewReap
- type PreviewRecord
- type PreviewTarget
- type PreviewUpConfig
- type Probe
- type Problem
- type Proxy
- type Report
- type ReportResponse
- type Result
- type Sample
- type Server
- type Signed
- type Span
- type StaticServing
- type Swap
- type SweepImage
- type SweepKnob
- type SweepRecord
- type System
- type Volume
Constants ¶
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" )
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.
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.
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.
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.
const ( PreviewDomainDefault = "preview.gdam.dev" PreviewTTLDefault = 72 * time.Hour PreviewMaxDefault = 5 PreviewMemLimitDefault = "512m" PreviewCPULimitDefault = "0.75" PreviewAskPortDefault = 8487 PreviewPortRangeDefault = "20000-21000" )
Preview defaults.
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.
const ( // HistoryMax is roughly a week at a handful of containers. HistoryMax = 16 << 20 // HistoryKeep is how many readings survive a trim. HistoryKeep = 20000 )
const ( SweepMinAgeDefault = 7 * 24 * time.Hour SweepIntervalDefault = 24 * time.Hour )
SweepMinAgeDefault and SweepIntervalDefault are the conservative knobs when the operator has written none.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
const PreviewKnobPath = "/etc/komizo/preview"
PreviewKnobPath is the operator's tuning file. Komizo never creates it; the defaults below are the conservative answer.
const PreviewsDir = StateDir + "/previews"
PreviewsDir is where preview state lives: one directory per preview, beside the app records and equally root-owned.
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.
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.
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.
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.
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.
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."
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.
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.
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.
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 ¶
var ApplyOps = []string{OpAppStart, OpAppStop, OpAppRestart, OpAppAdd, OpAppRotate}
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.
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.
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
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
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
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 ¶
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
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 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
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
ClearStoppedIfUnchanged cannot erase a newer owner request or provisioning change. It shares the mandatory record lock with every stop-marker writer.
func Decode ¶
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
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
FormatDeviceKey renders a public key as it is carried and stored.
func FormatLogKey ¶ added in v0.0.21
FormatLogKey renders an account log key as it is carried and stored.
func Handler ¶ added in v0.0.16
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
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
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
IsTemporary reports whether a name in the inbox is one of those.
func KnownOp ¶ added in v0.0.20
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
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
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
LockImageMetadata serializes the bounded root-owned immutable digest cache.
func LogServices ¶ added in v0.0.17
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
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
NewCommandID is 128 random bits, hex-free and URL-safe.
func Now ¶
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
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
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
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
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
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
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
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
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
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
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
PreviewHost is the web route, pr-<N>.<domain>; the API is <host>-api.
func PreviewProject ¶ added in v0.0.42
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
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
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
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
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
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
RemovePreviewRoute takes a route out with the same discipline: remove, validate, restore on failure, reload on success.
func SignReadToken ¶ added in v0.0.16
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
StateRevision is a bounded fingerprint, never the contents of an app record.
func StoppedByCommand ¶ added in v0.0.20
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
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
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
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
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
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
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
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
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 ¶
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 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
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
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
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
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) Enrolled ¶ added in v0.0.15
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
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
ReportURL and EnrolURL are the two endpoints, built from the base.
func (AgentConf) TrustedKeys ¶ added in v0.0.16
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.
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) AppOf ¶ added in v0.0.16
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
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 ¶
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 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
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
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.
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
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.
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 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.
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 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
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.
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) 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) Volumes ¶
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 ¶
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.
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 ¶
ReadReport loads a report written by WriteReport.
type ReportResponse ¶ added in v0.0.16
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
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.
type Sample ¶
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.
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.
type Signed ¶ added in v0.0.16
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 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 SweepImage ¶ added in v0.0.42
SweepImage is one image as the sweep needs to know it.
type SweepKnob ¶ added in v0.0.42
SweepKnob is the parsed knob file.
func ParseSweepKnob ¶ added in v0.0.42
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
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.
Source Files
¶
- access.go
- agent.go
- api.go
- command.go
- deployment_lock.go
- diagnose.go
- disk.go
- disk_linux.go
- docker.go
- du_linux.go
- endpoint.go
- fileowner_unix.go
- flock_supported.go
- inbox.go
- logline.go
- logs.go
- metrics.go
- operation.go
- operator.go
- paths.go
- pressure.go
- preview.go
- preview_ask.go
- preview_backend.go
- preview_hosts.go
- preview_ingress.go
- preview_paths.go
- preview_reserve.go
- preview_resources.go
- preview_revik.go
- probe.go
- query.go
- read.go
- report.go
- result.go
- runtime.go
- scoped_env.go
- static.go
- stopped.go
- store.go
- sweep.go
- sysprobe.go
- system.go
- token.go
- volume.go
- write.go