Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
*.sh text eol=lf
docker-entrypoint.sh text eol=lf

# Preserve the exact bytes covered by dashboard build manifests.
frontend/dist/** -text
www/** -text
15 changes: 12 additions & 3 deletions Dockerfile.overlay
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
# check=skip=InvalidDefaultArgInFrom
# Supply an approved ContextCortex release image, preferably pinned by digest.
ARG RUNTIME_IMAGE
FROM ${RUNTIME_IMAGE} AS dashboard-check
WORKDIR /dashboard-check
COPY scripts/frontend_artifact.py ./scripts/frontend_artifact.py
COPY frontend/ ./frontend/
COPY www/ ./www/
# Never attest the checked-in assets here: only a successful fresh build may do so.
RUN python scripts/frontend_artifact.py verify

FROM ${RUNTIME_IMAGE}
WORKDIR /app

Expand All @@ -14,9 +22,10 @@ ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \

COPY app/ ./app/
COPY main.py ./main.py
# The server prefers frontend/dist over www; keep both on this checkout's UI.
COPY www/ ./www/
COPY www/ ./frontend/dist/
# Remove base-image leftovers; the server prefers frontend/dist over www.
RUN rm -rf /app/www /app/frontend/dist
COPY --from=dashboard-check /dashboard-check/www/ ./www/
COPY --from=dashboard-check /dashboard-check/www/ ./frontend/dist/
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh

Expand Down
13 changes: 12 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`.

**Test Verification Baseline:** **1115 Automated Tests** (757 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E).
**Test Verification Baseline:** **1124 Automated Tests** (766 Pytest Backend + 308 Vitest Frontend + 50 Playwright E2E).

---

Expand Down Expand Up @@ -1034,6 +1034,17 @@ Asserts both Markdown and PDF docs are matched under doc_type='doc'._
- `test_set_file_settings_persists`
- `test_file_settings_api_endpoints`

#### `tests/test_frontend_artifact.py` (9 tests)
- `test_fresh_build_syncs_exact_output_and_removes_obsolete_assets`
- `test_unverified_bundle_cannot_be_stamped_or_synced`
- `test_source_or_output_changes_reject_previously_built_bundle`
- `test_deleted_asset_is_rejected`
- `test_source_line_endings_do_not_invalidate_transferred_artifacts`
- `test_failed_build_cannot_reuse_old_output_or_publish_manifest`
- `test_source_change_during_build_cannot_be_attested`
- `test_overlay_build_rejects_unverified_dashboard`
- `test_overlay_installs_verified_output_in_both_served_directories`

#### `tests/test_git_incremental.py` (6 tests)
- `test_compute_git_repo_delta`
- `test_compute_git_repo_delta_custom_extensions`
Expand Down
53 changes: 49 additions & 4 deletions docs/guide/enterprise-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,49 @@ credential-bearing proxy URLs in the image.
## Build an overlay without package-registry access

Use this when you already have an approved ContextCortex release image with
compatible dependencies and want to build these integration changes locally:
compatible dependencies and want to build these integration changes locally.
The overlay itself needs no npm access, but it **requires a dashboard built from
the matching frontend inputs**. It fails closed for missing provenance, changed
source/configuration/lockfiles, or missing, modified, or extra output files.
The historical checked-in bundle has no such provenance and is rejected.

On an approved build host with Python 3.11+, Node 22, and the locked frontend
dependencies installed, publish the dashboard from the checkout root:

```powershell
# Only when dependencies are not already installed; use your approved npm config.
npm ci --prefix frontend
python scripts\frontend_artifact.py build
python scripts\frontend_artifact.py sync
python scripts\frontend_artifact.py verify
```

`build` deletes old `frontend/dist`, runs the existing `npm run build`, and writes
`build-manifest.json` only after successful compilation with unchanged inputs.
`sync` verifies that output and replaces `www` exactly, removing obsolete assets.
There is deliberately no command to stamp an existing bundle. Plain `npm run build`
still supports local development, but does not publish an overlay artifact.
Review and commit the regenerated `frontend/dist` and `www` assets, including
their manifests, together with the frontend change. Transfer the matching checkout
and generated assets to an offline overlay builder if needed.

The manifest detects accidental staleness and output corruption; it is not a
signature or a substitute for trusted build provenance. It hashes frontend root
files and `src`, `public`, `packages`, and `scripts`, excluding npm credentials,
logs, and TypeScript build caches. Keep frontend build inputs in these locations.
UTF-8 source line endings are normalized for Windows/Linux checkouts; output
hashes cover exact bytes, preserved by `.gitattributes` for generated assets.
Frontend `.env` inputs excluded by `.dockerignore` cannot be verified in the
overlay; do not use them for a published overlay build. Root runtime `.env` is
unaffected.

If the approved mirror lacks an exact locked dependency (for example
`@antfu/install-pkg@2.1.0`), arrange mirror approval/import before building.
Do not downgrade dependencies, bypass the mirror, disable TLS/integrity checks,
or create a manifest for the old bundle. Until a genuine build is available,
the overlay is intentionally unavailable rather than serving a pre-auth dashboard.

Then build the overlay:

```powershell
docker build --file Dockerfile.overlay --tag contextcortex-enterprise:local `
Expand All @@ -178,9 +220,12 @@ docker build --file Dockerfile.overlay --tag contextcortex-enterprise:local `
`RUNTIME_IMAGE` is intentionally required; there is no implicit mutable `latest`
fallback. Use a release image matching this checkout's requirements and verify
compatibility. Review/scan/promote the base image according to enterprise policy.
This build uses the checked-in dashboard bundle, not a new npm build, and does
not upgrade dependencies. Dependency upgrades require a new approved base/source
build. Private CAs and the runtime entrypoint are included.
This build verifies the published dashboard before copying the same exact files
into both `/app/www` and the server-preferred `/app/frontend/dist`, removing any
base-image leftovers. It does not run npm or upgrade dependencies. Dependency
upgrades require a new approved base/source build. Private CAs and the runtime
entrypoint are included. The full source `Dockerfile` continues to compile the
frontend directly in its build stage instead of consuming `www`.

## Run the built image

Expand Down
114 changes: 114 additions & 0 deletions scripts/frontend_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Build, synchronize, and verify the dashboard shipped by image overlays."""

import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys


MANIFEST = "build-manifest.json"


def inventory(directory: Path, *, sources: bool = False) -> dict[str, str]:
if sources:
paths = [path for path in directory.iterdir() if path.is_file()]
for name in ("src", "public", "packages", "scripts"):
paths.extend((directory / name).rglob("*"))
paths = [
path for path in paths
if path.name != ".npmrc"
and not path.name.endswith((".tsbuildinfo", ".log"))
]
else:
paths = [path for path in directory.rglob("*") if path != directory / MANIFEST]
result = {}
for path in sorted(paths):
if not path.is_file():
continue
content = path.read_bytes()
if sources:
try:
content.decode("utf-8")
except UnicodeDecodeError:
pass
else:
content = content.replace(b"\r\n", b"\n")
result[path.relative_to(directory).as_posix()] = hashlib.sha256(content).hexdigest()
return result


def verify(root: Path, bundle: Path) -> None:
manifest_path = bundle / MANIFEST
if not manifest_path.is_file():
raise ValueError(
"Unverified dashboard: missing build-manifest.json. "
"Run python scripts/frontend_artifact.py build, then sync; "
"do not stamp an existing bundle."
)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if not isinstance(manifest, dict) or manifest.get("schema") != 1:
raise ValueError("Unverified dashboard: unsupported build manifest.")
if manifest.get("sources") != inventory(root / "frontend", sources=True):
raise ValueError("Stale dashboard: frontend inputs changed; rebuild and sync.")
if not (bundle / "index.html").is_file() or manifest.get("files") != inventory(bundle):
raise ValueError("Invalid dashboard: output files changed; rebuild and sync.")


def build(root: Path) -> None:
frontend = root / "frontend"
dist = frontend / "dist"
before = inventory(frontend, sources=True)
if dist.exists():
shutil.rmtree(dist)
npm = shutil.which("npm.cmd" if os.name == "nt" else "npm")
if not npm:
raise ValueError("Dashboard build requires npm and the locked frontend dependencies.")
subprocess.run([npm, "run", "build"], cwd=frontend, check=True)
if before != inventory(frontend, sources=True):
raise ValueError("Dashboard source changed during build; retry with stable inputs.")
if not (dist / "index.html").is_file():
raise ValueError("Dashboard build did not produce dist/index.html.")
manifest = {"schema": 1, "sources": before, "files": inventory(dist)}
(dist / MANIFEST).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
)
verify(root, dist)


def sync(root: Path) -> None:
dist = root / "frontend" / "dist"
verify(root, dist)
destination = root / "www"
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(dist, destination)
verify(root, destination)


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("build", "sync", "verify"))
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
args = parser.parse_args()
root = args.root.resolve()
try:
if args.command == "build":
build(root)
elif args.command == "sync":
sync(root)
else:
verify(root, root / "www")
except (OSError, ValueError, subprocess.CalledProcessError) as error:
print(f"Dashboard artifact error: {error}", file=sys.stderr)
return 1
print(f"Dashboard artifact {args.command}: OK")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading