Why houston is built the way it is, for a contributor about to change it. Each section is a
decision and what breaking it costs. README.md and guide.md
cover using houston. CONTRIBUTING.md covers setup, the gates and
writing a preset.
houston reproduces a customer's Rocket.Chat issue on their exact version, in minutes. One command boots a version-matched Rocket.Chat with a compatible MongoDB, optional backing services (LDAP, SAML/OIDC, email, S3, multi-instance) and sample data, and one more removes all of it. The Rocket.Chat support team maintains it.
houston up --version 8.5.1 --name TICKET-1234 --wait
houston down --name TICKET-1234 --volumes
It is TypeScript on Node.js 24.10 or newer. bin/houston is a POSIX sh script that execs
node tools/cli.js over the source on a checkout (any tree with src/) and
node dist/cli/entry.js in an installed package, so a clone needs no build step and never
runs a stale dist/. It execs rather than spawns, so the exit code and signals stay the
caller's. tsc emits dist/ with no bundler, because a bundler rewrites the data/ lookup
in lib/config.ts.
Two front-ends over one service layer. houston <cmd> is the terminal. houston serve
is a browser GUI a support engineer can use without a shell, with named accounts, roles and
an audit trail.
cli/ web/
commander app, usage errors Fastify app, guard chain,
verdicts, handlers, serve ROUTE_ROLES, jobs, SSE/WS
│ │
└───────────────────┬───────────────────┘
▼
core/
the entire behaviour of the product
lifecycle/ kube/ edge/ runner/ backup doctor eks ...
with perf/ and seed/ beside it
│
┌────────────┬────────────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
docker-cli kubectl aws-cli rc-http fs/lock
docker and kubectl, aws and the only the file
compose helm, kind eksctl HTTP client lock
└────────────┴────── seams/exec ───────┴─────────────┘
the one process-launch boundary
lib/ underneath all of it: errors, events, config, lock,
jsonout, compose, versions, presets/, the contract formatting modules
Both front-ends call the same core/ functions. This is the most important fact about
the codebase. A change the GUI needs almost always belongs in core/, not in web/routes/.
Behaviour placed in one front-end is missing from the other, and because developers use the
CLI, the GUI is the one that loses it. cli/serve.ts builds the app web/app.ts exports, so
cli/ ranks above web/. Service functions are asynchronous, state owners included: serve
is one event loop, and a state owner that blocked on a write would stall every request in it.
Six modules touch the outside world, and nothing bypasses them. Each is thin: it knows
argv, a timeout, an environment, a URL. The tool-specific ones have a domain half in core/.
| Seam | Launches | Who may import it |
|---|---|---|
seams/exec.ts |
every OS process, as an argv tuple, shell always false |
anything; nothing else may import child_process |
seams/docker-cli.ts |
docker, docker compose |
core/runner/, core/edge/, core/doctor, core/migrate, perf/constrain |
seams/kubectl.ts |
kubectl, helm, kind |
core/kube/exec.ts alone, which decides which client state a call may see |
seams/aws-cli.ts |
aws, eksctl |
core/eks.ts alone, which decides whose account is being spent in |
seams/rc-http.ts |
every HTTP request | core/rcapi.ts, where every Rocket.Chat path lives, and core/sso.ts, where the identity provider's do |
seams/fs/lock.ts |
the cross-process file lock (Node has no flock) |
the state owners: core/runner/locks, core/users, core/sessions, core/eventlog, core/promote |
Narrow seams are why the suite runs without Docker, a cluster, an AWS account or the
network. Error policy stays with the caller: kubectl never throws, because a preflight that
raised on a missing binary would take down doctor, the command people run when things are
missing. .dependency-cruiser.js enforces only-exec-launches-processes,
only-kube-exec-launches-kubectl, only-eks-launches-aws and only-rcapi-speaks-http.
eslint covers what a graph cannot see, no-restricted-imports on child_process and
no-restricted-globals on fetch, each switched off for exactly one file and for tests.
A module imports only from a strictly lower layer, never sideways within a layer, never upward; anything two siblings both need moves DOWN into their package's layer-0 module; the only permitted upward reference is a registry a lower layer owns and an upper layer registers into.
Layers are ranks, finer than directories, in the RANKS table of
tools/dependency-policy.js. lib/ fills the bottom and the five seams other than exec
share one rank above it. Then come the tiers of core/runner/, core/edge/, core/kube/,
core/lifecycle/, perf/ and seed/, the services that compose them, web/, and cli/.
Two modules in one rank may not import each other. .dependency-cruiser.js generates a
layer-N-imports-down-only rule per rank, because a hand-kept n-squared list drifts and a
drifted layer rule fails open. tsPreCompilationDeps makes a type-only import count.
The gate fails closed. yarn depcruise refuses a module that matches no rank or more than
one before it cruises a single edge. yarn depcruise:selftest plants a cycle, a type-only
rank-0 to rank-4 edge and a node:child_process import, and requires each to be reported:
dependency-cruiser reports a clean tree when it cannot load typescript, and a gate nobody
has watched fail is a green light with no bulb.
Registries are the one upward reference. A high module registers into a registry a low
one owns: lib/events.ts for event sinks, jsonout.onActivate() for moving prose to stderr,
and lib/audit.ts, lib/journal.ts and lib/teammode.ts. The real core/audit,
core/journal and core/users rank above every user of their registry, so the gate refuses
the direct edge.
The frozen CLI contract includes exact spellings: how a float prints, where a tie rounds, how
a truncated JSON file is reported, how a YAML scalar is quoted, which option a typo matches.
Node answers each one plausibly and differently. So lib/ carries small, measured
implementations of exactly those formats (format, float-text, json-text, yaml-dump,
close-matches, mkdir-parents, tempdir, login-name, platform-name, home, version-spec,
unicode-tables), and each module's header says where it departs from Node, and why.
Progress is an Event, not a print. lib/events.ts defines Event and
type Emit = (event: Event) => void, and every long-running service function takes emit,
defaulting to nullEmit. The CLI prints events, web/jobs.ts buffers them per job and
web/routes/jobs.ts streams them over SSE. A service function that prints instead is
invisible in the GUI, and a fact returned but never emitted never reaches the GUI's log.
Failure is a raised ReproError, not an exit. lib/errors.ts gives every subclass an
HTTP status for the web layer, a stable code for the --json envelope and an exit code for
the CLI, named in EXIT_CODES, which houston capabilities publishes. A service that ended
the process would be harmless in a one-shot command and fatal in serve, where one bad
request would take the server down. Nothing under core/ calls process.exit.
| Class | HTTP | Exit |
|---|---|---|
ReproError |
400 | 1 internal |
ValidationError |
400 | 2 usage |
DockerError, PreflightError |
502, 409 | 3 preflight |
NotFoundError (and MissingRecordError) |
404 | 4 not_found |
NotReadyError |
409 | 5 not_ready |
AuthorityGateError |
403 | 6 gate |
CreateFailedError |
500 | 7 create_failed |
ConflictError |
409 | 8 conflict |
Background jobs are bounded. web/jobs.ts keeps at most MAX_JOBS = 100 jobs of
MAX_EVENTS_PER_JOB = 2000 events, and only the 10 newest finished jobs keep their result. A
capacity search result is about 118 KB, and serve is a long-lived process on a box that runs
out of RAM first. Job kinds are a closed tuple mapped to pools, so a kind with no pool is a
compile error.
"I could not ask" is not "it is not there". This is the rule of core/kube/, and the
costliest to forget. A failed read answered with an empty list looks exactly like "the cluster
holds none of these", and the teardown or report that acts on it acts on something false. So
Answer (core/kube/ask.ts) is PRESENT, ABSENT or REFUSED, and Reach
(core/kube/reachability.ts) is REACH_OK, REACH_DENIED, REACH_NO_CONTEXT or
REACH_DOWN. JavaScript lets any object stand in a condition, so eslint's
strict-boolean-expressions disallows objects, and yarn lint:truthiness plants
if (answer) to prove the rule is live. test/core/invariants/kube-source.test.ts walks the
package so a new read cannot bring the collapse back, and core/cluster.ts shows a fact it
could not read as null, never 0.
Authorisation is a table, not scattered checks. ROUTE_ROLES in web/route-table.ts is
DEFAULT DENY: an unlisted route is refused for everyone with a 403 and an audit line, and
installRouteCompleteness keeps the app from becoming ready while any route is unlisted.
Reads need readonly. Writes, and reads that hand over credentials (logs, env, a PAT, a raw
REST call), need member. People management and building or deleting a cloud cluster need
admin. The table lives in the web layer because the CLI honours HOUSTON_USER as given
(cli/handlers/actor.ts), so a check in core/ would make HOUSTON_USER=<any admin> a
one-word privilege escalation. A policy that must hold at both
front doors is box configuration, which core/lifecycle/ownership.ts reads.
Sessions are server-side so they can be ended. core/sessions.ts issues a 256-bit id and
stores only its sha256. A session ends after 12h idle or 7d in total. Sign-in is rate-limited
per client address at doSignin in web/security.ts, where the scrypt is spent, because
core/users.ts cannot refuse on a counter without also refusing correct passwords. Hashing is
scrypt from node:crypto, with no bcrypt or argon dependency, and an unknown name is checked
against a dummy hash so it costs what a wrong password costs. yarn test:timing measures it.
Okta sign-in is a second way in, never the only one. core/sso.ts runs OpenID Connect
(authorization code, PKCE, a confidential client) from GET /signin, verifies the RS256 ID
token with node:crypto, and resolves the person to an account by the provider's sub, kept
in core/sso-identities.ts beside the unchanged users file. A first sign-in links an
account of exactly the same name, or creates a member; an admin account links only after
houston users link-sso, and a different name never links. Every failure is a fixed reason
code, so a provider that is down or misconfigured costs the button, never serve, a password
sign-in or a live session. Removing an account turns its link into a tombstone in the same
lock, so the person's next Okta sign-in is refused rather than recreating it.
TLS is a runtime property, not a build-time one. One Traefik edge holds :80/:443 for
the box. A workspace gains or loses HTTPS when a route file appears or disappears, never by a
rebuild. The edge joins each workspace's network live, and each workspace keeps its own
network, so workspaces still cannot reach each other. core/lifecycle/tlsplan.ts makes every
HTTPS decision before anything is built. Routes address the container name, never the
service alias: every workspace names its service rocketchat, so on an edge attached to many
networks the alias resolves to one of them and would silently serve the wrong workspace.
test/core/invariants/edge-and-doctor.test.ts holds a route to the container.
--json is one envelope, and it is the last line. lib/jsonout.ts defines
{schema, contract, houston_version, generated_at, ok, data, warnings, error}. up and
ready write NDJSON houston.event.v1 lines first, then exactly one envelope, failures
included, so a caller reads to the end and parses one line. stdout carries JSON and nothing
else: activate() moves every human line to stderr, which is how
houston info --json > case.json is a valid document while the person running it still sees
progress. PHASES is closed by the compiler, because Event.phase is typed EmittedPhase.
Capacity is a preflight that refuses, not a warning. checkCapacity() in
core/lifecycle/sizing.ts reserves host RAM headroom and blocks up. houston usually runs on
a laptop already running other stacks. When memory runs out the kernel picks its own victim,
and a create that cannot finish leaves a half-made workspace behind.
Everything binds loopback unless told otherwise. Reproductions ship weak fixed credentials
(admin / admin123), so everything binds DEFAULT_BIND_HOST (127.0.0.1) unless opted out
with --bind, HOUSTON_BIND_HOST or config set bind_host. A workspace reachable beyond
loopback gets a generated admin password instead, when it is made: a rebuild keeps the one
its database holds (keptAdminPassword), since Rocket.Chat sets it only on an empty
database. PRESET_PORTS in lib/config.ts is the one registry of PREFERRED side ports,
MONITOR_PORTS monitoring's. core/lifecycle/ports.ts allocates Rocket.Chat's own port
and every side and monitoring port under one lock (claimSidePorts): preferred first, the
next free one when another workspace holds it, never another preset's preferred port or a
voice port. A workspace records only what moved (extra.side_ports; monitoring in
extra.monitoring_ports), runner/record.ts reads it back (sidePortsOf,
grafanaPortOf, prometheusPortOf), and the builders take the result as their ports
argument. Only voice's call ports, which phones dial by number, are refused when taken.
core/topology.ts records the runtime, the deployment and the replicas. docker runs on this
machine as monolith or multi-instance; kubernetes runs on any cluster (kind, k3s, EKS) as
microservices or monolith. api, token, pat, config-import and ordinary seed speak REST to a URL.
Historical seed creates content through REST, then core/seed-db.ts resolves and validates
the live managed MongoDB target before adjusting only captured message/file timestamps.
It uses the recorded Docker project or Kubernetes namespace/context and feeds credentials
through stdin. External or ambiguous databases are refused. Operations that need a compose project call
topology.requireCompose(), which refuses and names the Kubernetes equivalent. "Not
supported" alone leaves the user to find what the author already knew.
The cloud is two verbs at two lifetimes. cluster create runs once per team, takes about
20 minutes and bills by the hour from minute one. up runs once per ticket, takes minutes
and is free. up can never create a cloud cluster, and that is structural: planCluster()
in core/kube/context.ts adopts an explicitly named context and never provisions one, and
core/kube/cluster.ts creates only houston's own local cluster. Its deleteCluster takes no
cluster parameter at all. AWS IAM decides who may build a cluster and the cluster's own RBAC
decides who may use it, so houston cluster iam-policy ships three documents from data/iam/.
eks.md, which houston cluster guide prints, is the procedure.
One answer about clusters, rendered twice. cluster.overview() in core/cluster.ts
reads every AWS context through status() (the ledger: nodes, workspaces, cost, who
built it from the cluster's own tags, expiry, whether it is prepared and whether its DNS
zone answers) and every other with a glance (whether it answers, its distribution and
its workspaces); whereOf() decides which, from the context name and the API server,
never a probe. It adds the AWS totals and the preflight, and asks nextStep() for the
AWS stage, a pure table over the six stages every screen names. houston cluster overview prints
it; /api/clusters/overview serves it to the Clusters page, Home's Needs you and the
EKS setup flow. Neither front-end decides what exists, what it costs or what comes next.
One line per directory. Where a line uses ·, it separates ranks, bottom first.
git ls-files src is the authority on what exists (tests live in test/), and each module's
header comment says what it is for.
lib/, below every service:errorseventsjsonoutconfiglockcontext, the registriesauditjournalteammode, thencomposeversionsmonitoringmongo-kernelredactseed-profilesseed-optionstargz, and the exact-format modulesformatfloat-textjson-textyaml-dumpclose-matchesmkdir-parentstempdirlogin-nameplatform-namehomeversion-specunicode-tables, andmap-limit(a bounded concurrent map the cluster ledger and the region sweep share)lib/presets/, the dynamic presets (static presets are YAML indata/presets/):typesguides(what a preset tells the GUI's preset card: sign in as, try it, good to know) ·registry·commonscenariocomposite(several presets as one) ·keycloakpublic(a preset's UIs at paths of the HTTPS name)voice-pbx(the voice PBX lab) ·e2eeemailldaplivechatmulti-instanceoidcs3-miniosamlvoicewebhooks·indexseams/, the only doors out:execdocker-clikubectlaws-clirc-httpseams/fs/:lockcore/, the shared brain. REST and cloud:rcapiekseks-docsclustertopology. HTTPS:tlstls-local. Data:backupbackup-compatdatabase-ofseed-dbupgradeenvvarsmonitordataconfigimportscaleseedloadtestpostready. Diagnosis:diagnosedoctor. Records:journalrecoveryeventlogaudit. People:userssessionsfirstrun, and Okta sign-in,ssowith its linkssso-identities. The home:skillmigratepromotecore/runner/, Docker per workspace:pathslocks·enginerecord·hostportsqueriescore/edge/, the shared Traefik edge:pathstypes·engine·acmenetworks·routeyml·spec·routes·runtimecore/kube/, the Kubernetes domain half, which selects by label and never by name prefix:names·exec·asktools·capabilitiesreachabilityreplicaset·context·clusterendpointsnamespace·manifestsprobescalestatustransfer·release·operator·chartmonitoring·certmanagerforward·ingressgrafana-ingress·workspacecore/lifecycle/, create, inspect, change and destroy, with no façade (import the module that defines a name):typesnotesadmin·namingrequestportstlsplan·sizingseedingownershipinspect·preconditionspayloadready·state·destroypruning·create-k8s·create-dockerperf/, whatloadtestandcapacitymeasure:timingsslobaseline·rcmetricsresourcesmongoprofconstraink6verdicttimeline·reportseed/, sample data planned before anything is created and read back after:kinds·wordingassets·planclient·receipt·executorhistoryverification·applyrepairs·checkcli/, the terminal front door:spec-typeswirecell-widths·spec-dataerror-panel·helpcapabilitiesperfserve·usage-error·app·render·handlers·indexentrycli/handlers/:actor·prompt·httpsusersweb/, theserveserver:jobssigninsecurity·route-depsroute-table·appweb/routes/, one module per family,sparegistered last because it matches every path:sessionpeoplemetareproslogsdatabackupclustersedgeperfjobsspa·index
Outside src/, bin/houston is the entry and data/ ships beside the code: versions.yaml,
the static presets, dashboards, k6 scripts, the GUI, the agent skill, the IAM documents and
these documents. test/ and tools/ do not ship.
lib/seed-options.ts supplies both front-ends with the profile/scenario catalogue.
seed/plan.ts assigns content and actions to named participants before mutation.
core/lifecycle/seeding.ts drives the executor, optional history and exact verification.
Workspace locks serialize all entry points. seed/receipt.ts fsyncs immutable manifests
and a hash-chained write-ahead log under repros/<name>/seed-receipts/; no credentials
are stored. Uncertain writes reconcile by exact identity or refuse replay. In particular,
media confirmation is not replayed: supported servers can create a second message.
ready resumes the recorded batch after rechecking its identity under the lock.
Verification checks the latest batch, preserves true/false/null, and reads personal
features with their actor token. Existing content and customized metadata remain intact.
The GUI is vanilla ES modules with no build step, in data/webui/ and served by
web/routes/spa.ts. yarn verify:gui-hash checks an explicit list of its files and one
digest over all of them, recorded when the GUI was frozen, so changing the GUI means
re-recording that digest on purpose.
Colour is a signal, never decoration. The rule is declared at the top of app.css: green
= running, amber = wants you, red = about to destroy something, blue = you can click it.
The payload-shape trap. Browser tests render from a hand-written detail() fixture in
test/web/browser/frozen-gui.browser.test.ts. A key that differs from what
core/lifecycle/payload.ts really returns breaks the panel in production while the suite
stays green. A browser test compares the fixture with the real payload, and
test/core/lifecycle/inspect.test.ts holds the real payload's keys.
Each read of a remote cluster is a round trip, a second or two against EKS, so each operation
has a budget. The GUI's cluster list (GET /api/clusters) probes nothing, so the page
draws its cards at once. The Clusters page, Home and step 1 of Create then read one
GET /api/clusters/overview, which reads every cluster with bounded concurrency (and a
cluster on this machine only lightly); GET /api/clusters/status is read only when a
local cluster's own page is opened. up and cluster create take minutes, and must say what they are doing while
they do it.
Independent reads run concurrently. A step that prints nothing until it returns, like
helm install --wait, is where somebody decides the tool has hung and kills it, leaving a
half-installed release.
Everything lives under HOUSTON_HOME (default ~/.houston). The layout is in the user guide,
under Where state lives. The compose file is generated, so
re-run up rather than editing it.
The IMPLEMENTATION marker says which build may write a home. This build is typescript.
dual-v1 means every state format in the home was read back and agreed about, and a fresh
home is claimed as dual-v1. A marker this build does not know is refused before anything is
written, and so is a home holding a transition file. Only core/promote.ts changes a claimed
marker, under all five state locks, and the transition file exists exactly while a promotion
is in flight. A home 1.x left is adopted once. On the first command, promote reads every
state format in it back under the locks, swaps the marker to dual-v1 atomically, clears the
transition file last, and says so on stderr. On any failure the home is left as it was.
A cloud cluster's ownership is a tag on the cluster, in AWS. A local file is one
mktemp -d away from a billable cluster houston can no longer prove is its own. A tag
survives a lost home, another machine and a colleague.
src/ is production code only. test/ mirrors it path for path, so a failure names the
module. Nothing in the suite needs Docker, a cluster, an AWS account or the network. The
test/testsetup*.ts files give every spec file a fresh HOUSTON_HOME, a deterministic engine,
absent cloud tools and no non-loopback network, and the engine, cloud and network setups each
have a spec of their own. Anything run outside Jest must isolate itself the same way, with
export HOUSTON_HOME=$(mktemp -d) first.
- Zero skips, and a floor.
tools/jest-zero-skips.jsfails a skipped, todo or.onlytest and a run below the measured floor. The commit that moves the floor says why. - The browser suite runs in-process, through the
playwrightlibrary with the Fastify app in the same process, and must pass an exact count.@playwright/testwould run the server out of process, where it sees none of the stubs, and the suite would stay green. - Invariant specs walk the source, from each layer's
invariants/:ROUTE_ROLESagainst the registered routes,doctor.CHECKSagainst its call sites, the lock every mutation takes. Registries drift; walks do not. - 1.x's answers are pinned under
test/fixtures/wherever the contract is what 1.x printed or wrote. Where 2.0.0 reworded a message into houston's own words, the fixture holds the new wording and the rest of 1.x's answer. - A regression test must fail against the pre-fix code. Reverse the fix, confirm the test fails for the stated reason, restore it byte for byte. A test that passes both ways proves nothing.
The gates, what each proves and what CI runs are in
CONTRIBUTING.md.
houston 2.x replaces 1.x in place. Every command, flag, exit code, --json envelope and state
file format is unchanged. What 2.0.0 changed is the wording of some messages: they name the file
and the reason in plain words, where 1.x printed its runtime's exception names and value
spellings. A 1.x install keeps working after 2.x is installed, on the same home, because
both read the dual-v1 marker and take the same flock(2) locks.
A support tool's worst failure is not crashing. It is answering confidently and wrongly: reporting zero volumes for a cluster it could not read, certifying a URL served by something else, saying a certificate failed when it had not arrived yet, or telling somebody a cluster costs nothing because the pointer file was lost. Most of this document applies that idea in different places. Say what you know, say what you could not find out, and never let the second turn into the first.