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
+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