diff --git a/.github/workflows/install-recovery.yml b/.github/workflows/install-recovery.yml new file mode 100644 index 00000000..c7d20b5c --- /dev/null +++ b/.github/workflows/install-recovery.yml @@ -0,0 +1,135 @@ +name: install recovery matrix + +on: + pull_request: + paths: + - scripts/install_recovery_matrix.py + - .github/workflows/install-recovery.yml + push: + branches: [main] + paths: + - scripts/install_recovery_matrix.py + - .github/workflows/install-recovery.yml + workflow_dispatch: + inputs: + zed_cli_ref: + description: Immutable zed-cli commit to test + required: false + default: 8cc6745c75fd46dabf2e383cbcfc14db5ff933da + +permissions: + contents: read + +concurrency: + group: install-recovery-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + ZED_CLI_REF: ${{ github.event.inputs.zed_cli_ref || '8cc6745c75fd46dabf2e383cbcfc14db5ff933da' }} + ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 + +jobs: + install-recovery: + name: Same-project races, rollback, repair, and tamper failures + runs-on: ubuntu-24.04 + timeout-minutes: 45 + steps: + - name: Require immutable component pins + run: | + set -euo pipefail + [[ "$ZED_CLI_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$ZED_INTERFACES_REF" =~ ^[0-9a-f]{40}$ ]] + + - name: Check out e2e harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-cli + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-cli + ref: ${{ env.ZED_CLI_REF }} + path: zed-cli + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out pinned zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: ${{ env.ZED_INTERFACES_REF }} + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install stable Rust + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Compile the recovery harness and check Rust formatting + run: | + set -euo pipefail + python3 -m py_compile harness/scripts/install_recovery_matrix.py + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + + - name: Build immutable zed binary + run: | + set -euo pipefail + cargo build --locked --release --manifest-path zed-cli/Cargo.toml --bin zed + install -Dm755 zed-cli/target/release/zed "$RUNNER_TEMP/zed-bin/zed" + "$RUNNER_TEMP/zed-bin/zed" --version + + - name: Run install recovery and rollback matrix + env: + WORK_ROOT: ${{ runner.temp }}/zed-install-recovery + run: | + set -euo pipefail + test ! -e "$WORK_ROOT" + python3 harness/scripts/install_recovery_matrix.py \ + --zed "$RUNNER_TEMP/zed-bin/zed" \ + --work-root "$WORK_ROOT" + + - name: Collect failure diagnostics + if: failure() + env: + WORK_ROOT: ${{ runner.temp }}/zed-install-recovery + run: | + set +e + mkdir -p "$WORK_ROOT/diagnostics" + find "$WORK_ROOT" -maxdepth 11 -printf '%y %P -> %l\n' \ + | LC_ALL=C sort > "$WORK_ROOT/diagnostics/work-tree.txt" 2>&1 + find "$WORK_ROOT/registry" -maxdepth 10 -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/registry-sha256.txt" 2>&1 + find "$WORK_ROOT" -path '*/cache/*.tar.gz' -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/cache-sha256.txt" 2>&1 + find "$WORK_ROOT" -path '*/.zpkg.lock' -type f -print -exec sed -n '1,240p' {} \; \ + > "$WORK_ROOT/diagnostics/lockfiles.txt" 2>&1 + true + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: install-recovery-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/zed-install-recovery/diagnostics + if-no-files-found: warn + retention-days: 14 + include-hidden-files: false diff --git a/.github/workflows/org-app-installs.yml b/.github/workflows/org-app-installs.yml new file mode 100644 index 00000000..90ac9c13 --- /dev/null +++ b/.github/workflows/org-app-installs.yml @@ -0,0 +1,136 @@ +name: zed-pkg-test app installs + +on: + pull_request: + paths: + - scripts/org_app_installs.py + - .github/workflows/org-app-installs.yml + push: + branches: [main] + paths: + - scripts/org_app_installs.py + - .github/workflows/org-app-installs.yml + workflow_dispatch: + inputs: + zed_cli_ref: + description: Immutable zed-cli commit to test + required: false + default: 8cc6745c75fd46dabf2e383cbcfc14db5ff933da + +permissions: + contents: read + +concurrency: + group: zed-pkg-test-app-installs-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + ZED_CLI_REF: ${{ github.event.inputs.zed_cli_ref || '8cc6745c75fd46dabf2e383cbcfc14db5ff933da' }} + ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 + +jobs: + org-app-installs: + name: Real app fixtures, adapters, uninstall/restore, and workspaces + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Require immutable component pins + run: | + set -euo pipefail + [[ "$ZED_CLI_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$ZED_INTERFACES_REF" =~ ^[0-9a-f]{40}$ ]] + + - name: Check out e2e harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-cli + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-cli + ref: ${{ env.ZED_CLI_REF }} + path: zed-cli + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out pinned zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: ${{ env.ZED_INTERFACES_REF }} + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install stable Rust + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + rustc --version --verbose + cargo --version --verbose + git --version + python3 --version + + - name: Compile the org install harness and check Rust formatting + run: | + set -euo pipefail + python3 -m py_compile harness/scripts/org_app_installs.py + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + + - name: Build immutable zed binary + run: | + set -euo pipefail + cargo build --locked --release --manifest-path zed-cli/Cargo.toml --bin zed + install -Dm755 zed-cli/target/release/zed "$RUNNER_TEMP/zed-bin/zed" + "$RUNNER_TEMP/zed-bin/zed" --version + + - name: Install real applications from zed-pkg-test + env: + WORK_ROOT: ${{ runner.temp }}/zed-pkg-test-app-installs + run: | + set -euo pipefail + test ! -e "$WORK_ROOT" + python3 harness/scripts/org_app_installs.py \ + --zed "$RUNNER_TEMP/zed-bin/zed" \ + --work-root "$WORK_ROOT" + + - name: Collect failure diagnostics + if: failure() + env: + WORK_ROOT: ${{ runner.temp }}/zed-pkg-test-app-installs + run: | + set +e + mkdir -p "$WORK_ROOT/diagnostics" + find "$WORK_ROOT" -maxdepth 12 -printf '%y %P -> %l\n' \ + | LC_ALL=C sort > "$WORK_ROOT/diagnostics/work-tree.txt" 2>&1 + find "$WORK_ROOT/registry" -maxdepth 10 -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/registry-sha256.txt" 2>&1 + find "$WORK_ROOT" -path '*/.zpkg.lock' -type f -print -exec sed -n '1,260p' {} \; \ + > "$WORK_ROOT/diagnostics/lockfiles.txt" 2>&1 + find "$WORK_ROOT" -path '*/.zed/*' -type f -print -exec sed -n '1,200p' {} \; \ + > "$WORK_ROOT/diagnostics/adapter-files.txt" 2>&1 + true + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: zed-pkg-test-app-installs-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/zed-pkg-test-app-installs/diagnostics + if-no-files-found: warn + retention-days: 14 + include-hidden-files: false diff --git a/.github/workflows/recursive-installs-stress.yml b/.github/workflows/recursive-installs-stress.yml new file mode 100644 index 00000000..20a29b84 --- /dev/null +++ b/.github/workflows/recursive-installs-stress.yml @@ -0,0 +1,131 @@ +name: recursive install stress + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + zed_cli_ref: + description: Immutable zed-cli commit to test + required: false + default: 8cc6745c75fd46dabf2e383cbcfc14db5ff933da + +permissions: + contents: read + +concurrency: + group: recursive-install-stress-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + ZED_CLI_REF: ${{ github.event.inputs.zed_cli_ref || '8cc6745c75fd46dabf2e383cbcfc14db5ff933da' }} + ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 + +jobs: + recursive-install-stress: + name: Five-worker and four-process stress + runs-on: ubuntu-24.04 + timeout-minutes: 40 + steps: + - name: Require immutable component pins + run: | + set -euo pipefail + [[ "$ZED_CLI_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$ZED_INTERFACES_REF" =~ ^[0-9a-f]{40}$ ]] + + - name: Check out e2e harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-cli + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-cli + ref: ${{ env.ZED_CLI_REF }} + path: zed-cli + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: ${{ env.ZED_INTERFACES_REF }} + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install the declared Rust toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Prove five live HTTP artifact transfers + env: + ZED_PKG_INSTALL_CONCURRENCY: "5" + run: | + set -euo pipefail + python3 -m py_compile harness/scripts/recursive_installs_stress.py + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + cargo test --locked --manifest-path zed-cli/Cargo.toml \ + --test recursive_install_concurrency -- --nocapture + + - name: Build immutable zed binary + run: | + set -euo pipefail + cargo build --locked --release --manifest-path zed-cli/Cargo.toml --bin zed + install -Dm755 zed-cli/target/release/zed "$RUNNER_TEMP/zed-bin/zed" + "$RUNNER_TEMP/zed-bin/zed" --version + + - name: Run four-process and frozen stress contract + env: + WORK_ROOT: ${{ runner.temp }}/zed-recursive-stress + run: | + set -euo pipefail + test ! -e "$WORK_ROOT" + python3 harness/scripts/recursive_installs_stress.py \ + --zed "$RUNNER_TEMP/zed-bin/zed" \ + --work-root "$WORK_ROOT" + + - name: Collect failure diagnostics + if: failure() + env: + WORK_ROOT: ${{ runner.temp }}/zed-recursive-stress + run: | + set +e + mkdir -p "$WORK_ROOT/diagnostics" + find "$WORK_ROOT" -maxdepth 10 -printf '%y %P -> %l\n' \ + | LC_ALL=C sort > "$WORK_ROOT/diagnostics/work-tree.txt" 2>&1 + find "$WORK_ROOT/registry" -maxdepth 10 -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/registry-sha256.txt" 2>&1 + find "$WORK_ROOT" -path '*/cache/*.tar.gz' -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/all-cache-sha256.txt" 2>&1 + true + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: recursive-install-stress-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/zed-recursive-stress/diagnostics + if-no-files-found: warn + retention-days: 14 + include-hidden-files: false diff --git a/.github/workflows/recursive-installs.yml b/.github/workflows/recursive-installs.yml new file mode 100644 index 00000000..b3abcd74 --- /dev/null +++ b/.github/workflows/recursive-installs.yml @@ -0,0 +1,131 @@ +name: recursive install graph + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + zed_cli_ref: + description: Immutable zed-cli commit to test + required: false + default: 8cc6745c75fd46dabf2e383cbcfc14db5ff933da + +permissions: + contents: read + +concurrency: + group: recursive-install-graph-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + ZED_CLI_REF: ${{ github.event.inputs.zed_cli_ref || '8cc6745c75fd46dabf2e383cbcfc14db5ff933da' }} + ZED_INTERFACES_REF: dc0e0a0620b9462817950b552d3d334a184b1cb1 + +jobs: + recursive-install-e2e: + name: Recursive graph and shared-home lock + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Require immutable component pins + run: | + set -euo pipefail + [[ "$ZED_CLI_REF" =~ ^[0-9a-f]{40}$ ]] + [[ "$ZED_INTERFACES_REF" =~ ^[0-9a-f]{40}$ ]] + + - name: Check out e2e harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: harness + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-cli + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-cli + ref: ${{ env.ZED_CLI_REF }} + path: zed-cli + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Check out zed-interfaces sibling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zed-pkg/zed-interfaces + ref: ${{ env.ZED_INTERFACES_REF }} + path: zed-interfaces + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install the declared Rust toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format and unit-test recursive installer and process locks + run: | + set -euo pipefail + python3 -m py_compile harness/scripts/recursive_installs.py + cargo fmt --all --check --manifest-path zed-cli/Cargo.toml + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib install_graph::hardening_tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib store::tests + cargo test --locked --manifest-path zed-cli/Cargo.toml --lib config::tests + + - name: Build immutable zed binary + run: | + set -euo pipefail + cargo build --locked --release --manifest-path zed-cli/Cargo.toml --bin zed + install -Dm755 zed-cli/target/release/zed "$RUNNER_TEMP/zed-bin/zed" + "$RUNNER_TEMP/zed-bin/zed" --version + + - name: Run recursive install and lock contract + env: + WORK_ROOT: ${{ runner.temp }}/zed-recursive-install + run: | + set -euo pipefail + test ! -e "$WORK_ROOT" + python3 harness/scripts/recursive_installs.py \ + --zed "$RUNNER_TEMP/zed-bin/zed" \ + --work-root "$WORK_ROOT" + + - name: Collect failure diagnostics + if: failure() + env: + WORK_ROOT: ${{ runner.temp }}/zed-recursive-install + run: | + set +e + mkdir -p "$WORK_ROOT/diagnostics" + find "$WORK_ROOT" -maxdepth 9 -printf '%y %P -> %l\n' \ + | LC_ALL=C sort > "$WORK_ROOT/diagnostics/work-tree.txt" 2>&1 + find "$WORK_ROOT/registry" -maxdepth 9 -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/registry-sha256.txt" 2>&1 + find "$WORK_ROOT" -path '*/cache/*.tar.gz' -type f -print0 2>/dev/null \ + | sort -z | xargs -0 -r sha256sum \ + > "$WORK_ROOT/diagnostics/all-cache-sha256.txt" 2>&1 + true + + - name: Upload failure diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: recursive-install-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/zed-recursive-install/diagnostics + if-no-files-found: warn + retention-days: 14 + include-hidden-files: false diff --git a/scripts/install_recovery_matrix.py b/scripts/install_recovery_matrix.py new file mode 100644 index 00000000..0512b97f --- /dev/null +++ b/scripts/install_recovery_matrix.py @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +"""Hermetic install recovery, rollback, and idempotency coverage for zed.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tomllib +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from threading import Barrier +from typing import Mapping, Sequence + +ORG = "zed-install-recovery" +VERSION = "1.0.0" +BASE_GRAPH: dict[str, tuple[str, ...]] = { + "leaf-a": (), + "leaf-b": (), + "root": ("leaf-a", "leaf-b"), +} +PREFETCH_RE = re.compile( + r"recursive install prefetch: (?P\d+) package\(s\), " + r"up to (?P\d+) concurrent, (?P\d+) downloaded" +) +PACKAGE_BLOCK_RE = re.compile( + r"(?ms)^\[\[package\]\]\n.*?(?=^\[\[package\]\]|\Z)" +) +SHA_ASSIGNMENT_RE = re.compile(r'(?m)^sha256 = "[0-9a-f]{64}"$') + + +@dataclass(frozen=True) +class Completed: + command: tuple[str, ...] + returncode: int + output: str + + +def shell_quote(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:@%+=,-]+", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def run( + command: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, + timeout: int = 240, +) -> Completed: + argv = tuple(str(value) for value in command) + shown = " ".join(shell_quote(value) for value in argv) + print(f"\n$ (cd {cwd or Path.cwd()} && {shown})", flush=True) + completed = subprocess.run( + argv, + cwd=cwd, + env=dict(env) if env is not None else None, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + timeout=timeout, + ) + if completed.stdout: + print(completed.stdout, end="", flush=True) + return Completed(argv, completed.returncode, completed.stdout) + + +def require_success(completed: Completed) -> None: + if completed.returncode != 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise RuntimeError(f"command failed with exit code {completed.returncode}: {shown}") + + +def require_failure(completed: Completed, *expected_fragments: str) -> None: + if completed.returncode == 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise AssertionError(f"command unexpectedly succeeded: {shown}") + for fragment in expected_fragments: + if fragment not in completed.output: + raise AssertionError( + f"failed command output did not contain {fragment!r}:\n{completed.output}" + ) + + +def zed_command(zed: Path, registry: Path, home: Path, *args: str) -> list[str | Path]: + return [ + zed, + "--registry", + f"file://{registry}", + "--home", + home, + *args, + ] + + +def write_package(root: Path, name: str, dependencies: Sequence[str]) -> None: + root.mkdir(parents=True, exist_ok=True) + lines = [ + "[package]", + f'org = "{ORG}"', + f'name = "{name}"', + f'version = "{VERSION}"', + 'description = "install recovery fixture"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://github.com/zed-pkg-test/zed-pkg-e2e"', + ] + if dependencies: + lines.extend(("", "[dependencies]")) + for dependency in dependencies: + lines.append(f'"{ORG}/{dependency}" = "={VERSION}"') + (root / ".zpkg.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + (root / "payload.txt").write_text(f"{ORG}/{name}@{VERSION}\n", encoding="utf-8") + + +def write_consumer(root: Path, dependencies: Sequence[str]) -> None: + root.mkdir(parents=True, exist_ok=True) + lines = [ + "[package]", + 'org = "install-recovery-consumer"', + f'name = "{root.name}"', + 'version = "0.1.0"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://example.invalid/install-recovery-consumer"', + "", + "[dependencies]", + ] + for dependency in dependencies: + lines.append(f'"{ORG}/{dependency}" = "={VERSION}"') + lines.append("") + (root / ".zpkg.toml").write_text("\n".join(lines), encoding="utf-8") + + +def manifest_dependencies(project: Path) -> set[str]: + with (project / ".zpkg.toml").open("rb") as handle: + document = tomllib.load(handle) + return set((document.get("dependencies") or {}).keys()) + + +def lock_packages(project: Path) -> list[dict[str, object]]: + with (project / ".zpkg.lock").open("rb") as handle: + document = tomllib.load(handle) + packages = document.get("package") or [] + if not isinstance(packages, list): + raise AssertionError("lockfile package table is not a list") + return packages + + +def lock_keys(project: Path) -> set[str]: + return { + f"{package['org']}/{package['name']}" + for package in lock_packages(project) + } + + +def expected_keys(names: Sequence[str]) -> set[str]: + return {f"{ORG}/{name}" for name in names} + + +def assert_lock(project: Path, names: Sequence[str]) -> None: + actual = lock_keys(project) + expected = expected_keys(names) + if actual != expected: + raise AssertionError( + f"lock graph mismatch for {project}: expected {sorted(expected)}, got {sorted(actual)}" + ) + + +def assert_symlink_install( + project: Path, + home: Path, + names: Sequence[str], +) -> dict[str, Path]: + store = (home / "store").resolve() + targets: dict[str, Path] = {} + for name in names: + installed = project / "zed_modules" / ORG / name + if not installed.is_symlink(): + raise AssertionError(f"expected symlink materialization: {installed}") + target = installed.resolve(strict=True) + try: + target.relative_to(store) + except ValueError as error: + raise AssertionError(f"{installed} resolves outside {store}: {target}") from error + payload = (target / "payload.txt").read_text(encoding="utf-8") + if payload != f"{ORG}/{name}@{VERSION}\n": + raise AssertionError(f"unexpected payload through {installed}: {payload!r}") + targets[name] = target + return targets + + +def assert_copy_install(project: Path, names: Sequence[str]) -> None: + modules = project / "zed_modules" + for name in names: + installed = modules / ORG / name + if installed.is_symlink() or not installed.is_dir(): + raise AssertionError(f"expected copied package directory: {installed}") + payload = (installed / "payload.txt").read_text(encoding="utf-8") + if payload != f"{ORG}/{name}@{VERSION}\n": + raise AssertionError(f"unexpected copied payload in {installed}: {payload!r}") + leaked = [path for path in modules.rglob("*") if path.is_symlink()] + if leaked: + raise AssertionError(f"copy install leaked symlinks: {leaked}") + + +def assert_not_materialized(project: Path, names: Sequence[str]) -> None: + for name in names: + installed = project / "zed_modules" / ORG / name + if installed.exists() or installed.is_symlink(): + raise AssertionError(f"unexpected materialized package: {installed}") + + +def assert_no_transaction_leaks(project: Path) -> None: + staging = project / ".zpkg-staging" + if staging.exists(): + entries = list(staging.iterdir()) if staging.is_dir() else [staging] + raise AssertionError(f"transaction staging leaked in {project}: {entries}") + + +def assert_no_cache_staging(home: Path) -> None: + cache = home / "cache" + if not cache.exists(): + return + leaked_directories = sorted(path for path in cache.iterdir() if path.is_dir()) + leaked_downloads = sorted(cache.glob("**/artifact.download")) + if leaked_directories or leaked_downloads: + raise AssertionError( + f"cache staging leaked in {home}: dirs={leaked_directories}, downloads={leaked_downloads}" + ) + + +def parse_prefetch(output: str) -> tuple[int, int, int]: + matches = list(PREFETCH_RE.finditer(output)) + if len(matches) != 1: + raise AssertionError( + f"expected one recursive prefetch summary, got {len(matches)}:\n{output}" + ) + match = matches[0] + return tuple( + int(match.group(field)) + for field in ("resolved", "concurrency", "downloaded") + ) + + +def assert_prefetch( + completed: Completed, + *, + resolved: int, + downloaded: int, +) -> tuple[int, int, int]: + actual = parse_prefetch(completed.output) + expected = (resolved, 5, downloaded) + if actual != expected: + raise AssertionError(f"prefetch mismatch: expected {expected}, got {actual}") + return actual + + +def registry_version_path(registry: Path, name: str) -> Path: + return registry / "packages" / ORG / name / "versions" / f"{VERSION}.json" + + +def registry_artifact(registry: Path, name: str) -> tuple[str, Path]: + metadata_path = registry_version_path(registry, name) + payload = json.loads(metadata_path.read_text(encoding="utf-8")) + sha256 = str(payload["sha256"]) + if not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise AssertionError(f"invalid registry SHA for {name}: {sha256!r}") + artifact = registry / "artifacts" / f"{sha256}.tar.gz" + if not artifact.is_file(): + raise AssertionError(f"registry artifact missing before test: {artifact}") + return sha256, artifact + + +def tamper_first_sha(lock_text: str) -> str: + tampered, count = SHA_ASSIGNMENT_RE.subn( + 'sha256 = "' + ("0" * 64) + '"', + lock_text, + count=1, + ) + if count != 1: + raise AssertionError("could not locate first SHA assignment in lockfile") + return tampered + + +def duplicate_first_package(lock_text: str) -> str: + match = PACKAGE_BLOCK_RE.search(lock_text) + if match is None: + raise AssertionError("could not locate a package block in lockfile") + return lock_text.rstrip() + "\n\n" + match.group(0).rstrip() + "\n" + + +def publish( + zed: Path, + registry: Path, + home: Path, + source: Path, + env: Mapping[str, str], +) -> None: + completed = run( + zed_command(zed, registry, home, "publish", "--skip-vcs-checks"), + cwd=source, + env=env, + ) + require_success(completed) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--zed", type=Path, required=True) + parser.add_argument("--work-root", type=Path, required=True) + args = parser.parse_args() + + zed = args.zed.resolve() + root = args.work_root.resolve() + if not zed.is_file(): + raise RuntimeError(f"zed binary does not exist: {zed}") + if root.exists(): + raise RuntimeError(f"work root must be fresh: {root}") + + registry = root / "registry" + publish_home = root / "publish-home" + shared_home = root / "shared-home" + copy_home = root / "copy-home" + tamper_home = root / "tamper-home" + duplicate_home = root / "duplicate-home" + corrupt_home = root / "corrupt-home" + sources = root / "sources" + project = root / "same-project" + copy_project = root / "copy-project" + tamper_project = root / "tamper-project" + duplicate_project = root / "duplicate-project" + corrupt_project = root / "corrupt-project" + registry.mkdir(parents=True) + + environment = os.environ.copy() + environment.update( + { + "CI": "true", + "GIT_TERMINAL_PROMPT": "0", + "RUST_BACKTRACE": "1", + "ZED_PKG_INSTALL_CONCURRENCY": "5", + } + ) + + for name, dependencies in BASE_GRAPH.items(): + source = sources / name + write_package(source, name, dependencies) + publish(zed, registry, publish_home, source, environment) + + write_consumer(project, ("root",)) + start = Barrier(3) + + def concurrent_install() -> Completed: + start.wait() + return run( + zed_command(zed, registry, shared_home, "install", "--adapter", "none"), + cwd=project, + env=environment, + timeout=300, + ) + + with ThreadPoolExecutor(max_workers=2, thread_name_prefix="zed-same-project") as executor: + futures = [executor.submit(concurrent_install) for _ in range(2)] + start.wait() + concurrent = [future.result(timeout=360) for future in futures] + + for completed in concurrent: + require_success(completed) + summaries = [parse_prefetch(completed.output) for completed in concurrent] + if any(summary[:2] != (len(BASE_GRAPH), 5) for summary in summaries): + raise AssertionError(f"same-project resolver summaries are wrong: {summaries}") + if sum(summary[2] for summary in summaries) != len(BASE_GRAPH): + raise AssertionError( + f"same-project processes did not deduplicate downloads: {summaries}" + ) + + all_base = tuple(BASE_GRAPH) + assert_lock(project, all_base) + initial_targets = assert_symlink_install(project, shared_home, all_base) + assert_no_transaction_leaks(project) + assert_no_cache_staging(shared_home) + lock_before = (project / ".zpkg.lock").read_bytes() + manifest_before = (project / ".zpkg.toml").read_bytes() + + warm = run( + zed_command(zed, registry, shared_home, "install", "--adapter", "none"), + cwd=project, + env=environment, + ) + require_success(warm) + assert_prefetch(warm, resolved=len(BASE_GRAPH), downloaded=0) + if (project / ".zpkg.lock").read_bytes() != lock_before: + raise AssertionError("warm ordinary install changed lockfile bytes") + if assert_symlink_install(project, shared_home, all_base) != initial_targets: + raise AssertionError("warm ordinary install changed immutable store targets") + + broken = project / "zed_modules" / ORG / "leaf-a" + broken.unlink() + repair = run( + zed_command( + zed, + registry, + shared_home, + "install", + "--frozen", + "--adapter", + "none", + ), + cwd=project, + env=environment, + ) + require_success(repair) + assert_prefetch(repair, resolved=len(BASE_GRAPH), downloaded=0) + if (project / ".zpkg.lock").read_bytes() != lock_before: + raise AssertionError("frozen repair changed lockfile bytes") + assert_symlink_install(project, shared_home, all_base) + + selective_uninstall = run( + zed_command(zed, registry, shared_home, "uninstall", f"{ORG}/root"), + cwd=project, + env=environment, + ) + require_success(selective_uninstall) + assert_not_materialized(project, ("root",)) + assert_symlink_install(project, shared_home, ("leaf-a", "leaf-b")) + if (project / ".zpkg.lock").read_bytes() != lock_before: + raise AssertionError("selective uninstall changed lockfile bytes") + selective_restore = run( + zed_command( + zed, + registry, + shared_home, + "install", + "--frozen", + "--adapter", + "none", + ), + cwd=project, + env=environment, + ) + require_success(selective_restore) + assert_prefetch(selective_restore, resolved=len(BASE_GRAPH), downloaded=0) + assert_symlink_install(project, shared_home, all_base) + + uninstall_all = run( + zed_command(zed, registry, shared_home, "uninstall"), + cwd=project, + env=environment, + ) + require_success(uninstall_all) + assert_not_materialized(project, all_base) + if (project / ".zpkg.lock").read_bytes() != lock_before: + raise AssertionError("full uninstall changed lockfile bytes") + if (project / ".zpkg.toml").read_bytes() != manifest_before: + raise AssertionError("full uninstall changed manifest bytes") + full_restore = run( + zed_command( + zed, + registry, + shared_home, + "install", + "--frozen", + "--adapter", + "none", + ), + cwd=project, + env=environment, + ) + require_success(full_restore) + assert_prefetch(full_restore, resolved=len(BASE_GRAPH), downloaded=0) + assert_symlink_install(project, shared_home, all_base) + + write_consumer(copy_project, ("root",)) + copy_install = run( + zed_command( + zed, + registry, + copy_home, + "install", + "--install-mode", + "copy", + "--adapter", + "none", + ), + cwd=copy_project, + env=environment, + ) + require_success(copy_install) + assert_prefetch(copy_install, resolved=len(BASE_GRAPH), downloaded=len(BASE_GRAPH)) + assert_lock(copy_project, all_base) + assert_copy_install(copy_project, all_base) + copy_lock = (copy_project / ".zpkg.lock").read_bytes() + copy_warm = run( + zed_command( + zed, + registry, + copy_home, + "install", + "--frozen", + "--install-mode", + "copy", + "--adapter", + "none", + ), + cwd=copy_project, + env=environment, + ) + require_success(copy_warm) + assert_prefetch(copy_warm, resolved=len(BASE_GRAPH), downloaded=0) + if (copy_project / ".zpkg.lock").read_bytes() != copy_lock: + raise AssertionError("warm copy-mode frozen install changed lockfile bytes") + assert_copy_install(copy_project, all_base) + + good_lock_text = (project / ".zpkg.lock").read_text(encoding="utf-8") + write_consumer(tamper_project, ("root",)) + (tamper_project / ".zpkg.lock").write_text( + tamper_first_sha(good_lock_text), encoding="utf-8" + ) + tampered_before = (tamper_project / ".zpkg.lock").read_bytes() + tampered = run( + zed_command( + zed, + registry, + tamper_home, + "install", + "--frozen", + "--adapter", + "none", + ), + cwd=tamper_project, + env=environment, + ) + require_failure(tampered, "registry artifact", "changed") + if (tamper_project / ".zpkg.lock").read_bytes() != tampered_before: + raise AssertionError("failed tampered-lock install changed lockfile bytes") + assert_not_materialized(tamper_project, all_base) + assert_no_transaction_leaks(tamper_project) + assert_no_cache_staging(tamper_home) + + write_consumer(duplicate_project, ("root",)) + (duplicate_project / ".zpkg.lock").write_text( + duplicate_first_package(good_lock_text), encoding="utf-8" + ) + duplicate_before = (duplicate_project / ".zpkg.lock").read_bytes() + duplicate = run( + zed_command( + zed, + registry, + duplicate_home, + "install", + "--frozen", + "--adapter", + "none", + ), + cwd=duplicate_project, + env=environment, + ) + require_failure(duplicate, "duplicate package") + if (duplicate_project / ".zpkg.lock").read_bytes() != duplicate_before: + raise AssertionError("failed duplicate-lock install changed lockfile bytes") + assert_not_materialized(duplicate_project, all_base) + assert_no_transaction_leaks(duplicate_project) + + # A new direct dependency whose registry artifact is unavailable must not + # damage the already-installed graph or rewrite its last known-good lock. + extra_source = sources / "extra" + write_package(extra_source, "extra", ()) + publish(zed, registry, publish_home, extra_source, environment) + extra_sha, extra_artifact = registry_artifact(registry, "extra") + extra_bytes = extra_artifact.read_bytes() + extra_artifact.unlink() + write_consumer(project, ("root", "extra")) + preserved_targets = assert_symlink_install(project, shared_home, all_base) + unavailable = run( + zed_command(zed, registry, shared_home, "install", "--adapter", "none"), + cwd=project, + env=environment, + ) + require_failure(unavailable) + if (project / ".zpkg.lock").read_bytes() != lock_before: + raise AssertionError("missing-artifact failure replaced the good lockfile") + if assert_symlink_install(project, shared_home, all_base) != preserved_targets: + raise AssertionError("missing-artifact failure changed the installed graph") + assert_not_materialized(project, ("extra",)) + assert_no_transaction_leaks(project) + assert_no_cache_staging(shared_home) + if (shared_home / "cache" / f"{extra_sha}.tar.gz").exists(): + raise AssertionError("missing artifact produced a final cache entry") + + extra_artifact.write_bytes(extra_bytes) + recovered = run( + zed_command(zed, registry, shared_home, "install", "--adapter", "none"), + cwd=project, + env=environment, + ) + require_success(recovered) + assert_prefetch(recovered, resolved=len(BASE_GRAPH) + 1, downloaded=1) + extended = (*all_base, "extra") + assert_lock(project, extended) + assert_symlink_install(project, shared_home, extended) + assert_no_cache_staging(shared_home) + + # Corrupt bytes must fail before project mutation, leave no final cache + # entry for the bad hash, then recover by downloading exactly that hash. + write_consumer(corrupt_project, ("root",)) + corrupt_sha, corrupt_artifact = registry_artifact(registry, "leaf-b") + corrupt_bytes = corrupt_artifact.read_bytes() + corrupt_artifact.write_bytes(b"not a valid zed artifact\n") + corrupt = run( + zed_command(zed, registry, corrupt_home, "install", "--adapter", "none"), + cwd=corrupt_project, + env=environment, + ) + require_failure(corrupt, "artifact hash mismatch") + if (corrupt_project / ".zpkg.lock").exists(): + raise AssertionError("corrupt-artifact failure wrote a lockfile") + assert_not_materialized(corrupt_project, all_base) + assert_no_transaction_leaks(corrupt_project) + assert_no_cache_staging(corrupt_home) + if (corrupt_home / "cache" / f"{corrupt_sha}.tar.gz").exists(): + raise AssertionError("corrupt artifact was published to the final cache path") + + corrupt_artifact.write_bytes(corrupt_bytes) + corrupt_recovery = run( + zed_command(zed, registry, corrupt_home, "install", "--adapter", "none"), + cwd=corrupt_project, + env=environment, + ) + require_success(corrupt_recovery) + recovery_summary = parse_prefetch(corrupt_recovery.output) + if recovery_summary[:2] != (len(BASE_GRAPH), 5) or recovery_summary[2] < 1: + raise AssertionError(f"unexpected corrupt-artifact recovery summary: {recovery_summary}") + assert_lock(corrupt_project, all_base) + assert_symlink_install(corrupt_project, corrupt_home, all_base) + assert_no_cache_staging(corrupt_home) + + print("\ninstall recovery matrix passed", flush=True) + print(f"same-project concurrent summaries: {summaries}", flush=True) + print(f"corrupt recovery summary: {recovery_summary}", flush=True) + print(f"extended manifest dependencies: {sorted(manifest_dependencies(project))}", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # noqa: BLE001 - CI boundary must print full context. + print(f"error: {error}", file=sys.stderr, flush=True) + raise diff --git a/scripts/org_app_installs.py b/scripts/org_app_installs.py new file mode 100644 index 00000000..c20709cb --- /dev/null +++ b/scripts/org_app_installs.py @@ -0,0 +1,966 @@ +#!/usr/bin/env python3 +"""Install real zed-pkg-test app fixtures and verify adapter/workspace behavior.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping, Sequence + +GITHUB_ORG = "zed-pkg-test" +APP_REPOS = ( + "node-app", + "rust-app", + "go-app", + "python-app", + "dart-app", + "gleam-app", + "polyglot-node-app", + "polyglot-go-app", +) +PACKAGE_SOURCES: dict[str, tuple[str, str]] = { + "zed-pkg-test/node-lib": ("node-lib", "."), + "zed-pkg-test/rust-lib": ("rust-lib", "."), + "zed-pkg-test/go-lib": ("go-lib", "."), + "zed-pkg-test/python-lib": ("python-lib", "."), + "zed-pkg-test/dart-lib": ("dart-lib", "."), + "zed-pkg-test/gleam-lib": ("gleam-lib", "."), + "zedtest/polyglot-lib-nodejs": ("polyglot-lib", "."), + "zedtest/polyglot-lib-python": ("polyglot-lib", "."), + "zedtest/polyglot-lib-golang": ("polyglot-lib", "."), + "zedtest/polyglot-lib-rust": ("polyglot-lib", "."), + "zedtest/shared-schema": ("shared-schema", "."), +} +EXPECTED_ADAPTER = { + "node-app": "node", + "rust-app": "rust", + "go-app": "go", + "python-app": "python", + "dart-app": "dart", + "gleam-app": "none", + "polyglot-node-app": "node", + "polyglot-go-app": "go", +} +PREFETCH_RE = re.compile( + r"recursive install prefetch: (?P\d+) package\(s\), " + r"up to (?P\d+) concurrent, (?P\d+) downloaded" +) +GENERATED_WIRING_FILES = ( + "paths.json", + "node_path", + "classpath", + "go.work", + "pythonpath", + "cargo-paths.toml", + "pub-deps.yaml", +) + + +@dataclass(frozen=True) +class PackageRef: + full_name: str + version: str + + @property + def org(self) -> str: + return self.full_name.split("/", 1)[0] + + @property + def name(self) -> str: + return self.full_name.split("/", 1)[1] + + +@dataclass(frozen=True) +class Completed: + command: tuple[str, ...] + returncode: int + output: str + + +def shell_quote(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:@%+=,-]+", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def run( + command: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, + timeout: int = 300, +) -> Completed: + argv = tuple(str(value) for value in command) + shown = " ".join(shell_quote(value) for value in argv) + print(f"\n$ (cd {cwd or Path.cwd()} && {shown})", flush=True) + completed = subprocess.run( + argv, + cwd=cwd, + env=dict(env) if env is not None else None, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + timeout=timeout, + ) + if completed.stdout: + print(completed.stdout, end="", flush=True) + return Completed(argv, completed.returncode, completed.stdout) + + +def require_success(completed: Completed) -> None: + if completed.returncode != 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise RuntimeError(f"command failed with exit code {completed.returncode}: {shown}") + + +def require_failure(completed: Completed, *fragments: str) -> None: + if completed.returncode == 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise AssertionError(f"command unexpectedly succeeded: {shown}") + for fragment in fragments: + if fragment not in completed.output: + raise AssertionError( + f"failed command output did not contain {fragment!r}:\n{completed.output}" + ) + + +def tree_fingerprint(root: Path) -> tuple[tuple[str, int, str], ...]: + if not root.exists(): + return () + rows: list[tuple[str, int, str]] = [] + for candidate in sorted(root.rglob("*")): + if candidate.is_file() and not candidate.is_symlink(): + data = candidate.read_bytes() + rows.append( + ( + candidate.relative_to(root).as_posix(), + len(data), + hashlib.sha256(data).hexdigest(), + ) + ) + return tuple(rows) + + +def zed_command(zed: Path, registry: Path, home: Path, *args: str) -> list[str | Path]: + return [ + zed, + "--registry", + f"file://{registry}", + "--home", + home, + *args, + ] + + +def read_manifest(directory: Path) -> dict: + with (directory / ".zpkg.toml").open("rb") as handle: + return tomllib.load(handle) + + +def dependencies(manifest: dict) -> dict[str, str]: + section = manifest.get("dependencies") or {} + if not isinstance(section, dict): + raise AssertionError("[dependencies] must be a table") + return {str(key): str(value) for key, value in section.items()} + + +def expected_packages(manifest: dict) -> list[PackageRef]: + package = manifest["package"] + org = str(package["org"]) + base_name = str(package["name"]) + version = str(package["version"]) + targets = manifest.get("targets") or {} + if not targets: + return [PackageRef(f"{org}/{base_name}", version)] + result: list[PackageRef] = [] + for target_name, target in sorted(targets.items()): + target_table = target or {} + name = str(target_table.get("name") or f"{base_name}-{target_name}") + result.append(PackageRef(f"{org}/{name}", version)) + return result + + +def modules_dir(manifest: dict) -> str: + install = manifest.get("install") or {} + return str(install.get("dir") or "zed_modules") + + +def clone_repo(root: Path, repo: str, clones: dict[str, Path]) -> Path: + if repo in clones: + return clones[repo] + destination = root / repo + completed = run( + [ + "git", + "clone", + "--depth", + "1", + "--recurse-submodules", + "--shallow-submodules", + f"https://github.com/{GITHUB_ORG}/{repo}.git", + destination, + ], + timeout=300, + ) + require_success(completed) + clones[repo] = destination + return destination + + +def copy_fixture(source: Path, destination: Path) -> None: + def ignore(directory: str, names: list[str]) -> set[str]: + ignored = {name for name in names if name in {".git", ".zed", ".zpkg-staging"}} + if Path(directory) == source: + ignored.update(name for name in names if name == ".zpkg.lock") + return ignored + + shutil.copytree(source, destination, symlinks=True, ignore=ignore) + + +def registry_metadata_path(registry: Path, package: PackageRef) -> Path: + return ( + registry + / "packages" + / package.org + / package.name + / "versions" + / f"{package.version}.json" + ) + + +def assert_registry_package(registry: Path, package: PackageRef) -> None: + metadata_path = registry_metadata_path(registry, package) + if not metadata_path.is_file(): + raise AssertionError(f"missing registry metadata: {metadata_path}") + metadata = json.loads(metadata_path.read_text(encoding="utf-8")) + sha256 = str(metadata.get("sha256") or "") + if not re.fullmatch(r"[0-9a-f]{64}", sha256): + raise AssertionError(f"invalid SHA in {metadata_path}: {sha256!r}") + artifact = registry / "artifacts" / f"{sha256}.tar.gz" + if not artifact.is_file(): + raise AssertionError(f"missing registry artifact: {artifact}") + + +def parse_lock(project: Path) -> list[dict[str, object]]: + with (project / ".zpkg.lock").open("rb") as handle: + document = tomllib.load(handle) + packages = document.get("package") or [] + if not isinstance(packages, list): + raise AssertionError(f"invalid lock package table in {project}") + return packages + + +def package_key(package: Mapping[str, object]) -> str: + return f"{package['org']}/{package['name']}" + + +def materialized_path(project: Path, install_dir: str, package: Mapping[str, object]) -> Path: + return project / install_dir / str(package["org"]) / str(package["name"]) + + +def assert_symlink_graph( + project: Path, + home: Path, + install_dir: str, + packages: Sequence[Mapping[str, object]], +) -> dict[str, Path]: + store = (home / "store").resolve() + targets: dict[str, Path] = {} + for package in packages: + key = package_key(package) + installed = materialized_path(project, install_dir, package) + if not installed.is_symlink(): + raise AssertionError(f"{project.name}: expected symlink for {key}: {installed}") + target = installed.resolve(strict=True) + try: + target.relative_to(store) + except ValueError as error: + raise AssertionError(f"{installed} resolves outside {store}: {target}") from error + if not (target / ".zpkg.toml").is_file(): + raise AssertionError(f"installed artifact has no manifest: {target}") + targets[key] = target + return targets + + +def assert_paths_index(project: Path, keys: Sequence[str]) -> None: + path = project / ".zed" / "paths.json" + if not path.is_file(): + raise AssertionError(f"missing adapter-independent path index: {path}") + text = path.read_text(encoding="utf-8") + json.loads(text) + for key in keys: + if key not in text: + raise AssertionError(f"{path} does not mention installed package {key}") + + +def assert_adapter( + project: Path, + adapter: str, + install_dir: str, + packages: Sequence[Mapping[str, object]], +) -> None: + keys = [package_key(package) for package in packages] + assert_paths_index(project, keys) + zed_dir = project / ".zed" + + if adapter == "node": + node_path = zed_dir / "node_path" + if node_path.read_text(encoding="utf-8").strip() != install_dir: + raise AssertionError(f"unexpected node_path in {project}: {node_path.read_text()!r}") + for package in packages: + node_link = ( + project + / "node_modules" + / f"@{package['org']}" + / str(package["name"]) + ) + if not node_link.is_symlink(): + raise AssertionError(f"missing Node adapter symlink: {node_link}") + if node_link.resolve(strict=True) != materialized_path( + project, install_dir, package + ).resolve(strict=True): + raise AssertionError(f"Node adapter target mismatch: {node_link}") + elif adapter == "go": + wiring = zed_dir / "go.work" + if not wiring.is_file(): + raise AssertionError(f"missing Go wiring: {wiring}") + text = wiring.read_text(encoding="utf-8") + for package in packages: + if str(package["name"]) not in text: + raise AssertionError(f"{wiring} omits {package_key(package)}") + elif adapter == "python": + wiring = zed_dir / "pythonpath" + if not wiring.is_file(): + raise AssertionError(f"missing Python wiring: {wiring}") + text = wiring.read_text(encoding="utf-8") + for package in packages: + if str(package["name"]) not in text: + raise AssertionError(f"{wiring} omits {package_key(package)}") + elif adapter == "rust": + wiring = zed_dir / "cargo-paths.toml" + if not wiring.is_file(): + raise AssertionError(f"missing Rust wiring: {wiring}") + text = wiring.read_text(encoding="utf-8") + for package in packages: + if str(package["name"]) not in text: + raise AssertionError(f"{wiring} omits {package_key(package)}") + elif adapter == "dart": + wiring = zed_dir / "pub-deps.yaml" + if not wiring.is_file(): + raise AssertionError(f"missing Dart wiring: {wiring}") + text = wiring.read_text(encoding="utf-8") + for package in packages: + if str(package["name"]) not in text: + raise AssertionError(f"{wiring} omits {package_key(package)}") + elif adapter != "none": + raise AssertionError(f"unknown expected adapter: {adapter}") + + +def assert_not_materialized( + project: Path, + install_dir: str, + packages: Sequence[Mapping[str, object]], +) -> None: + for package in packages: + path = materialized_path(project, install_dir, package) + if path.exists() or path.is_symlink(): + raise AssertionError(f"uninstall left {path}") + node_path = ( + project + / "node_modules" + / f"@{package['org']}" + / str(package["name"]) + ) + if node_path.exists() or node_path.is_symlink(): + raise AssertionError(f"uninstall left Node adapter path {node_path}") + + +def assert_generated_wiring_absent(project: Path) -> None: + leaked = [ + project / ".zed" / name + for name in GENERATED_WIRING_FILES + if (project / ".zed" / name).exists() + or (project / ".zed" / name).is_symlink() + ] + if leaked: + raise AssertionError(f"uninstall left generated adapter wiring: {leaked}") + + +def assert_no_staging(project: Path) -> None: + staging = project / ".zpkg-staging" + if staging.exists(): + raise AssertionError(f"transaction staging leaked in {project}: {list(staging.iterdir())}") + + +def parse_prefetch(output: str) -> tuple[int, int, int]: + matches = list(PREFETCH_RE.finditer(output)) + if len(matches) != 1: + raise AssertionError(f"expected one prefetch summary, got {len(matches)}:\n{output}") + match = matches[0] + return tuple( + int(match.group(field)) + for field in ("resolved", "concurrency", "downloaded") + ) + + +def publish_prerequisite( + package_key_value: str, + *, + zed: Path, + registry: Path, + publish_home: Path, + clone_root: Path, + clones: dict[str, Path], + published_sources: set[tuple[str, str]], + environment: Mapping[str, str], +) -> None: + try: + repo, relative = PACKAGE_SOURCES[package_key_value] + except KeyError as error: + raise AssertionError( + f"no source mapping for dependency {package_key_value!r}" + ) from error + source_root = clone_repo(clone_root, repo, clones) + source = (source_root / relative).resolve() + source_manifest = read_manifest(source) + for dependency in dependencies(source_manifest): + publish_prerequisite( + dependency, + zed=zed, + registry=registry, + publish_home=publish_home, + clone_root=clone_root, + clones=clones, + published_sources=published_sources, + environment=environment, + ) + + source_key = (repo, relative) + if source_key in published_sources: + return + completed = run( + zed_command( + zed, + registry, + publish_home, + "publish", + "--skip-vcs-checks", + ), + cwd=source, + env=environment, + ) + require_success(completed) + published_sources.add(source_key) + for output in expected_packages(source_manifest): + assert_registry_package(registry, output) + + +def exercise_app( + repo: str, + *, + zed: Path, + registry: Path, + shared_home: Path, + clone_root: Path, + app_root: Path, + clones: dict[str, Path], + published_sources: set[tuple[str, str]], + environment: Mapping[str, str], +) -> tuple[int, set[str]]: + source = clone_repo(clone_root, repo, clones) + source_manifest = read_manifest(source) + direct = dependencies(source_manifest) + if not direct: + raise AssertionError(f"app fixture {repo} has no Zed dependencies") + for dependency in direct: + publish_prerequisite( + dependency, + zed=zed, + registry=registry, + publish_home=app_root.parent / "publish-home", + clone_root=clone_root, + clones=clones, + published_sources=published_sources, + environment=environment, + ) + + project = app_root / repo + copy_fixture(source, project) + manifest_bytes = (project / ".zpkg.toml").read_bytes() + install_dir = modules_dir(source_manifest) + + cold = run( + zed_command(zed, registry, shared_home, "install"), + cwd=project, + env=environment, + ) + require_success(cold) + packages = parse_lock(project) + keys = {package_key(package) for package in packages} + if not set(direct).issubset(keys): + raise AssertionError( + f"{repo}: lock omits direct dependencies {sorted(set(direct) - keys)}" + ) + summary = parse_prefetch(cold.output) + if summary[:2] != (len(packages), 5): + raise AssertionError(f"{repo}: unexpected cold prefetch summary {summary}") + + targets = assert_symlink_graph(project, shared_home, install_dir, packages) + assert_adapter(project, EXPECTED_ADAPTER[repo], install_dir, packages) + assert_no_staging(project) + lock_bytes = (project / ".zpkg.lock").read_bytes() + + warm = run( + zed_command(zed, registry, shared_home, "install", "--frozen"), + cwd=project, + env=environment, + ) + require_success(warm) + if parse_prefetch(warm.output) != (len(packages), 5, 0): + raise AssertionError(f"{repo}: warm frozen install downloaded bytes") + if (project / ".zpkg.lock").read_bytes() != lock_bytes: + raise AssertionError(f"{repo}: warm frozen install rewrote the lockfile") + if assert_symlink_graph(project, shared_home, install_dir, packages) != targets: + raise AssertionError(f"{repo}: warm frozen install changed store targets") + + first = packages[0] + materialized_path(project, install_dir, first).unlink() + repaired = run( + zed_command(zed, registry, shared_home, "install", "--frozen"), + cwd=project, + env=environment, + ) + require_success(repaired) + if parse_prefetch(repaired.output) != (len(packages), 5, 0): + raise AssertionError(f"{repo}: repair downloaded bytes") + assert_symlink_graph(project, shared_home, install_dir, packages) + assert_adapter(project, EXPECTED_ADAPTER[repo], install_dir, packages) + + uninstalled = run( + zed_command(zed, registry, shared_home, "uninstall"), + cwd=project, + env=environment, + ) + require_success(uninstalled) + assert_not_materialized(project, install_dir, packages) + assert_generated_wiring_absent(project) + if (project / ".zpkg.lock").read_bytes() != lock_bytes: + raise AssertionError(f"{repo}: uninstall rewrote the retained lockfile") + if (project / ".zpkg.toml").read_bytes() != manifest_bytes: + raise AssertionError(f"{repo}: uninstall rewrote the manifest") + assert_no_staging(project) + + restored = run( + zed_command(zed, registry, shared_home, "install", "--frozen"), + cwd=project, + env=environment, + ) + require_success(restored) + if parse_prefetch(restored.output) != (len(packages), 5, 0): + raise AssertionError(f"{repo}: post-uninstall restore downloaded bytes") + assert_symlink_graph(project, shared_home, install_dir, packages) + assert_adapter(project, EXPECTED_ADAPTER[repo], install_dir, packages) + assert_no_staging(project) + + return summary[2], {str(package["sha256"]) for package in packages} + + +def exercise_workspace( + *, + zed: Path, + registry: Path, + home: Path, + clone_root: Path, + workspace_root: Path, + clones: dict[str, Path], + environment: Mapping[str, str], +) -> None: + source = clone_repo(clone_root, "workspace-monorepo", clones) + + def expected_sources(repo_root: Path) -> dict[str, Path]: + return { + "ws-utils": repo_root / "packages" / "utils", + "ws-core": repo_root / "packages" / "core", + } + + def assert_projection(repo_root: Path, project: Path, mode: str) -> None: + expected = expected_sources(repo_root) + path_index = project / ".zed" / "paths.json" + if not path_index.is_file(): + raise AssertionError(f"workspace install omitted {path_index}") + index_text = path_index.read_text(encoding="utf-8") + json.loads(index_text) + for name, expected_source in expected.items(): + key = f"zedtest/{name}" + installed = project / "zed_modules" / "zedtest" / name + node_link = project / "node_modules" / "@zedtest" / name + for projection in (installed, node_link): + if mode == "symlink": + if not projection.is_symlink(): + raise AssertionError( + f"workspace dependency was not symlinked: {projection}" + ) + if projection.resolve(strict=True) != expected_source.resolve( + strict=True + ): + raise AssertionError( + f"workspace target mismatch: {projection} -> {projection.resolve()}" + ) + else: + if projection.is_symlink() or not projection.is_dir(): + raise AssertionError( + f"workspace dependency was not copied: {projection}" + ) + if not (projection / ".zpkg.toml").is_file(): + raise AssertionError( + f"workspace copied projection has no manifest: {projection}" + ) + if key not in index_text or '"version": "workspace"' not in index_text: + raise AssertionError(f"workspace path index omits {key}: {index_text}") + + node_path = project / ".zed" / "node_path" + if node_path.read_text(encoding="utf-8").strip() != "zed_modules": + raise AssertionError(f"unexpected workspace node_path: {node_path.read_text()!r}") + + symlink_repo = workspace_root / "symlink" + copy_fixture(source, symlink_repo) + symlink_project = symlink_repo / "apps" / "cli" + symlink_install = run( + zed_command(zed, registry, home, "install"), + cwd=symlink_project, + env=environment, + ) + require_success(symlink_install) + if PREFETCH_RE.search(symlink_install.output): + raise AssertionError( + "workspace-only install unexpectedly reported registry artifact prefetch" + ) + if parse_lock(symlink_project): + raise AssertionError("workspace packages must not be written as registry lock entries") + symlink_lock = (symlink_project / ".zpkg.lock").read_bytes() + assert_projection(symlink_repo, symlink_project, "symlink") + + live_sentinel = symlink_repo / "packages" / "core" / "live-sentinel.txt" + live_sentinel.write_text("visible after install\n", encoding="utf-8") + visible = ( + symlink_project + / "zed_modules" + / "zedtest" + / "ws-core" + / "live-sentinel.txt" + ) + if visible.read_text(encoding="utf-8") != "visible after install\n": + raise AssertionError("workspace symlink did not expose a post-install source edit") + + registry_before_frozen = tree_fingerprint(registry) + cache_before_frozen = tree_fingerprint(home / "cache") + for projection in [ + symlink_project / "zed_modules" / "zedtest" / "ws-utils", + symlink_project / "zed_modules" / "zedtest" / "ws-core", + symlink_project / "node_modules" / "@zedtest" / "ws-utils", + symlink_project / "node_modules" / "@zedtest" / "ws-core", + ]: + projection.unlink() + + frozen_repair = run( + zed_command(zed, registry, home, "install", "--frozen"), + cwd=symlink_project, + env=environment, + ) + require_success(frozen_repair) + if PREFETCH_RE.search(frozen_repair.output): + raise AssertionError("frozen workspace repair accessed registry artifacts") + if (symlink_project / ".zpkg.lock").read_bytes() != symlink_lock: + raise AssertionError("frozen workspace repair rewrote the empty artifact lock") + if tree_fingerprint(registry) != registry_before_frozen: + raise AssertionError("frozen workspace repair mutated the registry") + if tree_fingerprint(home / "cache") != cache_before_frozen: + raise AssertionError("frozen workspace repair mutated the artifact cache") + assert_projection(symlink_repo, symlink_project, "symlink") + + manifest_path = symlink_project / ".zpkg.toml" + manifest_before = manifest_path.read_text(encoding="utf-8") + incompatible = re.sub( + r'("zedtest/ws-utils"\s*=\s*)"[^"]+"', + r'\1"^2"', + manifest_before, + count=1, + ) + if incompatible == manifest_before: + raise AssertionError("could not rewrite workspace requirement for drift test") + manifest_path.write_text(incompatible, encoding="utf-8") + version_drift = run( + zed_command(zed, registry, home, "install", "--frozen"), + cwd=symlink_project, + env=environment, + ) + require_failure(version_drift, "ws-utils", "does not satisfy `^2`") + if (symlink_project / ".zpkg.lock").read_bytes() != symlink_lock: + raise AssertionError("workspace version-drift failure rewrote the lock") + assert_projection(symlink_repo, symlink_project, "symlink") + manifest_path.write_text(manifest_before, encoding="utf-8") + + symlink_uninstall = run( + zed_command(zed, registry, home, "uninstall"), + cwd=symlink_project, + env=environment, + ) + require_success(symlink_uninstall) + for projection in [ + symlink_project / "zed_modules" / "zedtest" / "ws-utils", + symlink_project / "zed_modules" / "zedtest" / "ws-core", + symlink_project / "node_modules" / "@zedtest" / "ws-utils", + symlink_project / "node_modules" / "@zedtest" / "ws-core", + ]: + if projection.exists() or projection.is_symlink(): + raise AssertionError(f"workspace uninstall left projection: {projection}") + assert_generated_wiring_absent(symlink_project) + assert_no_staging(symlink_project) + if (symlink_project / ".zpkg.lock").read_bytes() != symlink_lock: + raise AssertionError("workspace uninstall rewrote the retained lock") + + symlink_restore = run( + zed_command(zed, registry, home, "install", "--frozen"), + cwd=symlink_project, + env=environment, + ) + require_success(symlink_restore) + if PREFETCH_RE.search(symlink_restore.output): + raise AssertionError("workspace post-uninstall restore accessed registry artifacts") + if (symlink_project / ".zpkg.lock").read_bytes() != symlink_lock: + raise AssertionError("workspace post-uninstall restore rewrote the lock") + assert_projection(symlink_repo, symlink_project, "symlink") + assert_no_staging(symlink_project) + + copy_repo = workspace_root / "copy" + copy_fixture(source, copy_repo) + copy_source_sentinel = copy_repo / "packages" / "core" / "copy-sentinel.txt" + copy_source_sentinel.write_text("before install\n", encoding="utf-8") + copy_project = copy_repo / "apps" / "cli" + copy_install = run( + zed_command( + zed, + registry, + home, + "install", + "--install-mode", + "copy", + ), + cwd=copy_project, + env=environment, + ) + require_success(copy_install) + if PREFETCH_RE.search(copy_install.output): + raise AssertionError("workspace copy install accessed registry artifacts") + if parse_lock(copy_project): + raise AssertionError("workspace copy install wrote registry lock entries") + copy_lock = (copy_project / ".zpkg.lock").read_bytes() + assert_projection(copy_repo, copy_project, "copy") + + copied_sentinels = [ + copy_project / "zed_modules" / "zedtest" / "ws-core" / "copy-sentinel.txt", + copy_project + / "node_modules" + / "@zedtest" + / "ws-core" + / "copy-sentinel.txt", + ] + for copied_sentinel in copied_sentinels: + if copied_sentinel.read_text(encoding="utf-8") != "before install\n": + raise AssertionError(f"workspace copy omitted source content: {copied_sentinel}") + copy_source_sentinel.write_text("after install\n", encoding="utf-8") + for copied_sentinel in copied_sentinels: + if copied_sentinel.read_text(encoding="utf-8") != "before install\n": + raise AssertionError( + f"workspace copy remained coupled to mutable source: {copied_sentinel}" + ) + + for projection in [ + copy_project / "zed_modules" / "zedtest" / "ws-core", + copy_project / "node_modules" / "@zedtest" / "ws-core", + ]: + shutil.rmtree(projection) + copy_repair = run( + zed_command( + zed, + registry, + home, + "install", + "--frozen", + "--install-mode", + "copy", + ), + cwd=copy_project, + env=environment, + ) + require_success(copy_repair) + if PREFETCH_RE.search(copy_repair.output): + raise AssertionError("frozen workspace copy repair accessed registry artifacts") + if (copy_project / ".zpkg.lock").read_bytes() != copy_lock: + raise AssertionError("frozen workspace copy repair rewrote the lock") + assert_projection(copy_repo, copy_project, "copy") + leaked = [ + candidate + for root in (copy_project / "zed_modules", copy_project / "node_modules") + for candidate in root.rglob("*") + if candidate.is_symlink() + ] + if leaked: + raise AssertionError(f"workspace copy mode leaked symlinks: {leaked}") + + copy_uninstall = run( + zed_command(zed, registry, home, "uninstall"), + cwd=copy_project, + env=environment, + ) + require_success(copy_uninstall) + for projection in [ + copy_project / "zed_modules" / "zedtest" / "ws-utils", + copy_project / "zed_modules" / "zedtest" / "ws-core", + copy_project / "node_modules" / "@zedtest" / "ws-utils", + copy_project / "node_modules" / "@zedtest" / "ws-core", + ]: + if projection.exists() or projection.is_symlink(): + raise AssertionError(f"workspace copy uninstall left projection: {projection}") + assert_generated_wiring_absent(copy_project) + assert_no_staging(copy_project) + if (copy_project / ".zpkg.lock").read_bytes() != copy_lock: + raise AssertionError("workspace copy uninstall rewrote the retained lock") + + copy_restore = run( + zed_command( + zed, + registry, + home, + "install", + "--frozen", + "--install-mode", + "copy", + ), + cwd=copy_project, + env=environment, + ) + require_success(copy_restore) + if PREFETCH_RE.search(copy_restore.output): + raise AssertionError("workspace copy post-uninstall restore accessed registry artifacts") + if (copy_project / ".zpkg.lock").read_bytes() != copy_lock: + raise AssertionError("workspace copy post-uninstall restore rewrote the lock") + assert_projection(copy_repo, copy_project, "copy") + assert_no_staging(copy_project) + leaked = [ + candidate + for root in (copy_project / "zed_modules", copy_project / "node_modules") + for candidate in root.rglob("*") + if candidate.is_symlink() + ] + if leaked: + raise AssertionError(f"workspace restored copy mode leaked symlinks: {leaked}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--zed", type=Path, required=True) + parser.add_argument("--work-root", type=Path, required=True) + args = parser.parse_args() + + zed = args.zed.resolve() + root = args.work_root.resolve() + if not zed.is_file(): + raise RuntimeError(f"zed binary does not exist: {zed}") + if root.exists(): + raise RuntimeError(f"work root must be fresh: {root}") + + registry = root / "registry" + publish_home = root / "publish-home" + shared_home = root / "shared-home" + clone_root = root / "clones" + app_root = root / "apps" + workspace_root = root / "workspace" + for directory in (registry, clone_root, app_root, workspace_root): + directory.mkdir(parents=True, exist_ok=True) + + environment = os.environ.copy() + environment.update( + { + "CI": "true", + "GIT_TERMINAL_PROMPT": "0", + "RUST_BACKTRACE": "1", + "ZED_PKG_INSTALL_CONCURRENCY": "5", + } + ) + + clones: dict[str, Path] = {} + published_sources: set[tuple[str, str]] = set() + total_downloaded = 0 + unique_shas: set[str] = set() + per_app: dict[str, int] = {} + + for repo in APP_REPOS: + downloaded, shas = exercise_app( + repo, + zed=zed, + registry=registry, + shared_home=shared_home, + clone_root=clone_root, + app_root=app_root, + clones=clones, + published_sources=published_sources, + environment=environment, + ) + total_downloaded += downloaded + unique_shas.update(shas) + per_app[repo] = downloaded + + if total_downloaded != len(unique_shas): + raise AssertionError( + "shared-home app installs did not download each immutable hash exactly once: " + f"downloaded={total_downloaded}, unique_shas={len(unique_shas)}, per_app={per_app}" + ) + + exercise_workspace( + zed=zed, + registry=registry, + home=shared_home, + clone_root=clone_root, + workspace_root=workspace_root, + clones=clones, + environment=environment, + ) + + cache_files = sorted((shared_home / "cache").glob("*.tar.gz")) + if len(cache_files) != len(unique_shas): + raise AssertionError( + f"shared cache cardinality mismatch: {len(cache_files)} != {len(unique_shas)}" + ) + temporary = sorted((shared_home / "cache").glob("**/artifact.download")) + cache_directories = sorted( + path for path in (shared_home / "cache").iterdir() if path.is_dir() + ) + if temporary or cache_directories: + raise AssertionError( + f"shared cache leaked staging: downloads={temporary}, dirs={cache_directories}" + ) + + print("\nzed-pkg-test app install matrix passed", flush=True) + print(f"apps: {', '.join(APP_REPOS)}", flush=True) + print(f"cold downloads by app: {per_app}", flush=True) + print(f"unique immutable artifacts: {len(unique_shas)}", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # noqa: BLE001 - CI boundary must print full context. + print(f"error: {error}", file=sys.stderr, flush=True) + raise diff --git a/scripts/recursive_installs.py b/scripts/recursive_installs.py new file mode 100644 index 00000000..53932921 --- /dev/null +++ b/scripts/recursive_installs.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +"""Hermetic end-to-end coverage for recursive zed installs and shared-home locks.""" + +from __future__ import annotations + +import argparse +import os +import re +import subprocess +import sys +import tomllib +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from threading import Barrier +from typing import Mapping, Sequence + +ORG = "zed-recursive-test" +VERSION = "1.0.0" +PACKAGE_GRAPH: dict[str, tuple[str, ...]] = { + "leaf": (), + "left": ("leaf",), + "right": ("leaf",), + "root": ("left", "right"), +} +PREFETCH_RE = re.compile( + r"recursive install prefetch: (?P\d+) package\(s\), " + r"up to (?P\d+) concurrent, (?P\d+) downloaded" +) + + +@dataclass(frozen=True) +class Completed: + command: tuple[str, ...] + returncode: int + output: str + + +def shell_quote(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:@%+=,-]+", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def run( + command: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, +) -> Completed: + argv = tuple(str(value) for value in command) + shown = " ".join(shell_quote(value) for value in argv) + print(f"\n$ (cd {cwd or Path.cwd()} && {shown})", flush=True) + completed = subprocess.run( + argv, + cwd=cwd, + env=dict(env) if env is not None else None, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + timeout=180, + ) + if completed.stdout: + print(completed.stdout, end="", flush=True) + return Completed(argv, completed.returncode, completed.stdout) + + +def require_success(completed: Completed) -> None: + if completed.returncode != 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise RuntimeError(f"command failed with exit code {completed.returncode}: {shown}") + + +def write_package(root: Path, name: str, dependencies: Sequence[str]) -> None: + root.mkdir(parents=True, exist_ok=True) + lines = [ + "[package]", + f'org = "{ORG}"', + f'name = "{name}"', + f'version = "{VERSION}"', + 'description = "recursive install e2e fixture"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://github.com/zed-pkg-test/zed-pkg-e2e"', + ] + if dependencies: + lines.extend(("", "[dependencies]")) + for dependency in dependencies: + lines.append(f'"{ORG}/{dependency}" = "={VERSION}"') + (root / ".zpkg.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + (root / "payload.txt").write_text(f"{ORG}/{name}@{VERSION}\n", encoding="utf-8") + + +def write_consumer(root: Path, dependencies: Sequence[str]) -> None: + root.mkdir(parents=True, exist_ok=True) + lines = [ + "[package]", + 'org = "recursive-consumer"', + f'name = "{root.name}"', + 'version = "0.1.0"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://example.invalid/recursive-consumer"', + "", + "[dependencies]", + ] + for dependency in dependencies: + lines.append(f'"{ORG}/{dependency}" = "={VERSION}"') + lines.append("") + (root / ".zpkg.toml").write_text("\n".join(lines), encoding="utf-8") + + +def zed_command(zed: Path, registry: Path, home: Path, *args: str) -> list[str | Path]: + return [ + zed, + "--registry", + f"file://{registry}", + "--home", + home, + *args, + ] + + +def package_keys(names: Sequence[str]) -> set[str]: + return {f"{ORG}/{name}" for name in names} + + +def assert_manifest_dependencies(consumer: Path, expected_names: Sequence[str]) -> None: + with (consumer / ".zpkg.toml").open("rb") as handle: + document = tomllib.load(handle) + actual = set((document.get("dependencies") or {}).keys()) + expected = package_keys(expected_names) + if actual != expected: + raise AssertionError( + f"manifest dependency mismatch: expected {sorted(expected)}, got {sorted(actual)}" + ) + + +def assert_lock_packages(consumer: Path, expected_names: Sequence[str]) -> None: + with (consumer / ".zpkg.lock").open("rb") as handle: + document = tomllib.load(handle) + packages = document.get("package") or [] + actual = {f"{package['org']}/{package['name']}" for package in packages} + expected = package_keys(expected_names) + if actual != expected: + raise AssertionError( + f"lock graph mismatch: expected {sorted(expected)}, got {sorted(actual)}" + ) + + +def assert_symlink_packages( + consumer: Path, + store_root: Path, + expected_names: Sequence[str], +) -> dict[str, Path]: + targets: dict[str, Path] = {} + for name in expected_names: + installed = consumer / "zed_modules" / ORG / name + if not installed.is_symlink(): + raise AssertionError(f"default install did not symlink {installed}") + target = installed.resolve(strict=True) + try: + target.relative_to(store_root) + except ValueError as error: + raise AssertionError( + f"{installed} resolves outside the shared store: {target}" + ) from error + if (target / "payload.txt").read_text(encoding="utf-8") != f"{ORG}/{name}@{VERSION}\n": + raise AssertionError(f"unexpected payload for {installed}") + targets[name] = target + return targets + + +def assert_not_materialized(consumer: Path, names: Sequence[str]) -> None: + for name in names: + installed = consumer / "zed_modules" / ORG / name + if installed.exists() or installed.is_symlink(): + raise AssertionError(f"unexpected materialized package {installed}") + + +def assert_artifact_home(home: Path, expected_count: int) -> None: + cache_files = sorted((home / "cache").glob("*.tar.gz")) + if len(cache_files) != expected_count: + raise AssertionError( + f"expected {expected_count} cached artifacts in {home}, got {cache_files}" + ) + temporary_downloads = list((home / "cache").glob("**/artifact.download")) + if temporary_downloads: + raise AssertionError( + f"temporary downloads leaked into {home}: {temporary_downloads}" + ) + lock_files = sorted((home / "locks").glob("artifact-*.lock")) + if len(lock_files) != expected_count: + raise AssertionError( + f"expected {expected_count} artifact lock files in {home}, got {lock_files}" + ) + + +def parse_prefetch(output: str) -> tuple[int, int, int]: + matches = list(PREFETCH_RE.finditer(output)) + if len(matches) != 1: + raise AssertionError( + f"expected one recursive prefetch summary, got {len(matches)}\n{output}" + ) + match = matches[0] + return tuple( + int(match.group(field)) + for field in ("resolved", "concurrency", "downloaded") + ) + + +def assert_prefetch( + completed: Completed, + *, + resolved: int, + downloaded: int, +) -> tuple[int, int, int]: + summary = parse_prefetch(completed.output) + expected = (resolved, 5, downloaded) + if summary != expected: + raise AssertionError(f"prefetch summary mismatch: expected {expected}, got {summary}") + return summary + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--zed", type=Path, required=True) + parser.add_argument("--work-root", type=Path, required=True) + args = parser.parse_args() + + zed = args.zed.resolve() + root = args.work_root.resolve() + if not zed.is_file(): + raise RuntimeError(f"zed binary does not exist: {zed}") + if root.exists(): + raise RuntimeError(f"work root must be fresh: {root}") + + registry = root / "registry" + publish_home = root / "publish-home" + shared_home = root / "shared-home" + add_home = root / "add-home" + remove_home = root / "remove-home" + sources = root / "sources" + consumers = [root / "consumer-a", root / "consumer-b"] + add_consumer = root / "consumer-add" + remove_consumer = root / "consumer-remove" + registry.mkdir(parents=True) + + environment = os.environ.copy() + environment.update( + { + "CI": "true", + "GIT_TERMINAL_PROMPT": "0", + "RUST_BACKTRACE": "1", + "ZED_PKG_INSTALL_CONCURRENCY": "5", + } + ) + + for name, dependencies in PACKAGE_GRAPH.items(): + package_root = sources / name + write_package(package_root, name, dependencies) + completed = run( + zed_command( + zed, + registry, + publish_home, + "publish", + "--skip-vcs-checks", + ), + cwd=package_root, + env=environment, + ) + require_success(completed) + + for consumer in consumers: + write_consumer(consumer, ("root",)) + + start = Barrier(3) + + def install(consumer: Path) -> Completed: + start.wait() + return run( + zed_command( + zed, + registry, + shared_home, + "install", + "--adapter", + "none", + ), + cwd=consumer, + env=environment, + ) + + with ThreadPoolExecutor(max_workers=2, thread_name_prefix="zed-e2e") as executor: + futures = [executor.submit(install, consumer) for consumer in consumers] + start.wait() + completed_installs = [future.result(timeout=240) for future in futures] + + for completed in completed_installs: + require_success(completed) + + summaries = [parse_prefetch(completed.output) for completed in completed_installs] + if any(resolved != len(PACKAGE_GRAPH) for resolved, _, _ in summaries): + raise AssertionError( + f"each recursive resolver must discover four packages: {summaries}" + ) + if any(concurrency != 5 for _, concurrency, _ in summaries): + raise AssertionError( + f"installer did not honor five-worker concurrency: {summaries}" + ) + if sum(downloaded for _, _, downloaded in summaries) != len(PACKAGE_GRAPH): + raise AssertionError( + "shared-home installs did not deduplicate downloads across processes: " + f"{summaries}" + ) + + first_targets: dict[str, Path] | None = None + store_root = (shared_home / "store").resolve() + all_packages = tuple(PACKAGE_GRAPH) + for consumer in consumers: + assert_manifest_dependencies(consumer, ("root",)) + assert_lock_packages(consumer, all_packages) + targets = assert_symlink_packages(consumer, store_root, all_packages) + if first_targets is None: + first_targets = targets + elif targets != first_targets: + raise AssertionError( + f"consumers did not share identical store entries: {first_targets} != {targets}" + ) + assert_artifact_home(shared_home, len(PACKAGE_GRAPH)) + + # `zed add` installs against an in-memory proposed manifest before writing + # it. This must enter the same recursive prefetch facade as a normal install. + write_consumer(add_consumer, ()) + add_completed = run( + zed_command( + zed, + registry, + add_home, + "add", + f"{ORG}/root@={VERSION}", + ), + cwd=add_consumer, + env=environment, + ) + require_success(add_completed) + add_summary = assert_prefetch( + add_completed, + resolved=len(PACKAGE_GRAPH), + downloaded=len(PACKAGE_GRAPH), + ) + assert_manifest_dependencies(add_consumer, ("root",)) + assert_lock_packages(add_consumer, all_packages) + assert_symlink_packages(add_consumer, (add_home / "store").resolve(), all_packages) + assert_artifact_home(add_home, len(PACKAGE_GRAPH)) + + # `zed remove` also installs an in-memory proposed manifest. Starting with + # root + left and removing root leaves left -> leaf, so exactly two packages + # should be recursively prefetched and materialized. + write_consumer(remove_consumer, ("root", "left")) + remove_completed = run( + zed_command( + zed, + registry, + remove_home, + "remove", + f"{ORG}/root", + ), + cwd=remove_consumer, + env=environment, + ) + require_success(remove_completed) + remove_summary = assert_prefetch(remove_completed, resolved=2, downloaded=2) + assert_manifest_dependencies(remove_consumer, ("left",)) + assert_lock_packages(remove_consumer, ("left", "leaf")) + assert_symlink_packages( + remove_consumer, + (remove_home / "store").resolve(), + ("left", "leaf"), + ) + assert_not_materialized(remove_consumer, ("root", "right")) + assert_artifact_home(remove_home, 2) + + print("\nrecursive install e2e passed", flush=True) + print(f"concurrent install summaries: {summaries}", flush=True) + print(f"add summary: {add_summary}", flush=True) + print(f"remove summary: {remove_summary}", flush=True) + print(f"shared store: {store_root}", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # noqa: BLE001 - CI entry point must print context. + print(f"error: {error}", file=sys.stderr, flush=True) + raise diff --git a/scripts/recursive_installs_stress.py b/scripts/recursive_installs_stress.py new file mode 100644 index 00000000..8b109af2 --- /dev/null +++ b/scripts/recursive_installs_stress.py @@ -0,0 +1,421 @@ +#!/usr/bin/env python3 +"""Stress recursive zed installs across processes and a manifestless frozen replay.""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tomllib +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from threading import Barrier +from typing import Mapping, Sequence + +ORG = "zed-recursive-stress" +VERSION = "1.0.0" +LEAVES = tuple(f"leaf-{index:02d}" for index in range(12)) +ROOT = "root" +ALL_PACKAGES = (*LEAVES, ROOT) +PREFETCH_RE = re.compile( + r"recursive install prefetch: (?P\d+) package\(s\), " + r"up to (?P\d+) concurrent, (?P\d+) downloaded" +) + + +@dataclass(frozen=True) +class Completed: + command: tuple[str, ...] + returncode: int + output: str + + +def shell_quote(value: str) -> str: + if re.fullmatch(r"[A-Za-z0-9_./:@%+=,-]+", value): + return value + return "'" + value.replace("'", "'\"'\"'") + "'" + + +def run( + command: Sequence[str | Path], + *, + cwd: Path | None = None, + env: Mapping[str, str] | None = None, +) -> Completed: + argv = tuple(str(value) for value in command) + shown = " ".join(shell_quote(value) for value in argv) + print(f"\n$ (cd {cwd or Path.cwd()} && {shown})", flush=True) + completed = subprocess.run( + argv, + cwd=cwd, + env=dict(env) if env is not None else None, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + timeout=240, + ) + if completed.stdout: + print(completed.stdout, end="", flush=True) + return Completed(argv, completed.returncode, completed.stdout) + + +def require_success(completed: Completed) -> None: + if completed.returncode != 0: + shown = " ".join(shell_quote(value) for value in completed.command) + raise RuntimeError(f"command failed with exit code {completed.returncode}: {shown}") + + +def write_package(root: Path, name: str, dependencies: Sequence[str]) -> None: + root.mkdir(parents=True, exist_ok=True) + lines = [ + "[package]", + f'org = "{ORG}"', + f'name = "{name}"', + f'version = "{VERSION}"', + 'description = "recursive install stress fixture"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://github.com/zed-pkg-test/zed-pkg-e2e"', + ] + if dependencies: + lines.extend(("", "[dependencies]")) + for dependency in dependencies: + lines.append(f'"{ORG}/{dependency}" = "={VERSION}"') + (root / ".zpkg.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + (root / "payload.txt").write_text(f"{ORG}/{name}@{VERSION}\n", encoding="utf-8") + + +def write_consumer(root: Path) -> None: + root.mkdir(parents=True, exist_ok=True) + (root / ".zpkg.toml").write_text( + "\n".join( + ( + "[package]", + 'org = "recursive-stress-consumer"', + f'name = "{root.name}"', + 'version = "0.1.0"', + "", + "[package.repository]", + 'vcs = "git"', + 'url = "https://example.invalid/recursive-stress-consumer"', + "", + "[dependencies]", + f'"{ORG}/{ROOT}" = "={VERSION}"', + "", + ) + ), + encoding="utf-8", + ) + + +def zed_command(zed: Path, registry: Path, home: Path, *args: str) -> list[str | Path]: + return [ + zed, + "--registry", + f"file://{registry}", + "--home", + home, + *args, + ] + + +def package_keys(names: Sequence[str]) -> set[str]: + return {f"{ORG}/{name}" for name in names} + + +def assert_manifest_root_only(consumer: Path) -> None: + with (consumer / ".zpkg.toml").open("rb") as handle: + document = tomllib.load(handle) + actual = set((document.get("dependencies") or {}).keys()) + expected = package_keys((ROOT,)) + if actual != expected: + raise AssertionError( + f"manifest dependency mismatch: expected {sorted(expected)}, got {sorted(actual)}" + ) + + +def assert_lock_packages(consumer: Path, expected_names: Sequence[str]) -> None: + with (consumer / ".zpkg.lock").open("rb") as handle: + document = tomllib.load(handle) + packages = document.get("package") or [] + actual = {f"{package['org']}/{package['name']}" for package in packages} + expected = package_keys(expected_names) + if actual != expected: + raise AssertionError( + f"lock graph mismatch: expected {sorted(expected)}, got {sorted(actual)}" + ) + + +def assert_symlink_packages( + consumer: Path, + store_root: Path, + expected_names: Sequence[str], +) -> dict[str, Path]: + targets: dict[str, Path] = {} + for name in expected_names: + installed = consumer / "zed_modules" / ORG / name + if not installed.is_symlink(): + raise AssertionError(f"default install did not symlink {installed}") + target = installed.resolve(strict=True) + try: + target.relative_to(store_root) + except ValueError as error: + raise AssertionError( + f"{installed} resolves outside the shared store: {target}" + ) from error + payload = (target / "payload.txt").read_text(encoding="utf-8") + if payload != f"{ORG}/{name}@{VERSION}\n": + raise AssertionError(f"unexpected payload for {installed}: {payload!r}") + targets[name] = target + return targets + + +def assert_artifact_home(home: Path, expected_count: int) -> None: + cache = home / "cache" + cache_files = sorted(cache.glob("*.tar.gz")) + if len(cache_files) != expected_count: + raise AssertionError( + f"expected {expected_count} cached artifacts in {home}, got {cache_files}" + ) + leaked_directories = sorted(path for path in cache.iterdir() if path.is_dir()) + if leaked_directories: + raise AssertionError(f"temporary cache directories leaked: {leaked_directories}") + temporary_downloads = list(cache.glob("**/artifact.download")) + if temporary_downloads: + raise AssertionError(f"temporary downloads leaked: {temporary_downloads}") + lock_files = sorted((home / "locks").glob("artifact-*.lock")) + if len(lock_files) != expected_count: + raise AssertionError( + f"expected {expected_count} artifact locks in {home}, got {lock_files}" + ) + + +def parse_prefetch(output: str) -> tuple[int, int, int]: + matches = list(PREFETCH_RE.finditer(output)) + if len(matches) != 1: + raise AssertionError( + f"expected one recursive prefetch summary, got {len(matches)}\n{output}" + ) + match = matches[0] + return tuple( + int(match.group(field)) + for field in ("resolved", "concurrency", "downloaded") + ) + + +def assert_prefetch( + completed: Completed, + *, + resolved: int, + downloaded: int, +) -> tuple[int, int, int]: + summary = parse_prefetch(completed.output) + expected = (resolved, 5, downloaded) + if summary != expected: + raise AssertionError(f"prefetch summary mismatch: expected {expected}, got {summary}") + return summary + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--zed", type=Path, required=True) + parser.add_argument("--work-root", type=Path, required=True) + args = parser.parse_args() + + zed = args.zed.resolve() + root = args.work_root.resolve() + if not zed.is_file(): + raise RuntimeError(f"zed binary does not exist: {zed}") + if root.exists(): + raise RuntimeError(f"work root must be fresh: {root}") + + registry = root / "registry" + publish_home = root / "publish-home" + shared_home = root / "shared-home" + frozen_home = root / "frozen-home" + sources = root / "sources" + consumers = [root / f"consumer-{index}" for index in range(4)] + frozen_consumer = root / "consumer-frozen" + registry.mkdir(parents=True) + + environment = os.environ.copy() + environment.update( + { + "CI": "true", + "GIT_TERMINAL_PROMPT": "0", + "RUST_BACKTRACE": "1", + "ZED_PKG_INSTALL_CONCURRENCY": "5", + } + ) + + for name in LEAVES: + package_root = sources / name + write_package(package_root, name, ()) + completed = run( + zed_command( + zed, + registry, + publish_home, + "publish", + "--skip-vcs-checks", + ), + cwd=package_root, + env=environment, + ) + require_success(completed) + + root_package = sources / ROOT + write_package(root_package, ROOT, LEAVES) + root_publish = run( + zed_command( + zed, + registry, + publish_home, + "publish", + "--skip-vcs-checks", + ), + cwd=root_package, + env=environment, + ) + require_success(root_publish) + + for consumer in consumers: + write_consumer(consumer) + + start = Barrier(len(consumers) + 1) + + def install(consumer: Path) -> Completed: + start.wait() + return run( + zed_command( + zed, + registry, + shared_home, + "install", + "--adapter", + "none", + ), + cwd=consumer, + env=environment, + ) + + with ThreadPoolExecutor( + max_workers=len(consumers), + thread_name_prefix="zed-recursive-stress", + ) as executor: + futures = [executor.submit(install, consumer) for consumer in consumers] + start.wait() + installs = [future.result(timeout=300) for future in futures] + + for completed in installs: + require_success(completed) + + summaries = [parse_prefetch(completed.output) for completed in installs] + expected_count = len(ALL_PACKAGES) + if any(resolved != expected_count for resolved, _, _ in summaries): + raise AssertionError(f"every process must resolve the complete graph: {summaries}") + if any(concurrency != 5 for _, concurrency, _ in summaries): + raise AssertionError(f"five-worker configuration was not honored: {summaries}") + if sum(downloaded for _, _, downloaded in summaries) != expected_count: + raise AssertionError( + "four processes did not deduplicate shared-home downloads: " + f"{summaries}" + ) + + first_targets: dict[str, Path] | None = None + shared_store = (shared_home / "store").resolve() + for consumer in consumers: + assert_manifest_root_only(consumer) + assert_lock_packages(consumer, ALL_PACKAGES) + targets = assert_symlink_packages(consumer, shared_store, ALL_PACKAGES) + if first_targets is None: + first_targets = targets + elif targets != first_targets: + raise AssertionError( + f"consumers did not share identical store entries: {first_targets} != {targets}" + ) + assert_artifact_home(shared_home, expected_count) + + # Replay the complete transitive lockfile in a fresh project with no + # manifest. This exercises the lock-only recursive prefetch facade and its + # normal symlink materialization path. + frozen_consumer.mkdir(parents=True) + source_lock = consumers[0] / ".zpkg.lock" + frozen_lock = frozen_consumer / ".zpkg.lock" + shutil.copyfile(source_lock, frozen_lock) + lock_before = frozen_lock.read_bytes() + frozen_install = run( + zed_command( + zed, + registry, + frozen_home, + "install", + "--frozen", + "--allow-no-manifest", + "--adapter", + "none", + ), + cwd=frozen_consumer, + env=environment, + ) + require_success(frozen_install) + frozen_summary = assert_prefetch( + frozen_install, + resolved=expected_count, + downloaded=expected_count, + ) + if (frozen_consumer / ".zpkg.toml").exists(): + raise AssertionError("manifestless frozen install wrote a new manifest") + if frozen_lock.read_bytes() != lock_before: + raise AssertionError("frozen install modified the lockfile") + assert_lock_packages(frozen_consumer, ALL_PACKAGES) + assert_symlink_packages( + frozen_consumer, + (frozen_home / "store").resolve(), + ALL_PACKAGES, + ) + assert_artifact_home(frozen_home, expected_count) + + frozen_warm = run( + zed_command( + zed, + registry, + frozen_home, + "install", + "--frozen", + "--allow-no-manifest", + "--adapter", + "none", + ), + cwd=frozen_consumer, + env=environment, + ) + require_success(frozen_warm) + frozen_warm_summary = assert_prefetch( + frozen_warm, + resolved=expected_count, + downloaded=0, + ) + if frozen_lock.read_bytes() != lock_before: + raise AssertionError("warm frozen install modified the lockfile") + + print("\nrecursive install stress passed", flush=True) + print(f"concurrent process summaries: {summaries}", flush=True) + print(f"frozen summary: {frozen_summary}", flush=True) + print(f"warm frozen summary: {frozen_warm_summary}", flush=True) + print(f"shared store: {shared_store}", flush=True) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: # noqa: BLE001 - CI entry point prints full context. + print(f"error: {error}", file=sys.stderr, flush=True) + raise