Skip to content

Commit f10b850

Browse files
feat: support mounting secrets as files via _FILE env vars
Add _FILE variant support for 18 secret environment variables in the container entrypoint. When a _FILE-suffixed variable (e.g. OPENAI_API_KEY_FILE) points to a mounted file, its contents are read into the base variable at startup. This avoids exposing secrets through /proc/1/environ and subprocess environments in Kubernetes deployments. Add a pre-commit sync check in build.py that detects secret fields in build.yaml (by field name heuristic) and verifies they all have corresponding _FILE entries in entrypoint.sh, so new providers with secrets cannot be added without extending _FILE support. Add auto-generated documentation in distribution/README.md with the supported variable list and a Kubernetes Pod spec example. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Nathan Weinberg <nweinber@redhat.com>
1 parent 50a1d9f commit f10b850

5 files changed

Lines changed: 424 additions & 2 deletions

File tree

build/build.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@
3131

3232
OGX_GIT_REPO = "https://github.com/opendatahub-io/ogx.git"
3333

34+
PINNED_DEPENDENCIES = ["milvus-lite>=3.0.0", "pymilvus!=2.6.10"]
35+
36+
CONSTRAINTS_FILE = Path("distribution/constraints.txt")
37+
38+
_SECRET_FIELD_WORDS = {"password", "secret", "token", "credential"}
39+
_SECRET_FIELD_SUBSTRINGS = {"api_key", "access_key"}
40+
_SECRET_FIELD_EXCLUDE_SUFFIXES = ("_file", "_path", "_url", "_dir")
41+
42+
3443
STRIPPED_PROVIDER_TYPES = {
3544
"inline::sentence-transformers",
3645
"inline::milvus",
@@ -452,6 +461,85 @@ def generate_containerfile(version: str):
452461
print(f"Successfully generated {output_path}")
453462

454463

464+
def _is_secret_field(field_name: str) -> bool:
465+
"""Heuristic: does this YAML config field name hold a secret value?"""
466+
lower = field_name.lower()
467+
if lower.endswith(_SECRET_FIELD_EXCLUDE_SUFFIXES):
468+
return False
469+
words = set(lower.split("_"))
470+
if words & _SECRET_FIELD_WORDS:
471+
return True
472+
return any(sub in lower for sub in _SECRET_FIELD_SUBSTRINGS)
473+
474+
475+
def _extract_secret_env_vars_from_yaml(yaml_path: Path) -> set[str]:
476+
"""Walk build.yaml and return env var names referenced by secret fields."""
477+
env_ref = re.compile(r"\$\{env\.([^:}]+):[=+]")
478+
secrets: set[str] = set()
479+
480+
def _walk(node, parent_key=""):
481+
if isinstance(node, dict):
482+
for key, value in node.items():
483+
_walk(value, parent_key=key)
484+
elif isinstance(node, list):
485+
for item in node:
486+
_walk(item, parent_key=parent_key)
487+
elif isinstance(node, str):
488+
if _is_secret_field(parent_key):
489+
for match in env_ref.finditer(node):
490+
secrets.add(match.group(1))
491+
492+
with open(yaml_path) as f:
493+
from yaml import safe_load
494+
495+
data = safe_load(f)
496+
_walk(data)
497+
return secrets
498+
499+
500+
def _extract_entrypoint_secrets(entrypoint_path: Path) -> set[str]:
501+
"""Extract the secret var names from the entrypoint.sh for-loop."""
502+
text = entrypoint_path.read_text()
503+
match = re.search(r"for _secret_var in\s*\\(.*?);\s*do", text, re.DOTALL)
504+
if not match:
505+
print(f"Error: could not find _secret_var loop in {entrypoint_path}")
506+
sys.exit(1)
507+
body = match.group(1).replace("\\", " ")
508+
return {v for v in body.split() if v}
509+
510+
511+
def verify_file_secrets_sync():
512+
"""Verify that entrypoint.sh _FILE list matches secrets in build.yaml."""
513+
yaml_path = Path("build/build.yaml")
514+
entrypoint_path = Path("distribution/entrypoint.sh")
515+
516+
yaml_secrets = _extract_secret_env_vars_from_yaml(yaml_path)
517+
entrypoint_secrets = _extract_entrypoint_secrets(entrypoint_path)
518+
519+
missing_from_entrypoint = yaml_secrets - entrypoint_secrets
520+
extra_in_entrypoint = entrypoint_secrets - yaml_secrets
521+
522+
if missing_from_entrypoint or extra_in_entrypoint:
523+
print(
524+
"Error: distribution/entrypoint.sh _FILE secret list is out of sync "
525+
"with build/build.yaml."
526+
)
527+
if missing_from_entrypoint:
528+
print(f" Add to entrypoint.sh: {sorted(missing_from_entrypoint)}")
529+
if extra_in_entrypoint:
530+
print(f" Remove from entrypoint.sh: {sorted(extra_in_entrypoint)}")
531+
print(
532+
"\nWhen adding a new provider with secret fields (api_key, password, "
533+
"token, etc.) to build/build.yaml, also add the env var to the "
534+
"_FILE resolution loop in distribution/entrypoint.sh."
535+
)
536+
sys.exit(1)
537+
538+
print(
539+
f"Verified {len(yaml_secrets)} secret env vars have _FILE support in entrypoint.sh"
540+
)
541+
542+
455543
def main():
456544
config = BuildConfig()
457545

@@ -501,6 +589,9 @@ def main():
501589
print("Generating Containerfile...")
502590
generate_containerfile(config.ogx_version)
503591

592+
print("Verifying _FILE secret sync...")
593+
verify_file_secrets_sync()
594+
504595
print("Done!")
505596

506597

build/gen_distro_docs.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env python3
22

3-
import yaml
43
import re
4+
5+
import yaml
56
from pathlib import Path
67

78

@@ -182,6 +183,57 @@ def gen_distro_table(providers_data, runtime_provider_types=None):
182183
return "\n".join(table_lines)
183184

184185

186+
def extract_file_secret_vars():
187+
"""Extract the secret env var names from entrypoint.sh's _FILE resolution loop."""
188+
entrypoint = REPO_ROOT / "distribution" / "entrypoint.sh"
189+
text = entrypoint.read_text()
190+
match = re.search(r"for _secret_var in\s*\\(.*?);\s*do", text, re.DOTALL)
191+
if not match:
192+
return []
193+
body = match.group(1).replace("\\", " ")
194+
return sorted(v for v in body.split() if v)
195+
196+
197+
def gen_file_secrets_section(secret_vars):
198+
"""Generate a markdown section documenting _FILE secret support."""
199+
if not secret_vars:
200+
return ""
201+
202+
var_list = "\n".join(f"- `{var}` → `{var}_FILE`" for var in secret_vars)
203+
204+
return f"""
205+
## Mounting Secrets as Files
206+
207+
Instead of passing secrets directly as environment variables (which exposes them in
208+
`/proc/1/environ` and subprocess environments), you can mount them as files and
209+
point to them with `_FILE`-suffixed variables. At container startup, the entrypoint
210+
reads each file and populates the corresponding environment variable.
211+
212+
For example, to inject `OPENAI_API_KEY` from a mounted Kubernetes Secret:
213+
214+
```yaml
215+
env:
216+
- name: OPENAI_API_KEY_FILE
217+
value: /run/secrets/openai-api-key
218+
volumeMounts:
219+
- name: openai-secret
220+
mountPath: /run/secrets/openai-api-key
221+
subPath: api-key
222+
readOnly: true
223+
volumes:
224+
- name: openai-secret
225+
secret:
226+
secretName: openai-credentials
227+
```
228+
229+
Setting both the base variable and its `_FILE` variant is an error (mutually exclusive).
230+
231+
### Supported variables
232+
233+
{var_list}
234+
"""
235+
236+
185237
def gen_distro_docs():
186238
build_path = REPO_ROOT / "build" / "build.yaml"
187239
readme_path = REPO_ROOT / "distribution" / "README.md"
@@ -246,8 +298,13 @@ def gen_distro_docs():
246298
"definitions.\n"
247299
)
248300

301+
secret_vars = extract_file_secret_vars()
302+
file_secrets_section = gen_file_secrets_section(secret_vars)
303+
249304
with open(readme_path, "w") as readme_file:
250-
readme_file.write(header + table_content + "\n" + dep_only_note)
305+
readme_file.write(
306+
header + table_content + "\n" + dep_only_note + file_secrets_section
307+
)
251308

252309
print(f"Successfully generated {readme_path}")
253310
print(

distribution/README.md

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

distribution/entrypoint.sh

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,61 @@
11
#!/bin/sh
22
set -e
33

4+
# Resolve _FILE variants for secret environment variables.
5+
#
6+
# For each secret variable (e.g. OPENAI_API_KEY), if the corresponding
7+
# _FILE variant (OPENAI_API_KEY_FILE) is set, read the file contents
8+
# into the base variable. This lets Kubernetes operators mount secrets
9+
# as files instead of injecting them via env vars, avoiding exposure
10+
# through /proc/1/environ and subprocess environments.
11+
resolve_file_secret() {
12+
_rfs_var="$1"
13+
_rfs_file_var="${_rfs_var}_FILE"
14+
eval "_rfs_file_val=\${${_rfs_file_var}:-}"
15+
eval "_rfs_var_val=\${${_rfs_var}:-}"
16+
17+
if [ -n "$_rfs_file_val" ] && [ -n "$_rfs_var_val" ]; then
18+
printf 'Error: both %s and %s are set (mutually exclusive)\n' \
19+
"$_rfs_var" "$_rfs_file_var" >&2
20+
exit 1
21+
fi
22+
23+
if [ -n "$_rfs_file_val" ]; then
24+
if [ ! -f "$_rfs_file_val" ]; then
25+
printf 'Error: %s references %s, which is not a regular file\n' \
26+
"$_rfs_file_var" "$_rfs_file_val" >&2
27+
exit 1
28+
fi
29+
_rfs_content="$(cat "$_rfs_file_val")"
30+
eval "export ${_rfs_var}=\$_rfs_content"
31+
unset "$_rfs_file_var"
32+
fi
33+
}
34+
35+
for _secret_var in \
36+
ANTHROPIC_API_KEY \
37+
AWS_ACCESS_KEY_ID \
38+
AWS_BEDROCK_BEARER_TOKEN \
39+
AWS_SECRET_ACCESS_KEY \
40+
AZURE_API_KEY \
41+
BRAVE_SEARCH_API_KEY \
42+
DOCLING_SERVE_API_KEY \
43+
GEMINI_ACCESS_TOKEN \
44+
GEMINI_API_KEY \
45+
MILVUS_TOKEN \
46+
OPENAI_API_KEY \
47+
PGVECTOR_PASSWORD \
48+
POSTGRES_PASSWORD \
49+
QDRANT_API_KEY \
50+
TAVILY_SEARCH_API_KEY \
51+
VLLM_API_TOKEN \
52+
VLLM_EMBEDDING_API_TOKEN \
53+
WATSONX_API_KEY \
54+
; do
55+
resolve_file_secret "$_secret_var"
56+
done
57+
unset _secret_var
58+
459
# Resolve config path
560
if [ -n "$RUN_CONFIG_PATH" ] && [ -f "$RUN_CONFIG_PATH" ]; then
661
CONFIG="$RUN_CONFIG_PATH"

0 commit comments

Comments
 (0)