Skip to content

feat: open a published port at its configured service URL - #2124

Open
AnsoCode wants to merge 17 commits into
mainfrom
feat/service-url-override
Open

AnsoCode wants to merge 17 commits into
mainfrom
feat/service-url-override

Conversation

@AnsoCode

@AnsoCode AnsoCode commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • A published port opens the address in the sencho.service.url compose label when that service owns the port.
  • With no label, or when the service URL list cannot be loaded, the generated host and port link stays. A remote node with no host and no override shows the plain port. Retry appears only when that list failed to load.
  • Which compose file controls the label is established on the backend from the authored file chain. Setting a URL is refused when a file merged after the open one sets the key whatever its value, including an invalid one and a bare - sencho.service.url list item (Compose reads that as the empty value), when those later files cannot be traced (include, extends, a custom YAML tag, COMPOSE_FILE, a label list that builds the key from a variable, or an unreadable file), and when the open file's own label list builds the key from a variable or the open file cannot be read, because the requested value could not be proven effective; the pencil says the source cannot be verified. Clearing is refused before any write when another file still declares the label, when the chain cannot be traced or a label list builds the key from a variable on either side of the open file, or when the service inherits one through extends or label_file, because removing the label would reveal or keep another address. An unsaved draft that carries an interpolated list key is refused at the patch itself, before any write. A clear that is allowed returns the port to the generated host and port. The authority is re-read right before the write.
  • While a refresh is in flight, the last ready URL stays in use, including that lock. A different node or stack never sees the previous selection's URL.
  • A running container's card uses the address configured for that container's own Compose service, including after the saved port declarations changed or were removed since the container started, without a redeploy. It never takes another service's saved declaration of the same host number (that declaration does not prove the other service is running), and when its own service has no label it keeps the generated host and port even while other saved services declare that number. A port Docker assigns automatically, including an explicit 0 host port such as "0:80", resolves the same way. Open App opens the address of the service whose running container published the port; when the port could belong to another service's automatic binding and no runtime owner is known, it refuses rather than open the wrong address. On a cache miss, or while the first service URL lookup is still in flight, Open App opens the tab in the click and navigates it once the address is known.
  • Saving a URL changes only that label in the original compose text. Octal tokens, comments, line endings, indentation, and other keys stay as written. Flow-style services and labels are edited in place. When the edited service shares its block through a YAML anchor, the other side gets its own equivalent copy so it keeps its values. Clearing a URL that arrives through a << merge replaces only the merge key with the inherited labels copied from their own text, so the direct entries, comments, and tokens in the edited block stay exactly as written.
  • An edit that cannot add or remove the label without changing something else is refused before it reaches disk, and the pencil asks the operator to edit the file instead: a shared block reused inside a one-line { } service, a comment inside a bracketed [ ] or { } label list where removing the URL would drop it, a clear on a file or service that inherits labels through include, extends, or label_file, and any file that uses !reset, !override, or another custom YAML tag. A clear through a << merge is refused the same way when the merge source is written inline, when a merged label carries an anchor, alias, or tag its text cannot keep, when a comment sits on the merge key line, or when a merged value cannot be written into flow labels.
  • Service names that shadow Object properties (constructor, toString, hasOwnProperty, __proto__) resolve their generated link when they have no label and keep their own override when they do, across the extractor, the response parser, the resolvers, and the local anatomy facts.
  • The phone dossier uses the same service URL entry as the desktop dossier. An older node that omits service URLs keeps its other dossier facts, and the generated link stays.
  • A repeated list label is replaced by the new address. Clearing removes every copy of it, including a bare sencho.service.url entry. A list entry that is an alias is replaced or cleared on the edited service only.
  • The URL dialog stays on the node and stack it was opened for. Navigating to another stack closes it, and a later save does not write that other stack.
  • The editor reads whichever compose file is present: compose.yaml, compose.yml, docker-compose.yaml, or docker-compose.yml. A save writes that file. If it is no longer the file on disk, the editor keeps the draft until the operator reloads it.
  • A compose file that resolves outside its own stack directory is refused, including one that links into a sibling stack. A stack directory that is itself a symlink, or a dangling link, is refused too. Links that stay inside the stack keep working.
  • A saved address has to be an absolute http:// or https:// URL.

Test plan

  • Backend tests for the label, effective anatomy, dollar unescaping, filename conflicts, in-tree symlinks, dangling compose files, and symlink escapes
  • Backend tests for the URL source authority: a later file setting the label blocks the edit whatever its value (invalid, interpolated, or equal), including a bare - sencho.service.url list item and a label list whose key is interpolated (- "${URL_LABEL}=https://app"), while a literal key with an interpolated value stays a set and the uncertainty stays with the service that carries it; the open file's own interpolated list key refuses the set and the clear, including through a top-level include, and an unreadable open file refuses too; include, extends, custom tags, COMPOSE_FILE, unreadable files, and an open file outside the chain all refuse; a genuinely authoritative interpolated label stays editable; chain ordering counts only files merged after the open one, including a later same-basename file in a subdirectory
  • Backend tests for the clear authority: an earlier Git chain file, a later file, an earlier or later interpolated label key, an unreadable file, the open file's own include, a service extends, and a label_file all block the clear; a single-contributor file clears; the open file's own direct label does not block its own clear
  • Backend tests for the runtime main-port owner: the bulk status route serves mainPortService beside the port it belongs to, through its cache, and the controller leaves it unset when the container has no compose service label
  • Frontend tests for the label splice, byte preservation around octal tokens and CRLF, flow labels, flow services, inherited clears, service aliases, shared anchors, an end of file without a final newline, empty label values, the comment and custom-tag refusals, dynamic ports including an explicit published: 0 from both Compose syntaxes at its assigned runtime port, UDP bindings, Open App ambiguity, loading with a prior URL, a remote node with no host, Open App opening the tab before the lookup, and a stack or node switch never exposing the previous URL
  • Frontend tests for the first service URL lookup in flight: the published-port link stays plain text until the override is known, Open App opens the tab in the click and navigates to the configured URL instead of the generated address, and the link turns into the configured address once the lookup resolves; absent, failed, and older-node responses keep the generated fallback, and a refresh that still holds the prior model keeps rendering it
  • Frontend tests for clearing a URL that arrives through a << merge: the inherited labels are materialized from their own source text, a merge list and nested merges follow YAML precedence, direct entries, comments, octal and quoted tokens, CRLF endings, and a URL scalar another service aliases keep their exact text, flow and block layouts convert cleanly, and the layouts whose text cannot be kept (an inline merge source, an anchored, aliased, or tagged merged value, a comment on the merge key line, a value that cannot fit flow labels) are refused before any write
  • Frontend tests for a running container whose own service has no URL beside other saved services that declare its host port: the card keeps the generated link and its copy target, including after its saved binding changed or was removed, and Open App resolves the same target
  • Frontend tests for a bare list URL entry: setting replaces every copy so the new address wins, clearing removes every copy including a lone bare entry
  • Frontend tests for the metadata lock and save-guard messages, the clear-specific guard (set and clear authorities are selected separately, a missing clear authority reads as unverifiable), and the modal's clear gate (disabled Save with the reason, re-enabled when a URL is typed)
  • Frontend tests for absent and configured URLs on constructor, toString, hasOwnProperty, and __proto__ through the full parse-to-resolve path and the patch path
  • Frontend tests for the interpolated-key patch refusal: a set and a clear are refused when the target service's label list interpolates the key, while a $$ escape, a map-form key, and a sibling service stay editable; and for the runtime owner: a named container card keeps its own service URL after a fixed saved port changed, after the saved binding was removed, and on a remote node with no host, while a stopped service's declaration of the same host number never takes the link (omitted publication and explicit published: 0 included); the rendered card link and its copy target carry that address; a saved-only caller still refuses ambiguity instead of opening the stopped service, and Open App follows the runtime owner end to end
  • docker compose config renders confirm a direct label in the open file wins over its own include and extends; a later file shadows it; a later file's bare list label wins as the empty value; clearing the open file's label surfaces an earlier file's label, a label_file value (from either side), an extends target's label, and an include's label. A render of a label list with an interpolated key (- "${URL_LABEL}=https://app") confirms it sets the effective label, and a render of "0:80" and { target: 80, published: 0 } confirms both ask Docker for an automatic host port; the production source-authority and URL-resolution functions, driven by those renders, refuse the shadowed edit and the clear, and open the configured URL at the assigned port.
  • docker compose config renders of the merged-label fixture confirm the cleared service loses the label and keeps its other merged labels exactly, while the anchor's other consumer keeps the address, and the operator comment inside the edited block survives in the file text
  • Full frontend suite passes (413 files, 4657 tests)
  • Full backend suite passes (11360 tests, 5 skipped) except git-transport-ssh.integration, which needs loopback port 22 to be free
  • Backend and frontend TypeScript checks pass
  • Browser pass: saved a URL from the pencil and verified the link; a shadowed service shows a disabled pencil with "This URL is set in another compose file."; an untraceable source shows "Sencho cannot verify which compose file sets this URL."; a service whose URL also comes from a label_file shows the clear refusal in the dialog with Save URL disabled, and typing a new URL re-enables it; clearing a URL on a single-contributor file restores the generated host and port and writes only that label; a service named __proto__ renders its generated port fact and link, and its configured override; desktop and phone-width layouts
  • Save-boundary race: with the dialog open, a later compose file that sets the label appeared on disk before the save; the write was refused and the open file stayed unchanged
  • Browser pass on a saved-but-undeployed port change: a running whoami container published 8090:80 while the saved file was changed to 9090:80, then to no port at all; the container card kept its configured https://web.example.com link in both states, and with the label removed it fell back to the generated http://localhost:8090

Notes

  • A first service URL lookup with no resolved or retained model is pending: the published-port link renders as plain text and Open App waits for the lookup before navigating, so a configured URL controls the click even on a slow first load. Completed outcomes (no label, a failed load, an older node) keep the generated fallback, and a refresh that still holds the prior model keeps using it.
  • Review findings are addressed: a later bare list label now blocks the edit instead of being missed; a label list that builds the key from a variable (- "${URL_LABEL}=https://app") is unverifiable, so the edit and the clear are refused before any write rather than saved and re-checked afterwards, in the later files, in the open file's saved source, and in an unsaved draft, while a literal key with an interpolated value stays a known set; clearing a URL that another file or an inherited service still supplies is refused before it reaches disk; an explicit published: 0 counts as automatic allocation; and a published port resolves to the running service that owns it, so another service's saved declaration of the same host number cannot take over the card link or Open App, which refuses ambiguity when no runtime owner is known and an automatic binding could hold the port instead.
  • A running container card resolves its own Compose service's URL from the running container's port data, so a saved Compose change that is not deployed yet no longer switches the link to the generated address. The anatomy footer, which infers ownership from the saved file, keeps its ambiguity refusals.
  • A running container whose own service has no URL override keeps its generated host and port link even when other saved services declare the same host number: runtime ownership answers before saved-model inference, the same way Open App already did.
  • Clearing a URL that arrives through a << merge no longer rebuilds the labels map from parsed values. The merge key is replaced by the inherited entries copied from their own text and the direct URL label is removed, so comments, quoting, octal tokens, and the anchor itself stay as written. A layout whose text cannot be preserved that way is refused with the same edit-in-the-editor message.
  • The latest revision was validated with the focused tests, the full frontend suite, both TypeScript checks, ESLint on the changed files, and the docker compose config renders above.
  • PUT /api/stacks/:stackName accepts the resolved compose basename in X-Compose-File and answers 412 with compose_source_changed when the file on disk is no longer the one that was read. Callers that omit the header keep the previous behavior.
  • The effective-anatomy response reports the per-service set authority as serviceUrlEdit and the clear authority as serviceUrlClear.
  • GET /api/stacks/statuses entries may carry mainPortService, the compose service of the running container that supplied mainPort. Nodes without it keep the previous behavior: the port link falls back to the saved model and refuses ambiguity when another service could hold the port.
  • No database, schema, dependency, or workflow changes.

Closes #2121

Store the address as the compose label sencho.service.url so a port click follows that host, and keep the generated link when the label is absent.
Comment thread backend/src/services/FileSystemService.ts Fixed
The compose filename scan checks the path stays inside the compose directory before it stats the file, so a rejected path is not treated as a missing file.
A URL edit copies inherited labels onto that service, a changed compose filename keeps the draft unless the operator reloads, and a missing URL list still opens the generated link.
A shared label or service anchor is copied before the edit, clearing a merged URL does not restore the inherited address, and a running container opens only its own saved URL.
A URL edit dropped empty labels and failed when a nested anchor was still used. A Docker-assigned port now opens that service's own URL, and the pencil stays unavailable when the service is not in the open compose file.
A shared URL value no longer changes another service. Open App does not follow a different service's dynamic address, a named port on its own bind address stays clickable, and a repeated list label takes the new address.
The dialog now records the node and stack it was opened on, closes when that selection changes, and refuses the save before it can write a different stack. Clearing or replacing a list alias updates only the edited service. A URL supplied by another compose file stays on that file, and reloading the compose source refreshes the cached addresses.
A save splices the label into the original text, so octal modes, line endings, and unrelated keys stay as written. A node with no host keeps the plain port, a refresh keeps the last URL, and Open App opens its tab before the lookup.
- Refuse a compose file, or a stack directory, that resolves outside
  <compose root>/<stack>, so a read or save of one stack cannot reach a
  sibling stack's compose file. Covers saveStackContent too.
- Edit flow-style labels and services in place, copy anchored flow
  nodes without their defining key, keep source tokens such as 0440 and
  022 when labels are copied, append after the last real label (before
  trailing comments, and at an end of file with no newline), and keep
  the separator when an empty URL value is set or cleared.
- Check every patch before it can be saved: the file is read with YAML
  types and as untyped text, and anything that changes outside the URL
  label, drops a comment inside brackets, or touches a file with a
  custom YAML tag is refused with a clear message.
- Return the cached service URLs only for the node and stack they were
  read for, so a port never links to the previous selection.
- Log stacks the networking summary skips instead of dropping them
  silently.
A stack whose compose file resolves outside its own directory is skipped
with a warning. Assert the linked stack stays out of every bucket, the
healthy stack is still summarized, and the skip is announced.
The effective-anatomy response now reports, per service, whether the open
main compose file controls the sencho.service.url label (serviceUrlEdit).
That is established from the authored chain: the label key presence is read
in the files merged after the open one (deploy spec order, or the resolved
main file plus the auto-discovered override). A later file that sets the key
blocks the edit whatever its value (invalid, interpolated, or equal); a later
file that uses include, extends, a custom YAML tag, or COMPOSE_FILE, or that
cannot be read, refuses the edit as unverifiable. The pencil lock, the save
guard, and a fresh re-read immediately before the write consume this
metadata, so the editor no longer infers ownership from the label value or
its absence. Post-save verification stays.

Service-keyed maps are built on null prototypes and read through own-property
lookups across the backend extractor, the response parser and resolvers, and
the local anatomy facts. Services named constructor, toString, hasOwnProperty,
or __proto__ now resolve the generated link when they have no label and keep
an own override when they do.
A later bare `sencho.service.url` list item (`- sencho.service.url`) sets
the key to an empty value and shadows the open file, so the source
authority now counts it as a key contribution like the `key=value` form.

Clearing is not the mirror of setting: removing the open file's direct
label can surface an earlier chain file, the open file's own include, a
service's extends target, or its label_file. The anatomy response now
carries a per-service clear authority alongside the set authority, the
editor refuses an empty save the chain would keep winning or reveal, and
the patch helper refuses the same layouts before writing. Setting a URL
is unaffected.

The patch helper also treats a bare list item as the key when setting or
clearing, so a set no longer leaves a trailing empty entry that renders
as an empty value.

Operator docs describe the set and clear limits accurately.
Compose interpolates a list label's key, so `- "${URL_LABEL}=https://app"`
sets `sencho.service.url` even though the authored source never names it.
A later file using that shape reported the open file as editable and the
write did not take effect; an earlier or later one failed to block a clear.
Such a key now marks the service unverifiable, so both guards refuse before
the save. A literal key with an interpolated value stays a known set.

`published: 0` (from `"0:80"` or `{ target: 80, published: 0 }`) asks
Docker to assign the host port, but the dynamic-owner predicate treated it
as a fixed host, so a container-port click and Open App fell back to the
generated node address. It now counts as automatic allocation, leaving UDP
binding, named-service isolation, and ambiguity refusal unchanged.
- Read the open compose file's own source in the service URL set
  authority: a labels list whose key is interpolated (`- "${NAME}=..."`)
  makes that service unverifiable instead of editable, including when the
  file's other facts cannot be traced (a top-level `include` or a custom
  tag) and when the file cannot be read at all.
- Refuse a set and a clear in the editor buffer when the target service's
  label list carries an interpolated key, so an unsaved draft is caught
  before any write.
- Open a named container's own automatic binding URL on its card when
  Docker assigned it the host port, even when another service's saved
  file declares the same number, and refuse a saved-only single fixed
  declaration when another service's automatic binding could hold it.
- Carry the runtime main-port owner (`com.docker.compose.service`) from
  the bulk stack status through to Open App so it opens the address of
  the running service that owns the port.
- Treat a named runtime container as direct ownership evidence: when a
  card carries both the compose service and the running container's own
  port, that service's configured URL applies even after the saved port
  declarations changed or were removed without a redeploy.
- Keep saved-model inference and its ambiguity refusals for callers
  without runtime port data, such as the anatomy footer.
- Cover the changed-port, removed-binding, hostless-remote, and rendered
  anchor cases in resolver and component tests.
- State in the stack management docs that the configured label applies
  first and the generated address is the fallback.
Clearing a service URL from a labels map that also merges a YAML anchor
materialized the whole map from parsed values, which deleted operator
comments inside the map and respelled unrelated scalar tokens. The clear
now copies inherited entries from their own source text, leaves direct
entries untouched, and refuses layouts whose text cannot be kept.

A running container whose compose service has no URL override fell
through to saved-model inference and could turn ambiguous when other
saved services claimed the same host port. Runtime ownership now answers
with the service's own URL or the ordinary generated host and port.

Regression tests cover the preserved comments and tokens (block, flow,
CRLF, merge lists, nested merges, shared anchors) and the generated
anchor plus copy target for a runtime owner without an override.
A first effective-anatomy lookup was read as "no override", so a
published-port link and Open App could open the generated host and port
while a configured service URL was still being fetched. The in-flight
entry now stays pending: the port renders as plain text until the outcome
is known, and Open App opens the tab in the click and navigates once the
lookup resolves. Completed outcomes (no label, a failed load, an older
node) keep the generated fallback, and a refresh that still holds the
prior model keeps rendering it.

This branch has not been deployed

No deployments
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.

Custom Service URL Overrides via Docker Labels or UI

2 participants