8000 perf(ui): pre-compile Angular UI on host and package via single-stage static container by VLukiantsev · Pull Request #740 · google/xmc · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
66c62c4
perf(ui): pre-compile Angular UI on host and package via single-stage…
VLukiantsev Sep 2, 2026
68273c3
fix(e2e): detect stale UI dist bundle and cleanup on failure
VLukiantsev Sep 2, 2026
d1bd0f7
perf(ui): ignore non-frontend files and skip redundant npm ci in UI b…
VLukiantsev Sep 2, 2026
69f2297
fix(ui): harden file filtering, error handling, and dependency tracking
VLukiantsev Sep 2, 2026
ec34dbb
fix(ui): follow symlinks when traversing UI directory in _is_ui_dist_…
VLukiantsev Sep 2, 2026
33c2ce5
fix(ui): use workspace temporary directory and verify npx availability
VLukiantsev Sep 2, 2026
851390f
refactor(ui): clean up package mtime check and deduplicate test config
VLukiantsev Sep 2, 2026
f11691a
fix(ui): revert to system tempdir and wrap os.makedirs in try-except
VLukiantsev Sep 2, 2026
a504009
fix(ui): add dist staleness check in build_ui_oci and use .package-lo…
VLukiantsev Sep 2, 2026
23206ab
fix(ui): simplify prebuilt dist presence check in build_ui_oci
VLukiantsev Sep 2, 2026
8386ffa
fix(ui): optimize fallback Dockerfile caching and invalidate node_mod…
VLukiantsev Sep 2, 2026
b4a0750
fix(ui): add cycle detection in directory walks and preserve package …
VLukiantsev Sep 2, 2026
49e0062
fix(ui): add path traversal guard in copy_flat and use .install-succe…
VLukiantsev Sep 2, 2026
ec6f9af
fix(ui): handle FileNotFoundError explicitly during dist cleanup
VLukiantsev Sep 2, 2026
cd16dd4
docs(ui): add explanatory docstrings and comments for two-tier packag…
VLukiantsev Sep 3, 2026
1717991
fix(e2e): check UI dist staleness before Node check and harden test d…
VLukiantsev Sep 3, 2026
256f539
fix(ui): filter symlinked ignored directories and handle rmtree OSError
VLukiantsev Sep 3, 2026
a968aea
test(ui): add build_ui_oci unit tests and document container packagin…
VLukiantsev Sep 3, 2026
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
41 changes: 30 additions & 11 deletions cmd/ui/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ package(default_visibility = ["//visibility:public"])
# 1. Build the OCI image tarball using the custom python builder
genrule(
name = "ui_image_tarball",
srcs = glob([
"app/**/*",
"static/**/*",
"*.ts",
"*.scss",
"*.html",
"*.json",
"*.svg",
"nginx.conf",
"generate_config.sh",
]) + [":build_ui_oci.py"],
srcs = glob(
[
"app/**/*",
"static/**/*",
"dist/**/*",
"*.ts",
"*.scss",
"*.html",
"*.json",
"*.svg",
"nginx.conf",
"generate_config.sh",
],
allow_empty = True,
) + [":build_ui_oci.py"],
outs = ["ui_image_tarball.tar"],
cmd = "python3 $(location :build_ui_oci.py) $@",
tools = [":build_ui_oci.py"],
Expand Down Expand Up @@ -111,6 +115,21 @@ py_test(
],
)

py_library(
name = "build_ui_oci_lib",
srcs = ["build_ui_oci.py"],
imports = ["."],
visibility = ["//visibility:public"],
)

py_test(
name = "build_ui_oci_test",
srcs = ["build_ui_oci_test.py"],
deps = [
":build_ui_oci_lib",
],
)

py_test(
name = "ui_unit_tests",
srcs = ["run_ui_tests.py"],
Expand Down
42 changes: 42 additions & 0 deletions cmd/ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# UI Container Packaging (`cmd/ui`)

This directory contains the packaging logic for the XMC Web UI container image (`//cmd/ui:ui_image_tarball`).

---

## Packaging Architecture (`build_ui_oci.py`)

The container build uses a two-tier strategy to optimize local and CI build times while maintaining hermetic reproducibility:

```mermaid
graph TD
A["build_ui_oci.py"] --> B{"Is 'dist/browser' present<br/>and fresher than source files?"}
B -->|Yes| C["Fast Single-Stage Build<br/>(FROM nginx:alpine, ~3s)"]
B -->|No / Missing / Stale| D["Hermetic Multi-Stage Build<br/>(node:22-alpine -> nginx:alpine, ~90s)"]
```

1. **Fast Single-Stage Nginx Packaging (Tier 1)**:
- When pre-compiled Angular assets exist in `dist/browser` and are fresher than UI source files, `build_ui_oci.py` generates a single-stage `Dockerfile` (`FROM nginx:alpine`) that directly copies the pre-built bundle.
- Packaging takes **~3 seconds**.
2. **Hermetic Multi-Stage Fallback (Tier 2)**:
- If `dist/browser` is absent, stale, or source files have been modified, it automatically falls back to a hermetic multi-stage build (`node:22-alpine` compiling with `npm ci` and `ng build`, copied into `nginx:alpine`).
- Requires zero host dependencies beyond Docker.

---

## Build Commands

- **Bazel Target**:
```bash
bazel build //cmd/ui:ui_image_tarball
```
- **Direct CLI Execution**:
```bash
python3 cmd/ui/build_ui_oci.py <output_tar>
```

---

## E2E Runner Host Pre-Compilation (`test/e2e/lib/image_builder.py`)

The E2E test runner accelerates test execution by running `npm ci` and `ng build` directly on the host runner (where Node.js 22 is available) before invoking Bazel, allowing `build_ui_oci.py` to consistently hit the fast single-stage Nginx path.
225 changes: 163 additions & 62 deletions cmd/ui/build_ui_oci.py
10BC8
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,31 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""Script for compiling Angular UI application into an OCI container tarball."""
"""Script for compiling Angular UI application into an OCI container tarball.

This script implements a two-tier container build strategy for the Angular UI:

1. Tier 1 - Fast Path (Pre-compiled bundle exists):
When `test/e2e/lib/image_builder.py` pre-compiles the Angular bundle on the
host before Bazel runs, `cmd/ui/dist/browser/index.html` is present in the
source directory. The builder skips Node.js compilation entirely and packages
the static assets directly into a minimal `nginx:alpine` image in 2-3 seconds.

2. Tier 2 - Fallback Path (Containerized compilation):
When no pre-compiled bundle is available (e.g. host lacks Node.js/npm or direct
`bazel build //cmd/ui:ui_image_tarball` is invoked), the builder falls back to
a self-contained multi-stage Docker build (`node:22-alpine` builder stage ->
`nginx:alpine` runtime stage). Package files are copied first to leverage
Docker layer caching across source-only edits.
"""

import contextlib
import hashlib
import os
import platform
import shutil
import subprocess
import sys
import tempfile


def get_sha256(filepath: str) -> str:
Expand Down Expand Up @@ -54,34 +70,88 @@ def translate_imports(content: str) -> str:


def copy_flat(src_dir: str, dest_dir: str):
"""Copies files resolving symlinks and translates legacy path imports."""
"""Copies UI source files resolving symlinks and translating legacy imports.

Bazel sandboxes and monorepos often rely heavily on symlinks. When building
in Docker, symlinks pointing outside the build context fail. This function
recursively traverses `src_dir`, dereferences symlinks into flat physical files
in `dest_dir`, and rewrites Google-internal/monorepo import paths into standard
npm package names required by Angular CLI.

Defensive safeguards:
- Symlink cycles: Tracks canonical real paths (`os.path.realpath`) in `visited`
to prevent infinite recursion.
- Path traversal: Ensures relative paths do not escape `dest_dir` (`..` or absolute).
- Build artifacts: Skips transient and non-source directories (`node_modules`,
`dist`, `.angular`, `.git`, `BUILD.bazel`, python scripts).
"""
if os.path.exists(dest_dir):
shutil.rmtree(dest_dir)
os.makedirs(dest_dir, exist_ok=True)

for root, _, files in os.walk(src_dir, followlinks=True):
if "build_tmp" in root:
ignored_dirs = {"build_tmp", "node_modules", ".angular", "dist", ".git", "__pycache__"}
ignored_files = {"BUILD.bazel", "BUILD"}
visited = set()
for root, dirs, files in os.walk(src_dir, followlinks=True):
# Guard against symlink cycles by tracking visited real filesystem paths.
real_root = os.path.realpath(root)
if real_root in visited:
dirs[:] = []
continue
visited.add(real_root)
filtered_dirs = []
for d in dirs:
if d in ignored_dirs:
continue
real_dir_path = os.path.realpath(os.path.join(root, d))
if any(ignored in real_dir_path.split(os.sep) for ignored in ignored_dirs):
continue
filtered_dirs.append(d)
dirs[:] = filtered_dirs
for file in files:
if file.endswith(".py") or file in ignored_files:
continue
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, src_dir)
# Guard against path traversal if symlinks point outside src_dir.
if rel_path.startswith("..") or os.path.isabs(rel_path):
continue
dest_path = os.path.join(dest_dir, rel_path)
Comment thread
VLukiantsev marked this conversation as resolved.
Resolved
os.makedirs(os.path.dirname(dest_path), exist_ok=True)

if file.endswith((".scss", ".ts", ".html")):
with open(full_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
try:
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
if file.endswith((".scss", ".ts", ".html")):
with open(full_path, encoding="utf-8", errors="ignore") as f:
content = f.read()

translated = translate_imports(content)
translated = translate_imports(content)

with open(dest_path, "w", encoding="utf-8") as f:
f.write(translated)
else:
shutil.copy2(full_path, dest_path)
with open(dest_path, "w", encoding="utf-8") as f:
f.write(translated)
else:
shutil.copy2(full_path, dest_path)
except OSError as e:
print(f"ERROR: Failed to copy {full_path} to {dest_path}: {e}", file=sys.stderr)
raise


def main():
"""Main entrypoint for building UI container image."""
"""Main entrypoint for building UI container image.

Strategy:
1. Fast Path (Pre-compiled bundle exists):
When `test/e2e/lib/image_builder.py` has already pre-compiled the UI on
the host, `cmd/ui/dist/browser/index.html` is present. We bypass the heavy
Node.js compilation container entirely and construct a lightweight,
single-stage `nginx:alpine` image in 2-3 seconds.
2. Fallback Path (Containerized compilation):
If no pre-compiled bundle is found (e.g. host lacks Node.js, running direct
`bazel build`), we perform a self-contained multi-stage Docker build
(`node:22-alpine` -> `nginx:alpine`). Package files are copied first to
maximize Docker layer caching across source-only changes.

The resulting image is tagged as `ui-image:latest` and exported as a Docker
tarball to the specified output path for KinD or Docker loading.
"""
if len(sys.argv) < 2:
print("Usage: build_ui_oci.py <output_tar>")
sys.exit(1)
Expand All @@ -90,16 +160,52 @@ def main():
workspace_dir = os.getcwd()
ui_src_dir = os.path.join(workspace_dir, "cmd/ui")

temp_build_dir = os.path.join(ui_src_dir, "build_tmp")
print(f"Preparing flat physical copy of UI codebase at {temp_build_dir}...")
copy_flat(ui_src_dir, temp_build_dir)
# Detect if host pre-compilation occurred (index.html exists in dist/browser).
# Note: Modification time staleness checks are performed on the host side in
# `image_builder.py` prior to Bazel invocation, as Bazel sandbox normalizes mtimes.
dist_browser_src = os.path.join(ui_src_dir, "dist", "browser")
index_html = os.path.join(dist_browser_src, "index.html")
has_prebuilt_dist = os.path.isdir(dist_browser_src) and os.path.isfile(index_html)

# Use an isolated temporary directory for the Docker build context.
# This prevents polluting the repository workspace and avoids concurrent write
# conflicts during parallel Bazel actions.
with tempfile.TemporaryDirectory() as temp_build_dir:
dockerfile_path = os.path.join(temp_build_dir, "Dockerfile")

dockerfile_path = os.path.join(temp_build_dir, "Dockerfile")
with open(dockerfile_path, "w") as f:
f.write("""FROM node:22-alpine AS builder
if has_prebuilt_dist:
# Fast Path: Static assets are already compiled. Copy dist/browser and Nginx config
# into a lean single-stage container. Build time: ~2-3s.
print(
"Found pre-compiled Angular UI bundle. Using fast single-stage Nginx container"
" build..."
)
dest_dist = os.path.join(temp_build_dir, "dist", "browser")
shutil.copytree(dist_browser_src, dest_dist)
shutil.copy2(os.path.join(ui_src_dir, "nginx.conf"), temp_build_dir)
shutil.copy2(os.path.join(ui_src_dir, "generate_config.sh"), temp_build_dir)
with open(dockerfile_path, "w") as f:
f.write("""FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY dist/browser /usr/share/nginx/html
RUN chmod -R o+rX /usr/share/nginx/html
COPY generate_config.sh /docker-entrypoint.d/generate_config.sh
RUN chmod +x /docker-entrypoint.d/generate_config.sh
EXPOSE 8080
CMD ["nginx", "-g", "daemon off;"]
""")
else:
# Fallback Path: No pre-compiled bundle available. Run full multi-stage Docker build.
# Copy package.json first so Docker can cache npm dependencies across source code edits.
print(f"Preparing flat physical copy of UI codebase at {temp_build_dir}...")
copy_flat(ui_src_dir, temp_build_dir)
with open(dockerfile_path, "w") as f:
f.write("""FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
COPY . .
RUN npm ci --legacy-peer-deps && npx ng build --configuration production
RUN npx ng build --configuration production

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
Expand All @@ -111,47 +217,42 @@ def main():
CMD ["nginx", "-g", "daemon off;"]
""")

tag = "ui-image:latest"
print("Building Docker OCI frontend image...")
build_cmd = ["docker", "build", "--no-cache"]
target_platform = os.environ.get("XMC_UI_PLATFORM")
if not target_platform and platform.machine().lower() in (
"arm64",
"aarch64",
):
print(
"INFO: Building on an ARM host without XMC_UI_PLATFORM set. "
"Defaulting target platform to 'linux/amd64' for deployment"
" compatibility. To build for a different platform, set"
" XMC_UI_PLATFORM (e.g. XMC_UI_PLATFORM=linux/arm64)."
)
target_platform = "linux/amd64"

if target_platform:
build_cmd.extend(["--platform", target_platform])

build_cmd.extend(["-t", tag, temp_build_dir])

try:
subprocess.run(build_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Docker build failed: {e}")
with contextlib.suppress(Exception):
shutil.rmtree(temp_build_dir)
sys.exit(1)
tag = "ui-image:latest"
print("Building Docker OCI frontend image...")
build_cmd = ["docker", "build"]

print(f"Saving Docker image to {output_tar}...")
save_cmd = ["docker", "save", "-o", output_tar, tag]
try:
subprocess.run(save_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Docker save failed: {e}")
sys.exit(1)
# Support cross-architecture building (e.g. ARM developer Mac targeting amd64 cluster).
target_platform = os.environ.get("XMC_UI_PLATFORM")
if not target_platform and platform.machine().lower() in (
"arm64",
"aarch64",
):
print(
"INFO: Building on an ARM host without XMC_UI_PLATFORM set. "
"Defaulting target platform to 'linux/amd64' for deployment"
" compatibility. To build for a different platform, set"
" XMC_UI_PLATFORM (e.g. XMC_UI_PLATFORM=linux/arm64)."
)
target_platform = "linux/amd64"

if target_platform:
build_cmd.extend(["--platform", target_platform])

build_cmd.extend(["-t", tag, temp_build_dir])

try:
subprocess.run(build_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Docker build failed: {e}")
sys.exit(1)

try:
shutil.rmtree(temp_build_dir)
except Exception as e:
print(f"Warning: Cleanup of {temp_build_dir} failed: {e}")
print(f"Saving Docker image to {output_tar}...")
save_cmd = ["docker", "save", "-o", output_tar, tag]
try:
subprocess.run(save_cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Docker save failed: {e}")
sys.exit(1)

print(f"Successfully built loadable OCI UI tarball: {output_tar}")

Expand Down
Loading
Loading
0