Skip to content

fix(connections): resolve the machine a project runs on instead of comparing serverId columns - #644

Merged
Hydralerne merged 1 commit into
mainfrom
fix/connection-host-resolution
Aug 19, 2026
Merged

fix(connections): resolve the machine a project runs on instead of comparing serverId columns#644
Hydralerne merged 1 commit into
mainfrom
fix/connection-host-resolution

Conversation

@Hydralerne

@Hydralerne Hydralerne commented Aug 19, 2026

Copy link
Copy Markdown
Member

Fixes two regressions I introduced in 26ddd69 (merged in #632), both in the reachability
gate that commit added to resolveInternalValue. They are on main now.

1. The co-location check refused pairs that are on the same machine

It compared (source.serverId ?? null) !== (target.serverId ?? null). That is not a
same-box test:

  • project.serverId is written only in the deploy success tail (deployment-lifecycle)
    and never on a local deploy, so it is null for any project that hasn't completed a
    server-bound deploy
    .
  • This codebase holds that a derived local target, the auto-registered isLocal "This
    Server" row, and a plain loopback/SERVER_IP row for this host are one machine
    resolveTargetPlatform ("The auto-registered 'This Server' row IS the OpenShip host …
    local host executor, host docker socket"), isLocalHostRow, resolvesToLocalHost. One
    daemon, therefore one set of openship-<slug> networks.

So it read three encodings of one machine as three machines, and refused the mainline
self-hosted pair (a catalog app bound to the "This Server" row, wired into a project that
deploys local) plus connect-before-deploy — which is what the app-install wizard does
("Wire declared connections BEFORE deploy so the injected env is present"). An explicit
mode: "internal" got a 400 where it used to work; a defaulted one silently downgraded to
public.

2. The cloud check went silent

26ddd69 swapped activeDeployment.meta.deployTarget === "cloud" for
deriveProjectDeployTarget, i.e. cloudWorkspaceId alone. deployment-lifecycle
deliberately skips setCloudWorkspaceId for a local-orchestrated cloud deploy
(!CLOUD_MODE && deployTarget === "cloud" && buildStrategy === "local") because "the
project MUST stay local-canonical"
, and the serverId persist below it is gated on
deployTarget !== "cloud". For that shape both columns are null by design — the gate
never fired, and the serverId comparison then answered "same box". The repo already takes
the union one file away (project-resources.service.ts:43).

The fix

resolveProjectHost answers "which machine" once, for both ends:

  • serverId: snapshot first, durable column second — the order readDeployMeta uses,
    and for its reason: the snapshot is where the live release actually runs, and an alias
    only resolves next to the running container. resolveSnapshotTarget and
    countActiveByServer invert that deliberately; they answer where the next deploy would
    go, which is a different question. (26ddd69's comment cited them as precedent for this
    order — that was backwards, and is corrected.)
  • cloud: the union of snapshot and column.
  • this box: every encoding collapses to one identity via isLocalHostRow.
  • bound to nothing, never deployedbox, not "unknown": resolveSnapshotTarget
    resolves exactly that shape to the host default, so its first deploy lands here.

The gate then refuses iff the two identities differ.

That last point is worth calling out, because an earlier cut of this fix got it wrong.
Reporting an unbound project as unknown and skipping the comparison reads as "internal
is available"
at the two callers that never asked for internal — the object-storage bind's
mode chooser and the scope-derived default. A MinIO deployed on server B wired into a
brand-new project would then inject S3_ENDPOINT=http://minio:9000; the target's first
deploy lands on this box, openship-<minio-slug> isn't there, attachLinkedNetworks only
warns, the deploy is green and every upload fails. Now an unbound target is co-located with
a source on this box (allowed — the wizard's case) and not with a source on a remote server
(refused, steered to Public, which is the endpoint probeBucket just verified).

Hosts resolve only when the value carries a host, so the GH-631 credential path — a JWT,
a password, a bucket name — does no extra queries at all.

Tests

apps/api/test/modules/projects/connection-internal-synth.test.ts:

  • the isLocal "This Server" row and an unbound-but-deployed project are one machine
  • connect-before-deploy is allowed when the source is on this box
  • connect-before-deploy is refused when the source is on a remote server
  • a cloud source from the deployment snapshot with a null column is refused

The new tests fail against 26ddd69 and pass here; the eight pre-existing tests are
unchanged in both, which is what makes them a regression pin rather than a rewrite.

Verified locally: tsc --noEmit clean (apps/api, packages/core); apps/api 376 files /
4504 tests, apps/dashboard 1001, packages/core 731, packages/db 154, apps/cli 458 — all
passing.

Known limits, deliberately not in this commit

The gate answers "same machine?" from what is recorded in the DB. For a target that has not
deployed yet, that is a prediction, not a derivation — and the callers that hit this all
run before the first deploy while the operator's chosen destination sits in the caller's own
hand (destination.serverId, twelve lines from connectionsApi.bundle) and is never passed
in. Two consequences, both inherited from 26ddd69 rather than introduced here, and both
failing in the safe direction (a refusal you can act on, not a dead env var):

  1. A source on a remote server + a target not yet deployed is refused, even when that
    target is about to be deployed to the same remote server. Correcting an earlier note in
    this PR: this is wider than custom templates with requires — it also reaches the
    shipped "Use in a project" modal (which defaults to and sends mode: "internal"), the
    scope-derived default for a DB endpoint, and the object-storage bind on any multi-server
    org. The user gets an actionable error and Public still works; previously the link was
    accepted and silently dead.

  2. A row that IS this box but spelled in a way isLocalHostRow rejects — a LAN IP, a DNS
    name, SERVER_IP unset, sshd on a non-22 port, or reached via a jump host — resolves to
    its own machine, so it is refused against a local sibling. resolveServerExecutor
    self-heals the isLocal flag on deploy, so this converges in practice.

The durable closures are to thread the caller's chosen destination into the gate, and/or to
re-validate a link's mode at deploy time in attachLinkedNetworks — the only place that
knows the real machine, and which today only warns and leaves a wrong value in place. Both
are follow-ups, not something to bolt onto a regression fix.

…mparing serverId columns

Two regressions in 26ddd69, both in the reachability gate it added.

The co-location check compared `(source.serverId ?? null) !== (target.serverId ?? null)`,
which is not a same-box test. `project.serverId` is written only in the deploy success
tail (deployment-lifecycle) and never on a local deploy, so it is null for every project
that has not completed a server-bound deploy — and the codebase holds that a derived
`local` target, the auto-registered isLocal "This Server" row, and a plain
loopback/SERVER_IP row for this host are ONE machine (`resolveTargetPlatform`,
`isLocalHostRow`, `resolvesToLocalHost`): one docker daemon, therefore one set of
`openship-<slug>` networks. The check read three encodings of one machine as three
machines, so it refused the mainline self-hosted pair — a catalog app bound to the "This
Server" row wired into a project that deploys local — and refused connect-before-deploy,
which is what the app-install wizard does ("Wire declared connections BEFORE deploy so
the injected env is present"). An explicit `mode: "internal"` got a 400 where it used to
work; a defaulted one silently downgraded to public.

The cloud check was swapped from `activeDeployment.meta.deployTarget === "cloud"` to
`deriveProjectDeployTarget`, i.e. `cloudWorkspaceId` alone. deployment-lifecycle
DELIBERATELY skips `setCloudWorkspaceId` for a local-orchestrated cloud deploy
(`!CLOUD_MODE && deployTarget === "cloud" && buildStrategy === "local"`) because "the
project MUST stay local-canonical", and the serverId persist below it is gated on
`deployTarget !== "cloud"` — so for that shape BOTH columns are null by design, the gate
went silent, and the serverId comparison then answered "same box". A swap where the repo
already takes a union (project-resources.service: `deployTarget === "cloud" ||
!!cloudWorkspaceId`).

`resolveProjectHost` now answers "which machine" once, for both ends: snapshot serverId
first and durable column second — the order `readDeployMeta` uses, because the snapshot is
where the live release actually runs and an alias only resolves next to the running
container — with cloud as the union of both signals and every encoding of this box
collapsed to one identity. `resolveSnapshotTarget` and `countActiveByServer` invert that
order deliberately; they answer where the NEXT deploy would go, which is not this question.

A project bound to nothing and never deployed resolves to `box`, not to a "don't know":
`resolveSnapshotTarget` resolves exactly that shape to the host default, so its first
deploy lands here. An earlier cut of this fix reported it as unknown and skipped the
comparison, which the two callers that never asked for internal — the object-storage bind's
mode chooser and the scope-derived default — read as "internal is available". That handed
a remote source's container alias to a project about to deploy elsewhere, and a wrong guess
is invisible because attachLinkedNetworks only warns. Now an unbound target is co-located
with a source on this box (allowed, the wizard's case) and NOT with a source on a remote
server (refused, steered to Public — which is the endpoint probeBucket just verified).

Hosts are resolved only when the value carries a host, so the GH-631 credential path (a
JWT, a password, a bucket name) still does no extra queries.

Known gap, not fixed here: a multi-server org installing an app whose declared requirement
asks explicitly for internal, where the wizard will deploy the target to the SAME remote
server as the source, is now refused rather than allowed — the wizard holds
`destination.serverId` twelve lines from the bundle call but does not pass it in. No shipped
catalog app declares `requires`, so this is latent for custom templates; plumbing that
destination through is the follow-up.

The new tests fail against 26ddd69 and pass here; the eight pre-existing ones are
unchanged in both, which is what makes them a regression pin rather than a rewrite.
@Hydralerne
Hydralerne merged commit c112fb9 into main Aug 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant