Skip to content

Commit 217e8fa

Browse files
authored
Merge pull request #267 from helpfulengineering/feat-frontend-container
feat: containerize the frontend for provider-agnostic deployment (Slices 1–2)
2 parents cc5a912 + 27a1a41 commit 217e8fa

9 files changed

Lines changed: 352 additions & 3 deletions

File tree

.repo-map.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ REPOSITORY MAP (Aider Style)
33
================================================================================
44
Repository: supply-graph-ai
55

6-
Total Python files: 433
6+
Total Python files: 434
77

88
├── deploy/
99
│ ├── __init__.py
@@ -45,6 +45,8 @@ Total Python files: 433
4545
│ └── scripts/
4646
│ ├── deploy_azure.py
4747
│ │ def main()
48+
│ ├── deploy_azure_frontend.py
49+
│ │ def main()
4850
│ ├── deploy_gcp.py
4951
│ │ def main()
5052
│ ├── test_aws_deployer.py
@@ -295,6 +297,7 @@ Total Python files: 433
295297
│ │ │ def enforce_startup_config()
296298
│ │ │ def _secret_field_names()
297299
│ │ │ def deploy_env_vars()
300+
│ │ │ def frontend_deploy_env_vars()
298301
│ │ ├── settings.py
299302
│ │ │ def _get_secret_or_env()
300303
│ │ ├── storage_config.py
@@ -1790,6 +1793,7 @@ Total Python files: 433
17901793
│ class TestInitOverride (test_init_kwargs_win)
17911794
│ class TestStartupPosture (test_azure_complete_has_no_problems, test_azure_missing_fields_reported, test_local_has_no_problems...)
17921795
│ class TestDeployEnvVars (test_production_applies_storage_target, test_development_is_local, test_missing_environment_returns_empty...)
1796+
│ class TestFrontendDeployEnvVars (test_production_frontend_config, test_keys_are_uppercased_env_names, test_absent_table_returns_empty...)
17931797
│ def clean_env()
17941798
├── test_deploy_cors_default.py
17951799
│ def _data()
@@ -2070,7 +2074,7 @@ Total Python files: 433
20702074

20712075
## Overview
20722076

2073-
Total files analyzed: 433
2077+
Total files analyzed: 434
20742078

20752079
## Entry Points
20762080

@@ -4271,11 +4275,13 @@ These pin the schema's behaviour to t...
42714275
- Methods: test_azure_complete_has_no_problems, test_azure_missing_fields_reported, test_local_has_no_problems, test_unknown_provider_reported, test_production_hard_fails_on_problem
42724276
- `TestDeployEnvVars`
42734277
- Methods: test_production_applies_storage_target, test_development_is_local, test_missing_environment_returns_empty, test_keys_are_uppercased_env_names, test_secret_keys_are_refused
4278+
- `TestFrontendDeployEnvVars`
4279+
- Methods: test_production_frontend_config, test_keys_are_uppercased_env_names, test_absent_table_returns_empty, test_missing_environment_returns_empty
42744280

42754281
**Functions:**
42764282
- `clean_env(monkeypatch)`
42774283

4278-
**Internal Dependencies:** 7 imports
4284+
**Internal Dependencies:** 8 imports
42794285

42804286
### `tests/unit/test_storage_config_env_parsing.py`
42814287
> Regression tests for storage_config._env quote-stripping.
@@ -4381,6 +4387,19 @@ This script uses the Azure deployer to update th...
43814387

43824388
**Internal Dependencies:** 1 imports
43834389

4390+
### `deploy/scripts/deploy_azure_frontend.py`
4391+
> Deploy the OHM **frontend** container to Azure Container Apps.
4392+
4393+
Updates the frontend container app's...
4394+
4395+
**Exports:** main
4396+
4397+
**Functions:**
4398+
- `main()`
4399+
- Deploy the frontend to Azure Container Apps....
4400+
4401+
**Internal Dependencies:** 1 imports
4402+
43844403
### `deploy/scripts/deploy_gcp.py`
43854404
> Manual deployment script for GCP Cloud Run.
43864405

config/environments/development.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,11 @@ storage_provider = "local"
99
# Optional overrides (unset here; resolved by code defaults):
1010
# okw_source = "storage" # "storage" | "mom"; unset -> union (storage ∪ MoM)
1111
# cors_origins = "*" # unset in development -> allow-all ("*")
12+
13+
# Frontend (nginx) container deploy config (see schema.frontend_deploy_env_vars).
14+
# api_upstream_url = backend ORIGIN (no /v1). Local dev normally runs Vite
15+
# (`npm run dev`, OHM_API_BASE_URL) instead of this container; these values apply
16+
# only when the frontend container itself is deployed for development.
17+
[frontend]
18+
api_upstream_url = "http://localhost:8001"
19+
port = 8080

config/environments/production.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@
1010
storage_provider = "azure_blob"
1111
azure_storage_account = "projdatablobstorage"
1212
azure_storage_container = "production"
13+
14+
# Frontend (nginx) container deploy config — applied to the frontend container
15+
# app by the frontend deploy step (schema.frontend_deploy_env_vars), NOT the
16+
# backend. api_upstream_url is the backend ORIGIN (scheme + host, no /v1 path).
17+
[frontend]
18+
api_upstream_url = "https://openhardwaremanager.blackdune-e38fce01.westus3.azurecontainerapps.io"
19+
port = 8080
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env python3
2+
"""Deploy the OHM **frontend** container to Azure Container Apps.
3+
4+
Updates the frontend container app's image and applies its non-secret
5+
per-environment config (``API_UPSTREAM_URL``, ``PORT``) from the
6+
``[frontend]`` table of ``config/environments/<environment>.toml`` via
7+
``--set-env-vars`` (additive). The frontend has no secrets.
8+
9+
The container app itself (ingress, target port 8080) is provisioned once
10+
out-of-band; this script only updates an existing app, mirroring
11+
``deploy_azure.py`` for the backend.
12+
"""
13+
14+
import argparse
15+
import logging
16+
import sys
17+
from pathlib import Path
18+
19+
# Add project root to path
20+
project_root = Path(__file__).parent.parent.parent
21+
sys.path.insert(0, str(project_root))
22+
23+
from deploy.providers.azure import (
24+
AzureContainerAppsDeployer,
25+
AzureDeploymentConfig,
26+
DeploymentError,
27+
)
28+
from src.config.schema import frontend_deploy_env_vars
29+
30+
logging.basicConfig(
31+
level=logging.INFO,
32+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
33+
)
34+
logger = logging.getLogger(__name__)
35+
36+
37+
def main():
38+
"""Deploy the frontend to Azure Container Apps."""
39+
parser = argparse.ArgumentParser(description="Deploy the OHM frontend to Azure")
40+
parser.add_argument(
41+
"--resource-group",
42+
default="project_data_rg",
43+
help="Azure resource group (default: project_data_rg)",
44+
)
45+
parser.add_argument(
46+
"--subscription-id",
47+
default=None,
48+
help="Azure subscription ID (or set AZURE_SUBSCRIPTION_ID)",
49+
)
50+
parser.add_argument(
51+
"--container-app-name",
52+
default="openhardwaremanager-frontend",
53+
help="Frontend Container App name (default: openhardwaremanager-frontend)",
54+
)
55+
parser.add_argument(
56+
"--region",
57+
default="eastus",
58+
help="Azure region, only used if the resource group needs creating",
59+
)
60+
parser.add_argument(
61+
"--image",
62+
required=True,
63+
help="Frontend image to deploy (e.g. touchthesun/openhardwaremanager-frontend:0.8.8)",
64+
)
65+
parser.add_argument(
66+
"--environment",
67+
default="production",
68+
help=(
69+
"Target environment; selects config/environments/<env>.toml [frontend] "
70+
"(default: production). Deliberately NOT read from the ENVIRONMENT env "
71+
"var — importing src.config loads .env, so inferring the deploy target "
72+
"from it would let a local .env redirect a prod deploy. Pass explicitly "
73+
"for anything but production."
74+
),
75+
)
76+
parser.add_argument(
77+
"--memory", default="1Gi", help="Memory allocation (default: 1Gi)"
78+
)
79+
parser.add_argument(
80+
"--cpu", type=float, default=0.5, help="CPU allocation (default: 0.5)"
81+
)
82+
parser.add_argument("--min-instances", type=int, default=1)
83+
parser.add_argument("--max-instances", type=int, default=3)
84+
args = parser.parse_args()
85+
86+
import os
87+
88+
subscription_id = args.subscription_id or os.getenv("AZURE_SUBSCRIPTION_ID")
89+
if not subscription_id:
90+
print("❌ Error: --subscription-id is required or set AZURE_SUBSCRIPTION_ID")
91+
return 1
92+
93+
# Non-secret frontend config from the centralized config surface. The
94+
# frontend has no secrets; API_UPSTREAM_URL / PORT are the whole surface.
95+
environment_vars = frontend_deploy_env_vars(args.environment)
96+
if not environment_vars.get("API_UPSTREAM_URL"):
97+
print(
98+
f"❌ Error: no [frontend].api_upstream_url for environment "
99+
f"{args.environment!r} in config/environments/{args.environment}.toml"
100+
)
101+
return 1
102+
103+
print("=" * 80)
104+
print("Azure Container Apps Deployment — FRONTEND")
105+
print("=" * 80)
106+
print(f"Resource Group: {args.resource_group}")
107+
print(f"Container App: {args.container_app_name}")
108+
print(f"Image: {args.image}")
109+
print(f"Environment: {args.environment}")
110+
print("Applying frontend config from the config surface:")
111+
for key, value in environment_vars.items():
112+
print(f" {key}={value}")
113+
print("=" * 80)
114+
115+
try:
116+
config = AzureDeploymentConfig.from_dict(
117+
{
118+
"provider": "azure",
119+
"environment": args.environment,
120+
"region": args.region,
121+
"service": {
122+
"name": args.container_app_name,
123+
"image": args.image,
124+
"memory": args.memory,
125+
"cpu": args.cpu,
126+
"min_instances": args.min_instances,
127+
"max_instances": args.max_instances,
128+
"environment_vars": environment_vars,
129+
},
130+
"providers": {
131+
"azure": {
132+
"resource_group": args.resource_group,
133+
"subscription_id": subscription_id,
134+
}
135+
},
136+
}
137+
)
138+
139+
deployer = AzureContainerAppsDeployer(config)
140+
141+
print("\n🚀 Starting frontend deployment...")
142+
service_url = deployer.deploy()
143+
144+
print("\n" + "=" * 80)
145+
print("✅ Frontend Deployment Successful!")
146+
print("=" * 80)
147+
print(f"Service URL: {service_url}")
148+
print("=" * 80)
149+
return 0
150+
151+
except DeploymentError as e:
152+
print(f"\n❌ Deployment failed: {e}")
153+
return 1
154+
except Exception as e:
155+
print(f"\n❌ Unexpected error: {e}")
156+
import traceback
157+
158+
traceback.print_exc()
159+
return 1
160+
161+
162+
if __name__ == "__main__":
163+
sys.exit(main())

frontend/.dockerignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
node_modules
2+
dist
3+
.git
4+
.gitignore
5+
*.log
6+
coverage
7+
playwright-report
8+
test-results
9+
.vite
10+
Dockerfile
11+
.dockerignore

frontend/Dockerfile

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# syntax=docker/dockerfile:1
2+
3+
# ---- Build stage: compile the SPA to static assets ----
4+
FROM node:22-alpine AS build
5+
WORKDIR /app
6+
7+
# Install deps against the committed lockfile (reproducible, matches CI).
8+
COPY package.json package-lock.json ./
9+
RUN npm ci
10+
11+
# Build (tsc -b && vite build) → /app/dist
12+
COPY . .
13+
RUN npm run build
14+
15+
# ---- Runtime stage: nginx serving the SPA + proxying /v1 to the backend ----
16+
FROM nginx:1.27-alpine AS runtime
17+
18+
# The base image runs envsubst over templates at startup, rendering this into
19+
# /etc/nginx/conf.d/default.conf with the host-specific values below.
20+
COPY deploy/nginx.conf.template /etc/nginx/templates/default.conf.template
21+
COPY --from=build /app/dist /usr/share/nginx/html
22+
23+
# Host-specific config — injected at deploy time from the centralized config
24+
# surface (config/environments/<env>.toml [frontend]); NOT baked into the image.
25+
# API_UPSTREAM_URL has no default on purpose: an unset value fails nginx startup
26+
# loudly rather than silently proxying nowhere.
27+
ENV API_UPSTREAM_URL="" \
28+
PORT=8080
29+
30+
EXPOSE 8080
31+
32+
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
33+
CMD wget -qO- "http://localhost:${PORT}/healthz" >/dev/null 2>&1 || exit 1
34+
35+
# Base image ENTRYPOINT renders templates (envsubst) then starts nginx.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# nginx site config for the OHM frontend container.
2+
#
3+
# Rendered at container start: the base nginx image runs envsubst over
4+
# /etc/nginx/templates/*.template, substituting ONLY environment variables that
5+
# are set (so nginx's own $uri / $host / $proxy_host are left untouched).
6+
#
7+
# Host-specific values (${API_UPSTREAM_URL}, ${PORT}) are injected from the
8+
# centralized config surface at deploy time — never hardcoded here.
9+
#
10+
# API_UPSTREAM_URL backend ORIGIN, scheme + host, NO trailing slash and NO
11+
# /v1 path (e.g. https://api.example.com). The /v1 prefix is
12+
# preserved by proxy_pass because it carries no URI part.
13+
# PORT port nginx listens on (default 8080).
14+
15+
server {
16+
listen ${PORT};
17+
listen [::]:${PORT};
18+
server_name _;
19+
20+
root /usr/share/nginx/html;
21+
index index.html;
22+
23+
# Long-cache fingerprinted build assets; never cache the HTML entry point.
24+
location /assets/ {
25+
expires 1y;
26+
add_header Cache-Control "public, immutable";
27+
try_files $uri =404;
28+
}
29+
30+
# SPA: serve the file if it exists, else hand off to client-side routing.
31+
location / {
32+
try_files $uri $uri/ /index.html;
33+
}
34+
35+
# Versioned backend API, proxied so the browser sees ONE origin (no CORS).
36+
# The backend stays an independently-hosted service reached only via its API.
37+
location /v1/ {
38+
proxy_pass ${API_UPSTREAM_URL};
39+
proxy_http_version 1.1;
40+
proxy_ssl_server_name on;
41+
proxy_set_header Host $proxy_host;
42+
proxy_set_header X-Real-IP $remote_addr;
43+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
44+
proxy_set_header X-Forwarded-Proto $scheme;
45+
proxy_read_timeout 120s;
46+
}
47+
48+
# Container-platform liveness probe. Does not touch the backend.
49+
location = /healthz {
50+
access_log off;
51+
add_header Content-Type text/plain;
52+
return 200 "ok\n";
53+
}
54+
}

0 commit comments

Comments
 (0)