fix(connections): resolve the machine a project runs on instead of comparing serverId columns - #644
Merged
Merged
Conversation
…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.
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.
Fixes two regressions I introduced in 26ddd69 (merged in #632), both in the reachability
gate that commit added to
resolveInternalValue. They are onmainnow.1. The co-location check refused pairs that are on the same machine
It compared
(source.serverId ?? null) !== (target.serverId ?? null). That is not asame-box test:
project.serverIdis 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.
localtarget, the auto-registered isLocal "ThisServer" row, and a plain loopback/
SERVER_IProw 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. Onedaemon, 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 topublic.
2. The cloud check went silent
26ddd69 swapped
activeDeployment.meta.deployTarget === "cloud"forderiveProjectDeployTarget, i.e.cloudWorkspaceIdalone.deployment-lifecycledeliberately skips
setCloudWorkspaceIdfor a local-orchestrated cloud deploy(
!CLOUD_MODE && deployTarget === "cloud" && buildStrategy === "local") because "theproject MUST stay local-canonical", and the
serverIdpersist below it is gated ondeployTarget !== "cloud". For that shape both columns are null by design — the gatenever 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
resolveProjectHostanswers "which machine" once, for both ends:readDeployMetauses,and for its reason: the snapshot is where the live release actually runs, and an alias
only resolves next to the running container.
resolveSnapshotTargetandcountActiveByServerinvert that deliberately; they answer where the next deploy wouldgo, which is a different question. (26ddd69's comment cited them as precedent for this
order — that was backwards, and is corrected.)
isLocalHostRow.box, not "unknown":resolveSnapshotTargetresolves 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 firstdeploy lands on this box,
openship-<minio-slug>isn't there,attachLinkedNetworksonlywarns, 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
probeBucketjust 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 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 --noEmitclean (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 fromconnectionsApi.bundle) and is never passedin. 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):
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 theshipped "Use in a project" modal (which defaults to and sends
mode: "internal"), thescope-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.
A row that IS this box but spelled in a way
isLocalHostRowrejects — a LAN IP, a DNSname,
SERVER_IPunset, sshd on a non-22 port, or reached via a jump host — resolves toits own machine, so it is refused against a local sibling.
resolveServerExecutorself-heals the
isLocalflag 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 thatknows 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.