ReviewOS

stacks/ts-cloud

Server consolidation has no path: move an app between servers, attach an app as a site, rename a server

#167
Closed glennmichael123 opened this 2 months ago ·
2 months ago

Summary

There is no supported path for consolidating servers. Moving a deployed app from one server to another, hosting a second app on an existing server, and renaming a server are all manual today — which makes a routine "three boxes should be one box" cleanup an afternoon of SSH rather than a planned, reviewable operation.

The case that surfaced it

Three Hetzner servers, one app each:

serverappnote
statushqStatusHQlargest box of the three
bughqBugHQ
loghqLogHQ

The intended end state:

  1. bughq and loghq move onto the statushq box, attached as sites
  2. that server is renamed to something like hq-production-server
  3. the two now-empty servers are deleted
  4. StatusHQ then monitors all three sites plus the host

Every step of that is a supported concept in ts-cloud's model except the transitions between them. You can stand up a server with an app on it; you cannot move an app to a different server, add an app to a server that already has one, or rename a server without recreating it.

What's missing

Three operations, in rough dependency order:

1. Attach an additional app to an existing server as a site. The current model is effectively one app per server. Consolidation is impossible without this: it needs per-site isolation (user, directory, service unit, log stream), its own TLS certificate and vhost, and its own env/secret scope on a shared host.

2. Move a deployed app between servers. Code, environment and secrets, persistent data, TLS, scheduled jobs and queue workers, then a DNS cutover — with the source left intact until the target is verified, so the operation is reversible up to the cutover point.

3. Rename a server in place. Purely an identity change (inventory record, hostname, DNS, provider label) that today implies destroy-and-recreate, which is not an acceptable cost for a naming-convention fix.

Requirements

To fit the control-plane principles in #132:

  • Plan first. --dry-run prints the operation graph — what moves, what is created, what is deleted, what DNS changes — before anything is touched.
  • Idempotent and resumable. A migration that dies halfway (network, token expiry, a full disk on the target) can be re-run and continues rather than starting over or double-applying.
  • Typed confirmation for the destructive half. Deleting the drained source server is the one irreversible step and should be gated accordingly, separately from the move itself.
  • Non-interactive. The whole sequence must be drivable from CI with flags and env vars only — this is the "fully automatable" requirement, not a nicety.
  • Audited. Each step appends to the operation log with secrets redacted, so a migration can be reviewed after the fact.
  • No hidden state. After the move, the config in the repo describes the new topology; the dashboard shows drift rather than silently reconciling.

Acceptance criteria

  • An app can be attached to a server that already hosts another app, with isolated TLS, env, service unit and logs
  • An app can be moved from server A to server B, including data and scheduled work, without a manual SSH step
  • DNS cutover is part of the operation (Porkbun in this case) and is reversible until the source server is deleted
  • A server can be renamed without being recreated
  • The full sequence runs unattended from CI and is safe to re-run after a failure
  • --dry-run shows the complete plan, including deletions

Out of scope

Monitoring the resulting three sites and the host is StatusHQ's side of this and will be tracked separately. Provider credentials referenced here are HCLOUD_TOKEN for Hetzner and the Porkbun API keys for DNS — named only so the operation's inputs are clear.

  • #132 — production-grade application delivery and cloud control plane
  • #134 — Phase 2, git-native application delivery
  • CLI surface for this in stacksjs/stacks (linked below once filed)
9 days ago

CLI surface for these operations: stacksjs/stacks#2342

9 days ago

One correction that should make operation 1 cheaper than the issue assumes, from reading ts-cloud 0.7.114 while planning a loghq deploy onto the statushq box.

Attach-as-a-site is not missing; it is implemented but unexposed. "The current model is effectively one app per server" is not quite right. There is a working cloud.attachTo path:

  • hasComputeConfigured() treats config.cloud.attachTo as satisfying the compute requirement
  • attachToComputeInfrastructure(ownerSlug, config, environment) resolves the host from listServers() by ts-cloud labels, keyed on the owner project's slug
  • ensureAttachModeDatabase(...) provisions the attached app's role and database inside the host's existing Postgres
  • the management dashboard defers: "attached to <owner>; the server owner's dashboard monitors every attached project"
  • allowStateOnly: Boolean(config.cloud?.attachTo) relaxes state requirements for the attached side

So the primitive exists and appears to work end to end.

What is actually missing for operation 1 is the surface and the guardrails:

  1. It is not in the public type. config.d.ts has zero mentions of attachTo; it only appears in the chunks and in drivers/hetzner/driver.d.ts. An app author writing cloud: { attachTo: 'statushq' } gets no completion, no type checking, and no way to discover it exists.
  2. It is undocumented, so the only way to find it is to read the bundled output.
  3. Ports collide the moment a second template-generated app attaches. Filed as #168 with evidence.
  4. The credential reach changes, because attaching requires the attached app's token to enumerate the owner's project. Filed as #169.

That reframes operation 1 from "build attach-as-a-site" to "type it, document it, and add the two guardrails". Operations 2 and 3, move and rename, still look genuinely absent.

Worth double-checking against main in case this has moved since 0.7.114.

9 days ago

Correcting myself on one point above: attachTo is properly typed. My "not in the public type" claim was wrong.

I based it on grep attachTo node_modules/@stacksjs/ts-cloud/dist/*.d.ts returning nothing, which only searched the top level of dist and missed the nested declarations. Checked properly this time:

const c: TsCloudConfig = {
  project: { name: 'x', slug: 'x', region: 'us-east-1' },
  cloud: { provider: 'hetzner', attachTo: 'statushq' },
}

typechecks clean, and swapping attachTo for an invented key fails with:

error TS2353: Object literal may only specify known properties,
and 'totallyMadeUpKeyThatShouldFail' does not exist in type 'CloudProviderConfig'

So the type is strict and attachTo is a declared member of it. Apologies for the noise.

The rest of that comment stands: the implementation is real and reachable (attachToComputeInfrastructure, ensureAttachModeDatabase, the dashboard deferral, allowStateOnly), so operation 1 is closer to done than "the current model is effectively one app per server" implies. What is genuinely missing for it is discoverability in the docs, and the two guardrails in #168 and #169. Operations 2 and 3 still look absent.

Sign in to comment on this issue.