# 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}"]