From f1b3467c0f9dfeaf0dba154ff88a2f23a87a9656 Mon Sep 17 00:00:00 2001 From: Max Natthawut Adulyanukosol <13469652+na399@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:14:07 +0700 Subject: [PATCH 1/2] Add GitHub Pages public web distribution Build and validate the Leptos/WASM application at the repository project path, deploy tested main-branch artifacts through GitHub Pages, and document the public-release, persistence, and certification boundaries. --- .github/workflows/pages.yml | 85 ++++++++++++ CHANGELOG.md | 7 + Cargo.toml | 2 +- MANIFEST.sha256 | 15 ++- Makefile | 12 +- README.md | 53 +++++--- crates/web-ui/index.html | 11 +- crates/web-ui/public.css | 40 ++++++ docs/adr/0011-github-pages-public-web.md | 37 ++++++ docs/runbooks/GITHUB_PAGES.md | 69 ++++++++++ scripts/validate_pages_dist.py | 157 +++++++++++++++++++++++ 11 files changed, 459 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100644 crates/web-ui/public.css create mode 100644 docs/adr/0011-github-pages-public-web.md create mode 100644 docs/runbooks/GITHUB_PAGES.md create mode 100644 scripts/validate_pages_dist.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..2548d08 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,85 @@ +name: GitHub Pages + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build static web distribution + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install content-validation dependencies + run: python -m pip install --disable-pip-version-check PyYAML jsonschema + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + components: rustfmt, clippy + + - name: Cache Rust build state + uses: Swatinem/rust-cache@v2 + + - name: Install pinned Trunk + run: cargo install trunk --version 0.21.14 --locked + + - name: Validate source and simulation boundary + run: | + make check + python3 scripts/static_rust_sanity.py + python3 scripts/verify_manifest.py + + - name: Set GitHub project-site base path + run: echo "PAGES_BASE_PATH=/${GITHUB_REPOSITORY#*/}/" >> "$GITHUB_ENV" + + - name: Build static Pages artifact + run: | + cargo metadata --locked --format-version 1 >/dev/null + cd crates/web-ui + trunk build --release --public-url "$PAGES_BASE_PATH" + + - name: Validate static Pages artifact + run: python3 scripts/validate_pages_dist.py crates/web-ui/dist "$PAGES_BASE_PATH" + + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + uses: actions/upload-pages-artifact@v5 + with: + path: crates/web-ui/dist + + deploy: + name: Deploy public web distribution + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: build + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index e5a0ee2..2d16c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Added GitHub Pages as the public static web distribution. +- Added a repository-path-aware Trunk build, generated-artifact validation, and deployment workflow. +- Added public independence, local-execution, and browser-storage notices. +- Documented repository setup, trust boundaries, custom-domain migration, and rollback. + ## 0.1.0-dev-pack — 2026-08-05 - Added DGX Lab PRD v1.0 and requirements traceability. diff --git a/Cargo.toml b/Cargo.toml index 28613c8..2d7484f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ version = "0.1.0" edition = "2024" license = "Apache-2.0" authors = ["DGX Lab contributors"] -repository = "https://example.invalid/dgx-lab" +repository = "https://github.com/sidataplus/DGX_Lab" rust-version = "1.88" [workspace.dependencies] diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 6440dd6..aea946e 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -2,19 +2,20 @@ aeac9b3ef812a24f379f8516078ed9fec8e9aea30d3052bc82571f4ef14d7a00 .github/dependabot.yml 949193274bb5b66db38744ae0475a272e784682bda5dcceefbe6974f67225c5c .github/pull_request_template.md c2343e3bcbca8163fc5094fa749106ac62ab7060a6cc608e2ffd59f54b81d5ac .github/workflows/ci.yml +6262dd904a8dda5b5af20dab5382326fae71d74e25157e0f055c4d4387286a43 .github/workflows/pages.yml 02d523396c0582dc194050eeec1bdd75c39dc85f8bea0f2b69cf873ce1431394 .github/workflows/release-blueprint.yml 7c0d8206830526e398531bb684dc1878a3f5c693ce1d666bd5ca6c743c847be0 .github/workflows/security-boundary.yml c6c44e5d4675a1644e3324dc17c5795d09d5055c6a5d99577d4f28e7ed055db6 .gitignore -32b6e864cb51587c020d103b93f5c104b8a0fbc7ad9c60658c757ec9f496c272 CHANGELOG.md +f88e2b610dfad2b93d81394afa44ecd8add5d633271dfa74574b07d559b4892f CHANGELOG.md 334744126cf33bc893e29a5dec5bf491e81be97978878119b53c17e531e24cd3 CONTENT-LICENSE.md 33b25f921344c2db05aa21018320342c2a2e76d100b92400b0cef1cc9f8c3a66 CONTRIBUTING.md 8b50db4f2f2594d574fda3413fa179ac5bf493989e01cc16875e963f99e12335 Cargo.lock 583559e02128faf10af9464268132289d4e189d2b76facda9fd5d49b48ae9a2d Cargo.lock.NOT_GENERATED.md -f8c44a7fa9c8b3004b5e93a31c63db994626119c25ce40f099743c12e2a476da Cargo.toml +70b4ee55c0bc6f6c560d70c0656dbfbfdd2d74315ed01aa3a545b5329dbd2d22 Cargo.toml a033dd6ee3b1aef3ba39126b6fc2c3002c266d60698f5a9db4fc8145ca2555af LICENSE -81252f4048abbf280f8b9977c67a56942d3d3970f58573970883dc10c4e7f6a3 Makefile +8716891999fd40ac315bc565012a84e372245812ad5c319f7aa397b2f346c134 Makefile bfd07db590fab27034c1f2640fe45718781e3c7a8bf6ede74b0df5cb1dab9f03 NOTICE -5b958cd6222dadaa62d816164b9eee540b4e12a8e82d4a00c62912c9761227cc README.md +8f656fec7315c089c97566a1417631b19645b7b51c97d36805b9f08ab570aaeb README.md 3dbbdf4a5a3ba3b6de3da1f673368274ee00982b7beae5912297e5e0b06aae6c SECURITY.md eadf2458f40e72f62a6080f0e846aada57efea8fa7558b03eb3cba0fa1046947 TRADEMARKS.md 5c9c112fc83af1a2f8c196a676c8f81d1a28aab1ffe9bacd0e8265a150e2e20e VERSION @@ -98,7 +99,8 @@ ef7dda1ff1f3af1332f63bfb33f2b01389c2d74b93ce9d5c93aa34f9399ef613 crates/web-ui/ 1d9506a4155489dc2d52df2e8bc0cecd39371b9f51f0db09cc3ec110596ad95d crates/web-ui/dist/style-86b3632c245ac7f3.css 3f1840337eea92ef4e86697fb59589ce28d88df04b1c435887c5249b8ee2ad1a crates/web-ui/dist/web-ui-f961d7fae81dd101.js f8986ca86a1f7aec7a1e6c6649cae9e63264bbba4d9cf742de42687a1a3edb46 crates/web-ui/dist/web-ui-f961d7fae81dd101_bg.wasm -0161ac66f9db29b1afde173fce2490fa8da279e727584b7533bd7832b40fc27f crates/web-ui/index.html +e986746ec40461a295774409ea8bb54fd6d766708834aaaf190da9e000c4e778 crates/web-ui/index.html +24bd1baa53f6c906097dfb2c924e0204f5ed13bc9dc08dc5fd1ede87a92900ae crates/web-ui/public.css 8d074f16804980915bb73756da6de904bd3bc63916685a4e0cb597cd5b3ba7c0 crates/web-ui/src/app.rs 9277c64111bff9e1924f4444d8e8a61519b38db5b27d500b7ed8020d1306ff4b crates/web-ui/src/bridge.rs 5af2a554eeddf75943aa6c2c3208f3be3c719fb84a8c70826bea65c395042d4a crates/web-ui/src/learning.rs @@ -131,6 +133,7 @@ d0324894185571d86acc5f3ebff0b8a7a0f9cd12c6c4866816359856ef6b6efc docs/adr/0007- 6e96d816955eefc39968aaa9e0b01e98dcdcd7f8c3592adede1b1a49c3b9a638 docs/adr/0008-local-certification-trust.md 77c2a84ade2af1fa3155bc0d6077555f2238a01f460b6c078562b9d200754848 docs/adr/0009-generic-dgx-profile.md b4262ba40d4414b15f2d32350236c203b6d8ba2f65ef08cb6b056dd2b2a1c7c8 docs/adr/0010-leptos-csr-and-worker-boundary.md +e168776cc57b98954e5d5ec79db423ac3c274ee58d11593f89bf3d66bea8a81b docs/adr/0011-github-pages-public-web.md 78941fa25d4db13efe0a795f5b338a409d9f8b720569af1daca575e643dd0027 docs/adr/README.md 45c18bff9bb4f434a4f8a6bd1f98ca14aa7c101f8985b5c31d1c225e4a8e7413 docs/content/COURSE_MAP.md 3efaa66539e495e8e53a58c4a1f355403aa4b73ed81935c1fdc134bdabe6b496 docs/design/MOCKUP_INDEX.md @@ -148,6 +151,7 @@ dcc83ee211acee6853acd6b46af6bba96a7f1195872da41836a6108aed44da01 docs/reference b0beb26021d265c7d5302007e8d6223f9e2b6f5ea980077bcd10135f7714e902 docs/reference/SLURM_COMMAND_MATRIX.md 48e0334673a3788f41db112988e7aad421e1b474c2f0c7b25eafc0f2fb864790 docs/runbooks/BUILD_RELEASE.md 8bba389584e3f36f4805f7fdff27d2aa6fa737dd119504fef39bae31941abe18 docs/runbooks/CONTENT_AUTHORING.md +0b63a97dabdb2db218ea66684324d74a46002d234a2bdff885a84525c8570574 docs/runbooks/GITHUB_PAGES.md a990ecfc1d38f9f1242e9f10334aa71da1b5994ae2f79f467e3347448a566ad5 docs/runbooks/LOCAL_DEVELOPMENT.md 603ddd2430b5d6d62b47eae86672ca1a01ae2c9d739850363c6f69c4e81bbf57 docs/runbooks/SECURITY_REVIEW.md 3dc23d49b57c3ba4707aa41603f62e6f719d059bd25184098d4aeb86170d2c1c prototype/README.md @@ -178,6 +182,7 @@ a9dafa68b579d2b57bc7328a9c9c9d4ab42cd0d0b67e2b25f89766c3356f6497 scripts/genera ae68d98a2ff824e173a691e87817894c5c3fb91b20caa52c1fbeabc143a6deed scripts/run_static_checks.sh 34a6b2bcd10fc232c3f45378cbeec898ebbb4f3cc0a5d67b18ffb7e9a2e73f14 scripts/static_rust_sanity.py 4f5ca1e0af1685ea961f841d003139822d326fd73f6a0889bac1b2f213fc82e6 scripts/validate_all.py +327a088d5e952418db42517d2510649a1c99a03e2ac92097af9eea043474768e scripts/validate_pages_dist.py ab7f73ff37df0ef8c6233b87bf3bea041c266bae8bd7e3ddd9dbb97f298674ef scripts/verify_manifest.py b3d82a8e468e482bc507c6fdb90c136dcecefb5e7a81267059faa20eb1532a4d src-tauri/Cargo.toml 30e44cbd5a7e4c0378934d1f6f173b416fdcbf5ff74e54d228e2ae2dfa918913 src-tauri/README.md diff --git a/Makefile b/Makefile index 37a60f7..bcc7b3d 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: validate check test web tauri prototype clean +.PHONY: validate check test web web-release web-pages tauri prototype clean # Prefer real toolchain binaries if ~/.cargo/bin rustup proxies are broken: # export PATH="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$HOME/.cargo/bin:$PATH" PYTHON ?= python3 +PAGES_BASE ?= /DGX_Lab/ validate: $(PYTHON) scripts/validate_all.py @@ -21,6 +22,15 @@ test: web: cd crates/web-ui && trunk serve --port 1420 --address 127.0.0.1 +web-release: + cargo metadata --locked --format-version 1 >/dev/null + cd crates/web-ui && trunk build --release + +web-pages: + cargo metadata --locked --format-version 1 >/dev/null + cd crates/web-ui && trunk build --release --public-url "$(PAGES_BASE)" + $(PYTHON) scripts/validate_pages_dist.py crates/web-ui/dist "$(PAGES_BASE)" + # Desktop app: Tauri shell + Leptos CSR UI (Trunk on port 1420). tauri: cargo tauri dev diff --git a/README.md b/README.md index 72dda8a..fc8b0f7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Interactive SLURM Training Simulator** -DGX Lab is a standalone, deterministic desktop simulation environment for learning SLURM and shared GPU-computing workflows. It presents a generic DGX-scale cluster, simulated concurrent users, synthetic AI workloads, failure scenarios, guided labs, and local certification. It is deliberately incapable of connecting to a real scheduler. +DGX Lab is a standalone, deterministic simulation environment for learning SLURM and shared GPU-computing workflows. It presents a generic DGX-scale cluster, simulated concurrent users, synthetic AI workloads, failure scenarios, guided labs, and local certification. It is deliberately incapable of connecting to a real scheduler. ## Learning experience @@ -22,7 +22,7 @@ Every module follows a consistent **Learn → Practice → Assess** rhythm. Lear This repository accompanies **DGX Lab PRD v1.0** and provides: -- a Tauri 2 + Leptos/Rust/WASM application workspace; +- a Tauri 2 desktop shell and static GitHub Pages web distribution over one Leptos/Rust/WASM application; - a responsive Learn, Practice, and Assess interface; - a deterministic simulation core with constrained virtual scheduler, shell, and filesystem; - state-backed practical grading and an offline knowledge assessment; @@ -45,17 +45,30 @@ This repository accompanies **DGX Lab PRD v1.0** and provides: | Practical grading and knowledge scoring | State-backed and tested | | Scenario compiler and report renderer | Implemented | | Leptos CSR UI | Responsive release build produced | +| GitHub Pages distribution | Path-aware build and deployment workflow implemented | | WASM worker API | Native/WASM boundary verified | | Tauri 2 shell | Minimal, no real-system commands | | Native/WASM compilation evidence | Verified in the current workspace | | Cargo lockfile | Resolved and committed | | Signed installers | Deferred to a signing/notarization release lane | -The current release has native Rust test evidence, a verified WebAssembly build, strict linting, browser QA at desktop and phone widths, validated course content, 241 requirement links, and a checksum-verified course pack. The browser build remains offline and never executes learner commands on the host. +The current release has native Rust test evidence, a verified WebAssembly build, strict linting, browser QA at desktop and phone widths, validated course content, 241 requirement links, and a checksum-verified course pack. The browser build runs entirely client-side and never executes learner commands on the host. + +## Public web distribution + +The canonical web edition is built from `crates/web-ui` and deployed from `main` by `.github/workflows/pages.yml`. GitHub Pages receives a generated static artifact rather than the checked-in `dist/` reference snapshot. The workflow derives the repository project path, rebuilds the Leptos/WASM application, and rejects broken or root-hosted asset references before deployment. + +Reproduce the project-site build locally with: + +```bash +make web-pages PAGES_BASE=/DGX_Lab/ +``` + +A repository administrator must select **GitHub Actions** as the Pages source before the first production deployment. See `docs/runbooks/GITHUB_PAGES.md` for setup, trust boundaries, custom-domain migration, and rollback. ## Fastest way to inspect the product -Serve the checked-in release build locally: +Serve the checked-in reference release build locally: ```bash cd crates/web-ui/dist @@ -88,22 +101,22 @@ See `docs/runbooks/LOCAL_DEVELOPMENT.md` for OS prerequisites and the build orde ## Architectural boundary ```text -Tauri 2 desktop shell - │ - ▼ -Leptos CSR interface (WASM) - │ - ▼ -Rust simulation worker (WASM) - │ - ├── virtual scheduler - ├── virtual users - ├── virtual shell/filesystem - ├── synthetic workloads - ├── grading and assessment - └── deterministic event replay - -NO SSH · NO SHELL · NO REAL SLURM · NO EXTERNAL NETWORK +GitHub Pages static host or Tauri 2 desktop shell + │ + ▼ + Leptos CSR interface (WASM) + │ + ▼ + Rust simulation worker (WASM) + │ + ├── virtual scheduler + ├── virtual users + ├── virtual shell/filesystem + ├── synthetic workloads + ├── grading and assessment + └── deterministic event replay + +NO SSH · NO SHELL · NO REAL SLURM · NO RUNTIME EXTERNAL NETWORK ``` The default cluster profile generalizes an eight-H200, 224-logical-CPU, cgroup-isolated Slurm environment into non-institutional names and paths. Production hostnames, IP addresses, credentials, and operational paths are intentionally absent. diff --git a/crates/web-ui/index.html b/crates/web-ui/index.html index 93a97eb..9851245 100644 --- a/crates/web-ui/index.html +++ b/crates/web-ui/index.html @@ -3,10 +3,17 @@ - + + + DGX Lab - + + + diff --git a/crates/web-ui/public.css b/crates/web-ui/public.css new file mode 100644 index 0000000..0b40784 --- /dev/null +++ b/crates/web-ui/public.css @@ -0,0 +1,40 @@ +body { + display: flex; + flex-direction: column; +} + +body > .app-shell { + order: 1; + flex: 1 0 auto; +} + +.public-release-footer { + order: 2; + width: 100%; + padding: 1rem max(1rem, calc((100vw - 90rem) / 2 + 1.5rem)) 1.5rem; + border-top: 1px solid #294143; + background: #071214; + color: #a8bab4; + font-size: 0.78rem; + line-height: 1.5; + text-align: center; +} + +.public-release-footer p { + max-width: 72rem; + margin: 0.2rem auto; +} + +.public-release-footer strong { + color: #eef7f3; +} + +body:has(> .app-shell.theme-light) > .public-release-footer { + border-top-color: #c5d2cc; + background: #f1eee6; + color: #556862; +} + +body:has(> .app-shell.theme-light) > .public-release-footer strong { + color: #182521; +} diff --git a/docs/adr/0011-github-pages-public-web.md b/docs/adr/0011-github-pages-public-web.md new file mode 100644 index 0000000..297b801 --- /dev/null +++ b/docs/adr/0011-github-pages-public-web.md @@ -0,0 +1,37 @@ +# ADR 0011: Publish the web edition through GitHub Pages + +- **Status:** Accepted +- **Date:** 2026-08-29 + +## Context + +DGX Lab already compiles its Leptos client-rendered interface and deterministic Rust simulation runtime into a static HTML, CSS, JavaScript, and WebAssembly artifact. The application does not require an API, database, real shell, scheduler connection, or external network service after its assets load. + +The Tauri shell remains useful for desktop distribution, but requiring installation creates needless friction for public, self-paced training. A separate JavaScript implementation would duplicate the simulator and invite behavioral drift. + +GitHub project Pages hosts the repository beneath a repository-name path rather than at the domain root. The existing Trunk build uses root-relative asset URLs, so the public path must be supplied during the Pages build. + +## Decision + +1. Publish the browser edition as a public GitHub Pages project site. +2. Build the Pages artifact from `crates/web-ui` on every push to `main`; do not publish the checked-in `dist/` snapshot directly. +3. Compile with a repository-derived public base path and validate every generated asset reference before upload. +4. Run the existing source, test, and forbidden-capability checks in the deployment workflow. +5. Keep Tauri as the desktop distribution over the same Rust simulation source. +6. Display the independent-product disclaimer and browser-local persistence notice in the static entry point. +7. Treat the in-browser assessment as educational readiness evidence, not a tamper-resistant credential. + +## Consequences + +- The web and desktop editions retain one simulation implementation and one content model. +- Public hosting requires no runtime server or operational credentials. +- Learner progress remains origin-scoped browser storage and does not synchronize across browsers or devices. +- Moving to a custom domain creates a new storage origin unless progress is explicitly exported and imported. +- Authenticated learners, protected question banks, centralized completion records, or authoritative certificates would require a separate service and a new architectural decision. +- Repository administrators must select **GitHub Actions** as the Pages source once before the first production deployment. + +## Rejected alternatives + +- **Rewrite the simulator in JavaScript:** unnecessary duplication with a high drift risk. +- **Publish checked-in build output:** permits stale or untested artifacts to become production. +- **Add a backend solely for hosting:** adds state, credentials, maintenance, and failure modes without supporting a current requirement. diff --git a/docs/runbooks/GITHUB_PAGES.md b/docs/runbooks/GITHUB_PAGES.md new file mode 100644 index 0000000..48ddf4d --- /dev/null +++ b/docs/runbooks/GITHUB_PAGES.md @@ -0,0 +1,69 @@ +# GitHub Pages public web distribution + +## Scope + +The public web edition is the release build from `crates/web-ui`. GitHub Pages serves the generated HTML, CSS, JavaScript, and WebAssembly files. The deterministic simulator, virtual shell and filesystem, course state, grading, and assessment all execute in the browser. + +There is no production API, database, real scheduler connection, telemetry service, or host command execution. + +## One-time repository setup + +After this change is merged, a repository administrator must complete the GitHub Pages setup once: + +1. Open **Settings → Pages**. +2. Under **Build and deployment**, select **GitHub Actions** as the source. +3. Confirm that the `github-pages` environment permits deployment from `main`. +4. Treat the deployed site as public even if the source repository remains private. + +The workflow cannot safely assume that repository visibility restricts the published site. + +## Build and deployment behavior + +`.github/workflows/pages.yml` has two paths: + +- Pull requests targeting `main` run the complete static build and artifact validation but do not upload or deploy. +- Pushes to `main` and manual runs on `main` build, validate, upload, and deploy the Pages artifact. + +The workflow derives the default project-site path from `GITHUB_REPOSITORY`. For this repository, the expected base path is `/DGX_Lab/`. `scripts/validate_pages_dist.py` rejects root-hosted asset URLs, missing files, external entry-point URLs, symlinks, oversized artifacts, and missing public-release notices. + +## Local reproduction + +Install the repository's normal Rust/WASM prerequisites and pinned Trunk version, then run: + +```bash +make web-pages PAGES_BASE=/DGX_Lab/ +``` + +For ordinary root-hosted local inspection: + +```bash +make web-release +cd crates/web-ui/dist +python3 -m http.server 1421 --bind 127.0.0.1 +``` + +A project-path build can be exercised locally by serving it beneath the same path: + +```bash +rm -rf /tmp/dgxlab-pages +mkdir -p /tmp/dgxlab-pages/DGX_Lab +cp -R crates/web-ui/dist/. /tmp/dgxlab-pages/DGX_Lab/ +python3 -m http.server 1421 --bind 127.0.0.1 --directory /tmp/dgxlab-pages +``` + +Then open `/DGX_Lab/` on the local server. + +## Trust and persistence boundary + +- Simulation commands never leave the local runtime and never reach a host shell or real scheduler. +- Web-edition progress is stored in browser-local storage for the current origin. +- Clearing site data, changing browsers, changing devices, or moving to a custom domain produces a separate learner state. +- Course content, answer keys, scoring logic, and thresholds are delivered to the browser and are therefore inspectable. The assessment is suitable for learning feedback and local readiness evidence, not secure certification. + +## Custom-domain migration + +The initial workflow deliberately targets the default GitHub project-site path. Before enabling a custom domain, change the build base to `/`, validate the resulting artifact, and communicate the browser-storage origin change. Do not serve one build artifact interchangeably at both base paths. + +## Rollback + +Revert the offending commit on `main`. The next successful workflow run replaces the deployed artifact. If a new build fails validation, the deploy job does not run and the last successful Pages deployment remains in place. diff --git a/scripts/validate_pages_dist.py b/scripts/validate_pages_dist.py new file mode 100644 index 0000000..ae6fc77 --- /dev/null +++ b/scripts/validate_pages_dist.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Validate the generated static distribution before GitHub Pages publication.""" +from __future__ import annotations + +import argparse +import re +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + +MAX_ARTIFACT_BYTES = 1_000_000_000 +REQUIRED_SUFFIXES = {".css", ".js", ".wasm"} +DISCLAIMER = ( + "DGX Lab is not affiliated with, sponsored by, or endorsed by " + "NVIDIA Corporation or SchedMD LLC." +) +LOCAL_PROGRESS_NOTICE = "Web-edition progress is stored only in this browser." +QUOTED_ROOT_URL = re.compile(r"""(?P["'])(?P/[^"'<>\\\s]+)(?P=quote)""") + + +class AssetReferenceParser(HTMLParser): + """Collect browser-loaded URLs and reject a base tag.""" + + def __init__(self) -> None: + super().__init__() + self.urls: set[str] = set() + self.has_base_tag = False + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "base": + self.has_base_tag = True + for name, value in attrs: + if name in {"href", "src"} and value: + self.urls.add(value) + + +def normalize_base(raw: str) -> str: + base = raw.strip() + if not base.startswith("/") or not base.endswith("/"): + raise ValueError("expected base path must start and end with '/'") + if "//" in base: + raise ValueError("expected base path must not contain an empty path segment") + if any(part in {".", ".."} for part in Path(base).parts): + raise ValueError("expected base path must not contain '.' or '..'") + return base + + +def local_target(url: str, base: str, root: Path) -> Path | None: + if url.startswith(("data:", "mailto:", "tel:", "#")): + return None + + parsed = urlsplit(url) + if parsed.scheme or parsed.netloc: + raise ValueError(f"external URL is not allowed in the static entry point: {url}") + if not parsed.path.startswith("/"): + raise ValueError(f"local asset URL is not absolute: {url}") + if not parsed.path.startswith(base): + raise ValueError(f"local asset URL escapes expected base {base!r}: {url}") + + relative_text = unquote(parsed.path[len(base) :]) + if not relative_text or relative_text.endswith("/"): + return None + + relative = Path(relative_text) + if relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"unsafe local asset path: {url}") + + target = (root / relative).resolve() + if not target.is_relative_to(root.resolve()): + raise ValueError(f"local asset path escapes distribution root: {url}") + return target + + +def validate_distribution(dist: Path, base: str) -> list[str]: + failures: list[str] = [] + if not dist.is_dir(): + return [f"distribution directory does not exist: {dist}"] + + paths = sorted(path for path in dist.rglob("*") if path.is_file() or path.is_symlink()) + symlinks = [path.relative_to(dist).as_posix() for path in paths if path.is_symlink()] + if symlinks: + failures.append(f"distribution contains symlinks: {', '.join(symlinks)}") + + files = [path for path in paths if path.is_file() and not path.is_symlink()] + total_bytes = sum(path.stat().st_size for path in files) + if total_bytes > MAX_ARTIFACT_BYTES: + failures.append( + f"distribution is {total_bytes} bytes; maximum is {MAX_ARTIFACT_BYTES} bytes" + ) + + suffixes = {path.suffix.lower() for path in files} + missing_suffixes = sorted(REQUIRED_SUFFIXES - suffixes) + if missing_suffixes: + failures.append(f"distribution lacks required assets: {', '.join(missing_suffixes)}") + + index = dist / "index.html" + if not index.is_file(): + failures.append("distribution lacks index.html") + return failures + + html = index.read_text(encoding="utf-8") + if DISCLAIMER not in html: + failures.append("index.html lacks the required independent-product disclaimer") + if LOCAL_PROGRESS_NOTICE not in html: + failures.append("index.html lacks the browser-local progress notice") + + parser = AssetReferenceParser() + parser.feed(html) + if parser.has_base_tag: + failures.append("index.html must not override the build-time public URL with ") + + root_urls = {match.group("url") for match in QUOTED_ROOT_URL.finditer(html)} + for url in sorted(parser.urls | root_urls): + try: + target = local_target(url, base, dist) + except ValueError as exc: + failures.append(str(exc)) + continue + if target is not None and not target.is_file(): + failures.append( + f"referenced asset does not exist: {url} -> {target.relative_to(dist)}" + ) + + return failures + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("dist", type=Path) + parser.add_argument("expected_base") + args = parser.parse_args() + + try: + base = normalize_base(args.expected_base) + except ValueError as exc: + print(f"PAGES DIST VALIDATION FAILED: {exc}", file=sys.stderr) + return 2 + + dist = args.dist.resolve() + failures = validate_distribution(dist, base) + if failures: + print("PAGES DIST VALIDATION FAILED", file=sys.stderr) + print("\n".join(f" - {failure}" for failure in failures), file=sys.stderr) + return 1 + + files = [path for path in dist.rglob("*") if path.is_file() and not path.is_symlink()] + total_bytes = sum(path.stat().st_size for path in files) + print( + f"validated GitHub Pages artifact: {len(files)} files, " + f"{total_bytes} bytes, base path {base}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From f2e1823135aede3460f6391bbefe33b11c5e1c0b Mon Sep 17 00:00:00 2001 From: Max Natthawut Adulyanukosol Date: Sat, 29 Aug 2026 23:15:00 -0400 Subject: [PATCH 2/2] Fix Pages CI and validation coverage --- .github/workflows/pages.yml | 1 + .github/workflows/security-boundary.yml | 3 + MANIFEST.sha256 | 33 +++---- Makefile | 1 + crates/actors/src/lib.rs | 6 +- crates/assessment/src/lib.rs | 49 ++++------- crates/dgxlab-contracts/src/lib.rs | 70 +++++++-------- crates/persistence-codec/src/lib.rs | 2 +- crates/scenario-compiler/src/main.rs | 7 +- crates/scheduler/src/lib.rs | 23 ++--- crates/sim-core/src/lib.rs | 46 +++------- crates/sim-session/src/cert_bank.rs | 2 +- crates/sim-session/src/lib.rs | 8 +- crates/sim-worker-wasm/src/lib.rs | 19 ++-- crates/slurm-model/src/lib.rs | 15 +--- crates/virtual-fs/src/lib.rs | 17 ++-- crates/workloads/src/lib.rs | 34 +++++--- tests/test_validate_pages_dist.py | 110 ++++++++++++++++++++++++ 18 files changed, 252 insertions(+), 194 deletions(-) create mode 100644 tests/test_validate_pages_dist.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2548d08..afcd8d4 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -48,6 +48,7 @@ jobs: run: | make check python3 scripts/static_rust_sanity.py + python3 scripts/build_course_pack.py python3 scripts/verify_manifest.py - name: Set GitHub project-site base path diff --git a/.github/workflows/security-boundary.yml b/.github/workflows/security-boundary.yml index 0b2a0d2..2827cce 100644 --- a/.github/workflows/security-boundary.yml +++ b/.github/workflows/security-boundary.yml @@ -7,6 +7,8 @@ on: - 'src-tauri/**' - 'prototype/**' - 'scripts/check_forbidden_apis.py' + - 'scripts/validate_pages_dist.py' + - '.github/workflows/pages.yml' pull_request: permissions: contents: read @@ -18,6 +20,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - run: python -m pip install --disable-pip-version-check PyYAML jsonschema - run: python3 scripts/check_forbidden_apis.py - name: Assert CSP and capability allowlist run: python3 scripts/validate_all.py diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index aea946e..7dcd4bc 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -2,9 +2,9 @@ aeac9b3ef812a24f379f8516078ed9fec8e9aea30d3052bc82571f4ef14d7a00 .github/dependabot.yml 949193274bb5b66db38744ae0475a272e784682bda5dcceefbe6974f67225c5c .github/pull_request_template.md c2343e3bcbca8163fc5094fa749106ac62ab7060a6cc608e2ffd59f54b81d5ac .github/workflows/ci.yml -6262dd904a8dda5b5af20dab5382326fae71d74e25157e0f055c4d4387286a43 .github/workflows/pages.yml +5d36d462e0ac613808e1120a502a6e66b2336850e1950c93f63f02e48d228315 .github/workflows/pages.yml 02d523396c0582dc194050eeec1bdd75c39dc85f8bea0f2b69cf873ce1431394 .github/workflows/release-blueprint.yml -7c0d8206830526e398531bb684dc1878a3f5c693ce1d666bd5ca6c743c847be0 .github/workflows/security-boundary.yml +579a7e86ad5341d706725aa48b61e1bdff8bbe6685b835a6f053968db3f4a5f8 .github/workflows/security-boundary.yml c6c44e5d4675a1644e3324dc17c5795d09d5055c6a5d99577d4f28e7ed055db6 .gitignore f88e2b610dfad2b93d81394afa44ecd8add5d633271dfa74574b07d559b4892f CHANGELOG.md 334744126cf33bc893e29a5dec5bf491e81be97978878119b53c17e531e24cd3 CONTENT-LICENSE.md @@ -13,7 +13,7 @@ f88e2b610dfad2b93d81394afa44ecd8add5d633271dfa74574b07d559b4892f CHANGELOG.md 583559e02128faf10af9464268132289d4e189d2b76facda9fd5d49b48ae9a2d Cargo.lock.NOT_GENERATED.md 70b4ee55c0bc6f6c560d70c0656dbfbfdd2d74315ed01aa3a545b5329dbd2d22 Cargo.toml a033dd6ee3b1aef3ba39126b6fc2c3002c266d60698f5a9db4fc8145ca2555af LICENSE -8716891999fd40ac315bc565012a84e372245812ad5c319f7aa397b2f346c134 Makefile +d4403145f55007dd77041ba93bc60265e6140c898372349040608ce9a8e76ee6 Makefile bfd07db590fab27034c1f2640fe45718781e3c7a8bf6ede74b0df5cb1dab9f03 NOTICE 8f656fec7315c089c97566a1417631b19645b7b51c97d36805b9f08ab570aaeb README.md 3dbbdf4a5a3ba3b6de3da1f673368274ee00982b7beae5912297e5e0b06aae6c SECURITY.md @@ -56,41 +56,41 @@ f8936fb38469507a87f308297fe6ad95260ab59495777ee4600b7b2c2338d355 course-src/slu ad280cfc64e5efbbfe8247c79ca0349e14582452fb0f7e123189cc36d8599c15 course-src/slurm-fundamentals/labs/12-capstone/guide.md 9ec4d731d0d58ab410b7adc4c24f195d3924b2fae8203183a5fcfb175fde0cd9 course-src/slurm-fundamentals/labs/12-capstone/lab.yaml 89a29b259bcc39510a79ec44c1cb13c7778f868e4bda25c7f9c196ff7b1554eb crates/actors/Cargo.toml -1fe626a409f9d6ba161932ee5657f327e66d15b7bbba07f40e29188e0bd8c587 crates/actors/src/lib.rs +3caef511d560135c2deaefe32134ceafc146584d9d25ba35c3647783d0acd61a crates/actors/src/lib.rs 118f56b9a2c624a56d3b9109b2ed0cb8207384305cc9156db3f0ea23b6824696 crates/assessment/Cargo.toml -865a4477573fad7ec1065ce69805016678ec508b1c6b27262f83a7ca1b4e9c20 crates/assessment/src/lib.rs +efb1663a61c9f5a4e109c4999e292167ea87755ffd946d78f71481c186536334 crates/assessment/src/lib.rs e946283b56852e6c07119d1ba527339c26c04d72483b49a22cd232bb37a97c6b crates/dgxlab-contracts/Cargo.toml -1eb0e589640963a2bea35f6554abf3f83e2c8b27b2a0eb1de9fad2ee8ea9b8e4 crates/dgxlab-contracts/src/lib.rs +ec6cbfd444800dc5366126a9e873d504e1d23dc59cacd9a7dbe8df6c14e5c7d5 crates/dgxlab-contracts/src/lib.rs 617caa968c63bc9f4b7df4db9091329d6a088ed948285384432de09f45c698f0 crates/grading/Cargo.toml 848533e6e6c98a4f7380f7dfaf8ad50426306f5f43defbde8ace5b37b3ceac3e crates/grading/src/lib.rs 5627c656d3cbb5c09bd2f6d2ace1888d1d6852f7bc0633bd78f0581352acf708 crates/persistence-codec/Cargo.toml -049f9a95ca3aca76309c33b9bf02371169713c71f1c191e57fdbf37c677be5e3 crates/persistence-codec/src/lib.rs +356a127397e0a1e12fd97f0e1d8412bc3f9e94d9f089efc92d839377c5049469 crates/persistence-codec/src/lib.rs 2f8eb73acea0c815efa978660285b9d318cb756ebc4b197731cb724158a24d4f crates/report-renderer/Cargo.toml 9b26e9cdea14a3ce081da6e20cc830dea1e9a8e3c267bffbb31b689144b1b174 crates/report-renderer/src/lib.rs 57f2590c1edc88330bdc18a29472ca015989f1d241a0c4d18aa3b140dc8cc19e crates/scenario-compiler/Cargo.toml -99e166afcfde1edf1dee53f6aa389ac8c85f3ac7a55e447fa8a6a7dc73e63744 crates/scenario-compiler/src/main.rs +2ad1f505ca29396b24a0ee5a0759b171993f060570de29fa64ae534f25060172 crates/scenario-compiler/src/main.rs 8f7009ecef5968aeac1351fd4e60f7ff8d5376f20e10f2f6b31e0e57231578a1 crates/scenarios/Cargo.toml b8d93f66e4580e5654e795d18d0fcb2949a968b5e8902b923ace6be31a512c89 crates/scenarios/src/lib.rs 25a92f9fe5fec8196232935a40179315e83bddcd8ee90ce8962d41f3d8d53434 crates/scheduler/Cargo.toml -0237222e3ef66c774223399123df5f4e492c55ce26f6169865a879a3555bea21 crates/scheduler/src/lib.rs +68be5548b90ceb284a21cadfdd2879a653090552d6939dce78e8b4218e032188 crates/scheduler/src/lib.rs 39ef4cfd4efab902f36d60832884b019b3d309bd6ef82295cf1b25fb1eb78602 crates/sim-core/Cargo.toml -96bfec9f21f1e2eeee464675c192360231e5623f3429cdafd94ea7426dc22c42 crates/sim-core/src/lib.rs +ff9ff77bd1bd31aad5ea856517e9f808b8622ecfe50dda4d9c49eff9644a8fab crates/sim-core/src/lib.rs 77d893e04f6a96d0c661133467822e52f8f5dcaa045e8f45a2f93405084d884e crates/sim-session/Cargo.toml -455f1d0b262a7e07257154ce4b09584f8601a75a633c09bc92d93bf6cd72bc79 crates/sim-session/src/cert_bank.rs +b087188477ff15b30f0195eba1327be4eb588bd4e2fb34b8103eeaef3bf3376e crates/sim-session/src/cert_bank.rs 846313974edf90391f8101a60b2a3f2d13c4e364cf3b12454d2d2214c1efbcab crates/sim-session/src/course.rs 39ea64bacfff0cf59c3cd71c757377692d5591d592194bf7e3d8a4b97155ea2c crates/sim-session/src/lab04.rs b7c9181b07949ce5017832fd979412670bec1907ff64a8e0c2c88b54935280ad crates/sim-session/src/lab06.rs 6cd8e3134e4e382b9116ed13695024d8b0301388c0aa6a23b2f4a0519c0caed0 crates/sim-session/src/lab07.rs f054391eb8d2e3fb8c1c7d67bb025f5f8a544f482d30e696b6daca9c6e9d7d4b crates/sim-session/src/lab09.rs -9dc94626f08ad2149b32d359787ddf32e484a1f6c5bc35b3dc31b9fefc47dff3 crates/sim-session/src/lib.rs +74ae9b8a62aa2bc85e690fd36abf25791dd80a5b8c6634fa878d45127786261d crates/sim-session/src/lib.rs 3c34f048ae5f2e63512ebb43c2a75dbe5d8974d81aa04cd21776995cacec9fdc crates/sim-session/src/pending.rs f99f340de1a64508e9bfc3ced2b2364c88fae2786c5e66990b5adef59d2676c6 crates/sim-session/tests/parity_lab04_scaffold.rs b76911e8cc3364eec84fa5644f87034fba84d1a94925ee37f84ff68bf3d5ce2d crates/sim-worker-wasm/Cargo.toml -cfacf3663fae72602685f48b1eb3173d918643a81d5618996f12daec0eb90bdd crates/sim-worker-wasm/src/lib.rs +39825da597080d3abe7e125844d719c434157f906207c57df78143e4db8c2786 crates/sim-worker-wasm/src/lib.rs edc8616d7e2ad9670925ccfa7c02d16af7246511d83b34f1a8357efcc796622f crates/slurm-model/Cargo.toml -d1192629701b0ca2727a68150389e6a6fbe6c86b3a43c42b14ce64c3c9012691 crates/slurm-model/src/lib.rs +6cfa761c803c6f7a25b1df9e3a8aaf38e5af7f8e0760e3c2a24c5cdb171b26e4 crates/slurm-model/src/lib.rs 8d2693b53ef0bcdb7ccdb2e607248e217697bfa4b1564123b6bbbfdffff5c05b crates/virtual-fs/Cargo.toml -d018f4abd3b1f24119c5c60403be8fde1e828c8d9d831848c526170bd112db64 crates/virtual-fs/src/lib.rs +22ca0e6dc2d423f719ed1fc6ea4d9cec48d5aab9afcedd7e0da828bf7617b218 crates/virtual-fs/src/lib.rs 2fe6720a03a0a3a8bd2c1bf58f1091d2bf84da68ed66d857a725ff633b6ba55b crates/virtual-shell/Cargo.toml a9da58111f629154b5979e9aa7d759c06123ff0c83f51511ba1a14f1c3bbf24b crates/virtual-shell/src/lib.rs 94199e2c4266508d91b7e36c95d5dbf9ad5781e886fe37399abca64f19e8b6e1 crates/web-ui/Cargo.toml @@ -108,7 +108,7 @@ e8d1a2cd11f5ae98a9f0f5fbf035910c7cbaa9e6061283efccf0c349bbf8140e crates/web-ui/ 0a1c112d8ff274d0c0deac304c545acefe3f793e8deed90f465d3da23aff67d2 crates/web-ui/src/persist.rs 1d9506a4155489dc2d52df2e8bc0cecd39371b9f51f0db09cc3ec110596ad95d crates/web-ui/style.css 2b618a4324404a17f7bb1dfb70d0c9cd38ba83e1ccdde0654dd4f1372afa6b75 crates/workloads/Cargo.toml -d4aea514115c9263d3f8270083b835ee8dcedac6be1c97720fa5ac523b8defc9 crates/workloads/src/lib.rs +e7c87f5c171418cad33876e39d216bcc02a3536c307b27065f8c18ed739e453a crates/workloads/src/lib.rs c0e1427d6c9dadeba65b1670323eecff1e7fcbb13e54dd75d6de0b6813eebd01 deny.toml 9f0102e019911c46b20936ff5c1ade57be1c331c9217bcc1dd755058f3e75e25 dist/DGX_Lab_SLURM_Fundamentals_v1.0.0.dgxlabpack 7b4fc7385dd518a21d149f14578a0b03cf948ebad862ff4baf0240d938b9438b docs/ARCHITECTURE.md @@ -205,3 +205,4 @@ fd6f96ec6ed1f7e677f621bdc7c89d640793a56090f4b1fa20d2cfb00e315b59 tests/replay/c 935907e7269a3737ebad49efcc52f15d791a70588e88bd658ffbb2f6e8e9a159 tests/replay/canonical_lab04_digest_test.rs ac33d6653d2ce2a690dca5d86fced47822c35557424728ba2c95481006461940 tests/security/hostile-inputs.txt 1c43c0e95e7038dfc64b8eec4aa37b4ca05aec10124525202cf414c80d6451d8 tests/security/pack-cases.yaml +a0d7babc9662e6209a7e3dc88432469cded1dbc1fd3f29faa6f1d945e441e050 tests/test_validate_pages_dist.py diff --git a/Makefile b/Makefile index bcc7b3d..346a5c6 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ PAGES_BASE ?= /DGX_Lab/ validate: $(PYTHON) scripts/validate_all.py $(PYTHON) scripts/check_forbidden_apis.py + $(PYTHON) -m unittest discover -s tests -p 'test_*.py' check: validate cargo fmt --all -- --check diff --git a/crates/actors/src/lib.rs b/crates/actors/src/lib.rs index 0727dc4..43c27c4 100644 --- a/crates/actors/src/lib.rs +++ b/crates/actors/src/lib.rs @@ -46,10 +46,8 @@ mod tests { #[test] fn actor_actions_are_data_not_callbacks() { - let action = ActorAction::DrainNode { - node_id: "dgx-h200-01".into(), - reason: "maintenance".into(), - }; + let action = + ActorAction::DrainNode { node_id: "dgx-h200-01".into(), reason: "maintenance".into() }; let encoded = serde_json::to_string(&action).unwrap(); assert!(encoded.contains("drain_node")); } diff --git a/crates/assessment/src/lib.rs b/crates/assessment/src/lib.rs index 11835c6..cb7c287 100644 --- a/crates/assessment/src/lib.rs +++ b/crates/assessment/src/lib.rs @@ -103,13 +103,13 @@ pub struct QuestionScore { pub feedback: String, } -pub fn score_question(question: &Question, answer: &Answer) -> Result { +pub fn score_question( + question: &Question, + answer: &Answer, +) -> Result { let possible = question.points().saturating_mul(1_000); match (question, answer) { - ( - Question::SingleChoice { id, correct, .. }, - Answer::SingleChoice { option_id }, - ) => { + (Question::SingleChoice { id, correct, .. }, Answer::SingleChoice { option_id }) => { let is_correct = option_id == correct; Ok(QuestionScore { question_id: id.clone(), @@ -120,12 +120,7 @@ pub fn score_question(question: &Question, answer: &Answer) -> Result { let correct_selected = option_ids.intersection(correct).count() as u32; @@ -147,16 +142,11 @@ pub fn score_question(question: &Question, answer: &Answer) -> Result { + (Question::FillBlank { id, blanks, .. }, Answer::FillBlank { values }) => { let correct_count = blanks .iter() .filter(|blank| { - values - .get(&blank.id) - .is_some_and(|value| blank_matches(blank, value)) + values.get(&blank.id).is_some_and(|value| blank_matches(blank, value)) }) .count() as u32; let denominator = blanks.len().max(1) as u32; @@ -169,19 +159,13 @@ pub fn score_question(question: &Question, answer: &Answer) -> Result Err(AssessmentError::AnswerTypeMismatch { - question_id: question.id().into(), - }), + _ => Err(AssessmentError::AnswerTypeMismatch { question_id: question.id().into() }), } } fn blank_matches(blank: &BlankDefinition, submitted: &str) -> bool { - let normalized = normalize_text( - submitted, - blank.case_insensitive, - blank.trim, - blank.normalize_whitespace, - ); + let normalized = + normalize_text(submitted, blank.case_insensitive, blank.trim, blank.normalize_whitespace); blank.accepted.iter().any(|accepted| match accepted { AcceptedAnswer::Literal { value } => { normalized @@ -254,10 +238,8 @@ pub fn finalize_certification( let knowledge_numerator = u16::from(multiple_choice_percent) * u16::from(weights.multiple_choice) + u16::from(fill_blank_percent) * u16::from(weights.fill_blank); - let knowledge_percent = knowledge_numerator - .checked_div(knowledge_weight) - .map(|value| value as u8) - .unwrap_or(0); + let knowledge_percent = + knowledge_numerator.checked_div(knowledge_weight).map(|value| value as u8).unwrap_or(0); let overall_percent = ((u16::from(practical_percent) * u16::from(weights.practical) + u16::from(multiple_choice_percent) * u16::from(weights.multiple_choice) + u16::from(fill_blank_percent) * u16::from(weights.fill_blank)) @@ -308,9 +290,8 @@ mod tests { points: 2, explanation: String::new(), }; - let answer = Answer::FillBlank { - values: BTreeMap::from([("blank-1".into(), " GPU:H200 ".into())]), - }; + let answer = + Answer::FillBlank { values: BTreeMap::from([("blank-1".into(), " GPU:H200 ".into())]) }; let score = score_question(&question, &answer).unwrap(); assert!(score.correct); } diff --git a/crates/dgxlab-contracts/src/lib.rs b/crates/dgxlab-contracts/src/lib.rs index 60a4900..fd06031 100644 --- a/crates/dgxlab-contracts/src/lib.rs +++ b/crates/dgxlab-contracts/src/lib.rs @@ -46,7 +46,9 @@ numeric_id!(EventId, u64); numeric_id!(StepId, u32); numeric_id!(SnapshotId, u64); -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, +)] #[serde(transparent)] pub struct SimTimeMs(pub u64); @@ -124,53 +126,51 @@ impl TerminalLine { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SimRequest { - Initialize { scenario_id: String, seed: u64 }, - ExecuteCommand { command: String }, - AdvanceClock { delta_ms: u64 }, - SetClockSpeed { multiplier: u32 }, + Initialize { + scenario_id: String, + seed: u64, + }, + ExecuteCommand { + command: String, + }, + AdvanceClock { + delta_ms: u64, + }, + SetClockSpeed { + multiplier: u32, + }, Pause, Resume, - Reset { scenario_id: String, seed: u64 }, + Reset { + scenario_id: String, + seed: u64, + }, Snapshot, - CancelJob { job_id: u64 }, + CancelJob { + job_id: u64, + }, /// Reveal the next progressive lab hint (recorded separately from correctness). UseHint, /// Read a virtual-filesystem text file (editor / log inspection). - ReadVfs { path: String }, + ReadVfs { + path: String, + }, /// Write a virtual-filesystem text file (never host paths). - WriteVfs { path: String, content: String }, + WriteVfs { + path: String, + content: String, + }, } /// Responses from the simulation session to the UI. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SimResponse { - Ready { - protocol_version: String, - compatibility_version: String, - seq: u64, - state: UiWorldView, - }, - CommandResult { - seq: u64, - prompt: String, - lines: Vec, - state: UiWorldView, - }, - State { - seq: u64, - state: UiWorldView, - }, - Error { - code: String, - message: String, - seq: u64, - }, - FileContent { - seq: u64, - path: String, - content: String, - }, + Ready { protocol_version: String, compatibility_version: String, seq: u64, state: UiWorldView }, + CommandResult { seq: u64, prompt: String, lines: Vec, state: UiWorldView }, + State { seq: u64, state: UiWorldView }, + Error { code: String, message: String, seq: u64 }, + FileContent { seq: u64, path: String, content: String }, } /// UI-facing authoritative snapshot. Derived only from the simulation worker. diff --git a/crates/persistence-codec/src/lib.rs b/crates/persistence-codec/src/lib.rs index 15d71ad..3d62148 100644 --- a/crates/persistence-codec/src/lib.rs +++ b/crates/persistence-codec/src/lib.rs @@ -2,7 +2,7 @@ //! Canonical session serialization and integrity checks. -use dgxlab_contracts::{SessionId, SESSION_FORMAT_VERSION}; +use dgxlab_contracts::{SESSION_FORMAT_VERSION, SessionId}; use grading::EvidenceLedger; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; diff --git a/crates/scenario-compiler/src/main.rs b/crates/scenario-compiler/src/main.rs index 0369e9b..cb7d265 100644 --- a/crates/scenario-compiler/src/main.rs +++ b/crates/scenario-compiler/src/main.rs @@ -43,8 +43,11 @@ fn main() -> Result<(), CompilerError> { } for source in sources { let bytes = fs::read(&source)?; - let scenario: ScenarioDefinition = serde_yaml::from_slice(&bytes) - .map_err(|source_error| CompilerError::Yaml { path: source.clone(), source: source_error })?; + let scenario: ScenarioDefinition = + serde_yaml::from_slice(&bytes).map_err(|source_error| CompilerError::Yaml { + path: source.clone(), + source: source_error, + })?; validate(&scenario, &source)?; let digest = hex::encode(Sha256::digest(&bytes)); let compiled = CompiledScenario { diff --git a/crates/scheduler/src/lib.rs b/crates/scheduler/src/lib.rs index 093090e..1a56193 100644 --- a/crates/scheduler/src/lib.rs +++ b/crates/scheduler/src/lib.rs @@ -52,10 +52,7 @@ pub fn validate_job(cluster: &ClusterState, spec: &JobSpec) -> Result<(), Valida if !cluster.qos.contains_key(&spec.qos) { return Err(ValidationError::QosNotFound(spec.qos.clone())); } - if partition - .max_time_ms - .is_some_and(|max_time| spec.time_limit_ms > max_time) - { + if partition.max_time_ms.is_some_and(|max_time| spec.time_limit_ms > max_time) { return Err(ValidationError::TimeLimitTooLong); } let satisfiable = partition @@ -209,14 +206,9 @@ fn apply_allocation( requested: &Tres, allocation: &Allocation, ) { - let node = cluster - .nodes - .get_mut(&allocation.node_id) - .expect("allocation node is known"); - node.allocated = node - .allocated - .checked_add(requested) - .expect("scheduler only combines compatible TRES"); + let node = cluster.nodes.get_mut(&allocation.node_id).expect("allocation node is known"); + node.allocated = + node.allocated.checked_add(requested).expect("scheduler only combines compatible TRES"); for index in &allocation.gpu_indices { if let Some(gpu) = node.gpus.iter_mut().find(|gpu| gpu.index == *index) { gpu.allocated_to = Some(job_id); @@ -254,12 +246,7 @@ mod tests { fn job(id: u64, gpus: u16) -> JobRecord { let spec = JobSpec { - resources: Tres { - cpus: 8, - memory_mib: 65_536, - gpu_type: Some("h200".into()), - gpus, - }, + resources: Tres { cpus: 8, memory_mib: 65_536, gpu_type: Some("h200".into()), gpus }, ..JobSpec::default() }; JobRecord { diff --git a/crates/sim-core/src/lib.rs b/crates/sim-core/src/lib.rs index 60425d1..9acbddb 100644 --- a/crates/sim-core/src/lib.rs +++ b/crates/sim-core/src/lib.rs @@ -13,7 +13,7 @@ use slurm_model::{ }; use std::collections::{BTreeMap, VecDeque}; use virtual_fs::VirtualFileSystem; -use workloads::{plan_workload, request_from_command, LogStream}; +use workloads::{LogStream, plan_workload, request_from_command}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SimulationWorld { @@ -170,11 +170,8 @@ impl SimulationWorld { .nodes .get_mut(&node_id) .ok_or_else(|| SimError::NodeNotFound(node_id.clone()))?; - node.status = if node.running_jobs.is_empty() { - NodeStatus::Idle - } else { - NodeStatus::Mixed - }; + node.status = + if node.running_jobs.is_empty() { NodeStatus::Idle } else { NodeStatus::Mixed }; node.drain_reason = None; self.record(WorldEventKind::NodeResumed { node_id }); self.schedule_at(self.now, ScheduledEventKind::TrySchedule); @@ -185,11 +182,10 @@ impl SimulationWorld { .nodes .get_mut(&node_id) .ok_or_else(|| SimError::NodeNotFound(node_id.clone()))?; - let gpu = node - .gpus - .iter_mut() - .find(|gpu| gpu.index == gpu_index) - .ok_or_else(|| SimError::GpuNotFound { node_id: node_id.clone(), gpu_index })?; + let gpu = + node.gpus.iter_mut().find(|gpu| gpu.index == gpu_index).ok_or_else(|| { + SimError::GpuNotFound { node_id: node_id.clone(), gpu_index } + })?; gpu.health = GpuHealth::Warning; self.record(WorldEventKind::GpuHealthChanged { node_id, @@ -203,11 +199,10 @@ impl SimulationWorld { .nodes .get_mut(&node_id) .ok_or_else(|| SimError::NodeNotFound(node_id.clone()))?; - let gpu = node - .gpus - .iter_mut() - .find(|gpu| gpu.index == gpu_index) - .ok_or_else(|| SimError::GpuNotFound { node_id: node_id.clone(), gpu_index })?; + let gpu = + node.gpus.iter_mut().find(|gpu| gpu.index == gpu_index).ok_or_else(|| { + SimError::GpuNotFound { node_id: node_id.clone(), gpu_index } + })?; gpu.health = GpuHealth::Ok; self.record(WorldEventKind::GpuHealthChanged { node_id, @@ -381,10 +376,7 @@ impl SimulationWorld { } fn schedule_at(&mut self, at: SimTimeMs, kind: ScheduledEventKind) { - let event = ScheduledEvent { - id: EventId(self.next_event_id), - kind, - }; + let event = ScheduledEvent { id: EventId(self.next_event_id), kind }; self.next_event_id = self.next_event_id.saturating_add(1); self.queue.push(at, event); } @@ -491,11 +483,7 @@ pub enum WorldEventKind { } fn resolve_output_path(spec: &JobSpec, job_id: JobId, stderr: bool) -> String { - let configured = if stderr { - spec.error_path.as_deref() - } else { - spec.output_path.as_deref() - }; + let configured = if stderr { spec.error_path.as_deref() } else { spec.output_path.as_deref() }; if let Some(template) = configured { let expanded = template .replace("%x", &spec.name) @@ -546,12 +534,7 @@ mod tests { fn gpu_job(name: &str, gpus: u16) -> JobSpec { JobSpec { name: name.into(), - resources: Tres { - cpus: 8, - memory_mib: 64 * 1024, - gpu_type: Some("h200".into()), - gpus, - }, + resources: Tres { cpus: 8, memory_mib: 64 * 1024, gpu_type: Some("h200".into()), gpus }, command: "python train.py --batch-size 64 --epochs 2".into(), workload_id: "pytorch-training-v1".into(), ..JobSpec::default() @@ -580,4 +563,3 @@ mod tests { assert_eq!(world.jobs[&second].status, JobStatus::Running); } } - diff --git a/crates/sim-session/src/cert_bank.rs b/crates/sim-session/src/cert_bank.rs index 9fd7cc3..578d920 100644 --- a/crates/sim-session/src/cert_bank.rs +++ b/crates/sim-session/src/cert_bank.rs @@ -177,7 +177,7 @@ pub fn score_certification( } fn percent(earned: u32, possible: u32) -> u8 { - if possible == 0 { 0 } else { ((earned * 100) / possible) as u8 } + earned.saturating_mul(100).checked_div(possible).unwrap_or(0) as u8 } fn default_empty_answer(question: &Question) -> Answer { diff --git a/crates/sim-session/src/lib.rs b/crates/sim-session/src/lib.rs index 5cf575a..74e762a 100644 --- a/crates/sim-session/src/lib.rs +++ b/crates/sim-session/src/lib.rs @@ -432,11 +432,9 @@ fn lab_progress(session: &SimSession) -> (Vec, u8, bool) { critical: result.critical, }) .collect::>(); - let percent = if score.possible_points == 0 { - 0 - } else { - ((score.earned_points * 100) / score.possible_points) as u8 - }; + let percent = + score.earned_points.saturating_mul(100).checked_div(score.possible_points).unwrap_or(0) + as u8; let complete = score.all_critical_passed && percent >= 80; (steps, percent, complete) } diff --git a/crates/sim-worker-wasm/src/lib.rs b/crates/sim-worker-wasm/src/lib.rs index 3739ad9..caeaf8e 100644 --- a/crates/sim-worker-wasm/src/lib.rs +++ b/crates/sim-worker-wasm/src/lib.rs @@ -33,32 +33,23 @@ impl SimWorkerRuntime { } pub fn execute(&mut self, command: &str) -> Result { - let response = self.session.handle(SimRequest::ExecuteCommand { - command: command.into(), - }); + let response = self.session.handle(SimRequest::ExecuteCommand { command: command.into() }); to_js(&response) } pub fn advance_by(&mut self, delta_ms: u64) -> Result { - let response = self - .session - .handle(SimRequest::AdvanceClock { delta_ms }); + let response = self.session.handle(SimRequest::AdvanceClock { delta_ms }); to_js(&response) } pub fn reset(&mut self, scenario_id: &str, seed: u64) -> Result { - let response = self.session.handle(SimRequest::Reset { - scenario_id: scenario_id.into(), - seed, - }); + let response = + self.session.handle(SimRequest::Reset { scenario_id: scenario_id.into(), seed }); to_js(&response) } pub fn snapshot(&self) -> Result { - to_js(&WorkerSnapshotView { - seq: self.session.seq(), - state: self.session.view(), - }) + to_js(&WorkerSnapshotView { seq: self.session.seq(), state: self.session.view() }) } pub fn snapshot_json(&self) -> Result { diff --git a/crates/slurm-model/src/lib.rs b/crates/slurm-model/src/lib.rs index 74f9fe1..dbc45c1 100644 --- a/crates/slurm-model/src/lib.rs +++ b/crates/slurm-model/src/lib.rs @@ -299,12 +299,8 @@ impl ClusterState { #[must_use] pub fn dgx_h200_8() -> Self { let node_id = "dgx-h200-01".to_string(); - let capacity = Tres { - cpus: 224, - memory_mib: 1_857_528, - gpu_type: Some("h200".into()), - gpus: 8, - }; + let capacity = + Tres { cpus: 224, memory_mib: 1_857_528, gpu_type: Some("h200".into()), gpus: 8 }; let gpus = (0..8) .map(|index| GpuDevice { index, @@ -316,12 +312,7 @@ impl ClusterState { let node = NodeState { id: node_id.clone(), capacity, - allocated: Tres { - cpus: 0, - memory_mib: 0, - gpu_type: Some("h200".into()), - gpus: 0, - }, + allocated: Tres { cpus: 0, memory_mib: 0, gpu_type: Some("h200".into()), gpus: 0 }, gpus, status: NodeStatus::Idle, drain_reason: None, diff --git a/crates/virtual-fs/src/lib.rs b/crates/virtual-fs/src/lib.rs index 5198462..1655025 100644 --- a/crates/virtual-fs/src/lib.rs +++ b/crates/virtual-fs/src/lib.rs @@ -73,11 +73,8 @@ singularity exec --nv /containers/pytorch-lab.sif \ } let mut current = String::from("/"); for segment in normalized.trim_start_matches('/').split('/') { - let next = if current == "/" { - format!("/{segment}") - } else { - format!("{current}/{segment}") - }; + let next = + if current == "/" { format!("/{segment}") } else { format!("{current}/{segment}") }; if !self.nodes.contains_key(&next) { self.insert_child(¤t, segment)?; self.nodes.insert(next.clone(), VfsNode::Directory { children: BTreeSet::new() }); @@ -101,9 +98,8 @@ singularity exec --nv /containers/pytorch-lab.sif \ Some(VfsNode::Directory { .. }) => return Err(VfsError::IsDirectory(normalized)), None => 0, }; - let projected = current_size - .saturating_sub(previous_size) - .saturating_add(content.len() as u64); + let projected = + current_size.saturating_sub(previous_size).saturating_add(content.len() as u64); if projected > self.quota_bytes { return Err(VfsError::QuotaExceeded { requested_bytes: projected, @@ -263,7 +259,10 @@ mod tests { #[test] fn traversal_cannot_escape_root() { - assert_eq!(normalize_path("../../etc/passwd"), Err(VfsError::TraversalDenied("../../etc/passwd".into()))); + assert_eq!( + normalize_path("../../etc/passwd"), + Err(VfsError::TraversalDenied("../../etc/passwd".into())) + ); } #[test] diff --git a/crates/workloads/src/lib.rs b/crates/workloads/src/lib.rs index f03f2ef..6ab629e 100644 --- a/crates/workloads/src/lib.rs +++ b/crates/workloads/src/lib.rs @@ -98,17 +98,27 @@ pub fn request_from_command(command: &str, workload_id: &str) -> WorkloadRequest request } -pub fn plan_workload(spec: &JobSpec, request: &WorkloadRequest, start_time: SimTimeMs) -> WorkloadPlan { +pub fn plan_workload( + spec: &JobSpec, + request: &WorkloadRequest, + start_time: SimTimeMs, +) -> WorkloadPlan { let natural_duration_ms = natural_duration(request); let inferred_failure = request.forced_failure.or_else(|| infer_failure(spec, request)); let (terminal_status, failure_at_ms) = match inferred_failure { - Some(FailureMode::GpuOutOfMemory) => (JobStatus::OutOfMemory, natural_duration_ms.min(45_000)), - Some(FailureMode::HostOutOfMemory) => (JobStatus::OutOfMemory, natural_duration_ms.min(75_000)), + Some(FailureMode::GpuOutOfMemory) => { + (JobStatus::OutOfMemory, natural_duration_ms.min(45_000)) + } + Some(FailureMode::HostOutOfMemory) => { + (JobStatus::OutOfMemory, natural_duration_ms.min(75_000)) + } Some(FailureMode::ScriptFailure | FailureMode::MissingInput) => { (JobStatus::Failed, natural_duration_ms.min(2_000)) } Some(FailureMode::NodeFailure) => (JobStatus::NodeFail, natural_duration_ms.min(30_000)), - None if spec.time_limit_ms < natural_duration_ms => (JobStatus::Timeout, spec.time_limit_ms), + None if spec.time_limit_ms < natural_duration_ms => { + (JobStatus::Timeout, spec.time_limit_ms) + } None => (JobStatus::Completed, natural_duration_ms), }; let mut logs = training_logs(request, terminal_status, failure_at_ms); @@ -119,7 +129,8 @@ pub fn plan_workload(spec: &JobSpec, request: &WorkloadRequest, start_time: SimT text: "slurmstepd: error: job cancelled due to time limit".into(), }); } - let artifacts = checkpoint_artifacts(request, terminal_status, failure_at_ms, natural_duration_ms); + let artifacts = + checkpoint_artifacts(request, terminal_status, failure_at_ms, natural_duration_ms); let telemetry = telemetry_curve(spec, request, terminal_status, failure_at_ms); WorkloadPlan { workload_id: request.workload_id.clone(), @@ -161,7 +172,11 @@ fn infer_failure(spec: &JobSpec, request: &WorkloadRequest) -> Option Vec { +fn training_logs( + request: &WorkloadRequest, + status: JobStatus, + terminal_after_ms: u64, +) -> Vec { if request.workload_id == "interactive-shell-v1" { return vec![PlannedLogLine { offset_ms: 0, @@ -246,11 +261,8 @@ fn telemetry_curve( .map(|point| { let offset = terminal_after_ms.saturating_mul(point) / points; let wave = ((point * 17 + request.batch_size as u64) % 23) as u8; - let gpu_memory = if spec.resources.gpus > 0 { - 30_000 + request.batch_size as u64 * 420 - } else { - 0 - }; + let gpu_memory = + if spec.resources.gpus > 0 { 30_000 + request.batch_size as u64 * 420 } else { 0 }; TelemetryPoint { offset_ms: offset, cpu_percent: 35 + wave.min(55), diff --git a/tests/test_validate_pages_dist.py b/tests/test_validate_pages_dist.py new file mode 100644 index 0000000..b4d89d1 --- /dev/null +++ b/tests/test_validate_pages_dist.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from scripts.validate_pages_dist import normalize_base, validate_distribution + + +DISCLAIMER = ( + "DGX Lab is not affiliated with, sponsored by, or endorsed by " + "NVIDIA Corporation or SchedMD LLC." +) +LOCAL_PROGRESS_NOTICE = "Web-edition progress is stored only in this browser." +ROOT = Path(__file__).resolve().parents[1] + + +class PagesDistributionValidationTests(unittest.TestCase): + def setUp(self) -> None: + (ROOT / "temp").mkdir(exist_ok=True) + self.temporary_directory = tempfile.TemporaryDirectory( + prefix="pages-dist-test-", dir=ROOT / "temp" + ) + self.dist = Path(self.temporary_directory.name) + + def tearDown(self) -> None: + self.temporary_directory.cleanup() + + def write_valid_distribution(self, asset_url: str = "/DGX_Lab/app.js") -> None: + (self.dist / "app.css").write_text("body {}\n", encoding="utf-8") + (self.dist / "app.js").write_text("export {};\n", encoding="utf-8") + (self.dist / "app.wasm").write_bytes(b"\x00asm") + (self.dist / "index.html").write_text( + "\n" + "\n" + ' \n' + f' \n' + "\n" + f"

{DISCLAIMER}

\n" + f"

{LOCAL_PROGRESS_NOTICE}

\n" + ' Start lesson\n' + "\n", + encoding="utf-8", + ) + + def test_accepts_complete_project_path_distribution(self) -> None: + self.write_valid_distribution() + + self.assertEqual(validate_distribution(self.dist, "/DGX_Lab/"), []) + + def test_rejects_root_hosted_asset(self) -> None: + self.write_valid_distribution(asset_url="/app.js") + + failures = validate_distribution(self.dist, "/DGX_Lab/") + + self.assertTrue( + any("escapes expected base" in failure for failure in failures), failures + ) + + def test_rejects_missing_referenced_asset(self) -> None: + self.write_valid_distribution(asset_url="/DGX_Lab/missing.js") + + failures = validate_distribution(self.dist, "/DGX_Lab/") + + self.assertTrue( + any("referenced asset does not exist" in failure for failure in failures), + failures, + ) + + def test_rejects_external_entry_point_asset(self) -> None: + self.write_valid_distribution(asset_url="https://example.invalid/app.js") + + failures = validate_distribution(self.dist, "/DGX_Lab/") + + self.assertTrue( + any("external URL is not allowed" in failure for failure in failures), failures + ) + + def test_rejects_missing_public_notices(self) -> None: + self.write_valid_distribution() + index = self.dist / "index.html" + index.write_text( + index.read_text(encoding="utf-8") + .replace(DISCLAIMER, "") + .replace(LOCAL_PROGRESS_NOTICE, ""), + encoding="utf-8", + ) + + failures = validate_distribution(self.dist, "/DGX_Lab/") + + self.assertIn( + "index.html lacks the required independent-product disclaimer", failures + ) + self.assertIn("index.html lacks the browser-local progress notice", failures) + + +class PagesBasePathValidationTests(unittest.TestCase): + def test_requires_leading_and_trailing_slashes(self) -> None: + for value in ("DGX_Lab/", "/DGX_Lab"): + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_base(value) + + def test_rejects_parent_segments_and_empty_segments(self) -> None: + for value in ("/DGX_Lab/../other/", "/DGX_Lab//assets/"): + with self.subTest(value=value), self.assertRaises(ValueError): + normalize_base(value) + + +if __name__ == "__main__": + unittest.main()