Skip to content

Commit 61fa4d9

Browse files
authored
Improve self-hosted Docker onboarding (#1456)
* Improve self-hosted Docker onboarding * Address review feedback on Docker onboarding * Fix CI versioning and test environment leaks * Stabilize auth integration tests in CI
1 parent c47f700 commit 61fa4d9

13 files changed

Lines changed: 249 additions & 27 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ dist/*
1515
**/*.h5
1616
**/*.csv.gz
1717
.env
18-
.ds_store
18+
.ds_store
19+
uv.lock

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,31 @@
22

33
A version of the PolicyEngine API that runs the `calculate` endpoint over household object. To debug locally, run `make debug`.
44

5+
## Quick self-hosted run
6+
7+
If you want to try the API without requesting hosted credentials, run the published Docker image:
8+
9+
```
10+
docker run --rm -p 8080:8080 ghcr.io/policyengine/policyengine-household-api:latest
11+
```
12+
13+
The image can take a little time to initialize on first start and is best run on a machine with roughly
14+
4 GB of RAM available.
15+
16+
Then inspect the service metadata:
17+
18+
```
19+
curl http://localhost:8080/
20+
```
21+
22+
and send calculations to:
23+
24+
```
25+
http://localhost:8080/us/calculate
26+
```
27+
28+
Hosted API docs live at https://www.policyengine.org/us/api.
29+
530
## Local development with Docker Compose
631

732
To run this app locally via Docker Compose:

changelog_entry.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
- bump: patch
2+
changes:
3+
changed:
4+
- Return JSON service metadata from the home endpoint and improve self-hosted Docker guidance, including a container healthcheck and non-root runtime user.

config/README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,15 +77,15 @@ CONFIG_FILE=config/local.yaml make debug
7777
# Mount a custom config file
7878
docker run -v /path/to/your/config.yaml:/custom/config.yaml \
7979
-e CONFIG_FILE=/custom/config.yaml \
80-
policyengine/household-api
80+
ghcr.io/policyengine/policyengine-household-api:latest
8181
```
8282

8383
#### Docker Compose
8484
```yaml
8585
version: '3.13'
8686
services:
8787
household-api:
88-
image: policyengine/household-api
88+
image: ghcr.io/policyengine/policyengine-household-api:latest
8989
volumes:
9090
- ./my-config.yaml:/app/config/custom.yaml
9191
environment:
@@ -123,7 +123,7 @@ spec:
123123
spec:
124124
containers:
125125
- name: api
126-
image: policyengine/household-api
126+
image: ghcr.io/policyengine/policyengine-household-api:latest
127127
env:
128128
- name: CONFIG_FILE
129129
value: /config/config.yaml
@@ -301,11 +301,12 @@ Use environment variables to override specific settings:
301301

302302
```bash
303303
docker run -e FLASK_DEBUG=1 \
304+
-p 8080:8080 \
304305
-e AUTH__ENABLED=false \ # Disable Auth0 for local dev
305306
-e ANALYTICS__ENABLED=false \ # Disable analytics for local dev
306307
-e AI__ENABLED=false \
307308
-e DATABASE__PROVIDER=sqlite \
308-
policyengine/household-api
309+
ghcr.io/policyengine/policyengine-household-api:latest
309310
```
310311

311312
#### Template Variable Substitution
@@ -359,15 +360,15 @@ docker run -v /path/to/config.yaml:/app/config/custom.yaml \
359360
-v /path/to/values.env:/app/config/values.env \
360361
-e CONFIG_FILE=/app/config/custom.yaml \
361362
-e CONFIG_VALUE_SETTINGS=/app/config/values.env \
362-
policyengine/household-api
363+
ghcr.io/policyengine/policyengine-household-api:latest
363364
```
364365

365366
Or with Docker Compose:
366367
```yaml
367368
version: '3.13'
368369
services:
369370
household-api:
370-
image: policyengine/household-api
371+
image: ghcr.io/policyengine/policyengine-household-api:latest
371372
volumes:
372373
- ./my-config.yaml:/app/config/custom.yaml
373374
- ./my-values.env:/app/config/values.env

gcp/policyengine_household_api/Dockerfile.production

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,19 @@ COPY ./config/default.yaml /app/config/default.yaml
3434
# Copy startup script
3535
COPY ./gcp/policyengine_household_api/start.sh /app/start.sh
3636
RUN chmod +x /app/start.sh
37-
38-
# Configure environment (runs as root by default)
37+
38+
# Drop root privileges in the runtime image.
39+
RUN groupadd policyapi && \
40+
useradd --gid policyapi --create-home policyapi && \
41+
chown -R policyapi:policyapi /app /opt/venv
42+
43+
# Configure runtime environment.
3944
ENV PATH="/opt/venv/bin:$PATH"
4045
EXPOSE 8080
41-
42-
CMD ["/app/start.sh"]
46+
47+
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
48+
CMD curl --fail --silent http://127.0.0.1:8080/liveness_check || exit 1
49+
50+
USER policyapi
51+
52+
CMD ["/app/start.sh"]

policyengine_household_api/decorators/auth.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,43 @@
88

99
from typing import Optional, Any, Callable
1010
from authlib.integrations.flask_oauth2 import ResourceProtector
11+
from authlib.oauth2.rfc6750 import BearerTokenValidator
1112
from ..auth.validation import Auth0JWTBearerTokenValidator
1213
from ..utils.config_loader import get_config, get_config_value
1314

1415

16+
class StaticBearerToken:
17+
"""Minimal token object for test-only bearer token validation."""
18+
19+
def __init__(self, token_string: str, scope: str = ""):
20+
self.token_string = token_string
21+
self.scope = scope
22+
23+
def is_expired(self) -> bool:
24+
return False
25+
26+
def is_revoked(self) -> bool:
27+
return False
28+
29+
def get_scope(self) -> str:
30+
return self.scope
31+
32+
33+
class StaticBearerTokenValidator(BearerTokenValidator):
34+
"""Accept a single configured bearer token for test environments."""
35+
36+
def __init__(self, expected_token: str):
37+
super().__init__()
38+
self.expected_token = expected_token
39+
40+
def authenticate_token(
41+
self, token_string: Optional[str]
42+
) -> Optional[StaticBearerToken]:
43+
if token_string == self.expected_token:
44+
return StaticBearerToken(token_string)
45+
return None
46+
47+
1548
class NoOpDecorator:
1649
"""
1750
No-operation decorator used when authentication is disabled.
@@ -63,14 +96,22 @@ def _setup_authentication(self) -> None:
6396
"""
6497
# Check if Auth0 is explicitly enabled via configuration
6598
self._auth_enabled = get_config_value("auth.enabled", False)
99+
app_environment = get_config_value("app.environment", "")
100+
auth0_test_token = get_config_value("auth.auth0.test_token", "")
66101

67102
# Get Auth0 configuration values
68103
auth0_address = get_config_value("auth.auth0.address", "")
69104
auth0_audience = get_config_value("auth.auth0.audience", "")
70105

71106
# Initialize the appropriate decorator
72107
if self._auth_enabled:
73-
if auth0_address and auth0_audience:
108+
if app_environment == "test_with_auth" and auth0_test_token:
109+
resource_protector = ResourceProtector()
110+
resource_protector.register_token_validator(
111+
StaticBearerTokenValidator(auth0_test_token)
112+
)
113+
self._decorator = resource_protector
114+
elif auth0_address and auth0_audience:
74115
# Set up real Auth0 authentication
75116
resource_protector = ResourceProtector()
76117
validator = Auth0JWTBearerTokenValidator(
Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,32 @@
1-
def get_home() -> str:
2-
"""Get the home page of the PolicyEngine household API.
1+
import json
32

4-
Returns:
5-
str: The home page.
6-
"""
7-
return f"<h1>PolicyEngine household API</h1><p>Use this API to compute the impact of public policy upon households.</p>"
3+
from flask import Response
4+
5+
6+
def get_home() -> Response:
7+
"""Return service metadata for self-serve and hosted API users."""
8+
9+
response_body = {
10+
"status": "ok",
11+
"message": "PolicyEngine household API",
12+
"result": {
13+
"docs_url": "https://www.policyengine.org/us/api",
14+
"container_image": "ghcr.io/policyengine/policyengine-household-api",
15+
"hosted_calculate_url_template": (
16+
"https://household.api.policyengine.org/{country_id}/calculate"
17+
),
18+
"local_calculate_url_template": (
19+
"http://localhost:8080/{country_id}/calculate"
20+
),
21+
"health_checks": {
22+
"liveness": "/liveness_check",
23+
"readiness": "/readiness_check",
24+
},
25+
},
26+
}
27+
28+
return Response(
29+
json.dumps(response_body),
30+
status=200,
31+
mimetype="application/json",
32+
)

policyengine_household_api/openapi_spec.yaml

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,39 @@ servers:
1313
paths:
1414
/:
1515
get:
16-
summary: Get the home page of the PolicyEngine API
16+
summary: Get service metadata for the PolicyEngine household API
1717
operationId: get_home
18-
description: Returns the home page of the PolicyEngine API as an HTML string.
18+
description: Returns service metadata, documentation links, and self-hosting hints.
1919
responses:
2020
200:
21-
description: The home page.
21+
description: Service metadata.
2222
content:
23-
text/html:
23+
application/json:
2424
schema:
25-
type: string
25+
type: object
26+
properties:
27+
status:
28+
type: string
29+
message:
30+
type: string
31+
result:
32+
type: object
33+
properties:
34+
docs_url:
35+
type: string
36+
container_image:
37+
type: string
38+
hosted_calculate_url:
39+
type: string
40+
local_calculate_url:
41+
type: string
42+
health_checks:
43+
type: object
44+
properties:
45+
liveness:
46+
type: string
47+
readiness:
48+
type: string
2649
/{country_id}/metadata:
2750
get:
2851
summary: Get metadata for a country
@@ -841,4 +864,4 @@ paths:
841864
paths:
842865
type: object
843866
servers:
844-
type: array
867+
type: array

tests/fixtures/decorators/auth.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@
2020
}
2121
}
2222

23+
AUTH_TEST_ENVIRONMENT_CONFIG = {
24+
"app": {
25+
"environment": "test_with_auth",
26+
},
27+
"auth": {
28+
"enabled": True,
29+
"auth0": {
30+
**AUTH0_CONFIG_DATA,
31+
"test_token": "test-jwt-token",
32+
},
33+
},
34+
}
35+
2336
AUTH_DISABLED_CONFIG = {
2437
"auth": {
2538
"enabled": False,
@@ -99,6 +112,27 @@ def config_side_effect(path: str, default: Any = None) -> Any:
99112
yield mock_config
100113

101114

115+
@pytest.fixture
116+
def auth_test_environment():
117+
"""Set up environment for local bearer-token validation in tests."""
118+
with patch(
119+
"policyengine_household_api.decorators.auth.get_config_value"
120+
) as mock_config:
121+
122+
def config_side_effect(path: str, default: Any = None) -> Any:
123+
config_map = {
124+
"app.environment": "test_with_auth",
125+
"auth.enabled": True,
126+
"auth.auth0.address": AUTH0_CONFIG_DATA["address"],
127+
"auth.auth0.audience": AUTH0_CONFIG_DATA["audience"],
128+
"auth.auth0.test_token": "test-jwt-token",
129+
}
130+
return config_map.get(path, default)
131+
132+
mock_config.side_effect = config_side_effect
133+
yield mock_config
134+
135+
102136
@pytest.fixture
103137
def auth_disabled_environment():
104138
"""Set up environment with authentication disabled."""

tests/fixtures/utils/computation_tree.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99

1010
@pytest.fixture
11-
def mock_config_ai_disabled():
11+
def mock_config_ai_disabled(monkeypatch):
12+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
1213
with patch(
1314
"policyengine_household_api.utils.computation_tree.get_config_value"
1415
) as mock_config:
@@ -25,7 +26,8 @@ def config_side_effect(key, default=None):
2526

2627

2728
@pytest.fixture
28-
def mock_config_ai_enabled_no_key():
29+
def mock_config_ai_enabled_no_key(monkeypatch):
30+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
2931
with patch(
3032
"policyengine_household_api.utils.computation_tree.get_config_value"
3133
) as mock_config:

0 commit comments

Comments
 (0)