From 052a85067b79845c55f1ac5650ab31553d75dab8 Mon Sep 17 00:00:00 2001 From: menzelj Date: Sat, 19 Sep 2026 13:49:52 +0200 Subject: [PATCH] Stop handing StackPilot's own settings to the stacks it deploys (0.66.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docker compose ran with StackPilot's whole environment, and compose resolves ${VAR} from the calling environment before a stack's own .env. A stack whose compose file says SECRET_KEY: ${SECRET_KEY} therefore ran with StackPilot's SECRET_KEY instead of the one in its .env — and with its own again whenever it was started from a shell. AnsiblePilot hit exactly this: credentials saved under one key became unreadable under the other, twice. config.child_env() is os.environ minus every StackPilot setting (SECRET_KEY, DATA_DIR, STACKS_DIR, PORT, ...); the docker CLI and git subprocesses now get that instead. Co-Authored-By: Claude Opus 5 --- README.md | 6 ++++++ backend/config.py | 15 +++++++++++++++ backend/services/git_service.py | 4 ++-- backend/services/registry_service.py | 7 ++++--- backend/tests/test_registries.py | 15 +++++++++++++++ backend/version.py | 2 +- frontend/package.json | 2 +- 7 files changed, 44 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index db11c02..8fcad2a 100644 --- a/README.md +++ b/README.md @@ -958,6 +958,12 @@ credentials in the database. Leave it unset and one is generated and written to credentials survive restarts — that file is then part of your backup. Setting it explicitly always wins and nothing is written. +None of these settings reach the stacks StackPilot deploys. Compose resolves +`${VAR}` from the calling environment before a stack's own `.env`, so until +0.66.3 a stack that uses `${SECRET_KEY}` (or `PORT`, `DATA_DIR`, …) ran with +StackPilot's value instead of its own — and with its own again when started from +a shell. Anything such a stack encrypted in between was keyed to the wrong one. + The host path for stacks is set via `STACKS_HOST_DIR` in `.env`, and it should be **the same path as `STACKS_DIR`** (`/opt/stacks` by default). Compose runs inside the backend container, so a stack's relative bind mounts (`./config`) are diff --git a/backend/config.py b/backend/config.py index 04567bf..98b2579 100644 --- a/backend/config.py +++ b/backend/config.py @@ -118,3 +118,18 @@ def get_settings() -> Settings: settings = get_settings() + + +def child_env() -> dict[str, str]: + """os.environ without StackPilot's own settings, for docker/git subprocesses. + + Compose resolves ``${VAR}`` from the calling environment *before* the + stack's own ``.env``, so everything StackPilot was configured with leaked + into every stack it deployed. A stack whose compose file says + ``SECRET_KEY: ${SECRET_KEY}`` silently ran with StackPilot's key instead of + its own — and flipped back whenever it was started from a shell, orphaning + whatever it had encrypted in between. Names like SECRET_KEY, PORT or + DATA_DIR are far too common to share. + """ + own = {name.upper() for name in Settings.model_fields} + return {k: v for k, v in os.environ.items() if k.upper() not in own} diff --git a/backend/services/git_service.py b/backend/services/git_service.py index 6720970..1c42832 100644 --- a/backend/services/git_service.py +++ b/backend/services/git_service.py @@ -39,7 +39,7 @@ from typing import Optional from sqlmodel import Session -from config import settings +from config import child_env, settings from models.git_source import GitSource, SyncResult from services import compose_service, crypto_service, stack_lock_service @@ -124,7 +124,7 @@ def _auth_env(source: GitSource) -> tuple[dict, Optional[str]]: with their whole stack. """ env = { - **os.environ, + **child_env(), # No interactive prompting: a private repo without credentials must fail # fast rather than hang forever waiting on a terminal that is not there. "GIT_TERMINAL_PROMPT": "0", diff --git a/backend/services/registry_service.py b/backend/services/registry_service.py index f5c8c56..de53fd0 100644 --- a/backend/services/registry_service.py +++ b/backend/services/registry_service.py @@ -31,7 +31,7 @@ from typing import Optional import httpx from sqlmodel import Session, select -from config import settings +from config import child_env, settings from models.registry import Registry from services import crypto_service @@ -186,9 +186,10 @@ def cli_env() -> dict: Points DOCKER_CONFIG at our generated file rather than writing into ``~/.docker``, so what StackPilot manages stays separate from anything the - image ships or an operator put there by hand. + image ships or an operator put there by hand. StackPilot's own settings are + left out: compose would otherwise interpolate them into the stacks. """ - return {**os.environ, "DOCKER_CONFIG": docker_config_dir()} + return {**child_env(), "DOCKER_CONFIG": docker_config_dir()} # --------------------------------------------------------------------------- # diff --git a/backend/tests/test_registries.py b/backend/tests/test_registries.py index 095e67a..29cace7 100644 --- a/backend/tests/test_registries.py +++ b/backend/tests/test_registries.py @@ -202,6 +202,21 @@ def test_compose_runs_against_our_config_not_the_home_directory(svc): assert svc.cli_env()["DOCKER_CONFIG"] == svc.docker_config_dir() +def test_compose_does_not_hand_our_settings_to_the_stacks(svc, monkeypatch): + """Compose interpolates ${SECRET_KEY} from this environment before the + stack's .env — a stack must never run with StackPilot's own key.""" + monkeypatch.setenv("SECRET_KEY", "stackpilots-own-key") + monkeypatch.setenv("data_dir", "/lowercase/still/ours") + monkeypatch.setenv("SOME_UNRELATED_VAR", "kept") + env = svc.cli_env() + assert "SECRET_KEY" not in env + assert "STACKS_DIR" not in env + assert "data_dir" not in env + # Everything else the docker CLI may rely on is still passed through. + assert env["SOME_UNRELATED_VAR"] == "kept" + assert env["PATH"] == os.environ["PATH"] + + # --------------------------------------------------------------------------- # # Through the API # --------------------------------------------------------------------------- # diff --git a/backend/version.py b/backend/version.py index 0968c78..6b057c8 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ """Single source of truth for the StackPilot release version.""" -APP_VERSION = "0.66.2" +APP_VERSION = "0.66.3" diff --git a/frontend/package.json b/frontend/package.json index 851bc23..58bdbfe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.66.2", + "version": "0.66.3", "type": "module", "scripts": { "dev": "vite",