Skip to content

Commit 32b9c49

Browse files
committed
refactor!: restore platform-service-framework compliance
1 parent 956923d commit 32b9c49

6 files changed

Lines changed: 220 additions & 139 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
name: Framework Validation
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- devel
8+
- main
9+
- 'stable**'
10+
- 'release**'
11+
jobs:
12+
validate:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout code
16+
uses: actions/checkout@v6
17+
- name: Install uv
18+
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7
19+
- name: Validate framework
20+
run: |
21+
BRANCH="${{ github.event.pull_request.base.ref || github.ref_name }}"
22+
ORG="${{ github.event.pull_request.base.repo.owner.login || github.repository_owner }}"
23+
uvx --refresh git+https://github.com/${ORG}/platform-service-framework@${BRANCH} validate

apps/core/segment_utils.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Segment analytics key loading utilities.
2+
3+
This module provides functions to load the SEGMENT_WRITE_KEY from a file
4+
in the production environment. The key file is typically installed by
5+
the pipeline at /etc/ansible-automation-platform/metrics/segment-write-key.
6+
"""
7+
8+
import logging
9+
import os
10+
from pathlib import Path
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def read_segment_key_from_path(path: Path) -> str | None:
16+
"""Read SEGMENT_WRITE_KEY from a single file. Returns the key or None.
17+
18+
Pipeline passes build secret metrics-service-segment-write-keys (SEGMENT_WRITE_KEY_DEV for
19+
PR/devel, SEGMENT_WRITE_KEY_PROD for GA); one key is installed into that path.
20+
File content is the raw plaintext key (no encoding).
21+
22+
Args:
23+
path: Path to the segment write key file
24+
25+
Returns:
26+
The segment write key as a string, or None if the file doesn't exist or is empty
27+
"""
28+
try:
29+
if not path.is_file():
30+
return None
31+
key = path.read_text().strip()
32+
return key if key else None
33+
except OSError as e:
34+
filename = getattr(e, "filename", path)
35+
logger.error(
36+
"Failed to read segment write key from %s: %s",
37+
filename,
38+
e,
39+
exc_info=True,
40+
)
41+
return None
42+
43+
44+
def load_segment_write_key_from_file(
45+
path: Path | None = None,
46+
dynaconf_instance=None,
47+
) -> None:
48+
"""Load SEGMENT_WRITE_KEY from file and set on Dynaconf.
49+
50+
Used at module load and in tests. Respects environment variable and settings
51+
precedence: does not overwrite if already set.
52+
53+
Args:
54+
path: Path to the segment write key file. If None, uses METRICS_SERVICE_SEGMENT_WRITE_KEY_FILE
55+
environment variable or defaults to /etc/ansible-automation-platform/metrics/segment-write-key
56+
dynaconf_instance: The Dynaconf instance to set the key on. If None, no action is taken
57+
(caller should pass the instance from their settings context)
58+
"""
59+
if path is None:
60+
_segment_write_key_path = os.environ.get(
61+
"METRICS_SERVICE_SEGMENT_WRITE_KEY_FILE",
62+
"/etc/ansible-automation-platform/metrics/segment-write-key",
63+
)
64+
path = Path(_segment_write_key_path)
65+
66+
if dynaconf_instance is None:
67+
return
68+
69+
# Respect env/settings precedence: do not overwrite if already set
70+
if os.environ.get("METRICS_SERVICE_SEGMENT_WRITE_KEY", "").strip():
71+
return
72+
if dynaconf_instance.get("SEGMENT_WRITE_KEY"):
73+
return
74+
if not path.exists():
75+
return
76+
77+
key = read_segment_key_from_path(path)
78+
if key:
79+
dynaconf_instance.set("SEGMENT_WRITE_KEY", key)

apps/settings/defaults.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from dynaconf import Dynaconf, post_hook
99

10+
from apps.core.segment_utils import load_segment_write_key_from_file
11+
1012
# Extra applications added after PSF templating
1113
extra_applications = [
1214
"django_prometheus",
@@ -147,3 +149,71 @@ def load_prometheus_middlewares(settings: Dynaconf) -> dict:
147149
"django_prometheus.middleware.PrometheusAfterMiddleware",
148150
]
149151
return {"MIDDLEWARE": new}
152+
153+
154+
@post_hook
155+
def load_segment_key(settings: Dynaconf) -> dict:
156+
"""Load Segment write key from file if not already set via environment or settings.
157+
158+
Hook runs after all settings are loaded, allowing file-based key loading
159+
to work in production while respecting environment variable precedence.
160+
"""
161+
load_segment_write_key_from_file(dynaconf_instance=settings)
162+
return {}
163+
164+
165+
@post_hook
166+
def parse_allowed_hosts(settings: Dynaconf) -> dict:
167+
"""Parse ALLOWED_HOSTS from environment variable."""
168+
import json
169+
import logging
170+
import os
171+
172+
raw = os.environ.get("METRICS_SERVICE_ALLOWED_HOSTS", "").strip()
173+
if not raw:
174+
return {}
175+
176+
if raw.startswith("["):
177+
# JSON array format
178+
try:
179+
parsed = json.loads(raw)
180+
except (json.JSONDecodeError, TypeError) as e:
181+
logging.getLogger(__name__).warning(
182+
"METRICS_SERVICE_ALLOWED_HOSTS: invalid JSON (%s), using empty list: %s",
183+
type(e).__name__,
184+
e,
185+
)
186+
parsed = []
187+
if not isinstance(parsed, list):
188+
logging.getLogger(__name__).warning(
189+
"METRICS_SERVICE_ALLOWED_HOSTS: expected JSON array, got %s, using empty list",
190+
type(parsed).__name__,
191+
)
192+
parsed = []
193+
allowed_hosts = [str(x).strip() for x in parsed if str(x).strip()]
194+
else:
195+
# Comma-separated format
196+
allowed_hosts = [str(x).strip() for x in raw.split(",") if x.strip()]
197+
198+
return {"ALLOWED_HOSTS": allowed_hosts}
199+
200+
201+
@post_hook
202+
def normalize_postgresql_options(settings: Dynaconf) -> dict:
203+
"""Normalize PostgreSQL session parameters for installer-friendly configuration.
204+
205+
Allows installers to set e.g. METRICS_SERVICE_DATABASES__awx__OPTIONS__datestyle=iso, mdy
206+
without needing to know the psycopg2-specific OPTIONS__options=-c datestyle=... syntax.
207+
"""
208+
PG_SESSION_PARAMS = {"datestyle", "search_path", "timezone", "application_name"}
209+
databases = settings.get("DATABASES", {})
210+
211+
for db_conf in databases.values():
212+
opts = db_conf.get("OPTIONS", {})
213+
session_params = {k: opts.pop(k) for k in list(opts) if k.lower() in PG_SESSION_PARAMS}
214+
if session_params:
215+
existing = opts.get("options", "")
216+
new = " ".join(f"-c {k}={v.replace(' ', '')}" for k, v in session_params.items())
217+
opts["options"] = f"{existing} {new}".strip()
218+
219+
return {"DATABASES": databases}

apps/settings/production.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@
3434
Validators are registered in metrics_service/settings.py and run during export().
3535
"""
3636

37-
from dynaconf import Validator
37+
import copy
38+
import os
39+
40+
from dynaconf import Dynaconf, Validator, post_hook
3841

3942
validators = []
4043

@@ -265,3 +268,23 @@
265268
},
266269
),
267270
)
271+
272+
273+
@post_hook
274+
def configure_json_logging(settings: Dynaconf) -> dict:
275+
"""Configure JSON logging for production environments.
276+
277+
Enables JSON-formatted logs when in production mode or when METRICS_SERVICE_LOG_FORMAT=json.
278+
This provides container-friendly structured logging for production deployments.
279+
"""
280+
environment = os.environ.get("METRICS_SERVICE_MODE", "").lower()
281+
log_format = os.environ.get("METRICS_SERVICE_LOG_FORMAT", "").lower()
282+
283+
if environment == "production" or log_format == "json":
284+
log_cfg = copy.deepcopy(settings.get("LOGGING") or {})
285+
log_cfg.setdefault("formatters", {})["json"] = {"()": "apps.core.logging_config.JsonFormatter"}
286+
for h in log_cfg.get("handlers", {}).values():
287+
if isinstance(h, dict) and "StreamHandler" in str(h.get("class", "")):
288+
h["formatter"] = "json"
289+
return {"LOGGING": log_cfg}
290+
return {}

0 commit comments

Comments
 (0)