apps / sockeye

sockeye is a development platform for private monorepos that I run on my own VMs. It hosts the git repos, runs their checks, and reviews and merges changes. It records each deploy, and it shows each service's logs, performance, errors, and uptime. Its site, sockeye.sh, has the install steps and the CLI reference.

It does the work that I would otherwise split across GitHub, a CI service, Sentry, a log search tool, and an uptime monitor. One Go binary, soc, is the server, the runner, and the CLI. One Postgres database holds the state, and bare git repos hold the code. An instance serves one organization, so it has no tenants and no billing tiers.

I judge a feature by what it lets an agent investigate. An agent starts with a question, such as why a route got slow after a deploy. It reads the change, the deploy, the errors, the logs, and the request metrics, and it follows one record to the next. So each record has a stable id, each read states the time it covers and what it dropped, and each export is complete.

An agent can compute more from the data that sockeye keeps, but it cannot recover a request that sockeye did not capture. So I state each limit: sockeye has no distributed tracing, and its request metrics are rollups for each route.

Checks

A Checkfile defines the checks for its directory. Each line has a name and a command, separated by a colon:

gotest: go test -race -cover ./...
lint: test -z "$(go tool goimports -l .)"

A push runs the Checkfile in each directory that holds a changed file, and in each directory above it. In this repo, a change to sdk/go/account.go runs the checks in sdk/go/, sdk/, and the root. It runs nothing in sdk/ruby/ or server/:

.
├── Checkfile
├── sdk
│   ├── Checkfile
│   ├── go
│   │   ├── Checkfile
│   │   └── account.go
│   └── ruby
│       └── Checkfile
└── server
    └── Checkfile

When a runner is free, a check starts within 1 second of a push. A runner runs each command in /bin/bash -eo pipefail. A check reports failed on a non-zero exit, and errored when the runner could not set up. The server keeps the output for 30 days. A repo pins its own tools, so a check runs the versions that the repo chose (see go / checks).

A check whose name starts with daily_ or weekly_ runs on a schedule against main, and a push does not run it. soc scheduled runs one on a change's head.

An artifact: line names a file that a build leaves. When the check passes, the runner uploads the file. The server stores it under the hash of the commit's tree, which the squash merge keeps. So a deploy installs the bytes that CI tested.

Changes

A change is the review unit. It has an id such as APP-42, which is also its branch name and its worktree directory. A change goes from start to merge like this:

soc checkout       # allocate APP-42, cut a worktree, print its path
soc edit           # title and description, before the work
git commit -am "retry: stop after five attempts"
soc push --wait    # push, then block until the checks finish
soc merge          # squash onto main

soc checkout cuts the worktree at ~/.worktrees/<repo>/<ID>. It symlinks an untracked .env from the main clone. Then it runs the command that the clone's tree.setup git config key names. One repo uses it to give each worktree its own database (see postgres / dev test clusters). The key is config, not a tracked hook, so a checkout of somebody else's change can't run their code.

I write the title and description before the work, so the dashboard says what each open change is.

Review

A review is one comment from the terminal. It has no inline comment, no thread, and no approval state. Each action has one way to do it, so an agent does it the same way every time:

soc show APP-42
git fetch origin && git diff origin/main...origin/APP-42
echo "the retry loop needs a ceiling" | soc comment APP-42

The server refuses a comment when the reviewer's commit is behind the change's head.

The page for one change: its title and description, the services where it is live, the diff by file, and each check with its time

The web UI shows the state. Its only forms activate or deactivate an account, revoke a token, and sign in with a one-time code. For each other write, it shows the command to run. A write wakes every open page, and each live section fetches itself again (see go / wakeups and web / live regions).

Merge

soc merge checks that the change has a title, that it merges cleanly, and that every check on its head passed. An instance can also require a title pattern. soc merge --when-green waits for the checks first, and syncs the change again if main moved.

The server writes a squash commit with Co-Authored-By and Reviewed-by trailers, moves main, and backs the commit up. If the backup fails, main stays where it was.

Deploys

A repo can deploy on merge. The server queues the merged SHA in Postgres, the jobs process runs the repo's deploy command, and soc merge prints its output. An interrupted deploy has an unknown outcome, so I inspect it before a retry.

Other repos deploy with their own tool, which posts the SHA to sockeye when each service is live. sockeye records only the deploys that a tool posts.

soc deploy live    # per service: the live SHA and the changes in it
soc status         # each live deploy, with recent requests, errors, and p95

A change is live when its merge commit is an ancestor of the live SHA. So soc show APP-42 says where each change runs.

Logs

soc logs enable --unit names the systemd units that a service logs to. Its logs page then shows the journal with a live tail, a time range, and a search. The search reads key=value fields from logfmt and JSON lines, so status>=500 path=/login finds the failed logins. soc logs takes the same terms.

The logs page in live tail: one request line per row, with the time, the server process, the status, the path, and the duration

The server can't read the journal itself. A root relay reads it for the units in an allowlist (see unix / systemd).

Performance

The perf page draws requests by status class, the p50, p95, and p99 latency, and the database time per request, with a marker at each deploy. Its table has one row per route: the pattern that the Go http.ServeMux matched, such as GET /repos/{slug}. A click on a chart opens the log lines for that span (see web / charts).

The perf page for a web app over four hours: requests by status class and the p50, p95, and p99 response time, with a marker at each deploy

The server parses the request lines in a service's journal, or a Go service posts its own records through errs. The latency charts leave out Server-Sent Events connections, because their duration is how long a client watched. A long poll, such as the one behind soc push --wait, gets a route row of its own.

Errors

A service posts its errors to the server with a key of its own. The server groups the events by fingerprint, or by type and top frames. The repo's errors tab and soc error show the groups:

soc error list           # open error groups
soc error show <ID>      # events, extras, and stack trace

A commit message with Fixes: err-12 resolves that group when the change merges. An event from a later release that holds the fix opens the group again.

Uptime and jobs

Each service has HTTP and TCP checks of its public URLs, which the deploy tool syncs with soc probe sync. Runners and Cloudflare Workers in other regions request each check every minute. A check's status is the worst runner answer in its newest minute. The server sends each alert to a webhook, with a guard against a check that flaps. A scheduled job requests /ping/<key> when it ends, and a missed ping opens an alert.

The jobs page lists each attempt at sockeye's own background work, such as the database snapshot and the restore rehearsal. The backups and the restore rehearsal each have a deadline, and a missed deadline opens an alert.

How it runs

One host runs two server processes, and Caddy sends requests to both. Postgres holds what the two share: the jobs, the live check output, and what each runner does. So either process can answer any request. A Postgres advisory lock serializes merges, and main moves by compare-and-set.

A deploy restarts one process at a time. It takes the process out of Caddy's list, waits for /healthz and /readyz on the new build, and puts the process back. Both run on one host, so this keeps the site up across a restart, not across a host failure. A separate soc jobs process holds another advisory lock and runs the recurring work.

Runners

A runner long polls the server for jobs. For each job, it checks out the job's tree and runs the command. A runner pings every second. After 15 seconds of silence, the server gives its job to another runner.

The runners share Go's build and module caches on a larger Ubicloud VM, in the server's private subnet. A runner's git fetch uses the libcurl resolve option to reach the server over that subnet, with the same HTTPS URL, certificate check, and token. A check command does not get that option, so a test of the public URL takes the public path.

Git and login

Each repo is a bare repo on the server's disk. The server runs git http-backend behind Caddy, which adds TLS. There is no ssh git transport. A pre-receive hook rejects direct writes to main.

On each machine, I point the CLI at the server, then log in:

soc git setup https://sockeye.example.com/git
soc login

soc login signs a challenge with a key from my ssh-agent and trades the signature for a token. The token lives seven days, and git reads it through soc auth git-credential. A server with WorkOS SSO signs in a browser through the organization's identity provider. A runner mints its read-only token with its host SSH key.

I run separate instances for personal projects and for work. They share no VM, database, or key. Inside a clone, the CLI talks to origin. Outside one, SOCKEYE_HOST names the server.

Mirrors and backups

For most repos, the server puts a git bundle of each squash commit in object storage, and a weekly job restores one (see git / restore rehearsals). A repo that go get or a deploy platform reads from GitHub gets a mirror of main there instead.

I develop hml, highlight, is, and errs on sockeye this way. GitHub receives main and the tags, never a change branch, so I close pull requests there.

← All articles