feat(cloud): check an attach against the box before deploying onto it - #2379
Merged
Conversation
Attaching this project to a server another project owns already works: set
`cloud.attachTo` and `buddy deploy` puts the sites there instead of
provisioning. What has never existed is the check BEFORE the deploy.
Every way an attach goes wrong is currently discovered while it is going wrong.
`assertPortsAreFree` catches a port clash over SSH from inside the deploy, and
the slug guard catches a tenant claiming the owner's gateway fragment - both
good, both after the operator has committed a config change and started
shipping. The worst case does not even error: ts-cloud's units do not bind
exclusively, so two services on one port both bind and the kernel load-balances
between them. That is how predicthq.org spent a day and a half serving a
storefront to half its visitors, with both services reporting healthy.
`buddy cloud:attach --server <name|owner-slug>` answers the question first, from
the box's own gateway registry rather than from config - the co-tenants deploy
from their own repositories and appear in no file here:
- the target resolves by provider name or by owning project slug, and refuses
an ambiguous match rather than picking one;
- it refuses a box with no `ts-cloud/project` label, one whose owner slug is
this project's own, and one that is not running;
- it reports every declared port already held by another project, and every
hostname and path another project already serves.
Only when the box has answered and answered clean is `config/cloud.ts` edited.
That edit is deliberately narrow: it handles exactly the shape the scaffold
generates and reports anything else instead of rewriting it, because a config
mangled by a clever regex is a far worse outcome than one the tool declined to
touch. ts-cloud has real editors for this in `deploy/site-config-editor`, but
they are not reachable from the published package (stacksjs/ts-cloud#191).
The listing refuses to guess in the two places it could mislead:
- a box that could not be read prints UNCHECKED rather than "no conflicts",
because "no conflicts" after failing to ask is the most dangerous thing
this command could say, and it exits non-zero so CI cannot proceed on a
check that never ran;
- a refused attach does not print the config edits at all, since "two edits
make this real" under a conflict list reads as though it is going ahead.
The second edit an attach needs lives in the owner's repository (adding this
slug to `tenants`, so this project's env keys are recognised as somebody
else's), so it is printed rather than made.
Also fixes the same class of bug in `cloud:sites`, landed last commit: its
non-Hetzner refusal paired `log.error` with `process.exit`, and because the
write is async and `process.exit` does not wait for it, the refusal printed
nothing at all and exited 1.
@stacksjs/actions
@stacksjs/ai
@stacksjs/alias
@stacksjs/analytics
@stacksjs/api
@stacksjs/arrays
@stacksjs/audio
@stacksjs/auth
@stacksjs/browser
@stacksjs/browser-extension
@stacksjs/buddy
@stacksjs/build
@stacksjs/cache
@stacksjs/calendar-api
@stacksjs/charts
@stacksjs/chat
@stacksjs/cli
@stacksjs/cloud
@stacksjs/cms
@stacksjs/collections
@stacksjs/commerce
@stacksjs/composables
@stacksjs/config
@stacksjs/cron
@stacksjs/database
@stacksjs/datetime
@stacksjs/defaults
@stacksjs/desktop-build
@stacksjs/dns
@stacksjs/docs
@stacksjs/email
@stacksjs/enums
@stacksjs/env
@stacksjs/error-handling
@stacksjs/events
@stacksjs/faker
@stacksjs/feature-flags
@stacksjs/forms
@stacksjs/git
@stacksjs/github
@stacksjs/health
@stacksjs/http
@stacksjs/i18n
@stacksjs/image
@stacksjs/lint
@stacksjs/logging
@stacksjs/mobile
@stacksjs/newsletter
@stacksjs/notifications
@stacksjs/objects
@stacksjs/orm
@stacksjs/path
@stacksjs/payments
@stacksjs/push
@stacksjs/query-builder
@stacksjs/queue
@stacksjs/realtime
@stacksjs/registry
@stacksjs/repl
@stacksjs/router
@stacksjs/scheduler
@stacksjs/search-engine
@stacksjs/security
@stacksjs/server
@stacksjs/shell
@stacksjs/sites
@stacksjs/skills
@stacksjs/slug
@stacksjs/sms
@stacksjs/socials
@stacksjs/storage
@stacksjs/strings
@stacksjs/testing
@stacksjs/tinker
@stacksjs/tunnel
@stacksjs/types
@stacksjs/ui
@stacksjs/utils
@stacksjs/validation
@stacksjs/video
@stacksjs/whois
commit: |
This was referenced Aug 28, 2026
glennmichael123
added a commit
that referenced
this pull request
Aug 31, 2026
…s-cloud The two commands landed in #2378 and #2379 carried their whole implementation here: a fleet inventory, an attach preflight, and hand-rolled copies of ts-cloud's own `buildHostSitePortsScript`, `parseHostSiteFragments` and `occupiedHostPorts`. The copies were not a shortcut - `deploy/site-ports` shipped declarations with no reachable runtime, so the real ones could not be imported at all - but they were still a second reading of ts-cloud's own file format living in another repository, with nothing to keep the two in step. None of that logic is buddy's. Reading a box's gateway registry, deciding whether a project may attach to somebody else's server, and writing `attachTo` back into a cloud config are all things ts-cloud owns, and they belong beside `site-ports` and `site-move` rather than beside a CLI. stacksjs/ts-cloud#192 moves them there and fixes the export gap that forced the duplication (stacksjs/ts-cloud#191). So `cloud-inventory.ts` and `cloud-attach.ts` are deleted, along with their 74 tests, which moved with them. What is left here is what is genuinely the CLI's: - resolving the provider token and listing the fleet, now through ts-cloud's own `HetznerClient.listServers()` rather than a hand-rolled fetch and pagination loop; - reading `config/cloud.ts` and shaping this project's sites, using ts-cloud's `resolveSiteKind` and `siteInstallBase` rather than re-deriving either; - the refusal, exit-code and `--json` behaviour; - describing the two config edits an attach needs, which stays here because `tenants` is a Stacks config key and the second edit lives in a repository this command cannot write to. The command surface is unchanged: `buddy docs:buddy` regenerates identically. BLOCKED on a ts-cloud release carrying #192. Every remaining typecheck error is `Property '<name>' does not exist on @stacksjs/ts-cloud`, and nothing else. No dependency bump is needed - the declared range is already `^0.12.7`, which the next patch satisfies; only the lockfile moves.
glennmichael123
added a commit
that referenced
this pull request
Aug 31, 2026
…s-cloud (#2380) The two commands landed in #2378 and #2379 carried their whole implementation here: a fleet inventory, an attach preflight, and hand-rolled copies of ts-cloud's own `buildHostSitePortsScript`, `parseHostSiteFragments` and `occupiedHostPorts`. The copies were not a shortcut - `deploy/site-ports` shipped declarations with no reachable runtime, so the real ones could not be imported at all - but they were still a second reading of ts-cloud's own file format living in another repository, with nothing to keep the two in step. None of that logic is buddy's. Reading a box's gateway registry, deciding whether a project may attach to somebody else's server, and writing `attachTo` back into a cloud config are all things ts-cloud owns, and they belong beside `site-ports` and `site-move` rather than beside a CLI. stacksjs/ts-cloud#192 moves them there and fixes the export gap that forced the duplication (stacksjs/ts-cloud#191). So `cloud-inventory.ts` and `cloud-attach.ts` are deleted, along with their 74 tests, which moved with them. What is left here is what is genuinely the CLI's: - resolving the provider token and listing the fleet, now through ts-cloud's own `HetznerClient.listServers()` rather than a hand-rolled fetch and pagination loop; - reading `config/cloud.ts` and shaping this project's sites, using ts-cloud's `resolveSiteKind` and `siteInstallBase` rather than re-deriving either; - the refusal, exit-code and `--json` behaviour; - describing the two config edits an attach needs, which stays here because `tenants` is a Stacks config key and the second edit lives in a repository this command cannot write to. The command surface is unchanged: `buddy docs:buddy` regenerates identically. BLOCKED on a ts-cloud release carrying #192. Every remaining typecheck error is `Property '<name>' does not exist on @stacksjs/ts-cloud`, and nothing else. No dependency bump is needed - the declared range is already `^0.12.7`, which the next patch satisfies; only the lockfile moves.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second command for #2342.
buddy cloud:attach --server <name|owner-slug>answers "is it safe for this project to attach to that box?" before a deploy, from the box's own gateway registry rather than from config.The failure this exists to prevent
Attaching already works: set
cloud.attachTo, andbuddy deployputs the sites on that box. Every way it goes wrong is currently discovered while it is going wrong.assertPortsAreFreecatches a port clash over SSH from inside the deploy, and the slug guard catches a tenant claiming the owner's gateway fragment. Both good, both after a config change is committed and files are shipping.The worst case does not error at all. ts-cloud's units do not set exclusive binding, so two services on one port both bind and the kernel load-balances between them. Both look healthy, nothing is logged, and each domain serves the other project's site about half the time. That is what happened to predicthq.org for a day and a half (pinned in
deploy-port-collision.test.ts).What it checks
And when it is not safe:
--env, and refuses an ambiguous match rather than picking one.ts-cloud/projectlabel, a box whose owner slug is this project's own, and a box that is not running.Where it refuses to guess
UNCHECKED, never "no conflicts." "No conflicts" after failing to ask is the most dangerous sentence this command could produce. It exits non-zero so a CI job cannot proceed on a check that never ran.cloud:blocks, a nested object inside one, no block at all. 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 scaffold byte for byte with exactly the two added lines removed.Order matters: the box is asked first, and
config/cloud.tsis only written once it has answered clean. Writing first would leave a repo claiming an attach that must not happen.Also fixes a bug in
cloud:sitesfrom #2378Its non-Hetzner refusal paired
log.errorwithprocess.exit. The write is async andprocess.exitdoes not wait for it, so the refusal printed nothing at all and exited 1. Reproduced and fixed here, and the new command's refusal path is built onlog.exitfrom the start.Upstream context that changed since #2378
I opened ts-cloud#190 claiming the
moveprimitive did not exist. That was wrong and I have closed it.dist/operations/holds a complete implementation of all of stacksjs/ts-cloud#167:planSiteMove(20 exports),planServerRename, a drained-site scanner, andoperations/plan- a plan-then-apply runner withsatisfied()-based resumability, typed confirmation for destructive steps, and an audit hook. That last one is exactly the "print the plan ts-cloud produced" this issue's--dry-runcriterion asks for.None of it is reachable:
dist/operations/ships no.js, the subpaths do not resolve, and across all 1067 exported names from root +/deploy+/driversnot one operations symbol appears. Tracked in ts-cloud#191, which I retitled and broadened.So
cloud:move,cloud:renameandcloud:destroyshould be thin callers of those functions rather than anything designed here, and they wait on that export.attachis the one operation that does not need them, which is why it is this PR.Verification
defaults/scaffold/config/cloud.ts) rather than a fixture, so a template that drifts fails the test instead of passing while the tool breaks.config/cloud.ts(2 lines added, in the right place, nothing else moved).buddy lintclean (3200 files).buddy typecheckgreen,bun run typecheckclean for every file touched.docs:buddy:checkpassing.Not verified live.
HCLOUD_TOKENis encrypted and I have no key, so the provider lookup and the SSH probe have not run against a real fleet. Refusal paths (no--server, no token) were exercised end to end and exit 1 correctly; the conflict logic is covered by tests with injected data.