| # AIOS web β the React shell + FastAPI over the platform data layer. THE CANONICAL IMAGE: | |
| # this is the one that actually runs, and the one a host migration re-points (C1e). | |
| # | |
| # β WHY A SECOND DOCKERFILE EXISTS (ops/Dockerfile.api), stated here so nobody "tidies" the wrong | |
| # one away. It is NOT a duplicated service β it is the SAME service built from a DIFFERENT BUILD | |
| # CONTEXT: | |
| # * HF Spaces: the context is the Space repo root, which `deploy_web.py` uploads FLATTENED β | |
| # `api/`, `web/`, `platform/`, `requirements.txt` all at top level. Hence the paths here. | |
| # * ops/compose.yml: the context is the local repo root, where the same files live at | |
| # `aios-web/api/`, `aios-web/web/`, `platform/`. Different prefixes, same content. | |
| # β THE REAL FIX, and it is a follow-up rather than something to do the day of a first deploy: | |
| # make `deploy_web.py` upload with the REPO layout so both contexts are identical, then this file | |
| # serves both and `ops/Dockerfile.api` is deleted. Until then `ops/verify_portability.py` gates | |
| # the two against each other on every property that matters (same app object, same launcher | |
| # contract, non-root, healthchecked, no baked secrets), so they cannot drift silently. | |
| # | |
| # Two stages: node builds the React bundle, python serves it + the JSON API. Login is the app's own | |
| # branded page over a signed session cookie (wave 1 removed the HTTP Basic gate). | |
| # ---- stage 1: build the React bundle ---- | |
| FROM node:22-slim AS web | |
| WORKDIR /web | |
| # Resolve deps FRESH from the public registry (the local lockfile pinned a mirror-only lodash | |
| # 4.18.1 that isn't on npmjs.org; a fresh install gets the public 4.17.x that glide accepts). | |
| COPY web/package.json ./ | |
| RUN npm install --no-audit --no-fund --no-package-lock | |
| COPY web/ ./ | |
| RUN rm -f package-lock.json && npm run build # -> /web/dist | |
| # ---- stage 2: python API serving the bundle ---- | |
| FROM python:3.12-slim | |
| WORKDIR /app | |
| COPY requirements.txt ./ | |
| RUN pip install --no-cache-dir -r requirements.txt | |
| COPY api/ ./api/ | |
| # the reused data layer (modules/core/harness) | |
| # β COMMENT ON ITS OWN LINE. Docker does NOT support a trailing comment on an instruction: | |
| # it parsed `# the reused data layer ...` as extra SOURCE PATHS and the first build ever run | |
| # died with `failed to calculate checksum ... "/the": not found`. Only RUN tolerates it, and | |
| # only because the shell eats the `#`. Gated in ops/verify_portability.py. | |
| COPY platform/ ./platform/ | |
| # the ephemeral-HF-Job scripts the API submits BY PATH (wave 31, R10/C5) | |
| # β WITHOUT THIS LINE THE DEPLOY UPLOADS jobs/ AND THE IMAGE STILL DOES NOT HAVE IT. Found at | |
| # wave 31 close: `deploy_web.py::_jobs_files()` was fixed to SHIP the two files, `--check` proved | |
| # `jobs=2`, and the container would still have answered "the browser job's script is missing from | |
| # this deployment" β because reaching the Space REPO and reaching the IMAGE are two different | |
| # things, and only this instruction does the second. Same shape as the manifest defect one layer | |
| # up: a file the product opens BY PATH is invisible to every import-following guard we own. | |
| COPY jobs/ ./jobs/ | |
| COPY --from=web /web/dist ./web/dist | |
| # Run unprivileged β the C2 security baseline, and matched to ops/Dockerfile.api so the two images | |
| # do not differ on a property that would only surface after a migration. uid 1000 is what HF | |
| # Spaces expects. | |
| RUN useradd --create-home --uid 1000 user && chown -R user:user /app | |
| USER user | |
| ENV RI_DIR=/app/platform \ | |
| PORT=7860 \ | |
| PYTHONUNBUFFERED=1 \ | |
| AIOS_PREWARM=1 | |
| # β AIOS_INSECURE_SSL is deliberately NOT set. It disables TLS verification for every outbound | |
| # call in the process and exists for one developer laptop. ops/verify_portability.py fails if any | |
| # deploy config sets it. | |
| EXPOSE 7860 | |
| # Liveness via stdlib rather than curl β it keeps the image free of an apt layer that exists only | |
| # for the healthcheck. | |
| HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ | |
| CMD python -c "import os,urllib.request,sys; sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{os.environ.get(\"PORT\",\"7860\")}/api/health',timeout=4).status==200 else 1)" | |
| # β THE PORT COMES FROM THE ENVIRONMENT, ALWAYS. The application itself reads PORT nowhere β it is | |
| # a launcher argument β which is what makes moving hosts a config change instead of a code change. | |
| # Secrets (Odoo creds, APP_PASSWORD, AIOS_SESSION_SECRET, APP_BASE_URL) arrive as env at RUN time | |
| # and are never baked; the repo-root .dockerignore drops **/.env from the build context. | |
| CMD ["sh", "-c", "uvicorn api.main:app --host 0.0.0.0 --port ${PORT}"] | |