Simplify V1 linked service manifests - #228
Conversation
Amp-Thread-ID: https://ampcode.com/threads/T-019fac48-72fe-7706-9ce1-45eb90eadbef Co-authored-by: Arjun Komath <arjunkomath@gmail.com>
|
React Doctor found no issues. 🎉
|
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
techulus-agent
left a comment
There was a problem hiding this comment.
Review
Overview
Collapses the manifest's project/environment/service identity triple down to target.serviceId, flattens service routes to /api/v1/services/{serviceId}, and turns PATCH /configuration into a strict full-replacement PUT. Responses now carry an authoritative target so the CLI stops printing identity it cached locally. The direction is right — the old manifest duplicated server-owned identity in three places and could silently drift — and given the stated pre-alpha no-shims policy, a clean break is the correct call.
Two things below I'd want resolved before merge; the rest are smaller.
1. tc init + tc link will silently rename the remote service on the next tc apply
This is the interaction of two individually-reasonable changes: name is now part of the strict PUT body, and finishLink preserves the local service block when a manifest already exists.
Concretely:
tc init # writes service.name: <folder-name>
tc link --service abc123 # existing != nil, not Linked() -> keeps local service block, sets target only
tc apply # PUTs {"name": "<folder-name>", ...} -> renames the remote service
The new test encodes exactly this: the remote service is named remote, and after link --service s the assertion is reflect.DeepEqual(linked.Manifest.Service, before.Manifest.Service) — i.e. the manifest keeps the init-generated name. Then printLinked reports app/prod/remote, the remote name, so the output actively conceals the divergence the user is now carrying.
The same applies to --force rebinds, and there it's broader than the name: after tc link --service other --force, the manifest still holds the previous service's source, ports, placement, and resources, and because PUT is now full-replacement, the next tc apply overwrites other's entire managed configuration. changed() reports it in the response, but only after the write has happened.
I don't think preserving the desired configuration is wrong — it's the point of the feature — but it needs a guard. Options, roughly in order of preference:
- Have
tc linkprint the divergence it's creating (service.name: my-foldervs remoteremote) and require confirmation, or - Adopt the remote
nameinto the manifest on link even when preserving the rest, sincenameis server identity in a way thatports/resourcesaren't, or - Make
tc applyshow the pending change set and confirm before writing.
Related: --force's help text still reads "Replace an existing techulus.yml", but the flag now means "rebind to a different service" — the old "file already exists, use --force" guard is gone entirely.
2. Default internal hostname changes for every existing null-hostname service
getDefaultServiceHostname(service.name) → getDefaultServiceHostname(service.id) at both call sites (lib/public-api.ts:462, lib/service-revision-spec.ts:210). The function is a slugifier, so feeding it a UUID returns the UUID unchanged.
The stated goal — a default hostname that survives a rename — is achieved, but there are two consequences the summary doesn't mention:
- Existing services drift. Any service with a null
hostnamehas an active revision spec whose hostname is name-derived (hello-service). The new comparable computes the UUID instead, so those services will now reporthasPendingChanges: true, and the next deploy rewrites their internal DNS name. Any sibling service resolvinghello-service.internalbreaks at that moment. Pre-alpha policy covers the API contract; this is a data-plane change to already-running installs, which is a different kind of break. Worth confirming it's intended and, if so, calling it out in the PR description. - The default becomes unusable as a DNS name.
0400075c-69aa-46c2-bccc-fc172b8c6b28.internalisn't something anyone types. Combined with #226 removing the.internaldisplay from service cards, a user with a default hostname now has no practical way to discover or use it. A rename-stable and human-usable option would be to slugify the name once at creation and persist it, rather than deriving it at read time.
Minor: the parameter is still named name in getDefaultServiceHostname(name: string), so passing an ID typechecks silently. Rename the parameter, or introduce a distinct function, so the next reader doesn't "fix" it back.
Smaller findings
- Dead code in
environmentsCommand. The diff leaves:The second block is unreachable, and its message still promises the manifest fallback that was just removed. Delete it.if id == "" { return errors.New("missing --project") } if id == "" { return errors.New("missing --project (or link this directory)") }
resolveServiceTargetfabricatesService{Name: service}— the service ID stored in theNamefield. It's currently harmless becauseapplyhas no--serviceflag (addServiceTargetFlagsis only wired to status/logs/rollout) and status/logs now renderresult.Target. But withnamein the PUT body, any future command that both accepts--serviceand applies configuration would rename the service to its own ID. LeavingNameempty would be safer than filling it with something false.serviceBasereturns/api/v1/services/whenTargetis nil rather than erroring. Unreachable today thanks to theLinked()check, but a trailing-slash request is a bad failure mode; returning an error would be clearer.- Vestigial optionality after the schema tightening.
input.source?.type === "image"andinput.hostname !== undefinedinreplaceConfigurationare now always-true/always-defined, since the schema makes both required. Harmless but misleading about the contract. explicitIDsinlinkCommandis now anintthat only ever holds 0 or 1. Aboolwould read better.tc environments/tc serviceslosing their manifest fallback is an unavoidable consequence of dropping the IDs, and the errors are accurate. Fine.
Verified, not a problem
- Authorization.
findNestedServiceenforced project+environment containment;findServiceContextlooks a service up by ID alone. This is not a privilege escalation — the API's own docs state roles are global with no project-level permissions, so containment was validation rather than an authz boundary, and every role that can now read a service by ID could already read it via the nested path. Worth stating in the PR description since the diff looks alarming out of context. findServiceContextstill filtersisNull(services.deletedAt), and the redundantenvironments.projectId = projects.idjoin condition is a harmless consistency guard.- Resource null-normalization in
replaceConfiguration(persisted{null, null}→nullbefore comparison) is correct and avoids a spurious change entry.
Tests
Good coverage on the parts that matter: the direct-link/rebind/force matrix, the unmanaged-service path asserting the manifest is left byte-identical on failure, the strict-schema rejections, and rename-stability of the hostname. The one gap is the scenario in #1 — there's no test asserting what tc apply sends after tc init + tc link, which is precisely where the surprise lives.
Docs
docs/api/public-api.mdx is updated thoroughly and matches the implementation, including the rootDir required-nullable change and the single-environment auto-select behaviour. Nice.
Verdict
Sound refactor. I'd resolve the link/rename interaction and get a decision on the hostname migration before merging; the rest are cleanups.
Amp-Thread-ID: https://ampcode.com/threads/T-019fac48-72fe-7706-9ce1-45eb90eadbef Co-authored-by: Arjun Komath <arjunkomath@gmail.com>
techulus-agent
left a comment
There was a problem hiding this comment.
Re-review (4f1aaa1 — plan and confirm manifest apply)
Both earlier findings are addressed, and in better ways than I suggested.
The silent-rename problem is gone at the root: tc apply now POSTs /configuration/plan, renders every change as field: from -> to, and prompts. A rename surfaces as name: old-web -> web before anything is written. On top of that, --force rebinding is removed entirely (relinking requires deleting target.serviceId by hand), and the write is guarded by If-Match on a currentVersion fingerprint with a 409 CONFIGURATION_PLAN_STALE replan loop. The detail I like most is that --yes refuses to auto-apply a replanned diff — automation can't silently write something the operator never saw. That's a stronger guarantee than the confirmation prompt I asked for.
The hostname problem is solved by making hostnames concrete rather than derived: getDefaultServiceHostname goes back to name-derived (so existing services keep hello-service, no data-plane drift), the ID is only a fallback for empty/non-ASCII names, and there's now 63-char truncation with an ID suffix. Meanwhile hostname became required and non-null through the manifest, the PUT schema, and tc init, and replaceConfiguration materializes it on first apply via if (!persisted.hostname?.trim() || hostnameChanged). The derived default stops being a moving target instead of being made stable-but-unusable. Good call.
Also fixed: resolveServiceTarget no longer fabricates Name: <serviceId>, explicitIDs is a bool, the vestigial input.source?. optionality is cleaned up, and the flat-route authorization model is now stated explicitly in the docs.
New findings
1. validateDockerImageInternal now runs on every plan and every apply. In replaceConfigurationInternal the guard dropped from input.source.image !== service.image to just input.source.type === "image". Since tc apply calls plan then PUT, an image service makes two external registry round-trips per invocation — including when the plan is a no-op and nothing is written. Restoring the !== persisted.image condition needs the persisted row, so it'd have to move inside the transaction, but it's worth doing; this is on the hot path for every apply.
2. Human-mode tc apply prints nothing on success, and the PUT response is discarded.
var result applyResponse
err = client.RequestJSONWithHeaders(..., http.MethodPut, base, ..., &result)
...
if a.isMachineOutput() { return a.writeData(plan, "Applied") }
return nilresult is decoded and never read. In human mode you answer y and get silence — no confirmation that anything happened. In machine mode the envelope is labelled "Applied" but carries the pre-apply plan, so any divergence between what the server planned and what it actually wrote is invisible to both audiences. Print result (or at least an "Applied N changes" line) rather than echoing the plan.
3. serviceBase panics on a missing target. It's genuinely unreachable behind the Linked() check, but a panic in a CLI means the user gets a goroutine dump instead of a message. An error return costs nothing here.
4. Port diffs will render badly. configurationChanges recurses into plain objects but compares arrays atomically, so a port change is one entry whose from/to are whole arrays. printApplyResult formats those with %v, so the terminal shows ports: [map[containerPort:8080 domain:<nil> public:false]] -> [...]. The plan output is the centrepiece of this commit — ports deserve either element-wise diffing or a dedicated formatter.
5. output.Error uses a direct type assertion (err.(errorWithPlan)) rather than errors.As. It works today because applyPlanError reaches the writer unwrapped, but any future fmt.Errorf("%w") silently drops the plan from the JSON envelope.
6. Carried over, still unfixed: the unreachable second block in environmentsCommand (cli/internal/cli/app.go:811-816) — two consecutive if id == "" returns, and the dead one's message still advertises the manifest fallback that no longer exists.
7. Minor: hashtext() returns int4, so distinct service IDs can collide and serialize against each other. Correctness is unaffected, it's just occasional false contention — not worth changing, just worth knowing.
Verified
- Advisory lock placement is right.
pg_advisory_xact_lockis the first statement inreplaceConfigurationInternal's transaction, before thepersistedread, so theIf-Matchstaleness check is genuinely serialized against the UI mutation paths that take the same lock. This was my main worry when I saw the ETag and the locks arrive as separate mechanisms. - Hostname uniqueness is backstopped by the DB (
services.hostnameis.unique()), so the in-transaction duplicate check is a friendlier error rather than the sole guard — the cross-service race the advisory lock can't cover is handled. hostnameSchema(max 63,^[a-z0-9]+(?:-[a-z0-9]+)*$) matches the CLI'shostnamePatternand length cap exactly, so client and server reject the same values.updateServiceNamematerializes fromcurrent.name, notvalidatedName— i.e. the pre-rename derived hostname is frozen, so renaming doesn't move DNS. Subtle and correct; worth a comment so it doesn't get "fixed" later.createServicerouting throughgetDefaultServiceHostnamefixes the unbounded${slug}-${name}-${env}that could exceed 63 chars.
Tests
The stale-plan matrix is thorough — interactive replan-and-reconfirm, --yes refusing to write a replanned diff, the bounded-retry ceiling, and the machine-mode error envelope carrying the replacement plan. TestApplyPlansAndRequiresConfirmation covers no-op, decline, and non-interactive. The canonicalization tests (ordering stability, GitHub casing, omitted-domain normalization) are the right shape for a fingerprint that gates writes.
One gap matching finding #2: no test asserts what a human sees after a successful apply, which is why the empty success path went unnoticed.
Verdict
The two blockers from my last pass are resolved. Everything above is small — #1 and #2 are the ones I'd fix before merge.
Amp-Thread-ID: https://ampcode.com/threads/T-019fac48-72fe-7706-9ce1-45eb90eadbef Co-authored-by: Arjun Komath <arjunkomath@gmail.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
Summary
techulus.ymlwith one immutable link attarget.serviceId; everything else in the single manifest is desired service configurationtc link, reject relinking to a different service without an explicit unlink, and automatically select the environment when a project has exactly one--force;tc applyalways asks the backend to plan the complete desired state, shows every change, and applies only after explicit confirmation (--yesis required for noninteractive use)If-Matchvalidation, bounded interactive replanning, and no silent materially changed retry under--yesThis intentionally replaces the pre-alpha V1 manifest and API contracts without compatibility shims.
API scope
The service endpoints use
/api/v1/services/{serviceId}rather than repeating project and environment IDs. This does not weaken authorization: API-key authorization is installation-wide today, not project-scoped, and every operation still loads and validates the target service before planning or applying.Apply safety
POST /configuration/plan.y/yes, unless interactive confirmation was explicitly skipped with--yes.If-Match.CONFIGURATION_PLAN_STALE; interactive use replans and reconfirms, while--yesrefuses a materially changed replacement plan.A no-op plan does not prompt or send a PUT.
Validation
cd cli && go test ./...cd cli && go build ./...cd cli && test -z \"$(gofmt -l .)\"cd web && ./node_modules/.bin/tsc --noEmitcd web && mise exec -- pnpm test(47 files, 321 tests)git diff --checkReview
Oracle reviewed the complete plan/apply and linked-manifest design across multiple passes. Its findings around concurrent writers, transaction boundaries, stale-plan retries, null normalization, GitHub repository identity, hostname invariants, and deletion races were fixed and revalidated. The final review returned SHIP.