Files
sencho/docs/features/node-compatibility.mdx
T
Hambrook a6eaea0f54 feat(editor): create the stack .env from the editor on first save (#2097)
* feat(composeEditor): create .env file on demand when saved instead of relying on doing so via Files

* fix(editor): scope .env creation to the editor and adopt the resolved path

- PUT /:stackName/env creates the default .env only when the request carries
  create=1 and the stack exists; every other caller keeps the handled 404, so a
  Fleet Secrets push to a stack with no env file never creates one.
- The create response returns the resolved path; the editor adopts it, so the
  next save targets the file directly instead of failing the allowed-file check.
- Deep links to /env open the env tab on a stack with no env file.
- Mobile always shows the compose/.env toggle and the creation notice.
- Backend and frontend tests pin the create path, the canonical second save,
  the no-flag 404, the deep-link behavior, and the notice; docs, the guardrails
  tutorial, and the mobile screenshot are updated.

* fix(editor): make the first env save exclusive and pin the confirmed overwrite

Audit follow-up on the create-on-first-save path:

- PUT /:stackName/env?create=1 now creates exclusively with open(..., 'wx').
  An existing target answers the same 412 conflict the optimistic-concurrency
  path uses, with its current content and path, so a stale editor that still
  believes no env file exists can never overwrite one it has not read.
- The editor adopts the conflict path, and its confirmed overwrite retries with
  create=1&force=1&file=<path>, pinned to the file the dialog named and
  re-validated against the allowlist.
- With project env files configured, the create targets the first configured
  file (the one Compose reads), derived from the shared env resolver instead of
  a second copy of the fallback; a configured path that resolves outside the
  stack directory is refused with 409 rather than written.
- The conflict read repeats the containment check and refuses to follow a link,
  so the conflict payload cannot be used to read outside the compose root.
- A directory target maps to 409 instead of an opaque 500.
- Tests cover the conflict instead of an overwrite, the forced retry, the
  pinned retry, the configured target with a stray .env untouched, the escaping
  config refused, force ignored on the non-create path, and the frontend
  conflict, decline, and fallback flows.
- Docs restore the compose row's "Always available." note, describe the
  creation target and the no-overwrite guarantee, and make the notices generic.

* fix(editor): guard the env create against older nodes and unknown state

Audit follow-up on the create-on-first-save path:

- The non-forced create request now carries If-Match: W/"0". This build's
  exclusive create ignores it; a node that predates create-on-save treats it as
  a normal conditional write, so an existing env file answers 412 with its
  content instead of being overwritten, and a missing file keeps the handled
  404.
- A failed stack load clears the previous stack's env state instead of leaving
  a stale "no env file" assertion that the next save could act on.
- A failed env inventory or content read is tracked as unknown, shows a
  failure notice, locks the buffer, and refuses the save; it is no longer
  presented as an empty file.
- Declining the create conflict on a node whose 412 names no path keeps the
  conflicted content and refreshes the inventory under the current load's
  signal; a failed refresh reports the failure instead of a false reload.
- Save & Deploy and Save & Pull on an untouched empty env tab no longer create
  an empty file; the deploy proceeds.
- The exclusive create opens with O_NOFOLLOW so a planted symlink cannot be
  followed, and a failed write's leftover is removed only when the path still
  names that same file (inode and size match).
- Tests cover the older-node precondition, concurrent creates, a directory
  target, escaping and dangling symlinks, the write-failure cleanups, the
  unknown-state refusal, the conflict-retry copy, and the deploy skip.

* fix(editor): leave create mode on older nodes and return 409 for path conflicts

Audit follow-up:

- A create-mode 200 without envPath is the normal response of a node that
  predates the envPath field. The editor now refreshes the env inventory in
  that case, so it leaves create mode and later saves are normal conditional
  writes with a fresh ETag instead of prompting for an overwrite every time.
- The recovery refresh uses the load signal captured before the PUT, so a
  stack switch during the request aborts it and the new load's state is not
  repainted.
- PUT /:stackName/env maps INVALID_PATH and SYMLINK_ESCAPE from the create
  path to 409, so a symlink at the target is a recoverable conflict instead of
  an opaque 500.
- Docs: deep-links no longer says the created file is always the default .env.
- Tests: the older-node create, confirm and overwrite sequence asserts the
  editor leaves create mode and the next save is a conditional write; a route
  test covers an in-root symlinked .env returning 409 without leaking content.
- Comment cleanups from the audit.

* fix(editor): do not paint a create response that lands after a stack switch

Audit follow-up:

- saveFile checks the load signal captured before the PUT (opLoadSignal) in
  the 412 and success paths: a response landing after the editor switched
  stacks now returns without adopting the old stack's envPath, ETag, or
  buffer state and without prompting, so the new stack's editor state stays
  intact.
- Tests: two hook tests switch stacks mid-PUT, for both the success and the
  conflict response, and assert no adoption and no prompt.
- Docs: node-compatibility no longer claims every older node detects the
  conflict (nodes before 0.87 cannot) and notes that the confirmed-overwrite
  prompt does not repeat.
- Comment: createTarget wording matches the resolver's containment filter.

* fix(editor): create missing parent directories for a configured env file

Audit follow-up:

- A configured project env file can point into a subdirectory that does not
  exist yet; the exclusive open failed with ENOENT and the route answered an
  opaque 500. The create now makes the missing parents inside the checked,
  stack-bounded path and retries the exclusive open once, so the first save
  creates the file Compose reads at its configured path.
- The retry re-runs the containment check and still loses to EEXIST if another
  actor created the file meanwhile.
- Tests: a service test for a nested missing-parent target and a route test
  that configures config/prod.env with no config directory, creates it, and
  saves again through the returned path.

* fix(editor): report a vanished stack on env create instead of resurrecting it

Audit follow-up:

- Drop openExclusiveCreatingParents and its recursive mkdir. The state it
  served (a configured project env file in a missing subdirectory) cannot be
  produced by the API: PUT /project-env-files rejects names with "/" or "\"
  and requires the file to exist, so no product path reaches that branch.
  The only reachable ENOENT is a stack directory deleted between
  requireStackExists and the exclusive create; recreating it left the deleted
  stack visible only as a hidden folder holding the env content and blocked
  the stack name.
- Restore the direct exclusive open and map ENOENT in the route catch to the
  handled 404 Stack not found, so the race is reported and nothing is written
  or recreated.
- Replace the two tests for the unreachable nested target with regression
  tests: the service refuses a target under a missing stack directory and
  creates nothing; the route answers 404 Stack not found when the directory
  vanishes before the create (real service ENOENT path) and does not recreate
  it.

* fix(editor): recover when the create conflict target is deleted mid-dialog

QA follow-up:

- The confirmed create retry pins the file the conflict named. When that file
  is deleted before the operator confirms, the route rejected the pinned path
  with a 400 because it was no longer a present env source, and the editor
  (which had already adopted the path) failed every later save the same way.
  The create branch now accepts the pinned path when it is the resolved create
  target, so the retry recreates the file; any other pinned path keeps the
  scoped rejection.
- Declining a create conflict now reloads the env state instead of trusting
  the 412 payload: if the file was deleted meanwhile, the editor returns to
  create mode instead of adopting a path that no longer exists.
- Save failures toast the server's error text (404 vanished stack, 409
  unusable path, 413 too large) instead of the raw JSON envelope.
- The save-refusal toast for an unreadable env state uses the same wording as
  the editor notice; the file list may have loaded while the content did not.
- A viewer with no env file sees "This stack has no environment file yet."
  instead of an unexplained empty read-only tab; editors keep the create
  promise.
- The route test for a vanished stack restores its spy in finally.

---------

Co-authored-by: Anso <dev@anso.codes>
2026-10-08 12:12:26 -04:00

131 lines
11 KiB
Plaintext

---
title: Node Compatibility
description: How Sencho handles version differences across nodes and gracefully gates features that are not available on the active node.
---
When you manage [multiple nodes](/features/multi-node), each running its own Sencho instance, those instances may be on different versions. A node running an older release will not have features that shipped in a newer one. Sencho detects this automatically and gates each feature on whether the active node advertises support for it, with no errors and no broken pages.
## How it works
Every Sencho instance exposes a public `GET /api/meta` endpoint that advertises its version and a list of **capabilities**, one flag per feature it implements. When you switch to a node, your control instance fetches that node's metadata and caches it (3 minutes on the server, and 5 minutes on success or 30 seconds on failure in the dashboard), so the version pill and the per-feature gates stay in sync without re-hitting the remote on every navigation. Testing a node from **Settings · Nodes**, or a node finishing an update, refreshes its metadata right away rather than waiting for the cache to expire.
Features whose capability is missing from the active node's list are replaced by a lock card naming the missing feature and prompting you to upgrade the node. Core features (stack management, containers, resources, logs) work on every Sencho version and are never gated this way.
<Note>
Capabilities are flag-based, not version-based. Sencho never compares version numbers; it checks whether the active node explicitly advertises support for each feature. This keeps the system forward-compatible as new capabilities are added.
</Note>
An action is not always behind a capability. A capability gates a feature whose absence would leave you with a broken or misleading panel, which is what makes a lock card the right answer there. An action added by a later release has nothing to fall back to on a node without it, so it is sent anyway and the node refuses it. **Save & Pull Images** in the [compose editor](/features/editor#save-options) behaves this way: on a mixed-version fleet it writes the compose file and then fails the image download, naming a missing API endpoint rather than the version difference. Saving from an empty **.env** tab behaves the same way, and does not become a silent overwrite on a supported node: on a node that predates create-on-save, a stack with no env file is refused with `No env file exists for this stack`, and if a file does exist the save is refused as a conflict with the file's current content, so the editor asks before replacing it and does not ask again after a confirmed overwrite. That conflict detection relies on the conditional env writes added in Sencho 0.87.0; a node older than that cannot detect a file that appeared after the tab loaded, so update the node.
## What you will see
### Version in the node switcher
The node switcher popover shows each node's Sencho version next to its type chip, separated by a middle dot.
<Frame>
<img src="/images/node-compatibility/node-switcher-versions.png" alt="Node switcher popover listing four connected nodes. The Local row reads LOCAL · v0.95.0 with a star icon for the default node. The Opsix row reads REMOTE · v0.95.0. The Pitt-Moba and SLX-Mars rows read REMOTE only, with no version pill, because this dashboard session has not switched to them yet." />
</Frame>
A node's version pill appears once Sencho has fetched its metadata. The metadata cache populates the first time you visit a node, so a node you have not switched to yet in the current session shows its type chip alone, with no version pill, until you do. A node that does not return metadata at all shows no version pill on either visit.
### Lock card on unsupported features
When the active node does not advertise a capability that a feature needs, the feature panel is replaced by a centered lock card.
<Frame>
<img src="/images/node-compatibility/lock-card.png" alt="Capability lock card centered in the editor area. A circular icon badge holds an Unplug glyph at the top. Below it, a title reads Audit Log is not available on this node and a body line reads Local is running v0.95.0. Upgrade the node to use this feature." />
</Frame>
The card carries the feature name, the node name, and (when known) the running version, so you can see at a glance what is missing and where. When the node has not reported a version, the body line drops the version and reads "*node-name* does not advertise this capability. Upgrade the node to use this feature." instead. The gated panel itself does not load; the lock card replaces it, which keeps slow capability-heavy views from briefly flashing into view before being hidden.
### Connection test details
When you test a remote node from **Settings · Nodes**, the Connection Details panel that appears under the table lists the node's running Sencho version alongside its OS, architecture, container count, image count, and CPU count.
<Frame>
<img src="/images/node-compatibility/connection-test-version.png" alt="Connection Details panel for Opsix showing Instance: Remote Sencho, Sencho: v0.95.0, OS: Remote, Arch: Remote, Containers: 4, Images: 4, CPUs: 2, with a wifi-style icon in the heading." />
</Frame>
This is the most direct way to confirm what version a remote node is on without switching to it first.
## Capability list
Every Sencho release ships with a static list of capabilities. The current list is:
| Capability | Feature |
|-----------|---------|
| `stacks` | Stack management (always available) |
| `containers` | Container operations (always available) |
| `resources` | Resource browser (always available) |
| `templates` | App Store (always available) |
| `global-logs` | Global log viewer (always available) |
| `system-stats` | Host system statistics (always available) |
| `fleet` | Fleet management view |
| `auto-updates` | Auto-Update policies |
| `labels` | Stack labels |
| `webhooks` | Webhook triggers |
| `network-topology` | Network topology view |
| `notifications` | Alert notifications |
| `notification-routing` | Notification routing rules |
| `notification-suppression` | Mute rules |
| `host-console` | Host Console (legacy advertisement retained for mixed-version fleets) |
| `host-console-community` | Host Console without a paid-license requirement on this node |
| `container-exec` | Container exec terminal |
| `audit-log` | Audit log |
| `scheduled-ops` | Scheduled operations |
| `sso` | SSO authentication |
| `api-tokens` | API token management |
| `users` | User management |
| `registries` | Private registry management |
| `self-update` | Self-update from the dashboard |
| `vulnerability-scanning` | Image vulnerability scanning |
| `compose-doctor` | Compose Doctor preflight |
| `update-guard` | Pre-update rollback-readiness dialog |
| `compose-networking` | Stack Networking tab |
| `env-inventory` | Stack Environment tab (env var inventory) |
| `container-label-inventory` | Stack Compose Labels tab and Fleet label filters |
| `project-env-files` | Project-level env file selection in the Environment tab |
| `compose-storage` | Stack Storage tab (volume explorer) |
| `cross-node-rbac` | Role enforcement on requests forwarded to a remote node |
| `stack-down-remove-volumes` | The "also remove volumes" option on Take Down |
| `stack-delete-prune-volumes` | Preserve volumes on Delete unless the operator opts in to remove them |
| `guided-external-network-preflight` | Guided missing-external-network check before deploy |
| `service-scoped-update` | Per-service update, rebuild, and restore on multi-service stacks |
<Note>
`vulnerability-scanning` is advertised only when the Trivy binary is present on the node. If a node has no scanner installed, it omits this capability and the scanning UI is replaced by a lock card whose body reads "Install a scanner from the Scanner setup tab." instead of the generic upgrade message.
</Note>
<Note>
A node connected in [Pilot Agent](/features/multi-node#add-a-remote-node-pilot-agent) mode never advertises `host-console` or `host-console-community`, even after upgrading, because that feature's control-to-agent path is not yet wired through the enrollment tunnel. This is the one capability gap that upgrading the node cannot close; it only closes on a Distributed API Proxy connection.
</Note>
A handful of capabilities gate a smaller piece of behavior rather than a whole panel, so a missing one falls back to an older behavior instead of a lock card:
- Nodes that do not advertise `guided-external-network-preflight` keep legacy deploy behavior (no guided missing-external dialog). When the capability is present but the check cannot run, deploy fails closed instead of guessing.
- Nodes that do not advertise `service-scoped-update` fall back to the legacy per-container layout on multi-service stacks: no declared-service headers, and no per-service update, rebuild, or restore.
- Nodes that do not advertise `update-guard` run manual updates directly, with no pre-update rollback-readiness dialog.
- `cross-node-rbac` is a security boundary, not a convenience: the control instance refuses to forward a non-admin request, or a confirmed stop-by-label, to a remote node that does not advertise it. This stops a mixed-version fleet from letting a lower-privileged action escalate on an un-upgraded node.
- `stack-delete-prune-volumes` is also a data-safety boundary: a node that does not advertise it cannot guarantee volumes survive a Delete, so the confirmation dialog states that up front and forwards the delete with removal already acknowledged, and the control instance refuses to forward an *unacknowledged* delete to such a node at all rather than risk it silently destroying volumes the operator meant to keep.
## Handling nodes that do not advertise metadata
If a remote node does not respond to `/api/meta` (for example, an unreachable instance, a slow handshake, or one that does not implement the endpoint), Sencho falls back to an offline metadata record with no version and an empty capability list. In that state:
- The node's row in the switcher and the connection-test panel show no version pill.
- Every capability-gated feature on that node shows the lock card.
- Core features (stacks, containers, resources, logs) continue to work normally, with one exception: Delete requests preservation instead of guessing, so a genuinely un-upgraded remote returns an "upgrade this node" error rather than risk destroying volumes silently (see `stack-delete-prune-volumes` above).
- No other errors are surfaced in the UI; the control instance retries the metadata fetch after a short backoff.
If you expect a node to support a feature that is being gated, the fastest fix is to update that node to the latest Sencho release. See the [upgrade guide](/operations/upgrade) for instructions.
## Interaction with license tiers
Some features need both an Admiral license **and** node capability support. The two gates evaluate in this order:
1. The license gate is checked first. On the wrong tier, the feature's entry point (sidebar item, top-nav button, settings section) is hidden entirely, so you never reach the panel.
2. If you are on the right tier but the active node does not advertise the capability, the entry point is visible but the panel is replaced by the capability lock card.
The practical effect is that you only see the lock card for features your plan already covers, so it is always actionable: upgrading the node restores access to that feature.