ReviewOS

also looking at this

stacks/ts-cloud

feat(operations): fleet inventory, attach preflight, and the exports that were missing

#192
Merged glennmichael123 wants to merge feat/fleet-inventory-and-attach-preflight into main
11 files +1313 -5

Closes #191.

The export bug, first

src/operations/ has held a complete implementation of #167 for several releases - planSiteMove, planServerRename, the drained-site scanner, and the plan-then-apply runner they share - and none of it has ever been reachable. operations was in neither build.ts's entrypoint list nor the package's exports map, so dist/operations/ shipped .d.ts files with no JavaScript beside them. Because the exports map ends in a "./*" wildcard, an import of it type-checked and then threw at runtime.

deploy/site-ports and deploy/site-config-editor were in the same state for the same reason: never re-exported from the deploy barrel, so never bundled at all.

build.ts already documented this precise failure:

Bundling only src/index.ts leaves those subpaths as .d.ts-only in dist, so import '@stacksjs/ts-cloud/drivers' fails at runtime for consumers.

operations was simply never added to the list. It is now, along with the ./operations subpath, and publint (already in the lint run) verifies the declared entry resolves - it failed the build until dist/operations/index.js existed, which is a useful guard going forward.

Before and after, measured against the built dist:

        before   after
root      996  →  1054 exports

with planSiteMove, resolvePlan, applyPlan, formatPlan, parseDrainedSites, occupiedHostPorts, buildHostSitePortsScript, parseHostSiteFragments, addSiteToCloudConfig and siteInstallBase all going from unreachable to reachable.

The two operations that were being written outside this repo because of it

Both were built in stacksjs/stacks (stacksjs/stacks#2378, stacksjs/stacks#2379) with hand-rolled copies of buildHostSitePortsScript, parseHostSiteFragments and occupiedHostPorts, because those could not be imported. They belong here, and here they drop the duplication and call the real ones.

operations/inventory

What is hosted on which box, across every project sharing it.

A project's own config cannot answer that. It describes ONE project's sites, the boxes are multi-tenant, and the other tenants deploy from their own repositories with cloud.attachTo - so reading config and calling it an inventory reports a shared box as if one project were alone on it. The answer comes from the box instead, out of the same rpx registry fragments site-ports already reads for port allocation. The routes rather than the ports, from the same files, so the two can never disagree about what is on a host.

2 servers:

  stacks-production-app  5.161.0.1  cpx41  fsn1  running
    owned by 'stacks' (production, role app)
    serves 4 routes for 2 projects:
      stacks (this project)
        stacksjs.com/         ->  127.0.0.1:3000
        stacksjs.com/docs     ->  static /var/www/stacks-docs
        stacksjs.com/discord  ->  redirect to https://discord.gg/...
      rappid
        rappid.hq.training/   ->  127.0.0.1:3024

Where it refuses to guess:

  • A box that could not be read is reported as unread, never as empty.
  • With no box answering, nothing is reconciled at all. "Every site is missing" is a true statement about the listing and a false one about the deployment.
  • A site missing from the boxes that did answer names the unread boxes as the other explanation.
  • A site with no domain is never gateway-routed by design, so it is labelled that way rather than reported as undeployed.

operations/site-attach

Whether a project may safely attach to a server another project owns, checked before the deploy rather than during it.

validateDeploymentConfig only ever sees one project's sites, so a second attach passes it - and the failure it passes is the quiet one this repo already knows well: the units do not set exclusive binding, so two services on one port both bind, both look healthy, nothing is logged, and each domain serves the other's site about half the time. That is what happened to predicthq.org for a day and a half.

  • Target resolves by provider name or owning project slug, narrowed by environment, refusing an ambiguous match rather than picking one.
  • Refuses a box with no ts-cloud/project label, a box whose owner slug is the attaching project's own (which would overwrite the owner's gateway fragment), and a box that is not running.
  • Ports come from occupiedHostPorts rather than from the routes directly, so the preflight and the allocator can never disagree about which ports are taken.
  • formatAttachPlan prints UNCHECKED, never "no conflicts", when the box was not read.

setAttachToInCloudConfig

Writes the attach back into a cloud config. Deliberately narrow: it handles exactly the shape the templates generate and throws on anything else - two cloud: blocks, a nested object inside one, no block at all - matching how the rest of site-config-editor reports what it will not do. A config mangled by a clever regex is a far worse outcome than one the tool declined to touch. The test asserts the output is the input byte for byte with the two added lines removed.

HostSiteFragment

Gains the route fields beyond from (to, path, static, redirect). Optional and additive; the port allocator still reads only from.

Verification

  • 47 new tests. Full suite 4329 pass / 0 fail (4380 across 327 files).
  • tsc --noEmit clean, bun run lint clean (864 files, 0 errors, 0 warnings).
  • Built dist probed directly to confirm every name is now importable from root, ./operations and ./deploy.

Not verified against a live box. The provider listing and the SSH probe take their effects as arguments (the shape site-move already uses), and the tests inject them, but neither has run against a real fleet from here.

Follow-up

stacksjs/stacks#2378 and #2379 carry local copies of this logic. Once this is released, those become thin callers and the duplicates are deleted - which is the point of moving it here.

11 changed files on the files tab, with 0 review threads.