da15bda6d2
---ci--- phase: 3 milestone: v0.2 status: complete requirements: covered: [REQ-21, REQ-22, REQ-23] partial: [] ---/ci---
112 lines
4.1 KiB
Python
112 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Sanctioned secrets loader for the v0.2 distribution scripts (stdlib only).
|
|
|
|
Secrets resolve ONLY from env files on disk — never from the process
|
|
environment (``GITEA_TOKEN`` sits in ``config.security.bash_allowlist
|
|
.blocked_env_vars``, so shell-env tokens are structurally blocked; the
|
|
v1.8 lesson: an empty or mis-sourced token makes gitea answer 404 rather
|
|
than 401, masking the auth failure).
|
|
|
|
Parsing mirrors ``coreci_broker._parse_env_file`` semantics exactly:
|
|
blank lines and ``#`` comments are skipped, keys/values are whitespace-
|
|
stripped, and a value wrapped in matching single or double quotes is
|
|
unquoted.
|
|
|
|
File precedence — later files win. Locations in order (``root``, then the
|
|
sanctioned ``.ciagent/`` dir under it); each location in ``config.json``
|
|
``secrets.env_files`` pattern order (``.env``, ``.env.secrets``, then the
|
|
``.env.*`` glob, sorted)::
|
|
|
|
<root>/.env, <root>/.env.secrets, <root>/.env.*
|
|
<root>/.ciagent/.env, <root>/.ciagent/.env.secrets, <root>/.ciagent/.env.*
|
|
|
|
``load()`` returns the merged mapping; ``get_token()`` enforces the
|
|
non-empty assert that callers MUST let happen before their first API
|
|
call. This module never prints secret values.
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
|
|
ENV_FILE_PATTERNS = (".env", ".env.secrets", ".env.*")
|
|
CIAGENT_SUBDIR = ".ciagent"
|
|
|
|
|
|
class SecretsError(RuntimeError):
|
|
"""A required secret is missing or empty in the sanctioned env files."""
|
|
|
|
|
|
def default_root():
|
|
"""Repo root: the parent directory of this scripts/ folder."""
|
|
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
def _parse_env_file(path):
|
|
"""Mirror coreci_broker._parse_env_file (comments, blanks, quotes)."""
|
|
entries = []
|
|
if not os.path.isfile(path):
|
|
return entries
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for raw in handle:
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = (part.strip() for part in line.partition("="))
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
if key:
|
|
entries.append((key, value))
|
|
return entries
|
|
|
|
|
|
def _candidate_files(root):
|
|
"""Ordered, deduplicated env-file paths; later files win."""
|
|
seen = set()
|
|
files = []
|
|
for location in (root, os.path.join(root, CIAGENT_SUBDIR)):
|
|
for pattern in ENV_FILE_PATTERNS:
|
|
for path in sorted(glob.glob(os.path.join(location, pattern))):
|
|
if not os.path.isfile(path):
|
|
continue
|
|
real = os.path.abspath(path)
|
|
if real in seen:
|
|
continue
|
|
seen.add(real)
|
|
files.append(real)
|
|
return files
|
|
|
|
|
|
def load(root=None):
|
|
"""Merge every sanctioned env file under ``root`` into one dict.
|
|
|
|
``root`` defaults to the repo root (the parent of scripts/).
|
|
"""
|
|
base = os.path.abspath(root) if root is not None else default_root()
|
|
values = {}
|
|
for path in _candidate_files(base):
|
|
for key, value in _parse_env_file(path):
|
|
values[key] = value
|
|
return values
|
|
|
|
|
|
def get_token(var="GITEA_TOKEN", root=None):
|
|
"""Return the non-empty value of ``var`` from the env files only.
|
|
|
|
Raises SecretsError when the value is missing, empty, or whitespace-
|
|
only — callers must surface this BEFORE any HTTP call (an empty token
|
|
makes gitea answer 404 and masks the auth failure — the v1.8 lesson).
|
|
"""
|
|
token = load(root).get(var, "")
|
|
if not token.strip():
|
|
base = os.path.abspath(root) if root is not None else default_root()
|
|
searched = ", ".join(
|
|
os.path.join(location, pattern)
|
|
for location in (base, os.path.join(base, CIAGENT_SUBDIR))
|
|
for pattern in ENV_FILE_PATTERNS
|
|
)
|
|
raise SecretsError(
|
|
"{} is missing or empty in the sanctioned env files "
|
|
"(searched: {}); it never resolves from the process "
|
|
"environment".format(var, searched)
|
|
)
|
|
return token.strip() |