feat(stacks): per-stack environment inventory and secret-safe guardrails (#1397)

* feat(stacks): per-stack environment inventory and secret-safe guardrails

Add an Environment tab to Stack Anatomy that derives a per-stack inventory
of environment variables from the compose files and env files. Each variable
shows its source, whether Compose interpolates it or injects it into a
container, and a status (present, missing, unused, duplicate, or shell-only),
plus likely-secret classification. The inventory works from variable names
only: a value is never read, returned, or logged, and a likely secret shows
presence only. A copy env checklist action exports names and status without
values.

Surface a missing required env_file as a Compose Doctor preflight finding,
and add an opt-in node setting that refuses a deploy or update when a
required ${VAR:?...} variable is unset or empty, before any backup, pull, or
up runs. Default off.

The Environment tab is capability-gated so it hides on older remote nodes.

* fix(stacks): harden env-file reader against a stat-then-open race

Open the env-file handle first and fstat the open handle instead of
stat-ing the path before opening, removing the check-then-use window in
readEnvFileKeys. Use a secure mkdtemp directory for the out-of-base test
path instead of a predictable name in the temp root.

* fix(stacks): resolve nested env_file paths per compose file, reconcile inline keys per service

Resolve each env_file relative to the directory of the compose file that
declared it, so a nested multi-file Git override (infra/prod.yml referencing
./prod.env) lands next to that file instead of the stack root. The root
compose file is unaffected, since its directory is the stack directory.

Reconcile inline environment provenance per service, so a key an override
removed from one service's effective env is not labeled compose-inline just
because another service injects the same name from a different source.
This commit is contained in:
Anso
2026-06-20 11:58:42 -04:00
committed by GitHub
parent d26ab58189
commit 57a0856ffc
34 changed files with 2117 additions and 127 deletions
+1
View File
@@ -107,6 +107,7 @@
"features/stack-drift",
"features/compose-doctor",
"features/compose-networking",
"features/environment-guardrails",
"features/stack-labels",
"features/sidebar"
]
+73
View File
@@ -0,0 +1,73 @@
---
title: Environment and Secrets Guardrails
description: See every environment variable a stack uses, where it comes from, and whether it is likely a secret, without ever exposing a value. Sencho derives a per-stack env inventory, flags missing and duplicate variables, and can block a deploy when a required variable has no value.
---
The **Environment** tab in the right-hand **Anatomy** panel answers a question Compose makes surprisingly hard: *which environment variables does this stack actually use, where does each one come from, and is anything missing or sensitive?* Sencho derives the answer from the stack's Compose files and env files and presents it as a single inventory.
The inventory is advisory and read only. It never changes a stack, and it works entirely from variable **names**: a value is never read into the report, the checklist, or the logs, so nothing sensitive is exposed.
## Interpolation versus container injection
Compose treats environment in two distinct ways, and mixing them up is a common source of "it works on one host but not another":
- **Interpolation.** A `${VAR}` reference in the Compose file is resolved from the project `.env` file and the shell environment. It substitutes a value into the file before the container is created.
- **Container injection.** Values under a service's `environment:` block and in any `env_file:` are handed to the running container. They are never used to resolve `${VAR}` in the Compose file.
The inventory labels each variable with how it is used, so you can tell at a glance whether a variable feeds Compose interpolation, is injected into a service, or both.
## What the inventory shows
For every variable, Sencho records its source, its scope, and a status:
| Status | Meaning |
|--------|---------|
| **Present** | Referenced or injected, and defined in a stack-local source. |
| **Missing** | Referenced by the Compose file but not set anywhere, so Compose substitutes an empty string or fails on a required variable. |
| **Unused** | Defined in the project `.env` but never referenced or injected. |
| **Duplicate** | Defined in two different places, which can resolve to different effective values. |
| **Shell-only** | Referenced and resolved only from the shell of the host, not persisted with the stack, so it will not follow the stack to another node. |
Variables whose name suggests a secret, such as `DB_PASSWORD`, `API_KEY`, or `CLIENT_SECRET`, are marked with a lock and show presence only. Their value is never read, so it cannot appear in the inventory, the checklist, or anywhere else.
### Copy env checklist
The **copy env checklist** action copies the inventory as a Markdown checklist of variable names, status, and source. It is built for sharing in a ticket or a runbook, so it deliberately contains no values, including for likely secrets.
## Preflight: missing env files
When a service declares an `env_file:` that does not exist in the stack directory, Compose refuses to start the stack. Sencho surfaces this as a **high-risk** finding in the [Compose Doctor](/features/compose-doctor) preflight, naming the file and the service that declares it, so you catch it before you deploy. An entry marked `required: false`, and a path that Sencho cannot resolve, are not reported.
## Blocking a deploy on missing required variables
A `${VAR:?message}` reference tells Compose the variable is required: the deploy fails if it is unset or empty. By default Sencho surfaces that as an advisory finding and lets Compose report it at deploy time.
If you would rather fail fast with a clear message before anything runs, turn on **Block deploy on missing required env vars** under **Settings → Host alerts → Deploy guardrails**. With it on, a deploy or update is refused up front when a required variable is unset or empty, before any backup, image pull, or container change happens. It is off by default, applies to the node you set it on, and requires an admin to change.
## Opening the inventory
1. Click any stack in the left sidebar to open it.
2. Switch to the **Environment** tab in the Anatomy panel header.
3. Review the variables grouped by status, and use **copy env checklist** to share a values-free summary.
The inventory is built against the **active node**, so selecting a remote node inspects the stack on the machine that owns it.
## Troubleshooting
<AccordionGroup>
<Accordion title="A variable I set in .env is marked unused">
The project `.env` is read for Compose `${VAR}` interpolation. A variable that lives only in `.env` and is never referenced by the Compose file, and never injected into a service, has nothing using it. Reference it with `${VAR}`, move it into a service's `environment:` or `env_file:` if the container needs it, or remove it.
</Accordion>
<Accordion title="A variable shows as shell-only">
The variable resolves from the host shell that Sencho runs in, not from a file stored with the stack. It works on this host but will not travel with the stack to another node. Add it to the project `.env` or an `env_file:` so the stack carries its own configuration.
</Accordion>
<Accordion title="The same key counts once even though it is in .env and env_file">
When the project `.env` is also listed as an `env_file:`, it is one physical file doing two jobs: interpolation and injection. Sencho counts it once, so this common setup is never flagged as a duplicate. A duplicate means the key is genuinely defined in two different locations.
</Accordion>
<Accordion title="A likely secret is flagged that is not actually sensitive">
The classification is a heuristic based on the variable name, and it errs toward marking things as secret. Marking a non-secret as a likely secret only hides a value that the inventory never reads anyway, so there is no downside; the variable still shows its name, source, and status.
</Accordion>
<Accordion title="The Environment tab is not there on a remote node">
The tab appears when the active node reports that it supports the env inventory. A node running an older version of Sencho does not advertise it, so the tab is hidden for that node until it is updated.
</Accordion>
</AccordionGroup>
+98
View File
@@ -820,6 +820,104 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/env-inventory:
get:
operationId: getStackEnvInventory
tags: [Stacks]
summary: Get environment inventory
description: >-
Returns a per-stack inventory of environment variables: each variable's
source, whether Compose interpolates it or injects it into a container,
and a status (present, missing, unused, duplicate, or unpersisted), plus
likely-secret classification. Names only: a variable value is never read
or returned. When the effective model cannot be rendered, `renderable` is
false and the inventory is derived from the authored source alone.
Requires `stack:read` permission.
parameters:
- $ref: "#/components/parameters/stackName"
- $ref: "#/components/parameters/nodeId"
responses:
"200":
description: The environment inventory.
content:
application/json:
schema:
type: object
required: [stackName, renderable, items, envFiles, summary]
properties:
stackName:
type: string
renderable:
type: boolean
description: False when the effective model could not be rendered; the inventory is then partial.
items:
type: array
items:
type: object
required: [key, sources, usedForInterpolation, injectedIntoService, required, hasDefault, likelySecret, status]
properties:
key:
type: string
sources:
type: array
items:
type: string
enum: [compose-inline, env-file, dotenv, process-env, compose-ref]
usedForInterpolation:
type: boolean
injectedIntoService:
type: boolean
required:
type: boolean
hasDefault:
type: boolean
likelySecret:
type: boolean
status:
type: string
enum: [present, missing, unused, duplicate, unpersisted]
envFiles:
type: array
items:
type: object
properties:
rawPaths:
type: array
items:
type: string
existence:
type: string
enum: [present, missing, unverifiable]
required:
type: boolean
isInterpolationSource:
type: boolean
isInjectionSource:
type: boolean
declaringServices:
type: array
items:
type: string
summary:
type: object
properties:
total: { type: integer }
missing: { type: integer }
unused: { type: integer }
duplicate: { type: integer }
unpersisted: { type: integer }
likelySecret: { type: integer }
"403":
$ref: "#/components/responses/Forbidden"
"404":
description: Stack not found.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
/api/stacks/{stackName}/env:
get:
operationId: getStackEnv