Stop handing StackPilot's own settings to the stacks it deploys (0.66.3)
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
34458c702c
commit
052a85067b
7 files changed
+44
-7
No files matched your search
@@ -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
|
credentials survive restarts — that file is then part of your backup. Setting it
|
||||||
explicitly always wins and nothing is written.
|
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
|
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
|
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
|
inside the backend container, so a stack's relative bind mounts (`./config`) are
|
||||||
|
|||||||
@@ -118,3 +118,18 @@ def get_settings() -> Settings:
|
|||||||
|
|
||||||
|
|
||||||
settings = get_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}
|
||||||
@@ -39,7 +39,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from config import settings
|
from config import child_env, settings
|
||||||
from models.git_source import GitSource, SyncResult
|
from models.git_source import GitSource, SyncResult
|
||||||
from services import compose_service, crypto_service, stack_lock_service
|
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.
|
with their whole stack.
|
||||||
"""
|
"""
|
||||||
env = {
|
env = {
|
||||||
**os.environ,
|
**child_env(),
|
||||||
# No interactive prompting: a private repo without credentials must fail
|
# No interactive prompting: a private repo without credentials must fail
|
||||||
# fast rather than hang forever waiting on a terminal that is not there.
|
# fast rather than hang forever waiting on a terminal that is not there.
|
||||||
"GIT_TERMINAL_PROMPT": "0",
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from typing import Optional
|
|||||||
import httpx
|
import httpx
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from config import settings
|
from config import child_env, settings
|
||||||
from models.registry import Registry
|
from models.registry import Registry
|
||||||
from services import crypto_service
|
from services import crypto_service
|
||||||
|
|
||||||
@@ -186,9 +186,10 @@ def cli_env() -> dict:
|
|||||||
|
|
||||||
Points DOCKER_CONFIG at our generated file rather than writing into
|
Points DOCKER_CONFIG at our generated file rather than writing into
|
||||||
``~/.docker``, so what StackPilot manages stays separate from anything the
|
``~/.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()}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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()
|
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
|
# Through the API
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
"""Single source of truth for the StackPilot release version."""
|
"""Single source of truth for the StackPilot release version."""
|
||||||
|
|
||||||
APP_VERSION = "0.66.2"
|
APP_VERSION = "0.66.3"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.66.2",
|
"version": "0.66.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
Reference in new issue
Block a user