diff --git a/.env.example b/.env.example index 4304863..c46995f 100644 --- a/.env.example +++ b/.env.example @@ -6,8 +6,15 @@ GITHUB_BASE_URL=https://github.com # Leave empty to use the Incus default storage pool. INCUS_STORAGE_POOL= -# Build-runner safety floor. The full image profile reserves 200 GiB. -MIN_BUILD_FREE_GIB=220 +# Optional build disk floor. Zero uses the variant-specific manifest requirement. +MIN_BUILD_FREE_GIB=0 + +# The bootstrap installs the newest Incus candidate from repositories that the +# operator has already configured. It never adds or removes package sources. +MIN_INCUS_VERSION=6.0.5 + +# Reuse verified APT/npm/pip/Playwright downloads between clean build VMs. +VDM_BUILD_CACHE=true # LLM gateway and local OpenAI-compatible inference endpoint. VDM_LLM_GATEWAY_URL= diff --git a/.gitea/workflows/build-images.yaml b/.gitea/workflows/build-images.yaml index 81d95d9..7e7ea35 100644 --- a/.gitea/workflows/build-images.yaml +++ b/.gitea/workflows/build-images.yaml @@ -41,7 +41,7 @@ jobs: run: ./tests/validate-repository.sh - name: Verify Incus build runner run: | - ./scripts/ci/check-incus-runner.sh + ./scripts/ci/check-incus-runner.sh "${{ matrix.variant }}" ./scripts/ci/check-architecture.sh "${{ matrix.architecture }}" - name: Build image run: ./scripts/build-image.sh "${{ matrix.variant }}" diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml index d3c72f7..45b0665 100644 --- a/.github/workflows/build-images.yaml +++ b/.github/workflows/build-images.yaml @@ -44,7 +44,7 @@ jobs: run: ./tests/validate-repository.sh - name: Verify Incus build runner run: | - ./scripts/ci/check-incus-runner.sh + ./scripts/ci/check-incus-runner.sh "${{ matrix.variant }}" ./scripts/ci/check-architecture.sh "${{ matrix.architecture }}" - name: Build image run: ./scripts/build-image.sh "${{ matrix.variant }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index b743095..76f4f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 0.3.0-rc.3 - 2026-07-29 + +- Separated adaptive image-build resources from launch-time runtime sizes, + reserving host memory/CPU and failing before Incus changes when current + capacity is insufficient. +- Added resumable stopped build checkpoints, non-secret failure diagnostics and + a detached managed cache for APT, npm, pip and Playwright downloads. +- Removed unnecessary build and first-launch snapshots; verified images now + publish directly from stopped instances. +- Moved bridge/ACL ownership to the Incus default project, added collision and + legacy-migration guards, made ACL state explicit and retained restricted + project isolation. +- Hardened host bootstrap around dpkg/APT health, active Incus upgrades and + latest configured package candidates without changing repositories, + coreutils providers, Docker or firewall policy. +- Fixed TSX version normalisation, replaced an undeclared Python `uv` + dependency with the installed `pipx`, and corrected the Joomla live-test + executable typo with an executed guest-dispatch regression test. +- Added complete host, hardware, caching, recovery and network documentation. + ## 0.3.0-rc.2 - 2026-07-27 - Added `joomla-mcp` as a manifest-owned composable capability for PHP and full images. diff --git a/README.md b/README.md index 0f9ec86..292219e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ cp .env.example .env ./tests/validate-repository.sh ./scripts/bootstrap-host.sh ./scripts/apply-incus.sh +./scripts/ci/check-incus-runner.sh php export LOCAL_BUILD_VARIANTS=php ./scripts/local-image-release.sh @@ -25,6 +26,21 @@ export LOCAL_BUILD_VARIANTS=php An existing VM created from an older image does not gain newly installed packages. Launch or replace it from the new versioned PHP image. +`bootstrap-host.sh` first verifies APT/dpkg and the host's essential `cp`, `mv` +and `rm` providers. It installs the newest Incus candidate from repositories +already configured by the operator, but never adds/removes a package source, +changes the coreutils provider, or alters Docker firewall rules. An Incus +upgrade is refused while instances are running unless the operator explicitly +schedules and acknowledges that downtime. + +Image builds use manifest-owned build limits, not the larger launch-time VM +sizes. The preflight reserves at least 20% of host memory, ignores swap as a +capacity source, leaves a host CPU available, and creates nothing when current +capacity is insufficient. Failed builds are stopped and retained as resumable +checkpoints with diagnostics under `build/diagnostics/`. Verified APT, npm, +pip and Playwright downloads are reused from a managed cache that is detached +before an image is published. + ## Configure Joomla MCP Configure one fixed public HTTPS Joomla origin. The default profile is read-only: @@ -90,6 +106,7 @@ Start with: - `docs/joomla-mcp-study.md` - `docs/credentials-and-brokers.md` - `docs/local-image-builds.md` +- `docs/host-and-network-safety.md` - `docs/versioning-and-promotion.md` ## Licence and ownership diff --git a/VERSION b/VERSION index c23d448..c60a9cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0-rc.2 +0.3.0-rc.3 diff --git a/docs/architecture.md b/docs/architecture.md index 818f932..2a15545 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -50,3 +50,9 @@ must be explicitly retained or pushed to CI artifact storage. Use Incus projects for organisational separation, profiles for resource classes and versioned images for rollout. For large central installations, add an Incus cluster and a dedicated image-builder project. + +The restricted workload project shares two host-managed Incus bridge networks +from the `default` project (`features.networks=false`). This matches Incus +project isolation: the project may use only the two names listed by +`restricted.networks.access`, while network lifecycle remains with the host +administrator. No OVN or Open vSwitch service is required. diff --git a/docs/credentials-and-brokers.md b/docs/credentials-and-brokers.md index 2424b44..85743f6 100644 --- a/docs/credentials-and-brokers.md +++ b/docs/credentials-and-brokers.md @@ -15,6 +15,8 @@ The host file is created by: It lives under `/run/user/$UID/vdm-opencode`, must be a regular single-link file owned by the caller with mode `0600`, and is never passed as a string of secret values to `incus exec`. +`VDM_RUNTIME_BASE` may select another caller-owned mode-`0700` tmpfs mount; +non-tmpfs paths are rejected so credentials cannot be persisted accidentally. This reduces persistence but does not hide a token from the process that must use it. Therefore all injected tokens must be: diff --git a/docs/host-and-network-safety.md b/docs/host-and-network-safety.md new file mode 100644 index 0000000..c60b7e8 --- /dev/null +++ b/docs/host-and-network-safety.md @@ -0,0 +1,114 @@ +# Host and network safety + +This platform uses native Incus virtual machines and managed Linux bridge +networks. Host preparation is intentionally fail-closed: a repository command +must never repair the package manager by removing packages, replace a core +utility provider, rewrite an existing network, or inject firewall rules behind +the operator's back. + +## Incus installation and upgrades + +`scripts/bootstrap-host.sh` supports Ubuntu and Debian hosts. It runs +`apt-get update`, installs the newest `incus` candidate from repositories that +are already configured, and verifies the installed package and server version. +It does not add the Zabbly repository or any other package source. + +Incus recommends an LTS branch for production. Ubuntu and Debian provide native +packages; Zabbly also provides supported current packages for listed +distributions. Selecting or changing that source is an operator-controlled host +policy, not something this repository automates. + +Before APT changes anything, bootstrap verifies: + +```bash +dpkg --audit +sudo apt-get check +test -x /usr/bin/cp +test -x /usr/bin/mv +test -x /usr/bin/rm +``` + +If the configured candidate would upgrade an active Incus daemon, the script +lists running instances and exits. Stop them during scheduled downtime, or set +`VDM_ALLOW_INCUS_UPGRADE_WITH_RUNNING_INSTANCES=true` for that one explicitly +approved run. + +The platform never installs or removes `gnu-coreutils`, +`coreutils-from-uutils`, or an alternative `dd`; it never creates a systemd +`PATH` override for Incus. + +## Network ownership + +The workload project has: + +```yaml +features.networks: "false" +restricted.networks.access: vdm-buildbr0,vdm-agentbr0 +``` + +The bridges and their ACLs live in the Incus `default` project and carry: + +```yaml +user.vdm.managed: "true" +user.vdm.platform: vdm-opencode-platform +``` + +`apply-incus.sh` refuses to overwrite a same-named network or ACL without those +markers. Before creating a bridge, it also rejects overlapping host routes for +`10.248.17.0/24` or `10.248.18.0/24`. + +Older repository revisions created the bridges inside `vdm-agents`. The apply +script can migrate that layout only when the project contains no instances and +owns no unrelated networks or ACLs. Otherwise it stops and lists the blocker; +it never deletes an instance to force a migration. + +All ACL rules explicitly use `state: enabled`, avoiding version-dependent empty +state parsing. Ingress stays empty and the NIC default ingress/egress actions +stay `reject`. + +## Bridge networking, not OVN + +Both networks are native Incus `bridge` networks with NAT, DHCP and DNS. This +layout does not install or require Open vSwitch, `ovn-central`, `ovn-host`, or +the OVN database sockets. + +Verify the applied state: + +```bash +incus project get vdm-agents features.networks +incus --project default network show vdm-buildbr0 +incus --project default network show vdm-agentbr0 +incus --project default network acl show vdm-build +``` + +The first command must print `false`. Each network and ACL must show +`user.vdm.platform: vdm-opencode-platform`. + +## Docker coexistence + +The repository does not modify Docker, nftables, iptables, UFW, or the +`DOCKER-USER` chain. Docker can set the global forwarding policy to `drop`, +which may block Incus bridge egress. Diagnose this separately: + +```bash +sysctl net.ipv4.conf.all.forwarding +sudo iptables -S FORWARD +sudo iptables -S DOCKER-USER 2>/dev/null || true +``` + +Choose one of the Incus-documented host policies deliberately: configure +Docker's `ip-forward-no-drop`, enable forwarding before Docker starts, or add +persistent bridge-specific egress rules. Do not let a project bootstrap choose +between those system-wide policies automatically. See the +[Incus firewall documentation](https://linuxcontainers.org/incus/docs/main/howto/network_bridge_firewalld/#prevent-connectivity-issues-with-incus-and-docker). + +## Publication and failure handling + +The builder publishes the verified stopped instance directly. It does not make +a `sanitized` snapshot, and the launcher does not make a `factory` snapshot. +This avoids an unnecessary full raw-disk copy on the `dir` storage driver. + +On failure, the exact managed build VM is stopped and retained. The next +identical build resumes it; `--clean` deletes only a checkpoint whose platform, +variant and fingerprint ownership metadata all match. No broad project or host +cleanup is performed. diff --git a/docs/image-variants.md b/docs/image-variants.md index c81ad03..a464e28 100644 --- a/docs/image-variants.md +++ b/docs/image-variants.md @@ -46,8 +46,9 @@ instead of weakening the common ACL. ## Resource sizes Tiny, small, medium, standard, large, xlarge and builder profiles are selected -at VM launch. They are independent of image composition, avoiding a -combinatorial image catalogue. +at VM launch. They are independent of image composition and of the separate +manifest-owned build plans. A `standard` 16 GiB runtime profile therefore does +not cause a temporary image builder to allocate 16 GiB. ## Adding support diff --git a/docs/known-limitations.md b/docs/known-limitations.md index a1af657..e2bfdba 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -2,6 +2,9 @@ - Static and mocked validation is available, but the six real Incus images have not yet been built in this delivery environment. +- Build-resource selection, resumable checkpoints and cache detachment are + covered by repository tests, but release promotion still requires a real + clean `--no-cache` build on two independent hardware-virtualisation hosts. - JoomEngine MCP for Joomla `0.7.0` is now pinned, installed and fail-closed in PHP/full images, but upstream still retains live-production evidence gates for action families. This repository's safe helper proves only the configured diff --git a/docs/local-image-builds.md b/docs/local-image-builds.md index 31af73e..5be9cf1 100644 --- a/docs/local-image-builds.md +++ b/docs/local-image-builds.md @@ -4,18 +4,85 @@ This path builds, packages, verifies and optionally publishes Incus images entir ## One-time builder setup -Use a trusted Ubuntu host with hardware virtualisation, read/write access to `/dev/kvm`, Incus, and at least the configured `MIN_BUILD_FREE_GIB`. +Use a trusted Ubuntu or Debian host with hardware virtualisation, read/write +access to `/dev/kvm`, and a supported Incus package. The bootstrap installs the +newest candidate from the host's already configured repositories. Incus LTS is +recommended for production builders; changing from the distribution package to +another supported package source is a separate operator decision. ```bash git clone https://github.com/vast-development-method/opencode-platform.git cd opencode-platform cp .env.example .env ./scripts/bootstrap-host.sh -./scripts/ci/check-incus-runner.sh +./scripts/ci/check-incus-runner.sh typescript ``` The builder does not need a GitHub Actions runner or a Gitea Actions runner. +`bootstrap-host.sh` is deliberately conservative: + +- APT and dpkg must already be internally consistent. +- `/usr/bin/cp`, `/usr/bin/mv`, and `/usr/bin/rm` must exist and be executable. +- No package repository or coreutils provider is added, removed or replaced. +- If a newer Incus package would restart a daemon with running instances, the + command stops and lists those instances. Set + `VDM_ALLOW_INCUS_UPGRADE_WITH_RUNNING_INSTANCES=true` only during scheduled + downtime. + +## Build capacity + +Build resources are distinct from launch-time runtime sizes. The manifest +defines a minimum and preferred build plan for every variant. Before it creates +or starts a VM, the builder: + +- keeps the greater of 20% of total RAM or 2 GiB available to the host; +- reserves an additional 512 MiB for QEMU overhead; +- leaves at least one online CPU to the host; +- checks current `MemAvailable`, not merely installed RAM; +- does not count swap; +- checks variant disk, cache and host reserve requirements. + +| Variant | Minimum build | Preferred build | Virtual disk | +|---|---:|---:|---:| +| `base` | 2 CPUs / 3 GiB | 2 CPUs / 4 GiB | 40 GiB | +| `php` | 2 CPUs / 6 GiB | 4 CPUs / 8 GiB | 100 GiB | +| `python` | 2 CPUs / 4 GiB | 4 CPUs / 6 GiB | 60 GiB | +| `cpp` | 2 CPUs / 4 GiB | 4 CPUs / 6 GiB | 100 GiB | +| `typescript` | 2 CPUs / 6 GiB | 4 CPUs / 8 GiB | 80 GiB | +| `full` | 4 CPUs / 8 GiB | 6 CPUs / 12 GiB | 160 GiB | + +A 14 GiB, 4-core/8-thread laptop is sufficient for TypeScript when other +workloads leave roughly 9.5 GiB available. If only 3.4 GiB is available, the +builder reports the exact shortfall and exits before applying Incus resources. +Closing other workloads is sufficient; adding swap is not a repository +requirement. + +## Caching, resume and clean builds + +The default managed `vdm-build-cache` volume retains only package/browser +downloads. Package managers still perform their normal signature and integrity +checks. The volume is detached before verification is published, so it is not +part of the image. + +If a build fails, the VM is stopped rather than destroyed. Completed stages +resume when the same source and variant are run again, and a non-secret +diagnostic bundle is written under `build/diagnostics/`. + +```bash +# Resume the exact retained checkpoint. +./scripts/build-image.sh typescript + +# Discard that checkpoint but retain verified download caches. +./scripts/build-image.sh --clean typescript + +# Independent clean-room evidence: no checkpoint and no shared cache. +./scripts/build-image.sh --clean --no-cache typescript +``` + +Successful builds delete their temporary VM automatically. The managed cache +remains bounded by the manifest. + ## Build locally without publishing Build all six variants: @@ -69,7 +136,7 @@ For a reproducible production release, build the exact protected tag and require ```bash git fetch --tags --prune origin -git switch --detach v0.3.0-rc.2 +git switch --detach v0.3.0-rc.3 export LOCAL_BUILD_VARIANTS=all export LOCAL_REQUIRE_CLEAN=true @@ -87,7 +154,7 @@ export GITEA_PACKAGE_TOKEN unset GITEA_PACKAGE_TOKEN ``` -Replace `v0.3.0-rc.2` with the version being released. Tags must not be recreated or force-moved after packages are published. +Replace `v0.3.0-rc.3` with the version being released. Tags must not be recreated or force-moved after packages are published. ## Supported environment variables @@ -101,6 +168,9 @@ Replace `v0.3.0-rc.2` with the version being released. Tags must not be recreate | `GITEA_PACKAGE_OWNER` | unset | Gitea package owner. | | `GITEA_PACKAGE_USER` | unset | Dedicated publishing user. | | `GITEA_PACKAGE_TOKEN` | unset | Runtime-only package-write token. | +| `MIN_BUILD_FREE_GIB` | `0` | Optional disk floor; zero uses the variant-specific manifest requirement. | +| `VDM_BUILD_CACHE` | `true` | Reuse the managed package/browser download cache. | +| `MIN_INCUS_VERSION` | `6.0.5` | Oldest supported Incus server; bootstrap still installs the newest configured candidate. | ## Import and use diff --git a/docs/quick-start.md b/docs/quick-start.md index 313f22a..8ab6bf8 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -17,9 +17,15 @@ secrets in `.env`. ```bash ./scripts/bootstrap-host.sh ./scripts/apply-incus.sh -./scripts/ci/check-incus-runner.sh +./scripts/ci/check-incus-runner.sh php ``` +The bootstrap fails before package installation when APT/dpkg is inconsistent +or essential core utilities are missing. It upgrades Incus only to the newest +candidate in repositories already configured by the operator. It does not add +a third-party repository, replace core utilities, install OVN/Open vSwitch, or +change Docker/firewall configuration. + ## 3. Build one image For Joomla/JCB work: @@ -34,6 +40,20 @@ For web/TypeScript: ./scripts/build-image.sh typescript ``` +The build command repeats the resource preflight before creating anything. +Runtime size profiles are not build profiles: TypeScript normally builds with +4 CPUs and 8 GiB, can safely fall back no lower than 2 CPUs and 6 GiB, and +always preserves the manifest's host-memory and QEMU reserve. Swap is not +required or counted. + +If provisioning fails, rerun the same command. The stopped checkpoint resumes +completed stages, and cached Playwright/APT/npm/pip downloads are reused. For a +deliberately clean verification build: + +```bash +./scripts/build-image.sh --clean --no-cache typescript +``` + Build every variant only on a dedicated builder with sufficient disk space: ```bash @@ -43,6 +63,9 @@ Build every variant only on a dedicated builder with sufficient disk space: A platform upgrade that changes the PHP image requires rebuilding and relaunching from the new versioned alias. Existing instances do not acquire newly installed packages merely because the repository changed. +The verified stopped build VM is published directly. No temporary full-disk +snapshot is created. + ## 4. Launch a personal VM ```bash diff --git a/docs/security-model.md b/docs/security-model.md index a8c4ef7..a295e6f 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -38,11 +38,19 @@ backup servers or production SSH directly. ## Snapshots -Only stateless stopped snapshots may become shared images. Never publish a stateful snapshot because guest memory -may contain session tokens. +Images are published directly from a verified stopped build instance. The +restricted project blocks snapshots by default, and the launcher does not +create an implicit `factory` snapshot. Exact stopped-instance exports remain +available because `restricted.backups=allow`; operators must protect exported +archives according to the backup policy. ## Build hygiene The finaliser removes OpenCode auth files, MCP auth files, shell history, SSH private keys and token-like files. The verification test blocks publication if these remain. This is defence in depth, not proof that arbitrary secret text never appeared elsewhere; build from a clean base and do not authenticate during image construction. + +The managed download cache contains package-manager and Playwright downloads, +not runtime credentials. It is detached before finalisation is published. +Release evidence should include at least one `--clean --no-cache` build on an +independent host. diff --git a/docs/versioning-and-promotion.md b/docs/versioning-and-promotion.md index 1e4dce8..5cff066 100644 --- a/docs/versioning-and-promotion.md +++ b/docs/versioning-and-promotion.md @@ -3,9 +3,9 @@ The repository version, image alias, Gitea package version and release tag must match. ```text -Repository tag: v0.3.0-rc.2 -Incus alias: vdm-opencode-php/0.3.0-rc.2-amd64 -Gitea package: vdm-opencode-php-amd64/0.3.0-rc.2 +Repository tag: v0.3.0-rc.3 +Incus alias: vdm-opencode-php/0.3.0-rc.3-amd64 +Gitea package: vdm-opencode-php-amd64/0.3.0-rc.3 Build manifest: /etc/vdm-opencode-platform/build.json ``` diff --git a/image/files/usr/local/libexec/vdm-opencode-session b/image/files/usr/local/libexec/vdm-opencode-session index 137cae8..331beaa 100755 --- a/image/files/usr/local/libexec/vdm-opencode-session +++ b/image/files/usr/local/libexec/vdm-opencode-session @@ -78,7 +78,7 @@ case "$VDM_SESSION_MODE" in exit 2 } install -d -m 0750 "$VDM_JOOMLA_TEST_OUTPUT" - exec /usr/local/bin/vdm-jomla-mcp-live-test \ + exec /usr/local/bin/vdm-joomla-mcp-live-test \ --config /etc/joomla-mcp/sites.json \ --site "$VDM_JOOMLA_SITE_ALIAS" \ --profile read \ diff --git a/image/provision/browser.sh b/image/provision/browser.sh index dfc2a7a..6c7a0f1 100755 --- a/image/provision/browser.sh +++ b/image/provision/browser.sh @@ -4,16 +4,37 @@ set -Eeuo pipefail npm install --global \ "${PLAYWRIGHT_MCP_PACKAGE:?PLAYWRIGHT_MCP_PACKAGE is required}" \ "${PLAYWRIGHT_PACKAGE:?PLAYWRIGHT_PACKAGE is required}" + install -d -m 0755 /opt/ms-playwright -PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright npx playwright install --with-deps chromium +if [ -n "${VDM_BUILD_CACHE_DIR:-}" ]; then + browser_cache="$VDM_BUILD_CACHE_DIR/playwright/$PLAYWRIGHT_EXPECTED_VERSION" + install -d -m 0755 "$browser_cache" + PLAYWRIGHT_BROWSERS_PATH="$browser_cache" \ + npx playwright install --with-deps chromium + rm -rf /opt/ms-playwright + install -d -m 0755 /opt/ms-playwright + cp -a "$browser_cache/." /opt/ms-playwright/ +else + PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright \ + npx playwright install --with-deps chromium +fi playwright_mcp_path="$(command -v playwright-mcp)" ln -sfn "$playwright_mcp_path" /usr/local/bin/vdm-playwright-mcp -[ "$(playwright --version | awk '{print $2}')" = "$PLAYWRIGHT_EXPECTED_VERSION" ] +installed_playwright="$(playwright --version | awk 'NR == 1 {print $2}')" +[ "$installed_playwright" = "$PLAYWRIGHT_EXPECTED_VERSION" ] || { + printf 'Playwright version mismatch: expected %s, got %s\n' \ + "$PLAYWRIGHT_EXPECTED_VERSION" "$installed_playwright" >&2 + exit 1 +} installed_mcp="$(npm list --global --json --depth=0 | jq -r '.dependencies["@playwright/mcp"].version')" -[ "$installed_mcp" = "$PLAYWRIGHT_MCP_EXPECTED_VERSION" ] +[ "$installed_mcp" = "$PLAYWRIGHT_MCP_EXPECTED_VERSION" ] || { + printf 'Playwright MCP version mismatch: expected %s, got %s\n' \ + "$PLAYWRIGHT_MCP_EXPECTED_VERSION" "$installed_mcp" >&2 + exit 1 +} test -x /usr/local/bin/vdm-playwright-mcp CONFIG=/home/opencode/.config/opencode/opencode.json diff --git a/image/provision/common.sh b/image/provision/common.sh index 5e976d0..f9e9cce 100755 --- a/image/provision/common.sh +++ b/image/provision/common.sh @@ -3,8 +3,19 @@ set -Eeuo pipefail AGENT_USER="${AGENT_USER:-opencode}" AGENT_HOME="/home/${AGENT_USER}" +BUILD_CACHE_DIR="${VDM_BUILD_CACHE_DIR:-}" export DEBIAN_FRONTEND=noninteractive +if [ -n "$BUILD_CACHE_DIR" ]; then + install -d -m 0755 \ + "$BUILD_CACHE_DIR/apt/archives/partial" \ + "$BUILD_CACHE_DIR/npm" \ + "$BUILD_CACHE_DIR/pip" + cat > /etc/apt/apt.conf.d/90-vdm-build-cache </dev/null 2>&1; then useradd --create-home --shell /bin/bash "$AGENT_USER" fi +if [ -n "$BUILD_CACHE_DIR" ]; then + chown -R "$AGENT_USER:$AGENT_USER" \ + "$BUILD_CACHE_DIR/npm" \ + "$BUILD_CACHE_DIR/pip" +fi install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0750 /workspace install -d -o "$AGENT_USER" -g "$AGENT_USER" -m 0750 "$AGENT_HOME/.local/bin" @@ -62,6 +78,7 @@ ln -sfn "$opencode_path" "$AGENT_HOME/.local/bin/opencode" sudo -u "$AGENT_USER" -H env \ PIPX_HOME="$AGENT_HOME/.local/share/pipx" \ PIPX_BIN_DIR="$AGENT_HOME/.local/bin" \ + PIP_CACHE_DIR="${PIP_CACHE_DIR:-$AGENT_HOME/.cache/pip}" \ pipx install --force "${GIT_MCP_PACKAGE:?GIT_MCP_PACKAGE is required}" test -x "$AGENT_HOME/.local/bin/opencode" diff --git a/image/provision/finalize.sh b/image/provision/finalize.sh index aafc96e..2272e07 100755 --- a/image/provision/finalize.sh +++ b/image/provision/finalize.sh @@ -64,7 +64,9 @@ rm -rf \ /var/log/journal/* \ /var/lib/cloud/instances/* \ /var/lib/dhcp/* \ + /var/lib/vdm-opencode-build \ /opt/vdm-build +rm -f /etc/apt/apt.conf.d/90-vdm-build-cache apt-get clean rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* chown -R "$AGENT_USER:$AGENT_USER" "$AGENT_HOME" /workspace diff --git a/image/provision/python.sh b/image/provision/python.sh index 800fedb..f513191 100755 --- a/image/provision/python.sh +++ b/image/provision/python.sh @@ -7,8 +7,32 @@ apt-get install -y --no-install-recommends \ python3-dev \ python3-pytest -sudo -u opencode -H uv tool install --force "${RUFF_PACKAGE:?RUFF_PACKAGE is required}" -sudo -u opencode -H uv tool install --force "${MYPY_PACKAGE:?MYPY_PACKAGE is required}" +sudo -u opencode -H env \ + PIPX_HOME=/home/opencode/.local/share/pipx \ + PIPX_BIN_DIR=/home/opencode/.local/bin \ + PIP_CACHE_DIR="${PIP_CACHE_DIR:-/home/opencode/.cache/pip}" \ + pipx install --force "${RUFF_PACKAGE:?RUFF_PACKAGE is required}" +sudo -u opencode -H env \ + PIPX_HOME=/home/opencode/.local/share/pipx \ + PIPX_BIN_DIR=/home/opencode/.local/bin \ + PIP_CACHE_DIR="${PIP_CACHE_DIR:-/home/opencode/.cache/pip}" \ + pipx install --force "${MYPY_PACKAGE:?MYPY_PACKAGE is required}" -[ "$(sudo -u opencode -H /home/opencode/.local/bin/ruff --version | awk '{print $2}')" = "$RUFF_EXPECTED_VERSION" ] -[ "$(sudo -u opencode -H /home/opencode/.local/bin/mypy --version | awk '{print $2}')" = "$MYPY_EXPECTED_VERSION" ] +installed_ruff="$( + sudo -u opencode -H /home/opencode/.local/bin/ruff --version | + awk 'NR == 1 {print $2}' +)" +installed_mypy="$( + sudo -u opencode -H /home/opencode/.local/bin/mypy --version | + awk 'NR == 1 {print $2}' +)" +[ "$installed_ruff" = "$RUFF_EXPECTED_VERSION" ] || { + printf 'Ruff version mismatch: expected %s, got %s\n' \ + "$RUFF_EXPECTED_VERSION" "$installed_ruff" >&2 + exit 1 +} +[ "$installed_mypy" = "$MYPY_EXPECTED_VERSION" ] || { + printf 'mypy version mismatch: expected %s, got %s\n' \ + "$MYPY_EXPECTED_VERSION" "$installed_mypy" >&2 + exit 1 +} diff --git a/image/provision/typescript.sh b/image/provision/typescript.sh index de5bac9..0a8d161 100755 --- a/image/provision/typescript.sh +++ b/image/provision/typescript.sh @@ -6,5 +6,29 @@ npm install --global \ "${TYPESCRIPT_PACKAGE:?TYPESCRIPT_PACKAGE is required}" \ "${TSX_PACKAGE:?TSX_PACKAGE is required}" -[ "$(tsc --version | awk '{print $2}')" = "$TYPESCRIPT_EXPECTED_VERSION" ] -[ "$(tsx --version | awk 'NR == 1 {print $2}')" = "$TSX_EXPECTED_VERSION" ] +installed_typescript="$( + tsc --version | + awk 'NR == 1 {print $2}' +)" + +installed_tsx="$( + tsx --version | + awk 'NR == 1 {print $2}' +)" +installed_tsx="${installed_tsx#v}" + +if [ "$installed_typescript" != "$TYPESCRIPT_EXPECTED_VERSION" ]; then + printf \ + 'TypeScript version mismatch: expected %s, got %s\n' \ + "$TYPESCRIPT_EXPECTED_VERSION" \ + "$installed_typescript" >&2 + exit 1 +fi + +if [ "$installed_tsx" != "$TSX_EXPECTED_VERSION" ]; then + printf \ + 'TSX version mismatch: expected %s, got %s\n' \ + "$TSX_EXPECTED_VERSION" \ + "$installed_tsx" >&2 + exit 1 +fi diff --git a/incus/acls/generated/brokered.yaml b/incus/acls/generated/brokered.yaml index d1a779b..f5215a3 100644 --- a/incus/acls/generated/brokered.yaml +++ b/incus/acls/generated/brokered.yaml @@ -1,19 +1,24 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: brokered' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Runtime DNS destination: 10.248.18.1/32 protocol: udp destination_port: '53' - action: allow + state: enabled description: Runtime DNS over TCP destination: 10.248.18.1/32 protocol: tcp destination_port: '53' - action: allow + state: enabled description: Capability gateway destination: 10.248.18.1/32 protocol: tcp diff --git a/incus/acls/generated/connected.yaml b/incus/acls/generated/connected.yaml index 2848980..3a2a676 100644 --- a/incus/acls/generated/connected.yaml +++ b/incus/acls/generated/connected.yaml @@ -1,43 +1,56 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: connected' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Runtime DNS destination: 10.248.18.1/32 protocol: udp destination_port: '53' - action: allow + state: enabled description: Runtime DNS over TCP destination: 10.248.18.1/32 protocol: tcp destination_port: '53' - action: allow + state: enabled description: Capability gateway destination: 10.248.18.1/32 protocol: tcp destination_port: '443' - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 10.0.0.0/8 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 100.64.0.0/10 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 127.0.0.0/8 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 169.254.0.0/16 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 172.16.0.0/12 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 192.168.0.0/16 - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 224.0.0.0/4 - action: allow + state: enabled description: Public IPv4 Internet diff --git a/incus/acls/generated/lab.yaml b/incus/acls/generated/lab.yaml index 32cdcea..93563f8 100644 --- a/incus/acls/generated/lab.yaml +++ b/incus/acls/generated/lab.yaml @@ -1,7 +1,10 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: lab' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Experimental lab egress diff --git a/incus/acls/generated/offline.yaml b/incus/acls/generated/offline.yaml index 63de650..89cc87f 100644 --- a/incus/acls/generated/offline.yaml +++ b/incus/acls/generated/offline.yaml @@ -1,5 +1,7 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: offline' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: [] diff --git a/incus/acls/generated/release.yaml b/incus/acls/generated/release.yaml index edbd57a..d089d06 100644 --- a/incus/acls/generated/release.yaml +++ b/incus/acls/generated/release.yaml @@ -1,24 +1,30 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: release' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Runtime DNS destination: 10.248.18.1/32 protocol: udp destination_port: '53' - action: allow + state: enabled description: Runtime DNS over TCP destination: 10.248.18.1/32 protocol: tcp destination_port: '53' - action: allow + state: enabled description: Capability gateway destination: 10.248.18.1/32 protocol: tcp destination_port: '443' - action: allow + state: enabled description: Release HTTP CONNECT proxy destination: 10.248.18.1/32 protocol: tcp diff --git a/incus/acls/generated/restricted.yaml b/incus/acls/generated/restricted.yaml index 50ac032..8fefa47 100644 --- a/incus/acls/generated/restricted.yaml +++ b/incus/acls/generated/restricted.yaml @@ -1,24 +1,30 @@ # Generated from manifest/images.yaml. Do not edit. description: 'VDM generated egress policy: restricted' -config: {} +config: + user.vdm.managed: 'true' + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Runtime DNS destination: 10.248.18.1/32 protocol: udp destination_port: '53' - action: allow + state: enabled description: Runtime DNS over TCP destination: 10.248.18.1/32 protocol: tcp destination_port: '53' - action: allow + state: enabled description: Capability gateway destination: 10.248.18.1/32 protocol: tcp destination_port: '443' - action: allow + state: enabled description: Restricted HTTP CONNECT proxy destination: 10.248.18.1/32 protocol: tcp diff --git a/incus/acls/vdm-build.yaml b/incus/acls/vdm-build.yaml index 98f1536..879ac19 100644 --- a/incus/acls/vdm-build.yaml +++ b/incus/acls/vdm-build.yaml @@ -1,37 +1,49 @@ description: Public build egress with host, private, metadata, and management networks denied -config: {} +config: + user.vdm.managed: "true" + user.vdm.platform: vdm-opencode-platform ingress: [] egress: - action: allow + state: enabled description: Build bridge DNS destination: 10.248.17.1/32 protocol: udp destination_port: "53" - action: allow + state: enabled description: Build bridge DNS over TCP destination: 10.248.17.1/32 protocol: tcp destination_port: "53" - action: reject + state: enabled description: Deny private or special-use IPv4 destination: 10.0.0.0/8 - action: reject + state: enabled description: Deny carrier-grade NAT destination: 100.64.0.0/10 - action: reject + state: enabled description: Deny loopback destination: 127.0.0.0/8 - action: reject + state: enabled description: Deny link-local and metadata destination: 169.254.0.0/16 - action: reject + state: enabled description: Deny private IPv4 destination: 172.16.0.0/12 - action: reject + state: enabled description: Deny private IPv4 destination: 192.168.0.0/16 - action: reject + state: enabled description: Deny multicast and reserved IPv4 destination: 224.0.0.0/4 - action: allow + state: enabled description: Public IPv4 package sources diff --git a/incus/networks/vdm-agentbr0.yaml b/incus/networks/vdm-agentbr0.yaml index 2eae4b3..30dbc6e 100644 --- a/incus/networks/vdm-agentbr0.yaml +++ b/incus/networks/vdm-agentbr0.yaml @@ -4,3 +4,5 @@ config: ipv4.nat: "true" ipv4.dhcp: "true" ipv6.address: none + user.vdm.managed: "true" + user.vdm.platform: vdm-opencode-platform diff --git a/incus/networks/vdm-buildbr0.yaml b/incus/networks/vdm-buildbr0.yaml index 9b367ca..922dfe8 100644 --- a/incus/networks/vdm-buildbr0.yaml +++ b/incus/networks/vdm-buildbr0.yaml @@ -4,3 +4,5 @@ config: ipv4.nat: "true" ipv4.dhcp: "true" ipv6.address: none + user.vdm.managed: "true" + user.vdm.platform: vdm-opencode-platform diff --git a/incus/projects/vdm-agents.yaml b/incus/projects/vdm-agents.yaml index 03f8445..1f515df 100644 --- a/incus/projects/vdm-agents.yaml +++ b/incus/projects/vdm-agents.yaml @@ -1,10 +1,12 @@ description: Isolated VDM OpenCode agent workloads config: features.images: "true" - features.networks: "true" + features.networks: "false" features.profiles: "true" features.storage.volumes: "true" restricted: "true" + restricted.backups: allow + restricted.snapshots: block restricted.devices.disk: managed restricted.devices.nic: managed restricted.networks.access: vdm-buildbr0,vdm-agentbr0 diff --git a/manifest/generated/platform.env b/manifest/generated/platform.env index d0d9eee..4718386 100644 --- a/manifest/generated/platform.env +++ b/manifest/generated/platform.env @@ -1,11 +1,18 @@ # Generated from manifest/images.yaml. Do not edit. PLATFORM_NAME=vdm-opencode-platform -PLATFORM_VERSION=0.3.0-rc.2 +PLATFORM_VERSION=0.3.0-rc.3 INCUS_BASE_IMAGE=images:ubuntu/24.04/cloud INCUS_IMAGE_PREFIX=vdm-opencode PLATFORM_IMAGES=(base php python cpp typescript full) PLATFORM_RELEASE_IMAGES=(base php python cpp typescript full) PLATFORM_RELEASE_ARCHITECTURES=(amd64) +PLATFORM_BUILD_HOST_MEMORY_RESERVE_PERCENT=20 +PLATFORM_BUILD_HOST_MEMORY_RESERVE_MIN_GIB=2 +PLATFORM_BUILD_QEMU_OVERHEAD_MIB=512 +PLATFORM_BUILD_HOST_CPU_RESERVE=1 +PLATFORM_BUILD_HOST_DISK_RESERVE_GIB=20 +PLATFORM_BUILD_CACHE_VOLUME=vdm-build-cache +PLATFORM_BUILD_CACHE_SIZE_GIB=20 declare -Ag PLATFORM_ARCH_ALIASES=( [x86_64]=amd64 @@ -41,6 +48,51 @@ declare -Ag PLATFORM_IMAGE_DEFAULT_SIZE=( [full]=xlarge ) +declare -Ag PLATFORM_BUILD_MIN_CPUS=( + [base]=2 + [php]=2 + [python]=2 + [cpp]=2 + [typescript]=2 + [full]=4 +) + +declare -Ag PLATFORM_BUILD_PREFERRED_CPUS=( + [base]=2 + [php]=4 + [python]=4 + [cpp]=4 + [typescript]=4 + [full]=6 +) + +declare -Ag PLATFORM_BUILD_MIN_MEMORY_GIB=( + [base]=3 + [php]=6 + [python]=4 + [cpp]=4 + [typescript]=6 + [full]=8 +) + +declare -Ag PLATFORM_BUILD_PREFERRED_MEMORY_GIB=( + [base]=4 + [php]=8 + [python]=6 + [cpp]=6 + [typescript]=8 + [full]=12 +) + +declare -Ag PLATFORM_BUILD_DISK_GIB=( + [base]=40 + [php]=100 + [python]=60 + [cpp]=100 + [typescript]=80 + [full]=160 +) + declare -Ag PLATFORM_COMPONENT_PROVISION=( [shell]='' [php]=php.sh diff --git a/manifest/images.yaml b/manifest/images.yaml index 8161f70..78921d7 100644 --- a/manifest/images.yaml +++ b/manifest/images.yaml @@ -2,7 +2,7 @@ schema: 2 platform: name: vdm-opencode-platform - version: 0.3.0-rc.2 + version: 0.3.0-rc.3 image_prefix: vdm-opencode base_image: images:ubuntu/24.04/cloud architectures: @@ -17,6 +17,54 @@ platform: holder: Vast Development Method year: 2026 +build: + host: + memory_reserve_percent: 20 + memory_reserve_min: 2GiB + qemu_overhead: 512MiB + cpu_reserve: 1 + disk_reserve: 20GiB + cache: + volume: vdm-build-cache + size: 20GiB + variants: + base: + min_cpus: 2 + preferred_cpus: 2 + min_memory: 3GiB + preferred_memory: 4GiB + disk: 40GiB + php: + min_cpus: 2 + preferred_cpus: 4 + min_memory: 6GiB + preferred_memory: 8GiB + disk: 100GiB + python: + min_cpus: 2 + preferred_cpus: 4 + min_memory: 4GiB + preferred_memory: 6GiB + disk: 60GiB + cpp: + min_cpus: 2 + preferred_cpus: 4 + min_memory: 4GiB + preferred_memory: 6GiB + disk: 100GiB + typescript: + min_cpus: 2 + preferred_cpus: 4 + min_memory: 6GiB + preferred_memory: 8GiB + disk: 80GiB + full: + min_cpus: 4 + preferred_cpus: 6 + min_memory: 8GiB + preferred_memory: 12GiB + disk: 160GiB + components: shell: {status: ready, provision: null} php: {status: ready, provision: php.sh} diff --git a/manifest/platform.env b/manifest/platform.env index 389f153..3709359 100644 --- a/manifest/platform.env +++ b/manifest/platform.env @@ -15,4 +15,6 @@ GITHUB_BASE_URL="${GITHUB_BASE_URL:-https://github.com}" DEFAULT_SESSION_TTL="${DEFAULT_SESSION_TTL:-2h}" DEFAULT_IMAGE_RETENTION="${DEFAULT_IMAGE_RETENTION:-5}" -MIN_BUILD_FREE_GIB="${MIN_BUILD_FREE_GIB:-220}" +MIN_BUILD_FREE_GIB="${MIN_BUILD_FREE_GIB:-0}" +MIN_INCUS_VERSION="${MIN_INCUS_VERSION:-6.0.5}" +VDM_BUILD_CACHE="${VDM_BUILD_CACHE:-true}" diff --git a/manifest/toolchain.env b/manifest/toolchain.env index a149e4c..24fe71b 100644 --- a/manifest/toolchain.env +++ b/manifest/toolchain.env @@ -1,4 +1,4 @@ -# Approved package versions for platform release 0.3.0-rc.2. +# Approved package versions for platform release 0.3.0-rc.3. # Update these values deliberately and validate every image before promotion. OPENCODE_PACKAGE=opencode-ai@1.18.4 diff --git a/scripts/apply-incus.sh b/scripts/apply-incus.sh index 9e6eb81..01be3cf 100755 --- a/scripts/apply-incus.sh +++ b/scripts/apply-incus.sh @@ -4,35 +4,188 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/common.sh" -require_command incus +for command in incus jq python3; do + require_command "$command" +done + +expected_networks=("$INCUS_BUILD_NETWORK" "$INCUS_RUNTIME_NETWORK") +expected_acls=(vdm-build) +for policy_file in "$ROOT_DIR"/incus/acls/generated/*.yaml; do + expected_acls+=("vdm-agent-$(basename "$policy_file" .yaml)") +done + +is_expected_name() { + local wanted="${1:?name required}" + shift + local item + for item in "$@"; do + [ "$item" = "$wanted" ] && return 0 + done + return 1 +} + +migrate_legacy_project_networks() { + local feature + local name + local -a instances=() + local -a networks=() + local -a acls=() + + feature="$(incus_cmd project get "$INCUS_PROJECT" features.networks)" + [ "$feature" = true ] || return 0 + + mapfile -t instances < <( + project_cmd list --format json | + jq -r '.[].name' + ) + if (("${#instances[@]}" > 0)); then + printf 'Instances in %s:\n' "$INCUS_PROJECT" >&2 + printf ' %s\n' "${instances[@]}" >&2 + die "The legacy project owns its networks. Automatic migration is safe only before instances exist. Export or remove these instances deliberately, then rerun apply-incus.sh." + fi + + mapfile -t networks < <( + project_cmd network list --format json | + jq -r '.[].name' + ) + for name in "${networks[@]}"; do + is_expected_name "$name" "${expected_networks[@]}" || + die "Refusing to migrate project $INCUS_PROJECT because it owns an unrelated network: $name" + done + + mapfile -t acls < <( + project_cmd network acl list --format json | + jq -r '.[].name' + ) + for name in "${acls[@]}"; do + is_expected_name "$name" "${expected_acls[@]}" || + die "Refusing to migrate project $INCUS_PROJECT because it owns an unrelated ACL: $name" + done + + log "Migrating legacy project-owned VDM networks to host-managed bridges" + for policy_file in "$ROOT_DIR"/incus/profiles/generated/policies/*.yaml; do + name="vdm-policy-$(basename "$policy_file" .yaml)" + project_cmd profile delete "$name" >/dev/null 2>&1 || true + done + for name in "${expected_networks[@]}"; do + project_cmd network delete "$name" >/dev/null 2>&1 || true + done + for name in "${expected_acls[@]}"; do + project_cmd network acl delete "$name" >/dev/null 2>&1 || true + done + incus_cmd project set "$INCUS_PROJECT" features.networks=false +} if ! incus_cmd project show "$INCUS_PROJECT" >/dev/null 2>&1; then log "Creating Incus project $INCUS_PROJECT" - incus_cmd project create "$INCUS_PROJECT" + incus_cmd project create "$INCUS_PROJECT" \ + --config features.images=true \ + --config features.networks=false \ + --config features.profiles=true \ + --config features.storage.volumes=true +else + migrate_legacy_project_networks fi + incus_cmd project edit "$INCUS_PROJECT" < "$ROOT_DIR/incus/projects/vdm-agents.yaml" +[ "$(incus_cmd project get "$INCUS_PROJECT" features.networks)" = false ] || + die "Project $INCUS_PROJECT must share host-managed networks (features.networks=false)." + +check_subnet_available() { + local network="${1:?network required}" + local subnet="${2:?subnet required}" + local routes_json + + default_cmd network show "$network" >/dev/null 2>&1 && return 0 + command -v ip >/dev/null 2>&1 || return 0 + routes_json="$(ip -j -4 route show)" + + if ! python3 - "$network" "$subnet" "$routes_json" <<'PY' +import ipaddress +import json +import sys + +network_name = sys.argv[1] +wanted = ipaddress.ip_network(sys.argv[2]) +routes = json.loads(sys.argv[3]) +for route in routes: + destination = route.get("dst") + if not destination or destination == "default": + continue + try: + current = ipaddress.ip_network(destination, strict=False) + except ValueError: + continue + if current.overlaps(wanted) and route.get("dev") != network_name: + print( + f"{wanted} overlaps host route {destination} " + f"on {route.get('dev', 'unknown')}", + file=sys.stderr, + ) + raise SystemExit(1) +PY + then + die "Refusing to create $network because its subnet conflicts with an existing host route." + fi +} + +assert_managed_default_resource() { + local kind="${1:?resource kind required}" + local name="${2:?resource name required}" + local marker + + case "$kind" in + network) + marker="$(default_cmd network get "$name" user.vdm.platform 2>/dev/null || true)" + ;; + acl) + marker="$(default_cmd network acl get "$name" user.vdm.platform 2>/dev/null || true)" + ;; + *) die "Unknown resource kind: $kind" ;; + esac + [ "$marker" = "$PLATFORM_NAME" ] || + die "Refusing to overwrite existing default-project $kind $name because it is not marked as managed by $PLATFORM_NAME." +} apply_network() { local name="$1" local file="$2" - if ! project_cmd network show "$name" >/dev/null 2>&1; then - project_cmd network create "$name" + local subnet="$3" + + if default_cmd network show "$name" >/dev/null 2>&1; then + assert_managed_default_resource network "$name" + else + check_subnet_available "$name" "$subnet" + default_cmd network create "$name" --type=bridge \ + user.vdm.managed=true \ + "user.vdm.platform=$PLATFORM_NAME" fi - project_cmd network edit "$name" < "$file" + default_cmd network edit "$name" < "$file" } -apply_network "$INCUS_BUILD_NETWORK" "$ROOT_DIR/incus/networks/vdm-buildbr0.yaml" -apply_network "$INCUS_RUNTIME_NETWORK" "$ROOT_DIR/incus/networks/vdm-agentbr0.yaml" - apply_acl() { local name="$1" local file="$2" - if ! project_cmd network acl show "$name" >/dev/null 2>&1; then - project_cmd network acl create "$name" + + if default_cmd network acl show "$name" >/dev/null 2>&1; then + assert_managed_default_resource acl "$name" + else + default_cmd network acl create "$name" \ + user.vdm.managed=true \ + "user.vdm.platform=$PLATFORM_NAME" fi - project_cmd network acl edit "$name" < "$file" + default_cmd network acl edit "$name" < "$file" } +apply_network \ + "$INCUS_BUILD_NETWORK" \ + "$ROOT_DIR/incus/networks/vdm-buildbr0.yaml" \ + 10.248.17.0/24 +apply_network \ + "$INCUS_RUNTIME_NETWORK" \ + "$ROOT_DIR/incus/networks/vdm-agentbr0.yaml" \ + 10.248.18.0/24 + apply_acl vdm-build "$ROOT_DIR/incus/acls/vdm-build.yaml" for policy_file in "$ROOT_DIR"/incus/acls/generated/*.yaml; do policy="$(basename "$policy_file" .yaml)" @@ -42,12 +195,16 @@ done apply_profile() { local profile="$1" local profile_file="$2" + if ! project_cmd profile show "$profile" >/dev/null 2>&1; then project_cmd profile create "$profile" fi if [ -n "${INCUS_STORAGE_POOL:-}" ]; then - sed "s/pool: default/pool: ${INCUS_STORAGE_POOL}/" "$profile_file" | project_cmd profile edit "$profile" + [[ "$INCUS_STORAGE_POOL" =~ ^[A-Za-z0-9_.-]+$ ]] || + die "Unsafe Incus storage pool name: $INCUS_STORAGE_POOL" + sed "s/pool: default/pool: ${INCUS_STORAGE_POOL}/" "$profile_file" | + project_cmd profile edit "$profile" else project_cmd profile edit "$profile" < "$profile_file" fi @@ -66,4 +223,8 @@ for profile_file in "$ROOT_DIR"/incus/profiles/generated/policies/*.yaml; do apply_profile "vdm-policy-$name" "$profile_file" done -log "Incus project definition applied" +default_cmd network show "$INCUS_BUILD_NETWORK" >/dev/null +default_cmd network show "$INCUS_RUNTIME_NETWORK" >/dev/null +default_cmd network acl show vdm-build >/dev/null + +log "Incus project, host-managed bridge networks, ACLs and profiles applied" diff --git a/scripts/bootstrap-host.sh b/scripts/bootstrap-host.sh index c4f76bc..4c774aa 100755 --- a/scripts/bootstrap-host.sh +++ b/scripts/bootstrap-host.sh @@ -12,12 +12,52 @@ case "${ID:-}:${ID_LIKE:-}" in *) die "Supported hosts are Ubuntu and Debian." ;; esac +for utility in /usr/bin/cp /usr/bin/mv /usr/bin/rm; do + [ -x "$utility" ] || die "Host package state is unsafe: $utility is missing or not executable. Repair dpkg before running bootstrap." +done + +dpkg_audit="$(dpkg --audit)" +[ -z "$dpkg_audit" ] || { + printf '%s\n' "$dpkg_audit" >&2 + die "dpkg reports interrupted or inconsistent packages. Repair the host before running bootstrap." +} +sudo apt-get check >/dev/null || + die "APT dependency checks failed. Repair the host before running bootstrap." + log "Installing host prerequisites" sudo apt-get update + +incus_candidate="$(apt-cache policy incus | awk '/Candidate:/ {print $2; exit}')" +if [ -z "$incus_candidate" ] || [ "$incus_candidate" = "(none)" ]; then + die "No Incus package candidate is available from the host's configured repositories." +fi + +incus_installed="$( + dpkg-query -W -f='${Version}' incus 2>/dev/null || true +)" +if [ -n "$incus_installed" ] && + dpkg --compare-versions "$incus_installed" lt "$incus_candidate" && + systemctl is-active --quiet incus.service; then + + running_instances="$( + incus_cmd list \ + --all-projects \ + status=Running \ + --format csv + )" || die "Unable to inspect running Incus instances before a daemon upgrade." + if [ -n "$running_instances" ] && + [ "${VDM_ALLOW_INCUS_UPGRADE_WITH_RUNNING_INSTANCES:-false}" != true ]; then + printf 'Running Incus instances:\n' >&2 + printf '%s\n' "$running_instances" >&2 + die "Incus $incus_candidate is available, but upgrading could restart the daemon. Stop the listed instances or set VDM_ALLOW_INCUS_UPGRADE_WITH_RUNNING_INSTANCES=true after scheduling downtime." + fi +fi + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ ca-certificates \ curl \ incus \ + iproute2 \ jq \ python3-yaml \ qemu-system \ @@ -25,7 +65,22 @@ sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \ yamllint \ zstd -sudo systemctl enable --now incus.service >/dev/null 2>&1 || true +incus_installed="$(dpkg-query -W -f='${Version}' incus)" +[ "$incus_installed" = "$incus_candidate" ] || + die "Incus installation did not reach the configured repository candidate: installed $incus_installed, candidate $incus_candidate." + +sudo systemctl enable --now incus.service +sudo systemctl is-active --quiet incus.service || + die "Incus did not become active after package installation." + +server_version="$( + incus_cmd version | + awk -F': ' '/^Server version:/ {print $2; exit}' +)" +[ -n "$server_version" ] || die "Unable to determine the Incus server version." +version_at_least "$server_version" "$MIN_INCUS_VERSION" || + die "Incus $server_version is older than the supported minimum $MIN_INCUS_VERSION." +log "Using Incus server $server_version (latest configured package candidate $incus_candidate)" if ! incus_cmd storage list --format csv 2>/dev/null | grep -q .; then log "Initialising Incus" diff --git a/scripts/build-image.sh b/scripts/build-image.sh index 8b7fb03..6131eb9 100755 --- a/scripts/build-image.sh +++ b/scripts/build-image.sh @@ -3,42 +3,309 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/build-resources.sh" + +VARIANT="" +CLEAN_BUILD=false +USE_CACHE="${VDM_BUILD_CACHE:-true}" + +while (($# > 0)); do + case "$1" in + --clean) + CLEAN_BUILD=true + ;; + --no-cache) + USE_CACHE=false + ;; + --) + shift + break + ;; + -*) + die "Unknown build option: $1" + ;; + *) + [ -z "$VARIANT" ] || die "Only one build variant may be specified." + VARIANT="$1" + ;; + esac + shift +done +(($# == 0)) || die "Unexpected build arguments: $*" -VARIANT="${1:-}" -variant_exists "$VARIANT" || die "Variant must be one of: ${PLATFORM_IMAGES[*]}" +case "${USE_CACHE,,}" in + true|1|yes) USE_CACHE=true ;; + false|0|no) USE_CACHE=false ;; + *) die "VDM_BUILD_CACHE must be true or false." ;; +esac + +variant_exists "$VARIANT" || + die "Usage: $0 [--clean] [--no-cache] {${PLATFORM_IMAGES[*]}}" + +for command in incus jq sha256sum tar; do + require_command "$command" +done +[ "$(uname -s)" = Linux ] || die "Image builds require a Linux host." +if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + die "Image builds require read/write access to /dev/kvm." +fi +incus_cmd info >/dev/null 2>&1 || + die "The build user cannot communicate with the Incus daemon." + +storage_pool="${INCUS_STORAGE_POOL:-default}" +incus_cmd storage show "$storage_pool" >/dev/null 2>&1 || + die "Incus storage pool not found: $storage_pool" + +select_build_resources "$VARIANT" "$storage_pool" "$USE_CACHE" +print_build_resource_plan "$VARIANT" "$SCRIPT_DIR/apply-incus.sh" -BUILD_NAME="vdm-build-${VARIANT}-$(date +%Y%m%d%H%M%S)" +build_fingerprint="$( + cd "$ROOT_DIR" + { + printf '%s\n' \ + "$PLATFORM_VERSION" \ + "$VARIANT" \ + "${PLATFORM_IMAGE_COMPONENTS[$VARIANT]}" \ + "$BUILD_SELECTED_DISK" + while IFS= read -r -d '' path; do + sha256sum "$path" + done < <( + find \ + image/files \ + image/provision \ + -type f \ + -print0 | + sort -z + ) + sha256sum \ + manifest/generated/platform.env \ + manifest/platform.env \ + manifest/toolchain.env \ + scripts/build-image.sh \ + scripts/lib/build-resources.sh \ + scripts/verify-image.sh + } | sha256sum | awk '{print $1}' +)" +BUILD_NAME="vdm-build-${VARIANT}-${build_fingerprint:0:12}" ALIAS="$(image_alias "$VARIANT")" +BUILD_SUCCESS=false +CACHE_DEVICE=build-cache +DIAGNOSTIC_DIR="" + +instance_exists() { + project_cmd info "$BUILD_NAME" >/dev/null 2>&1 +} + +instance_status() { + project_cmd list "$BUILD_NAME" --format json | + jq -r '.[0].status // "Unknown"' +} + +assert_managed_checkpoint() { + local managed + local fingerprint + local variant + + managed="$( + project_cmd config get "$BUILD_NAME" user.vdm.build.managed 2>/dev/null || + true + )" + fingerprint="$( + project_cmd config get "$BUILD_NAME" user.vdm.build.fingerprint 2>/dev/null || + true + )" + variant="$( + project_cmd config get "$BUILD_NAME" user.vdm.build.variant 2>/dev/null || + true + )" + if [ "$managed" = true ] && + [ "$fingerprint" = "$build_fingerprint" ] && + [ "$variant" = "$VARIANT" ]; then + return + fi + die "Refusing to reuse or delete $BUILD_NAME because its build ownership metadata is invalid." +} + +device_exists() { + project_cmd config device get "$BUILD_NAME" "$CACHE_DEVICE" source \ + >/dev/null 2>&1 +} + +detach_cache() { + device_exists || return 0 + project_cmd config device remove "$BUILD_NAME" "$CACHE_DEVICE" +} + +stop_checkpoint() { + local status + + instance_exists || return 0 + status="$(instance_status 2>/dev/null || true)" + [ "$status" = Running ] || return 0 + project_cmd stop "$BUILD_NAME" --timeout 60 >/dev/null 2>&1 || + project_cmd stop "$BUILD_NAME" --force >/dev/null 2>&1 || + true +} + +collect_failure_diagnostics() { + local timestamp + + timestamp="$(date -u +%Y%m%dT%H%M%SZ)" + DIAGNOSTIC_DIR="$ROOT_DIR/build/diagnostics/${BUILD_NAME}-${timestamp}" + install -d -m 0750 "$DIAGNOSTIC_DIR" + + { + printf 'variant=%s\n' "$VARIANT" + printf 'build_name=%s\n' "$BUILD_NAME" + printf 'fingerprint=%s\n' "$build_fingerprint" + printf 'selected_cpus=%s\n' "$BUILD_SELECTED_CPUS" + printf 'selected_memory=%s\n' "$BUILD_SELECTED_MEMORY" + printf 'selected_disk=%s\n' "$BUILD_SELECTED_DISK" + printf 'host_memory_available_mib=%s\n' "$BUILD_HOST_AVAILABLE_MIB" + printf 'host_memory_reserve_mib=%s\n' "$BUILD_HOST_RESERVE_MIB" + printf 'cache_enabled=%s\n' "$USE_CACHE" + } > "$DIAGNOSTIC_DIR/build-plan.env" -cleanup() { - project_cmd delete "$BUILD_NAME" --force >/dev/null 2>&1 || true + incus_cmd version > "$DIAGNOSTIC_DIR/incus-version.txt" 2>&1 || true + project_cmd info "$BUILD_NAME" > "$DIAGNOSTIC_DIR/instance-info.txt" 2>&1 || + true + project_cmd config show "$BUILD_NAME" --expanded \ + > "$DIAGNOSTIC_DIR/instance-config.yaml" 2>&1 || + true + project_cmd console "$BUILD_NAME" --show-log \ + > "$DIAGNOSTIC_DIR/console.log" 2>&1 || + true + free -h > "$DIAGNOSTIC_DIR/host-memory.txt" 2>&1 || true + df -hT > "$DIAGNOSTIC_DIR/host-filesystems.txt" 2>&1 || true + journalctl --kernel --boot -n 200 --no-pager \ + > "$DIAGNOSTIC_DIR/kernel.log" 2>&1 || + true } -trap cleanup EXIT - -log "Creating build VM $BUILD_NAME" -project_cmd init "$INCUS_BASE_IMAGE" "$BUILD_NAME" --vm \ - --profile "vdm-opencode-${VARIANT}" \ - --profile "vdm-size-${PLATFORM_IMAGE_DEFAULT_SIZE[$VARIANT]}" \ - --profile "vdm-policy-${PLATFORM_IMAGE_DEFAULT_POLICY[$VARIANT]}" - -# Builders need the broader build network. Replace the profile NIC for this temporary VM. -project_cmd config device override "$BUILD_NAME" eth0 \ - network="$INCUS_BUILD_NETWORK" \ - name=eth0 \ - security.acls=vdm-build \ - security.acls.default.ingress.action=reject \ - security.acls.default.egress.action=reject \ - security.ipv4_filtering=true + +finish() { + local status=$? + + trap - EXIT INT TERM + set +e + if [ "$BUILD_SUCCESS" = true ]; then + stop_checkpoint + detach_cache + project_cmd delete "$BUILD_NAME" --force >/dev/null 2>&1 + elif instance_exists; then + collect_failure_diagnostics + stop_checkpoint + detach_cache + warn "Build failed. The stopped checkpoint was retained as $BUILD_NAME." + warn "Rerun the same command to resume completed stages." + [ -z "$DIAGNOSTIC_DIR" ] || + warn "Diagnostics: $DIAGNOSTIC_DIR" + fi + exit "$status" +} +trap finish EXIT +trap 'exit 130' INT TERM + +if instance_exists; then + assert_managed_checkpoint + if [ "$CLEAN_BUILD" = true ]; then + log "Discarding requested clean-build checkpoint $BUILD_NAME" + stop_checkpoint + detach_cache + project_cmd delete "$BUILD_NAME" --force + fi +fi + +if ! instance_exists; then + log "Creating build VM $BUILD_NAME" + project_cmd init "$INCUS_BASE_IMAGE" "$BUILD_NAME" --vm \ + --profile "vdm-opencode-${VARIANT}" \ + --profile "vdm-policy-${PLATFORM_IMAGE_DEFAULT_POLICY[$VARIANT]}" \ + --storage "$storage_pool" \ + --config "limits.cpu=$BUILD_SELECTED_CPUS" \ + --config "limits.memory=$BUILD_SELECTED_MEMORY" \ + --device "root,size=$BUILD_SELECTED_DISK" + + project_cmd config set "$BUILD_NAME" user.vdm.build.managed true + project_cmd config set "$BUILD_NAME" user.vdm.build.variant "$VARIANT" + project_cmd config set "$BUILD_NAME" \ + user.vdm.build.fingerprint "$build_fingerprint" + project_cmd config set "$BUILD_NAME" user.vdm.build.state provisioning + project_cmd config set "$BUILD_NAME" user.vdm.build.cache "$USE_CACHE" + + # Builders use the dedicated build bridge. Runtime policy remains attached + # to the published image only through launch-time profiles. + project_cmd config device override "$BUILD_NAME" eth0 \ + network="$INCUS_BUILD_NETWORK" \ + name=eth0 \ + security.acls=vdm-build \ + security.acls.default.ingress.action=reject \ + security.acls.default.egress.action=reject \ + security.ipv4_filtering=true +else + log "Resuming retained build checkpoint $BUILD_NAME" + stop_checkpoint + project_cmd config set "$BUILD_NAME" limits.cpu "$BUILD_SELECTED_CPUS" + project_cmd config set "$BUILD_NAME" limits.memory "$BUILD_SELECTED_MEMORY" +fi + +build_state="$( + project_cmd config get "$BUILD_NAME" user.vdm.build.state 2>/dev/null || + true +)" +case "$build_state" in + provisioning|finalized) ;; + *) die "Invalid retained build state on $BUILD_NAME: ${build_state:-missing}" ;; +esac + +ensure_cache_volume() { + local marker + + if project_cmd storage volume show \ + "$storage_pool" "$PLATFORM_BUILD_CACHE_VOLUME" >/dev/null 2>&1; then + marker="$( + project_cmd storage volume get \ + "$storage_pool" "$PLATFORM_BUILD_CACHE_VOLUME" \ + user.vdm.platform 2>/dev/null || + true + )" + [ "$marker" = "$PLATFORM_NAME" ] || + die "Refusing to use existing volume $PLATFORM_BUILD_CACHE_VOLUME because it is not marked as managed by $PLATFORM_NAME." + else + project_cmd storage volume create \ + "$storage_pool" "$PLATFORM_BUILD_CACHE_VOLUME" \ + "size=${PLATFORM_BUILD_CACHE_SIZE_GIB}GiB" \ + user.vdm.managed=true \ + "user.vdm.platform=$PLATFORM_NAME" + fi +} + +attach_cache() { + ensure_cache_volume + if device_exists; then + [ "$( + project_cmd config device get \ + "$BUILD_NAME" "$CACHE_DEVICE" source + )" = "$PLATFORM_BUILD_CACHE_VOLUME" ] || + die "Unexpected cache device source on $BUILD_NAME." + return + fi + project_cmd config device add \ + "$BUILD_NAME" "$CACHE_DEVICE" disk \ + "pool=$storage_pool" \ + "source=$PLATFORM_BUILD_CACHE_VOLUME" \ + path=/var/cache/vdm-build +} + +if [ "$build_state" = provisioning ] && [ "$USE_CACHE" = true ]; then + attach_cache +fi + project_cmd start "$BUILD_NAME" wait_for_vm "$BUILD_NAME" || die "VM agent did not become ready: $BUILD_NAME" -log "Uploading platform files and provisioning scripts" -tar -C "$ROOT_DIR/image/files" -cf - . | project_cmd exec "$BUILD_NAME" -- tar -C / -xf - -project_cmd exec "$BUILD_NAME" -- install -d -m 0755 /opt/vdm-build -tar -C "$ROOT_DIR/image/provision" -cf - . | project_cmd exec "$BUILD_NAME" -- tar -C /opt/vdm-build -xf - - COMMON_ENV=( "AGENT_USER=$AGENT_USER" "NODE_MAJOR=$NODE_MAJOR" @@ -62,6 +329,13 @@ COMMON_ENV=( "MYPY_PACKAGE=$MYPY_PACKAGE" "MYPY_EXPECTED_VERSION=$MYPY_EXPECTED_VERSION" ) +if [ "$USE_CACHE" = true ]; then + COMMON_ENV+=( + VDM_BUILD_CACHE_DIR=/var/cache/vdm-build + NPM_CONFIG_CACHE=/var/cache/vdm-build/npm + PIP_CACHE_DIR=/var/cache/vdm-build/pip + ) +fi exec_with_env() { local args=() @@ -72,26 +346,65 @@ exec_with_env() { project_cmd exec "$BUILD_NAME" "${args[@]}" -- "$@" } -exec_with_env bash /opt/vdm-build/common.sh -for component in ${PLATFORM_IMAGE_COMPONENTS[$VARIANT]}; do - provision="${PLATFORM_COMPONENT_PROVISION[$component]:-}" - [[ -z "$provision" ]] || exec_with_env bash "/opt/vdm-build/$provision" -done +stage_complete() { + local stage="${1:?stage required}" + project_cmd exec "$BUILD_NAME" -- \ + test -f "/var/lib/vdm-opencode-build/stages/${stage}.done" +} + +mark_stage_complete() { + local stage="${1:?stage required}" + project_cmd exec "$BUILD_NAME" -- \ + install -D -o root -g root -m 0644 /dev/null \ + "/var/lib/vdm-opencode-build/stages/${stage}.done" +} + +run_stage() { + local stage="${1:?stage required}" + local provision="${2:?provision script required}" + + if stage_complete "$stage"; then + log "Reusing completed build stage $stage" + return + fi + log "Running build stage $stage" + exec_with_env bash "/opt/vdm-build/$provision" + mark_stage_complete "$stage" +} + +if [ "$build_state" = provisioning ]; then + log "Uploading platform files and provisioning scripts" + tar -C "$ROOT_DIR/image/files" -cf - . | + project_cmd exec "$BUILD_NAME" -- tar -C / -xf - + project_cmd exec "$BUILD_NAME" -- install -d -m 0755 /opt/vdm-build + tar -C "$ROOT_DIR/image/provision" -cf - . | + project_cmd exec "$BUILD_NAME" -- tar -C /opt/vdm-build -xf - + + run_stage common common.sh + for component in ${PLATFORM_IMAGE_COMPONENTS[$VARIANT]}; do + provision="${PLATFORM_COMPONENT_PROVISION[$component]:-}" + [ -z "$provision" ] || run_stage "component-$component" "$provision" + done -project_cmd exec "$BUILD_NAME" \ - --env "PLATFORM_VERSION=$PLATFORM_VERSION" \ - --env "AGENT_USER=$AGENT_USER" \ - -- bash /opt/vdm-build/finalize.sh "$VARIANT" + project_cmd exec "$BUILD_NAME" \ + --env "PLATFORM_VERSION=$PLATFORM_VERSION" \ + --env "AGENT_USER=$AGENT_USER" \ + -- bash /opt/vdm-build/finalize.sh "$VARIANT" + project_cmd config set "$BUILD_NAME" user.vdm.build.state finalized +fi "$SCRIPT_DIR/verify-image.sh" "$BUILD_NAME" "$VARIANT" -log "Publishing $ALIAS" +log "Publishing $ALIAS directly from the verified stopped instance" project_cmd stop "$BUILD_NAME" --timeout 60 -project_cmd snapshot create "$BUILD_NAME" sanitized -project_cmd publish "$BUILD_NAME/sanitized" --alias "$ALIAS" --reuse +detach_cache +project_cmd publish "$BUILD_NAME" --alias "$ALIAS" --reuse project_cmd image set-property "$ALIAS" org.vdm.platform "$PLATFORM_NAME" project_cmd image set-property "$ALIAS" org.vdm.version "$PLATFORM_VERSION" project_cmd image set-property "$ALIAS" org.vdm.variant "$VARIANT" -project_cmd image set-property "$ALIAS" org.vdm.architecture "$(canonical_architecture "$(uname -m)")" +project_cmd image set-property \ + "$ALIAS" org.vdm.architecture \ + "$(canonical_architecture "$(uname -m)")" +BUILD_SUCCESS=true log "Published $ALIAS" diff --git a/scripts/ci/check-incus-runner.sh b/scripts/ci/check-incus-runner.sh index 744afb4..d79d62d 100755 --- a/scripts/ci/check-incus-runner.sh +++ b/scripts/ci/check-incus-runner.sh @@ -4,8 +4,16 @@ set -Eeuo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" # shellcheck disable=SC1091 source "$SCRIPT_DIR/../lib/common.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/../lib/build-resources.sh" + +VARIANT="${1:-}" +if [ -n "$VARIANT" ]; then + variant_exists "$VARIANT" || + die "Runner preflight variant must be one of: ${PLATFORM_IMAGES[*]}" +fi -for command in incus jq tar zstd sha256sum; do +for command in incus jq tar zstd sha256sum getconf; do require_command "$command" done @@ -20,11 +28,23 @@ storage_pool="${INCUS_STORAGE_POOL:-default}" incus_cmd storage show "$storage_pool" >/dev/null 2>&1 || \ die "Incus storage pool not found: $storage_pool" -available_kib="$(df --output=avail -k "$ROOT_DIR" | awk 'NR == 2 {print $1}')" -required_kib="$((MIN_BUILD_FREE_GIB * 1024 * 1024))" -if [ "$available_kib" -lt "$required_kib" ]; then - available_gib="$((available_kib / 1024 / 1024))" - die "Insufficient build disk: ${available_gib} GiB available; ${MIN_BUILD_FREE_GIB} GiB required." +server_version="$( + incus_cmd version | + awk -F': ' '/^Server version:/ {print $2; exit}' +)" +[ -n "$server_version" ] || die "Unable to determine the Incus server version." +version_at_least "$server_version" "$MIN_INCUS_VERSION" || + die "Incus $server_version is older than the supported minimum $MIN_INCUS_VERSION." + +if [ -n "$VARIANT" ]; then + cache_value="${VDM_BUILD_CACHE:-true}" + case "${cache_value,,}" in + true|1|yes) cache_enabled=true ;; + false|0|no) cache_enabled=false ;; + *) die "VDM_BUILD_CACHE must be true or false." ;; + esac + select_build_resources "$VARIANT" "$storage_pool" "$cache_enabled" + print_build_resource_plan "$VARIANT" fi -log "Incus runner preflight passed" +log "Incus runner preflight passed${VARIANT:+ for $VARIANT}" diff --git a/scripts/create-runtime-env.sh b/scripts/create-runtime-env.sh index 37670b8..f98b882 100755 --- a/scripts/create-runtime-env.sh +++ b/scripts/create-runtime-env.sh @@ -10,7 +10,30 @@ NAME="${1:-}" [[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$ ]] || die "Usage: $0 INSTANCE_NAME" -DIR="/run/user/${UID}/vdm-opencode" +RUNTIME_BASE="${VDM_RUNTIME_BASE:-/run/user/${UID}}" +[[ "$RUNTIME_BASE" == /* && "$RUNTIME_BASE" != *$'\n'* ]] || + die "VDM_RUNTIME_BASE must be an absolute path without newlines" +[ ! -L "$RUNTIME_BASE" ] || + die "Runtime base must not be a symbolic link: $RUNTIME_BASE" +if [ -e "$RUNTIME_BASE" ]; then + [ -d "$RUNTIME_BASE" ] || + die "Runtime base must be a directory: $RUNTIME_BASE" +else + runtime_parent="$(dirname "$RUNTIME_BASE")" + [ -d "$runtime_parent" ] || + die "Runtime base parent does not exist: $runtime_parent" + [ "$(stat -f -c '%T' "$runtime_parent")" = tmpfs ] || + die "Runtime base parent must be backed by tmpfs: $runtime_parent" + install -d -m 0700 "$RUNTIME_BASE" +fi +[ "$(stat -c '%u' "$RUNTIME_BASE")" = "$UID" ] || + die "Runtime base must be owned by UID $UID: $RUNTIME_BASE" +[ "$(stat -c '%a' "$RUNTIME_BASE")" = 700 ] || + die "Runtime base must have mode 0700: $RUNTIME_BASE" +[ "$(stat -f -c '%T' "$RUNTIME_BASE")" = tmpfs ] || + die "Runtime base must be backed by tmpfs: $RUNTIME_BASE" + +DIR="${RUNTIME_BASE}/vdm-opencode" FILE="${DIR}/${NAME}.env" install -d -m 0700 "$DIR" [ "$(stat -c '%u' "$DIR")" = "$UID" ] || diff --git a/scripts/generate-platform.py b/scripts/generate-platform.py index fc87cfe..bb0d97b 100644 --- a/scripts/generate-platform.py +++ b/scripts/generate-platform.py @@ -76,6 +76,41 @@ def validate_manifest(data: Mapping[str, Any]) -> None: require(copyright_data.get("holder") == "Vast Development Method", "copyright holder must be Vast Development Method") require(isinstance(copyright_data.get("year"), int), "copyright year must be an integer") + build = require_mapping(data.get("build"), "build") + build_host = require_mapping(build.get("host"), "build.host") + require( + isinstance(build_host.get("memory_reserve_percent"), int) + and 10 <= build_host["memory_reserve_percent"] <= 50, + "build.host.memory_reserve_percent must be between 10 and 50", + ) + require( + isinstance(build_host.get("cpu_reserve"), int) + and build_host["cpu_reserve"] >= 1, + "build.host.cpu_reserve must be a positive integer", + ) + for field, pattern in ( + ("memory_reserve_min", r"[1-9]\d*GiB"), + ("qemu_overhead", r"[1-9]\d*MiB"), + ("disk_reserve", r"[1-9]\d*GiB"), + ): + require( + isinstance(build_host.get(field), str) + and re.fullmatch(pattern, build_host[field]) is not None, + f"invalid build.host.{field}", + ) + + build_cache = require_mapping(build.get("cache"), "build.cache") + require( + isinstance(build_cache.get("volume"), str) + and IDENTIFIER.fullmatch(build_cache["volume"]) is not None, + "build.cache.volume must be a safe identifier", + ) + require( + isinstance(build_cache.get("size"), str) + and re.fullmatch(r"[1-9]\d*GiB", build_cache["size"]) is not None, + "invalid build.cache.size", + ) + architectures = require_mapping(platform.get("architectures"), "platform.architectures") require({"amd64", "arm64"}.issubset(architectures), "amd64 and arm64 architectures are required") require_identifiers(architectures, "platform.architectures") @@ -146,6 +181,35 @@ def validate_manifest(data: Mapping[str, Any]) -> None: images = require_mapping(data.get("images"), "images") require_identifiers(images, "images") + build_variants = require_mapping(build.get("variants"), "build.variants") + require( + set(build_variants) == set(images), + "build.variants must define exactly the image catalog", + ) + for name, resources in build_variants.items(): + resources = require_mapping(resources, f"build.variants.{name}") + for field in ("min_cpus", "preferred_cpus"): + require( + isinstance(resources.get(field), int) and resources[field] > 0, + f"invalid build.variants.{name}.{field}", + ) + require( + resources["preferred_cpus"] >= resources["min_cpus"], + f"build.variants.{name}.preferred_cpus must be at least min_cpus", + ) + for field in ("min_memory", "preferred_memory", "disk"): + require( + isinstance(resources.get(field), str) + and re.fullmatch(r"[1-9]\d*GiB", resources[field]) is not None, + f"invalid build.variants.{name}.{field}", + ) + min_memory = int(resources["min_memory"][:-3]) + preferred_memory = int(resources["preferred_memory"][:-3]) + require( + preferred_memory >= min_memory, + f"build.variants.{name}.preferred_memory must be at least min_memory", + ) + require(any(image.get("release") is True for image in images.values()), "at least one release image is required") for name, image in images.items(): image = require_mapping(image, f"images.{name}") @@ -209,6 +273,10 @@ def shell_array(values: Iterable[str]) -> str: def render_shell(data: Mapping[str, Any]) -> str: platform = data["platform"] + build = data["build"] + build_host = build["host"] + build_cache = build["cache"] + build_variants = build["variants"] images = data["images"] release_images = [name for name, image in images.items() if image["release"]] release_architectures = [name for name, architecture in platform["architectures"].items() if architecture["release"]] @@ -221,6 +289,13 @@ def render_shell(data: Mapping[str, Any]) -> str: f"PLATFORM_IMAGES={shell_array(images)}", f"PLATFORM_RELEASE_IMAGES={shell_array(release_images)}", f"PLATFORM_RELEASE_ARCHITECTURES={shell_array(release_architectures)}", + f"PLATFORM_BUILD_HOST_MEMORY_RESERVE_PERCENT={build_host['memory_reserve_percent']}", + f"PLATFORM_BUILD_HOST_MEMORY_RESERVE_MIN_GIB={shlex.quote(build_host['memory_reserve_min'][:-3])}", + f"PLATFORM_BUILD_QEMU_OVERHEAD_MIB={shlex.quote(build_host['qemu_overhead'][:-3])}", + f"PLATFORM_BUILD_HOST_CPU_RESERVE={build_host['cpu_reserve']}", + f"PLATFORM_BUILD_HOST_DISK_RESERVE_GIB={shlex.quote(build_host['disk_reserve'][:-3])}", + f"PLATFORM_BUILD_CACHE_VOLUME={shlex.quote(build_cache['volume'])}", + f"PLATFORM_BUILD_CACHE_SIZE_GIB={shlex.quote(build_cache['size'][:-3])}", "", "declare -Ag PLATFORM_ARCH_ALIASES=(", ] @@ -236,6 +311,21 @@ def render_shell(data: Mapping[str, Any]) -> str: lines.extend([")", "", "declare -Ag PLATFORM_IMAGE_DEFAULT_SIZE=("]) for name, image in images.items(): lines.append(f" [{name}]={shlex.quote(image['default_size'])}") + lines.extend([")", "", "declare -Ag PLATFORM_BUILD_MIN_CPUS=("]) + for name, resources in build_variants.items(): + lines.append(f" [{name}]={shlex.quote(str(resources['min_cpus']))}") + lines.extend([")", "", "declare -Ag PLATFORM_BUILD_PREFERRED_CPUS=("]) + for name, resources in build_variants.items(): + lines.append(f" [{name}]={shlex.quote(str(resources['preferred_cpus']))}") + lines.extend([")", "", "declare -Ag PLATFORM_BUILD_MIN_MEMORY_GIB=("]) + for name, resources in build_variants.items(): + lines.append(f" [{name}]={shlex.quote(resources['min_memory'][:-3])}") + lines.extend([")", "", "declare -Ag PLATFORM_BUILD_PREFERRED_MEMORY_GIB=("]) + for name, resources in build_variants.items(): + lines.append(f" [{name}]={shlex.quote(resources['preferred_memory'][:-3])}") + lines.extend([")", "", "declare -Ag PLATFORM_BUILD_DISK_GIB=("]) + for name, resources in build_variants.items(): + lines.append(f" [{name}]={shlex.quote(resources['disk'][:-3])}") lines.extend([")", "", "declare -Ag PLATFORM_COMPONENT_PROVISION=("]) for name, component in data["components"].items(): if component["status"] == "ready": @@ -317,9 +407,9 @@ def policy_profile(name: str, policy: Mapping[str, Any]) -> str: def acl_rules(name: str) -> list[dict[str, str]]: gateway_rules = [ - {"action": "allow", "description": "Runtime DNS", "destination": "10.248.18.1/32", "protocol": "udp", "destination_port": "53"}, - {"action": "allow", "description": "Runtime DNS over TCP", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": "53"}, - {"action": "allow", "description": "Capability gateway", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": "443"}, + {"action": "allow", "state": "enabled", "description": "Runtime DNS", "destination": "10.248.18.1/32", "protocol": "udp", "destination_port": "53"}, + {"action": "allow", "state": "enabled", "description": "Runtime DNS over TCP", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": "53"}, + {"action": "allow", "state": "enabled", "description": "Capability gateway", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": "443"}, ] if name == "offline": return [] @@ -327,16 +417,16 @@ def acl_rules(name: str) -> list[dict[str, str]]: port = {"brokered": None, "restricted": "3128", "release": "3129"}[name] rules = list(gateway_rules) if port: - rules.append({"action": "allow", "description": f"{name.title()} HTTP CONNECT proxy", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": port}) + rules.append({"action": "allow", "state": "enabled", "description": f"{name.title()} HTTP CONNECT proxy", "destination": "10.248.18.1/32", "protocol": "tcp", "destination_port": port}) return rules if name == "connected": rules = list(gateway_rules) for network in ("10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4"): - rules.append({"action": "reject", "description": "Deny private or special-use IPv4", "destination": network}) - rules.append({"action": "allow", "description": "Public IPv4 Internet"}) + rules.append({"action": "reject", "state": "enabled", "description": "Deny private or special-use IPv4", "destination": network}) + rules.append({"action": "allow", "state": "enabled", "description": "Public IPv4 Internet"}) return rules if name == "lab": - return [{"action": "allow", "description": "Experimental lab egress"}] + return [{"action": "allow", "state": "enabled", "description": "Experimental lab egress"}] raise ManifestError(f"no ACL renderer for policy {name}") @@ -344,7 +434,10 @@ def policy_acl(name: str) -> str: return yaml_text( { "description": f"VDM generated egress policy: {name}", - "config": {}, + "config": { + "user.vdm.managed": "true", + "user.vdm.platform": "vdm-opencode-platform", + }, "ingress": [], "egress": acl_rules(name), } diff --git a/scripts/launch-vm.sh b/scripts/launch-vm.sh index 2300a34..0ceddb5 100755 --- a/scripts/launch-vm.sh +++ b/scripts/launch-vm.sh @@ -41,5 +41,4 @@ project_cmd config set "$NAME" user.vdm.session.ttl "${VDM_SESSION_TTL:-$DEFAULT project_cmd config set "$NAME" user.vdm.workspace.lifecycle "${VDM_WORKSPACE_LIFECYCLE:-disposable}" project_cmd start "$NAME" wait_for_vm "$NAME" || die "VM agent did not become ready: $NAME" -project_cmd snapshot create "$NAME" factory log "Launched $NAME from $ALIAS" diff --git a/scripts/lib/build-resources.sh b/scripts/lib/build-resources.sh new file mode 100755 index 0000000..228124d --- /dev/null +++ b/scripts/lib/build-resources.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +read_meminfo_mib() { + local key="${1:?meminfo key required}" + local override_name + case "$key" in + MemTotal) override_name=VDM_TEST_MEM_TOTAL_MIB ;; + MemAvailable) override_name=VDM_TEST_MEM_AVAILABLE_MIB ;; + *) override_name="VDM_TEST_${key^^}_MIB" ;; + esac + local override="${!override_name:-}" + + if [ -n "$override" ]; then + [[ "$override" =~ ^[0-9]+$ ]] || die "$override_name must be an integer MiB value" + printf '%s\n' "$override" + return + fi + + awk -v key="$key" ' + $1 == key ":" { + printf "%d\n", $2 / 1024 + found = 1 + exit + } + END { + if (!found) { + exit 1 + } + } + ' /proc/meminfo || die "Unable to read $key from /proc/meminfo" +} + +online_cpu_count() { + if [ -n "${VDM_TEST_CPU_COUNT:-}" ]; then + [[ "$VDM_TEST_CPU_COUNT" =~ ^[1-9][0-9]*$ ]] || + die "VDM_TEST_CPU_COUNT must be a positive integer" + printf '%s\n' "$VDM_TEST_CPU_COUNT" + return + fi + + getconf _NPROCESSORS_ONLN +} + +storage_available_gib() { + local pool="${1:?storage pool required}" + local info + local used + local total + local path + local available_kib + + if [ -n "${VDM_TEST_DISK_AVAILABLE_GIB:-}" ]; then + [[ "$VDM_TEST_DISK_AVAILABLE_GIB" =~ ^[0-9]+$ ]] || + die "VDM_TEST_DISK_AVAILABLE_GIB must be an integer GiB value" + printf '%s\n' "$VDM_TEST_DISK_AVAILABLE_GIB" + return + fi + + info="$(incus_cmd storage info "$pool" --bytes 2>/dev/null || true)" + used="$( + awk -F': *' 'tolower($1) == "space used" {print $2; exit}' <<< "$info" + )" + total="$( + awk -F': *' 'tolower($1) == "space total" {print $2; exit}' <<< "$info" + )" + if [[ "$used" =~ ^[0-9]+$ && "$total" =~ ^[0-9]+$ && "$total" -ge "$used" ]]; then + printf '%s\n' "$(((total - used) / 1024 / 1024 / 1024))" + return + fi + + path="${VDM_BUILD_STORAGE_PATH:-}" + if [ -z "$path" ]; then + if [ -e /var/lib/incus ]; then + path=/var/lib/incus + else + path="$ROOT_DIR" + fi + fi + available_kib="$(df --output=avail -k "$path" | awk 'NR == 2 {print $1}')" + [[ "$available_kib" =~ ^[0-9]+$ ]] || + die "Unable to determine free space for Incus storage pool $pool" + printf '%s\n' "$((available_kib / 1024 / 1024))" +} + +select_build_resources() { + local variant="${1:?variant required}" + local storage_pool="${2:?storage pool required}" + local cache_enabled="${3:-true}" + local total_mib + local available_mib + local reserve_mib + local percent_reserve_mib + local safe_memory_mib + local min_cpus + local min_memory_gib + local min_memory_mib + local preferred_memory_gib + local preferred_memory_mib + local disk_gib + local cpus + local safe_cpus + local available_disk_gib + local required_disk_gib + + variant_exists "$variant" || die "Unknown build variant: $variant" + + total_mib="$(read_meminfo_mib MemTotal)" + available_mib="$(read_meminfo_mib MemAvailable)" + cpus="$(online_cpu_count)" + min_cpus="${PLATFORM_BUILD_MIN_CPUS[$variant]}" + min_memory_gib="${PLATFORM_BUILD_MIN_MEMORY_GIB[$variant]}" + preferred_memory_gib="${PLATFORM_BUILD_PREFERRED_MEMORY_GIB[$variant]}" + disk_gib="${PLATFORM_BUILD_DISK_GIB[$variant]}" + + percent_reserve_mib="$((( + total_mib * PLATFORM_BUILD_HOST_MEMORY_RESERVE_PERCENT + 99 + ) / 100))" + reserve_mib="$((PLATFORM_BUILD_HOST_MEMORY_RESERVE_MIN_GIB * 1024))" + if ((percent_reserve_mib > reserve_mib)); then + reserve_mib="$percent_reserve_mib" + fi + + safe_memory_mib="$(( + available_mib - reserve_mib - PLATFORM_BUILD_QEMU_OVERHEAD_MIB + ))" + min_memory_mib="$((min_memory_gib * 1024))" + preferred_memory_mib="$((preferred_memory_gib * 1024))" + + if ((safe_memory_mib < min_memory_mib)); then + die "Insufficient currently available memory for $variant: ${available_mib} MiB available; $((min_memory_mib + reserve_mib + PLATFORM_BUILD_QEMU_OVERHEAD_MIB)) MiB required (${min_memory_mib} MiB guest + ${reserve_mib} MiB host reserve + ${PLATFORM_BUILD_QEMU_OVERHEAD_MIB} MiB QEMU overhead). Close other workloads and retry. Swap is not required and is not counted." + fi + + BUILD_SELECTED_MEMORY_MIB="$preferred_memory_mib" + if ((safe_memory_mib < BUILD_SELECTED_MEMORY_MIB)); then + BUILD_SELECTED_MEMORY_MIB="$safe_memory_mib" + fi + # Incus accepts MiB and integer values avoid unsafe floating-point shell math. + BUILD_SELECTED_MEMORY="${BUILD_SELECTED_MEMORY_MIB}MiB" + + safe_cpus="$((cpus - PLATFORM_BUILD_HOST_CPU_RESERVE))" + if ((safe_cpus < min_cpus)); then + die "Insufficient online CPUs for $variant: $cpus available; $min_cpus guest CPUs plus $PLATFORM_BUILD_HOST_CPU_RESERVE host CPU required." + fi + BUILD_SELECTED_CPUS="${PLATFORM_BUILD_PREFERRED_CPUS[$variant]}" + if ((safe_cpus < BUILD_SELECTED_CPUS)); then + BUILD_SELECTED_CPUS="$safe_cpus" + fi + + required_disk_gib="$((disk_gib + PLATFORM_BUILD_HOST_DISK_RESERVE_GIB))" + if [ "$cache_enabled" = true ]; then + required_disk_gib="$(( + required_disk_gib + PLATFORM_BUILD_CACHE_SIZE_GIB + ))" + fi + if ((MIN_BUILD_FREE_GIB > required_disk_gib)); then + required_disk_gib="$MIN_BUILD_FREE_GIB" + fi + available_disk_gib="$(storage_available_gib "$storage_pool")" + if ((available_disk_gib < required_disk_gib)); then + die "Insufficient Incus storage for $variant: ${available_disk_gib} GiB available; ${required_disk_gib} GiB required." + fi + + BUILD_SELECTED_DISK="${disk_gib}GiB" + BUILD_REQUIRED_FREE_GIB="$required_disk_gib" + BUILD_HOST_TOTAL_MIB="$total_mib" + BUILD_HOST_AVAILABLE_MIB="$available_mib" + BUILD_HOST_RESERVE_MIB="$reserve_mib" + BUILD_HOST_CPUS="$cpus" + BUILD_STORAGE_AVAILABLE_GIB="$available_disk_gib" + + export \ + BUILD_SELECTED_MEMORY_MIB \ + BUILD_SELECTED_MEMORY \ + BUILD_SELECTED_CPUS \ + BUILD_SELECTED_DISK \ + BUILD_REQUIRED_FREE_GIB \ + BUILD_HOST_TOTAL_MIB \ + BUILD_HOST_AVAILABLE_MIB \ + BUILD_HOST_RESERVE_MIB \ + BUILD_HOST_CPUS \ + BUILD_STORAGE_AVAILABLE_GIB +} + +print_build_resource_plan() { + local variant="${1:?variant required}" + + log "Safe build plan for $variant" + printf 'Host: %s CPUs, %s MiB total, %s MiB currently available\n' \ + "$BUILD_HOST_CPUS" "$BUILD_HOST_TOTAL_MIB" "$BUILD_HOST_AVAILABLE_MIB" + printf 'Reserved for host: %s MiB memory and %s CPU\n' \ + "$BUILD_HOST_RESERVE_MIB" "$PLATFORM_BUILD_HOST_CPU_RESERVE" + printf 'Build VM: %s CPUs, %s memory, %s disk\n' \ + "$BUILD_SELECTED_CPUS" "$BUILD_SELECTED_MEMORY" "$BUILD_SELECTED_DISK" + printf 'Incus storage: %s GiB available, %s GiB safety requirement\n' \ + "$BUILD_STORAGE_AVAILABLE_GIB" "$BUILD_REQUIRED_FREE_GIB" +} diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 8fc0ecd..bd74b0f 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -62,6 +62,17 @@ project_cmd() { incus_cmd --project "$INCUS_PROJECT" "$@" } +default_cmd() { + incus_cmd --project default "$@" +} + +version_at_least() { + local actual="${1:?actual version required}" + local minimum="${2:?minimum version required}" + + [ "$(printf '%s\n%s\n' "$minimum" "$actual" | sort -V | head -n 1)" = "$minimum" ] +} + wait_for_vm() { local name="$1" local _ diff --git a/scripts/verify-image.sh b/scripts/verify-image.sh index c86511e..86032b0 100755 --- a/scripts/verify-image.sh +++ b/scripts/verify-image.sh @@ -81,8 +81,22 @@ else fi if image_has_component "$VARIANT" typescript; then - assert_version TypeScript "$TYPESCRIPT_EXPECTED_VERSION" "$(guest_output tsc --version | awk '{print $2}')" - assert_version TSX "$TSX_EXPECTED_VERSION" "$(guest_output tsx --version | awk 'NR == 1 {print $2}')" + assert_version \ + TypeScript \ + "$TYPESCRIPT_EXPECTED_VERSION" \ + "$(guest_output tsc --version | awk 'NR == 1 {print $2}')" + + assert_version \ + TSX \ + "$TSX_EXPECTED_VERSION" \ + "$( + guest_output tsx --version | + awk 'NR == 1 { + version = $2 + sub(/^v/, "", version) + print version + }' + )" fi if image_has_component "$VARIANT" python; then diff --git a/tests/build-resources.sh b/tests/build-resources.sh new file mode 100755 index 0000000..26eaa2b --- /dev/null +++ b/tests/build-resources.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/common.sh" +# shellcheck disable=SC1091 +source "$ROOT_DIR/scripts/lib/build-resources.sh" + +export VDM_TEST_MEM_TOTAL_MIB=14336 +export VDM_TEST_MEM_AVAILABLE_MIB=12288 +export VDM_TEST_CPU_COUNT=8 +export VDM_TEST_DISK_AVAILABLE_GIB=785 + +select_build_resources typescript default true +[ "$BUILD_SELECTED_CPUS" = 4 ] +[ "$BUILD_SELECTED_MEMORY" = 8192MiB ] +[ "$BUILD_SELECTED_DISK" = 80GiB ] +[ "$BUILD_REQUIRED_FREE_GIB" = 120 ] +[ "$BUILD_HOST_RESERVE_MIB" = 2868 ] + +export VDM_TEST_MEM_AVAILABLE_MIB=3482 +if ( + select_build_resources typescript default true +) >"$ROOT_DIR/build-resource-test.out" 2>&1; then + printf 'The TypeScript resource plan accepted unsafe available memory.\n' >&2 + exit 1 +fi +grep -F 'Insufficient currently available memory for typescript' \ + "$ROOT_DIR/build-resource-test.out" >/dev/null +grep -F 'Swap is not required and is not counted.' \ + "$ROOT_DIR/build-resource-test.out" >/dev/null +rm -f "$ROOT_DIR/build-resource-test.out" + +export VDM_TEST_MEM_AVAILABLE_MIB=12288 +export VDM_TEST_DISK_AVAILABLE_GIB=119 +if ( + select_build_resources typescript default true +) >"$ROOT_DIR/build-resource-test.out" 2>&1; then + printf 'The TypeScript resource plan accepted unsafe free storage.\n' >&2 + exit 1 +fi +grep -F 'Insufficient Incus storage for typescript' \ + "$ROOT_DIR/build-resource-test.out" >/dev/null +rm -f "$ROOT_DIR/build-resource-test.out" + +printf 'Build resource tests passed.\n' diff --git a/tests/guest-exec.sh b/tests/guest-exec.sh new file mode 100755 index 0000000..185ead6 --- /dev/null +++ b/tests/guest-exec.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +SESSION_SCRIPT="$ROOT_DIR/image/files/usr/local/libexec/vdm-opencode-session" +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +credentials="$tmp_dir/credentials" +workspace="$tmp_dir/workspace" +capture="$tmp_dir/exec-arguments" +install -d -m 0700 "$credentials" "$workspace" +printf 'JOOMLA_MCP_SITE_TOKEN=test-token-that-is-never-captured\n' \ + > "$credentials/session.env" +chmod 0600 "$credentials/session.env" + +export \ + CREDENTIALS_DIRECTORY="$credentials" \ + VDM_SESSION_ID=guest-exec-test \ + VDM_SESSION_MODE=joomla-read-test \ + VDM_AGENT_HOME="$tmp_dir/home" \ + VDM_WORKSPACE_ROOT="$workspace" \ + VDM_JOOMLA_SITE_ALIAS=company \ + VDM_JOOMLA_TEST_OUTPUT="$workspace/.artifacts/joomla-mcp/test" \ + CAPTURE_FILE="$capture" \ + SESSION_SCRIPT + +bash -c ' + install() { + : + } + exec() { + printf "%s\n" "$@" > "$CAPTURE_FILE" + } + source "$SESSION_SCRIPT" +' + +mapfile -t arguments < "$capture" +expected=( + /usr/local/bin/vdm-joomla-mcp-live-test + --config + /etc/joomla-mcp/sites.json + --site + company + --profile + read + --joomla-path + api + --mcp-transport + stdio + --non-interactive + --output + "$workspace/.artifacts/joomla-mcp/test" +) +[ "${#arguments[@]}" -eq "${#expected[@]}" ] +for index in "${!expected[@]}"; do + [ "${arguments[$index]}" = "${expected[$index]}" ] || { + printf 'Guest argument %s was %q; expected %q.\n' \ + "$index" "${arguments[$index]}" "${expected[$index]}" >&2 + exit 1 + } +done + +if grep -R -n 'vdm-jomla' \ + "$ROOT_DIR/image" \ + "$ROOT_DIR/scripts" \ + "$ROOT_DIR/docs" \ + "$ROOT_DIR/README.md"; then + printf 'The misspelled Joomla executable is still referenced.\n' >&2 + exit 1 +fi + +printf 'Guest execution tests passed.\n' diff --git a/tests/joomla-mcp-configure.sh b/tests/joomla-mcp-configure.sh index 34260fe..8a1cd17 100755 --- a/tests/joomla-mcp-configure.sh +++ b/tests/joomla-mcp-configure.sh @@ -2,12 +2,14 @@ set -Eeuo pipefail ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" tmp_dir="$(mktemp -d)" +runtime_base="$(mktemp -d /dev/shm/vdm-opencode-test.XXXXXX)" instance="joomla-config-test-$$" -runtime_file="/run/user/${UID}/vdm-opencode/${instance}.env" -trap 'rm -rf "$tmp_dir"; rm -f "$runtime_file"' EXIT +runtime_file="${runtime_base}/vdm-opencode/${instance}.env" +trap 'rm -rf "$tmp_dir" "$runtime_base"' EXIT export PATH="$ROOT_DIR/tests/fixtures/joomla:$PATH" export CAPTURE_CONFIG="$tmp_dir/config.json" +export VDM_RUNTIME_BASE="$runtime_base" "$ROOT_DIR/scripts/configure-joomla-mcp.sh" \ "$instance" https://www.example.com company readonly >/dev/null diff --git a/tests/security-static.py b/tests/security-static.py index 178ab06..728fa8a 100644 --- a/tests/security-static.py +++ b/tests/security-static.py @@ -16,6 +16,9 @@ def test_incus_project_restrictions(self) -> None: project = yaml.safe_load((ROOT / "incus/projects/vdm-agents.yaml").read_text()) config = project["config"] self.assertEqual("true", config["restricted"]) + self.assertEqual("false", config["features.networks"]) + self.assertEqual("allow", config["restricted.backups"]) + self.assertEqual("block", config["restricted.snapshots"]) self.assertEqual("managed", config["restricted.devices.disk"]) self.assertEqual("managed", config["restricted.devices.nic"]) for key in ( @@ -35,6 +38,25 @@ def test_generated_network_profiles_fail_closed(self) -> None: self.assertEqual("reject", nic["security.acls.default.egress.action"]) self.assertEqual("true", nic["security.ipv4_filtering"]) + def test_networks_and_acls_are_explicitly_managed(self) -> None: + for path in (ROOT / "incus/networks").glob("*.yaml"): + network = yaml.safe_load(path.read_text()) + self.assertEqual( + "vdm-opencode-platform", + network["config"]["user.vdm.platform"], + ) + for path in [ + ROOT / "incus/acls/vdm-build.yaml", + *(ROOT / "incus/acls/generated").glob("*.yaml"), + ]: + acl = yaml.safe_load(path.read_text()) + self.assertEqual( + "vdm-opencode-platform", + acl["config"]["user.vdm.platform"], + ) + for rule in [*acl["ingress"], *acl["egress"]]: + self.assertEqual("enabled", rule["state"]) + def test_connected_policy_denies_private_networks_before_public_allow(self) -> None: acl = yaml.safe_load((ROOT / "incus/acls/generated/connected.yaml").read_text()) rules = acl["egress"] @@ -54,6 +76,38 @@ def test_session_secrets_do_not_enter_incus_arguments(self) -> None: for forbidden in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "XAI_API_KEY", "LD_PRELOAD", "BASH_ENV"): self.assertNotIn(forbidden, session) + def test_build_and_launch_avoid_implicit_snapshots(self) -> None: + build = (ROOT / "scripts/build-image.sh").read_text() + launch = (ROOT / "scripts/launch-vm.sh").read_text() + self.assertNotIn("snapshot create", build) + self.assertNotIn("snapshot create", launch) + self.assertIn( + 'project_cmd publish "$BUILD_NAME" --alias "$ALIAS" --reuse', + build, + ) + self.assertIn('user.vdm.build.state finalized', build) + self.assertIn("collect_failure_diagnostics", build) + self.assertIn("Build failed. The stopped checkpoint was retained", build) + self.assertNotIn( + 'PLATFORM_IMAGE_DEFAULT_SIZE[$VARIANT]', + build, + "Build VMs must not inherit launch-time runtime sizes.", + ) + + def test_bootstrap_never_changes_package_sources_or_coreutils_provider(self) -> None: + bootstrap = (ROOT / "scripts/bootstrap-host.sh").read_text() + for forbidden in ( + "gnu-coreutils", + "coreutils-from-uutils", + "add-apt-repository", + "/etc/apt/sources.list", + "/etc/apt/sources.list.d", + "DOCKER-USER", + ): + self.assertNotIn(forbidden, bootstrap) + self.assertIn("dpkg --audit", bootstrap) + self.assertIn("apt-get check", bootstrap) + def test_joomla_defaults_are_local_read_only_and_credential_free(self) -> None: example = json.loads( ( @@ -79,6 +133,12 @@ def test_joomla_defaults_are_local_read_only_and_credential_free(self) -> None: self.assertIn("allowIndefinite: false", configure) self.assertNotIn("--env JOOMLA_MCP_SITE_TOKEN", configure) + session = ( + ROOT / "image/files/usr/local/libexec/vdm-opencode-session" + ).read_text() + self.assertIn("/usr/local/bin/vdm-joomla-mcp-live-test", session) + self.assertNotIn("vdm-jomla", session) + def test_all_provider_routes_exist(self) -> None: config = yaml.safe_load((ROOT / "broker/config/litellm.yaml").read_text()) routes = {entry["model_name"] for entry in config["model_list"]} diff --git a/tests/test_platform_manifest.py b/tests/test_platform_manifest.py index d5a8c9b..7d9e1fc 100644 --- a/tests/test_platform_manifest.py +++ b/tests/test_platform_manifest.py @@ -42,6 +42,24 @@ def test_capability_policy_and_size_catalogs(self) -> None: self.assertEqual("planned", dimensions["capabilities"]["android"]["status"]) self.assertEqual("planned", dimensions["capabilities"]["gpu"]["status"]) + def test_build_resources_are_separate_and_complete(self) -> None: + build = self.data["build"] + self.assertEqual(20, build["host"]["memory_reserve_percent"]) + self.assertEqual("2GiB", build["host"]["memory_reserve_min"]) + self.assertEqual("512MiB", build["host"]["qemu_overhead"]) + self.assertEqual(set(self.data["images"]), set(build["variants"])) + + typescript = build["variants"]["typescript"] + self.assertEqual(4, typescript["preferred_cpus"]) + self.assertEqual("6GiB", typescript["min_memory"]) + self.assertEqual("8GiB", typescript["preferred_memory"]) + self.assertEqual("80GiB", typescript["disk"]) + self.assertEqual( + "standard", + self.data["images"]["typescript"]["default_size"], + "The runtime size remains independent from the build plan.", + ) + def test_joomla_mcp_is_composable_and_php_scoped(self) -> None: self.assertEqual( {"status": "ready", "provision": "joomla-mcp.sh"}, diff --git a/tests/validate-repository.sh b/tests/validate-repository.sh index 5777e76..a356caf 100755 --- a/tests/validate-repository.sh +++ b/tests/validate-repository.sh @@ -27,12 +27,14 @@ if command -v shellcheck >/dev/null 2>&1; then fi bash "$ROOT_DIR/tests/variant-selection.sh" || fail=1 +bash "$ROOT_DIR/tests/build-resources.sh" || fail=1 python3 "$ROOT_DIR/scripts/generate-platform.py" --check || fail=1 python3 -m unittest "$ROOT_DIR/tests/test_platform_manifest.py" || fail=1 python3 -m unittest "$ROOT_DIR/tests/security-static.py" || fail=1 bash "$ROOT_DIR/tests/generated-platform.sh" || fail=1 bash "$ROOT_DIR/tests/joomla-mcp-integration.sh" || fail=1 bash "$ROOT_DIR/tests/joomla-mcp-configure.sh" || fail=1 +bash "$ROOT_DIR/tests/guest-exec.sh" || fail=1 bash "$ROOT_DIR/tests/runtime-credentials.sh" || fail=1 bash "$ROOT_DIR/tests/session-secret-transport.sh" || fail=1 bash "$ROOT_DIR/tests/reaper.sh" || fail=1