Skip to content

fix(services): merge environment on partial service update instead of replacing - #620

Merged
Hydralerne merged 2 commits into
oblien:mainfrom
chbndrhnns:fix/service-env-partial-merge
Aug 18, 2026
Merged

fix(services): merge environment on partial service update instead of replacing#620
Hydralerne merged 2 commits into
oblien:mainfrom
chbndrhnns:fix/service-env-partial-merge

Conversation

@chbndrhnns

Copy link
Copy Markdown
Contributor

Closes #619

Summary

Fixes an issue where PATCH /api/projects/:id/services/:serviceId completely replaces a service's environment map when a partial environment update is supplied, wiping all other variables and stored secrets on the service.

Changes

  • API (secret-env.ts): Added mergeServiceEnv(stored, incoming) implementing JSON-merge patch semantics with masked secret restoration (ENV_MASK) and key deletion via explicit null.
  • API (service.schema.ts): Widened UpdateServiceBody.environment schema to allow nullable values per key (Type.Union([Type.String(), Type.Null()])) and a nullable environment record for full clearance.
  • API (service.service.ts): Updated updateService to call mergeServiceEnv on patch.environment instead of unmaskEnv.
  • Dashboard (ServiceDetailPanel.tsx): Updated handleSaveEnv to compute deleted keys as explicit null in the patch payload so UI deletions are transmitted properly to the backend.
  • Tests:
    • Added unit test suite in apps/api/test/lib/secret-env.test.ts for mergeServiceEnv testing partial merges, updates, explicit null deletions, masked sentinels, full clearance, and undefined/empty payloads.
    • Added reproduction and regression test suite in apps/api/test/modules/services/service-update-env.test.ts verifying updateService preserves untouched variables during partial updates.

chbndrhnns and others added 2 commits August 18, 2026 19:11
… replacing

PATCH /api/projects/:id/services/:serviceId ran patch.environment through
unmaskEnv, which builds its result from the incoming keys alone. A body naming
one variable therefore deleted every variable it did not mention, so the
documented "send only what changes" (docs/api/services.mdx) silently destroyed
the rest of the map.

That is worse here than on the endpoint's other collection fields because
`environment` is the only one MASKED on read, and env-reveal is deliberately
kept off the automation surface: a raw-API or MCP client cannot see what it is
about to overwrite and cannot read the values back afterwards. It is the same
pairing the project-level PUT /:id/env was removed for ("it could wipe/corrupt
masked secrets"); service-env masking then arrived without the merge path.

mergeServiceEnv merges onto the stored map with the same absent/null/present
triad as mergeAdvanced, plus the sentinel arm only a masked field needs: an
absent key is kept, an explicit null removes one, `null` clears the map, `{}`
changes nothing, and an echoed •••••••• restores the stored value. unmaskEnv is
untouched and keeps its whole-map semantics for the six writers that genuinely
own the whole set (create, compose sync, project-crud, migration, deploy) —
each rebuilds the map from an upstream spec and must be able to drop a variable
the spec no longer declares.

`environment` is nullable on update ONLY, for the reason already spelled out
for domain/customDomain in the same object literal: absent means "keep", so
"clear" needs its own spelling. Create and sync keep Record<string,string>.

Env-tab saves now name their removed keys explicitly via serviceEnvPatch, which
lives next to the other dashboard payload builders and is tested there — a
client that submits only its current rows can no longer no-op a deletion while
reporting success.

Fixes oblien#619
…lly read it

The merge semantics are only useful if a client can discover them, and the two
surfaces that mislead are the ones nobody edits by hand: the MCP tool schema and
the public API reference. The PATCH route is MCP-exposed and mcp-tools.ts emits
UpdateServiceBody verbatim as the tool inputSchema, so an agent saw `environment`
as a bare string map behind the description "Update a service's configuration."
with destructiveHint: false — nothing in the advertised surface distinguished
merge from replace, and after the merge change an agent's natural revocation
attempt (omit the key) returns 200 OK while the credential stays injected.

The route description and the field's TypeBox description now spell out the
absent/null/clear triad, the •••••••• round-trip, and — the part that is easy to
get wrong in the other direction — that every OTHER field on this endpoint still
replaces wholesale, so ports/volumes/dependsOn/publicEndpoints need the complete
list. docs/api/services.mdx gets the same table under "Update a service"; its
create-side type stays Record<string,string>, which is what create accepts.

service-schema-env pins both halves of the asymmetry. Moving the nullable field
into ComposeFieldsBlock (the obvious dedupe) would teach create and sync to
accept nulls that unmaskEnv writes through as dropped keys, and the update
override only holds because it is declared after the spread — reordering the
object literal reverts it with no other symptom. The value type is also pinned
UNBOUNDED: capping it on update alone, as the first cut did at 10000, makes a
long value (CA chain, base64 kubeconfig) creatable via POST or /sync and then
permanently un-PATCH-able.
@Hydralerne
Hydralerne force-pushed the fix/service-env-partial-merge branch from 677279c to 3a20904 Compare August 18, 2026 16:13
@Hydralerne
Hydralerne merged commit 5c60d72 into oblien:main Aug 18, 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.

Service-level environment update replaces entire environment map instead of merging

2 participants