diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index dd31daf73..6a81dbe3e 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -5,384 +5,384 @@ name: CI (macOS) # the macOS-relevant surface changes. Most PRs touch no minvmd code and skip # this workflow entirely, keeping the runner free. on: - push: - branches: ["main"] - paths: - - "crates/minvmd/**" - - "crates/minimal2/**" - - "crates/sessions/**" # platform-sensitive: canonicalization, symlinks - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/ci-macos.yml" - - "scripts/**" - pull_request: - branches: ["main"] - paths: - - "crates/minvmd/**" - - "crates/minimal2/**" - - "crates/sessions/**" # platform-sensitive: canonicalization, symlinks - - "Cargo.toml" - - "Cargo.lock" - - ".github/workflows/ci-macos.yml" - - "scripts/**" - workflow_dispatch: + push: + branches: ["main"] + paths: + - "crates/minvmd/**" + - "crates/minimal/**" + - "crates/sessions/**" # platform-sensitive: canonicalization, symlinks + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ci-macos.yml" + - "scripts/**" + pull_request: + branches: ["main"] + paths: + - "crates/minvmd/**" + - "crates/minimal/**" + - "crates/sessions/**" # platform-sensitive: canonicalization, symlinks + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/ci-macos.yml" + - "scripts/**" + workflow_dispatch: env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: always # Cancel a superseded run so the single runner isn't backed up by stale commits. concurrency: - group: ci-macos-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: ci-macos-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} # Least privilege: this workflow only checks out and builds. permissions: - contents: read + contents: read jobs: - artifacts: - # Build the `minimal` CLI from THIS repo's sources, then materialize the - # guest kernel + rootfs against the repo's own .minimal/minimal.toml. Both - # are cache pulls keyed by the pinned upstream commit (not compiles): the - # upstream virtio-kernel-raw / microvm-rootfs packages' prebuilt aarch64 - # artifacts are in the public cache, so they pull fine on a cheap x86_64 - # Linux runner — no native arm64 build and no self-hosted runner needed. - # The initramfs build cross-compiles minimald for aarch64-musl (~30 s with - # the fast `initramfs` profile, then cached by actions/cache), so the timeout - # has headroom. - runs-on: ubuntu-latest - timeout-minutes: 35 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Toolchain - uses: dtolnay/rust-toolchain@stable - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Cache cargo + build artifacts (incl. the aarch64 cross target) - # Raw actions/cache (not Swatinem) — it preserves the full - # target/aarch64-unknown-linux-musl tree, so the initramfs cross-compile - # is incremental after the first run. - uses: actions/cache@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-stage2-artifacts-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-stage2-artifacts- - - name: Materialize guest kernel (raw Image) - run: ./scripts/fetch-artifact.sh virtio-kernel "$RUNNER_TEMP/vmlinuz" aarch64 - - name: Materialize guest rootfs (ext4 image) - run: ./scripts/fetch-artifact.sh minvmd-rootfs "$RUNNER_TEMP/rootfs.img" aarch64 - - name: Install cross - uses: taiki-e/install-action@v2 - with: - tool: cross - - name: Build guest initramfs (minimald as /init) - # Cross-compile minimald to static aarch64 + pack as the initramfs /init. - # The guest rootfs stays generic; minimald is delivered by the initramfs, - # not baked into the rootfs. - run: ./scripts/build-initramfs.sh "$RUNNER_TEMP/initramfs.cpio" - - name: Upload kernel artifact - uses: actions/upload-artifact@v7 - with: - name: virtio-kernel-aarch64 - path: ${{ runner.temp }}/vmlinuz - if-no-files-found: error - - name: Upload rootfs artifact - uses: actions/upload-artifact@v7 - with: - name: minvmd-rootfs-aarch64 - path: ${{ runner.temp }}/rootfs.img - if-no-files-found: error - - name: Upload initramfs artifact - uses: actions/upload-artifact@v7 - with: - name: minimald-initramfs-aarch64 - path: ${{ runner.temp }}/initramfs.cpio - if-no-files-found: error + artifacts: + # Build the `minimal` CLI from THIS repo's sources, then materialize the + # guest kernel + rootfs against the repo's own .minimal/minimal.toml. Both + # are cache pulls keyed by the pinned upstream commit (not compiles): the + # upstream virtio-kernel-raw / microvm-rootfs packages' prebuilt aarch64 + # artifacts are in the public cache, so they pull fine on a cheap x86_64 + # Linux runner — no native arm64 build and no self-hosted runner needed. + # The initramfs build cross-compiles minimald for aarch64-musl (~30 s with + # the fast `initramfs` profile, then cached by actions/cache), so the timeout + # has headroom. + runs-on: ubuntu-latest + timeout-minutes: 35 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Toolchain + uses: dtolnay/rust-toolchain@stable + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Cache cargo + build artifacts (incl. the aarch64 cross target) + # Raw actions/cache (not Swatinem) — it preserves the full + # target/aarch64-unknown-linux-musl tree, so the initramfs cross-compile + # is incremental after the first run. + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-stage2-artifacts-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-stage2-artifacts- + - name: Materialize guest kernel (raw Image) + run: ./scripts/fetch-artifact.sh virtio-kernel "$RUNNER_TEMP/vmlinuz" aarch64 + - name: Materialize guest rootfs (ext4 image) + run: ./scripts/fetch-artifact.sh minvmd-rootfs "$RUNNER_TEMP/rootfs.img" aarch64 + - name: Install cross + uses: taiki-e/install-action@v2 + with: + tool: cross + - name: Build guest initramfs (minimald as /init) + # Cross-compile minimald to static aarch64 + pack as the initramfs /init. + # The guest rootfs stays generic; minimald is delivered by the initramfs, + # not baked into the rootfs. + run: ./scripts/build-initramfs.sh "$RUNNER_TEMP/initramfs.cpio" + - name: Upload kernel artifact + uses: actions/upload-artifact@v7 + with: + name: virtio-kernel-aarch64 + path: ${{ runner.temp }}/vmlinuz + if-no-files-found: error + - name: Upload rootfs artifact + uses: actions/upload-artifact@v7 + with: + name: minvmd-rootfs-aarch64 + path: ${{ runner.temp }}/rootfs.img + if-no-files-found: error + - name: Upload initramfs artifact + uses: actions/upload-artifact@v7 + with: + name: minimald-initramfs-aarch64 + path: ${{ runner.temp }}/initramfs.cpio + if-no-files-found: error - boot-e2e: - # Boot E2E (R2.4 READY round-trip) on real hardware, using the cache-pulled - # kernel + rootfs ext4 image (both from the `artifacts` job). - # Separate from build-macos so an artifact-fetch hiccup never blocks the - # always-green clippy/test/smoke checks. - # - # GATING: green on the runner — raw kernel load (KRUN_KERNEL_FORMAT_RAW), - # initramfs boot (minimald as `/init`, pid-1), minimald mounting the ext4 - # rootfs (/dev/vda via krun_add_disk2) + chrooting, and the guest READY - # connect-out on vsock 7350 (krun_add_vsock_port == listen=false; host + - # guest both 7350). The full vsock session round-trip is gated by the Session - # E2E step below (direct run_on_vsock; needs libkrun >= 1.19.0). - if: ${{ vars.RUN_MACOS_CI != 'false' }} - needs: [artifacts] - runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Toolchain - uses: dtolnay/rust-toolchain@stable - - name: Provision libkrun (slp/krun tap) - # Self-install libkrun so macOS jobs no longer depend on hand-provisioned - # runner state — a missing libkrun.dylib silently broke every code PR. - # Idempotent (a no-op when already present); same slp/krun tap the runner - # setup used, so the supply-chain surface is unchanged. - run: brew install slp/krun/libkrun - - name: Verify libkrun is available - run: | - if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then - echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 - exit 1 - fi - - name: Download virtio-linux kernel - uses: actions/download-artifact@v8 - with: - name: virtio-kernel-aarch64 - path: ${{ runner.temp }}/kernel - - name: Download minvmd-rootfs image - uses: actions/download-artifact@v8 - with: - name: minvmd-rootfs-aarch64 - path: ${{ runner.temp }}/rootfs - - name: Download guest initramfs - uses: actions/download-artifact@v8 - with: - name: minimald-initramfs-aarch64 - path: ${{ runner.temp }}/initramfs - - name: Build E2E harnesses + minvmd (no run) - # Build the test harnesses + the minvmd bin, then codesign, then run the - # test BINARIES DIRECTLY (below). Invoking `cargo test` again after - # signing relinks (and unsigns) target/debug/minvmd, so krun_start_enter - # fails with EINVAL. A direct-binary run never touches the signature, so - # the binaries must be built here before the single codesign. - run: cargo test -p minvmd --test boot_e2e --test minimald_session_e2e --no-run - - name: Codesign minvmd (hypervisor entitlement, R1.4) - run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd - - name: Boot E2E (initramfs minimald, READY round-trip) - # Gating: a real microVM must boot the initramfs (minimald as /init, - # pid-1), mount the ext4 rootfs (/dev/vda), and write READY over vsock - # 7350 within the budget. Also validates the virtio-linux kernel config - # (virtio-MMIO / VIRTIO_BLK / EXT4 / VSOCK / HVC). - run: | - testbin="$(ls -1t target/debug/deps/boot_e2e-* | grep -v '\.d$' | head -1)" - test -x "$testbin" || { echo "::error::boot_e2e test binary not found"; exit 1; } - MINVMD_E2E=1 \ - MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ - MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ - MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ - MINVMD_BOOT_LOG="$RUNNER_TEMP/boot.log" \ - "$testbin" --include-ignored --nocapture - - name: Session E2E (full session over the bridge) - # Gating: boots the GENERIC upstream rootfs with minimald shipped as the - # initramfs /init; a russh client over the bridge authenticates, creates - # a session, and execs a command, asserting stdout + exit status. Proves - # the full Stage-2 path (direct vsock session via run_on_vsock, no socat - # relay) with no minimald baked into the rootfs. Needs libkrun >= 1.19.0 - # on the runner. - run: | - testbin="$(ls -1t target/debug/deps/minimald_session_e2e-* | grep -v '\.d$' | head -1)" - test -x "$testbin" || { echo "::error::minimald_session_e2e test binary not found"; exit 1; } - MINVMD_E2E=1 \ - MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ - MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ - MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ - "$testbin" --include-ignored --nocapture --exact minimald_exec_over_bridge - - name: Boot latency benchmark (informational) - # Report boot-to-READY min/median/max on the runner. Uses the already - # codesigned minvmd; non-gating (boot correctness is gated by the e2e - # test above), so a timing hiccup never reds the build. - run: | - MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ - MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ - MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ - scripts/bench-minvmd-boot.sh 10 target/debug/minvmd || true - - name: Upload guest boot console log - # Capture hvc0 output for diagnosing a stuck/failed boot (e.g. a missing - # virtio driver in the kernel). Always runs so a red boot is debuggable. - if: always() - uses: actions/upload-artifact@v7 - with: - name: minvmd-boot-log - path: ${{ runner.temp }}/boot.log - if-no-files-found: ignore + boot-e2e: + # Boot E2E (R2.4 READY round-trip) on real hardware, using the cache-pulled + # kernel + rootfs ext4 image (both from the `artifacts` job). + # Separate from build-macos so an artifact-fetch hiccup never blocks the + # always-green clippy/test/smoke checks. + # + # GATING: green on the runner — raw kernel load (KRUN_KERNEL_FORMAT_RAW), + # initramfs boot (minimald as `/init`, pid-1), minimald mounting the ext4 + # rootfs (/dev/vda via krun_add_disk2) + chrooting, and the guest READY + # connect-out on vsock 7350 (krun_add_vsock_port == listen=false; host + + # guest both 7350). The full vsock session round-trip is gated by the Session + # E2E step below (direct run_on_vsock; needs libkrun >= 1.19.0). + if: ${{ vars.RUN_MACOS_CI != 'false' }} + needs: [artifacts] + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Toolchain + uses: dtolnay/rust-toolchain@stable + - name: Provision libkrun (slp/krun tap) + # Self-install libkrun so macOS jobs no longer depend on hand-provisioned + # runner state — a missing libkrun.dylib silently broke every code PR. + # Idempotent (a no-op when already present); same slp/krun tap the runner + # setup used, so the supply-chain surface is unchanged. + run: brew install slp/krun/libkrun + - name: Verify libkrun is available + run: | + if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then + echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 + exit 1 + fi + - name: Download virtio-linux kernel + uses: actions/download-artifact@v8 + with: + name: virtio-kernel-aarch64 + path: ${{ runner.temp }}/kernel + - name: Download minvmd-rootfs image + uses: actions/download-artifact@v8 + with: + name: minvmd-rootfs-aarch64 + path: ${{ runner.temp }}/rootfs + - name: Download guest initramfs + uses: actions/download-artifact@v8 + with: + name: minimald-initramfs-aarch64 + path: ${{ runner.temp }}/initramfs + - name: Build E2E harnesses + minvmd (no run) + # Build the test harnesses + the minvmd bin, then codesign, then run the + # test BINARIES DIRECTLY (below). Invoking `cargo test` again after + # signing relinks (and unsigns) target/debug/minvmd, so krun_start_enter + # fails with EINVAL. A direct-binary run never touches the signature, so + # the binaries must be built here before the single codesign. + run: cargo test -p minvmd --test boot_e2e --test minimald_session_e2e --no-run + - name: Codesign minvmd (hypervisor entitlement, R1.4) + run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd + - name: Boot E2E (initramfs minimald, READY round-trip) + # Gating: a real microVM must boot the initramfs (minimald as /init, + # pid-1), mount the ext4 rootfs (/dev/vda), and write READY over vsock + # 7350 within the budget. Also validates the virtio-linux kernel config + # (virtio-MMIO / VIRTIO_BLK / EXT4 / VSOCK / HVC). + run: | + testbin="$(ls -1t target/debug/deps/boot_e2e-* | grep -v '\.d$' | head -1)" + test -x "$testbin" || { echo "::error::boot_e2e test binary not found"; exit 1; } + MINVMD_E2E=1 \ + MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ + MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ + MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ + MINVMD_BOOT_LOG="$RUNNER_TEMP/boot.log" \ + "$testbin" --include-ignored --nocapture + - name: Session E2E (full session over the bridge) + # Gating: boots the GENERIC upstream rootfs with minimald shipped as the + # initramfs /init; a russh client over the bridge authenticates, creates + # a session, and execs a command, asserting stdout + exit status. Proves + # the full Stage-2 path (direct vsock session via run_on_vsock, no socat + # relay) with no minimald baked into the rootfs. Needs libkrun >= 1.19.0 + # on the runner. + run: | + testbin="$(ls -1t target/debug/deps/minimald_session_e2e-* | grep -v '\.d$' | head -1)" + test -x "$testbin" || { echo "::error::minimald_session_e2e test binary not found"; exit 1; } + MINVMD_E2E=1 \ + MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ + MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ + MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ + "$testbin" --include-ignored --nocapture --exact minimald_exec_over_bridge + - name: Boot latency benchmark (informational) + # Report boot-to-READY min/median/max on the runner. Uses the already + # codesigned minvmd; non-gating (boot correctness is gated by the e2e + # test above), so a timing hiccup never reds the build. + run: | + MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" \ + MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" \ + MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" \ + scripts/bench-minvmd-boot.sh 10 target/debug/minvmd || true + - name: Upload guest boot console log + # Capture hvc0 output for diagnosing a stuck/failed boot (e.g. a missing + # virtio driver in the kernel). Always runs so a red boot is debuggable. + if: always() + uses: actions/upload-artifact@v7 + with: + name: minvmd-boot-log + path: ${{ runner.temp }}/boot.log + if-no-files-found: ignore - autospawn-e2e: - # Auto-spawn E2E — the R4.5 CLI proof artifact, on real hardware. From a - # clean state `minimal ls` must auto-spawn minvmd and return within 8 s; - # `minvmd status` must then report running; a second `minimal ls` must - # complete in < 500 ms (warm reuse). This exercises the real - # minimal2 -> `minvmd run --detach` -> VM boot -> initramfs minimald session - # path end to end, the macOS integration gate that no unit test can cover. - if: ${{ vars.RUN_MACOS_CI != 'false' }} - needs: [artifacts] - runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - name: Toolchain - uses: dtolnay/rust-toolchain@stable - - name: Provision libkrun (slp/krun tap) - # Self-install libkrun so macOS jobs no longer depend on hand-provisioned - # runner state — a missing libkrun.dylib silently broke every code PR. - # Idempotent (a no-op when already present); same slp/krun tap the runner - # setup used, so the supply-chain surface is unchanged. - run: brew install slp/krun/libkrun - - name: Verify libkrun is available - run: | - if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then - echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 - exit 1 - fi - - name: Download virtio-linux kernel - uses: actions/download-artifact@v8 - with: - name: virtio-kernel-aarch64 - path: ${{ runner.temp }}/kernel - - name: Download minvmd-rootfs image - uses: actions/download-artifact@v8 - with: - name: minvmd-rootfs-aarch64 - path: ${{ runner.temp }}/rootfs - - name: Download guest initramfs - uses: actions/download-artifact@v8 - with: - name: minimald-initramfs-aarch64 - path: ${{ runner.temp }}/initramfs - - name: Build minvmd + minimal2 (no run) - # Build both bins before the single codesign. minimal2 spawns `minvmd` - # by name from PATH; only minvmd needs the hypervisor entitlement, so - # codesigning it must be the last thing to touch the binary. - run: cargo build -p minvmd --bin minvmd -p minimal2 --bin minimal2 - - name: Codesign minvmd (hypervisor entitlement, R1.4) - run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd - - name: Auto-spawn E2E (minimal ls cold/warm, R4.5) - # Run the prebuilt binaries directly (never `cargo run`, which would - # relink and unsign minvmd). minimal2 finds `minvmd` via PATH; all three - # of MINVMD_KERNEL_PATH / MINVMD_ROOTFS_PATH / MINVMD_INITRAMFS propagate - # through the `minvmd run --detach` re-exec. A fresh XDG_RUNTIME_DIR / - # XDG_STATE_HOME guarantees the clean (no-daemon) starting state. - run: | - set -uo pipefail # not -e: capture failures so we can dump diagnostics - export PATH="$PWD/target/debug:$PATH" - export MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" - export MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" - export MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" - # Capture the guest console; this env var propagates through the - # minimal2 -> `minvmd run --detach` -> `minvmd run` -> __krun-vmm chain, - # so the silent detached supervisor's boot is still observable here. - export MINVMD_BOOT_LOG="$RUNNER_TEMP/autospawn-boot.log" - export XDG_RUNTIME_DIR="$(mktemp -d)" - # minvmd resolves its state dir via dirs::state_dir(), which ignores - # XDG_STATE_HOME on macOS and uses ~/.local/state. Clear it so the - # proof starts from a genuinely clean (NotProvisioned) state on the - # persistent runner. - rm -rf "$HOME/.local/state/minimal/minvmd" + autospawn-e2e: + # Auto-spawn E2E — the R4.5 CLI proof artifact, on real hardware. From a + # clean state `minimal ls` must auto-spawn minvmd and return within 8 s; + # `minvmd status` must then report running; a second `minimal ls` must + # complete in < 500 ms (warm reuse). This exercises the real + # minimal -> `minvmd run --detach` -> VM boot -> initramfs minimald session + # path end to end, the macOS integration gate that no unit test can cover. + if: ${{ vars.RUN_MACOS_CI != 'false' }} + needs: [artifacts] + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Toolchain + uses: dtolnay/rust-toolchain@stable + - name: Provision libkrun (slp/krun tap) + # Self-install libkrun so macOS jobs no longer depend on hand-provisioned + # runner state — a missing libkrun.dylib silently broke every code PR. + # Idempotent (a no-op when already present); same slp/krun tap the runner + # setup used, so the supply-chain surface is unchanged. + run: brew install slp/krun/libkrun + - name: Verify libkrun is available + run: | + if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then + echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 + exit 1 + fi + - name: Download virtio-linux kernel + uses: actions/download-artifact@v8 + with: + name: virtio-kernel-aarch64 + path: ${{ runner.temp }}/kernel + - name: Download minvmd-rootfs image + uses: actions/download-artifact@v8 + with: + name: minvmd-rootfs-aarch64 + path: ${{ runner.temp }}/rootfs + - name: Download guest initramfs + uses: actions/download-artifact@v8 + with: + name: minimald-initramfs-aarch64 + path: ${{ runner.temp }}/initramfs + - name: Build minvmd + minimal (no run) + # Build both bins before the single codesign. minimal spawns `minvmd` + # by name from PATH; only minvmd needs the hypervisor entitlement, so + # codesigning it must be the last thing to touch the binary. + run: cargo build -p minvmd --bin minvmd -p minimal --bin minimal + - name: Codesign minvmd (hypervisor entitlement, R1.4) + run: codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - target/debug/minvmd + - name: Auto-spawn E2E (minimal ls cold/warm, R4.5) + # Run the prebuilt binaries directly (never `cargo run`, which would + # relink and unsign minvmd). minimal finds `minvmd` via PATH; all three + # of MINVMD_KERNEL_PATH / MINVMD_ROOTFS_PATH / MINVMD_INITRAMFS propagate + # through the `minvmd run --detach` re-exec. A fresh XDG_RUNTIME_DIR / + # XDG_STATE_HOME guarantees the clean (no-daemon) starting state. + run: | + set -uo pipefail # not -e: capture failures so we can dump diagnostics + export PATH="$PWD/target/debug:$PATH" + export MINVMD_KERNEL_PATH="$RUNNER_TEMP/kernel/vmlinuz" + export MINVMD_ROOTFS_PATH="$RUNNER_TEMP/rootfs/rootfs.img" + export MINVMD_INITRAMFS="$RUNNER_TEMP/initramfs/initramfs.cpio" + # Capture the guest console; this env var propagates through the + # minimal -> `minvmd run --detach` -> `minvmd run` -> __krun-vmm chain, + # so the silent detached supervisor's boot is still observable here. + export MINVMD_BOOT_LOG="$RUNNER_TEMP/autospawn-boot.log" + export XDG_RUNTIME_DIR="$(mktemp -d)" + # minvmd resolves its state dir via dirs::state_dir(), which ignores + # XDG_STATE_HOME on macOS and uses ~/.local/state. Clear it so the + # proof starts from a genuinely clean (NotProvisioned) state on the + # persistent runner. + rm -rf "$HOME/.local/state/minimal/minvmd" - # macOS `date` has no %N; use perl's high-resolution clock for ms. - now_ms() { perl -MTime::HiRes=time -e 'printf "%d", time()*1000'; } + # macOS `date` has no %N; use perl's high-resolution clock for ms. + now_ms() { perl -MTime::HiRes=time -e 'printf "%d", time()*1000'; } - # On any failure, dump what the silent detach hides — minimal2's own - # error, the persisted state, and the guest boot console — then stop - # the supervisor and fail. An empty boot log means the VM never - # started (supervisor died before krun_start_enter); kernel output - # without a full boot means a stuck boot; a full boot points at a - # UDS/timing issue. - fail() { - echo "::group::autospawn-e2e diagnostics" - echo "--- minimal2 ls stderr ---"; cat "$RUNNER_TEMP/ls.err" 2>/dev/null || true - echo "--- state.toml ---"; cat "$HOME/.local/state/minimal/minvmd/state.toml" 2>/dev/null || echo "(none)" - echo "--- guest boot console (tail) ---"; tail -80 "$MINVMD_BOOT_LOG" 2>/dev/null || echo "(no boot log — VM never started)" - echo "::endgroup::" - minvmd stop >/dev/null 2>&1 || true - exit 1 - } - trap 'minvmd stop >/dev/null 2>&1 || true' EXIT + # On any failure, dump what the silent detach hides — minimal's own + # error, the persisted state, and the guest boot console — then stop + # the supervisor and fail. An empty boot log means the VM never + # started (supervisor died before krun_start_enter); kernel output + # without a full boot means a stuck boot; a full boot points at a + # UDS/timing issue. + fail() { + echo "::group::autospawn-e2e diagnostics" + echo "--- minimal ls stderr ---"; cat "$RUNNER_TEMP/ls.err" 2>/dev/null || true + echo "--- state.toml ---"; cat "$HOME/.local/state/minimal/minvmd/state.toml" 2>/dev/null || echo "(none)" + echo "--- guest boot console (tail) ---"; tail -80 "$MINVMD_BOOT_LOG" 2>/dev/null || echo "(no boot log — VM never started)" + echo "::endgroup::" + minvmd stop >/dev/null 2>&1 || true + exit 1 + } + trap 'minvmd stop >/dev/null 2>&1 || true' EXIT - # Cold: must auto-spawn minvmd and return within 8 s. - t0=$(now_ms) - minimal2 ls 2>"$RUNNER_TEMP/ls.err" || { echo "::error::cold 'minimal ls' failed to auto-spawn minvmd"; fail; } - t1=$(now_ms); cold=$(( t1 - t0 )) - echo "cold 'minimal ls': ${cold}ms" - [ "$cold" -lt 8000 ] || { echo "::error::cold 'minimal ls' took ${cold}ms (>= 8000ms)"; fail; } + # Cold: must auto-spawn minvmd and return within 8 s. + t0=$(now_ms) + minimal ls 2>"$RUNNER_TEMP/ls.err" || { echo "::error::cold 'minimal ls' failed to auto-spawn minvmd"; fail; } + t1=$(now_ms); cold=$(( t1 - t0 )) + echo "cold 'minimal ls': ${cold}ms" + [ "$cold" -lt 8000 ] || { echo "::error::cold 'minimal ls' took ${cold}ms (>= 8000ms)"; fail; } - # minvmd must now report running (exit 0). Poll briefly to absorb the - # gap between the host UDS accepting and the Running state persisting. - running=0 - for _ in $(seq 1 50); do - if minvmd status >/dev/null 2>&1; then running=1; break; fi - sleep 0.1 - done - [ "$running" = 1 ] || { echo "::error::minvmd did not report running after auto-spawn"; fail; } + # minvmd must now report running (exit 0). Poll briefly to absorb the + # gap between the host UDS accepting and the Running state persisting. + running=0 + for _ in $(seq 1 50); do + if minvmd status >/dev/null 2>&1; then running=1; break; fi + sleep 0.1 + done + [ "$running" = 1 ] || { echo "::error::minvmd did not report running after auto-spawn"; fail; } - # Warm: minvmd already running, must return in < 500 ms. - t0=$(now_ms) - minimal2 ls >/dev/null 2>"$RUNNER_TEMP/ls.err" || { echo "::error::warm 'minimal ls' failed"; fail; } - t1=$(now_ms); warm=$(( t1 - t0 )) - echo "warm 'minimal ls': ${warm}ms" - [ "$warm" -lt 500 ] || { echo "::error::warm 'minimal ls' took ${warm}ms (>= 500ms)"; fail; } - - name: Upload autospawn guest boot console log - if: always() - uses: actions/upload-artifact@v7 - with: - name: autospawn-boot-log - path: ${{ runner.temp }}/autospawn-boot.log - if-no-files-found: ignore + # Warm: minvmd already running, must return in < 500 ms. + t0=$(now_ms) + minimal ls >/dev/null 2>"$RUNNER_TEMP/ls.err" || { echo "::error::warm 'minimal ls' failed"; fail; } + t1=$(now_ms); warm=$(( t1 - t0 )) + echo "warm 'minimal ls': ${warm}ms" + [ "$warm" -lt 500 ] || { echo "::error::warm 'minimal ls' took ${warm}ms (>= 500ms)"; fail; } + - name: Upload autospawn guest boot console log + if: always() + uses: actions/upload-artifact@v7 + with: + name: autospawn-boot-log + path: ${{ runner.temp }}/autospawn-boot.log + if-no-files-found: ignore - build-macos: - # Self-hosted Apple Silicon runner. minvmd links libkrun - # (`#[link(name = "krun")]`) and only compiles on macOS — the Linux CI - # builds it as a stub. Real hardware, so the hypervisor-backed FFI smoke - # runs here; GitHub-hosted macOS VMs can't. Scoped to minvmd, the only - # macOS-gated crate; widen to --workspace once the tree is mac-buildable. - # - # Gated on the RUN_MACOS_CI repo variable (defaults to enabled when unset). - # `timeout-minutes` counts time queued for a runner, so set - # RUN_MACOS_CI=false to skip while the runner is offline. - if: ${{ vars.RUN_MACOS_CI != 'false' }} - runs-on: [self-hosted, macOS, ARM64] - timeout-minutes: 30 - steps: - - uses: actions/checkout@v6 - with: - # Don't leave git credentials on the persistent self-hosted runner. - persist-credentials: false - - name: Toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - name: Provision libkrun (slp/krun tap) - # Self-install libkrun from the slp/krun tap so this job no longer depends - # on hand-provisioned runner state. Idempotent (a no-op when already - # present); same tap the runner setup used, so the supply-chain surface - # is unchanged. - run: brew install slp/krun/libkrun - - name: Verify libkrun is available - run: | - if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then - echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 - exit 1 - fi - ls -l /opt/homebrew/lib/libkrun*.dylib - - name: Clippy (minvmd) - run: cargo clippy -p minvmd --all-targets -- -D warnings - - name: Test (minvmd) - run: cargo test -p minvmd - - name: Test (sessions) - # Platform-sensitive surface: std::fs::canonicalize behavior on - # prefix-symlinked roots (/tmp → /private/tmp on macOS) and the - # follow_symlinks dual-path policy logic. Reuses the build - # cache from the minvmd job above. - run: cargo test -p sessions - - name: FFI smoke against real libkrun (no VM boot) - # Exercises create_ctx -> set_vm_config -> set_exec through the safe - # wrappers. Skips krun_start_enter (no kernel/rootfs), so it needs no - # codesigned hypervisor entitlement. - run: MINVMD_E2E=1 cargo test -p minvmd --test krun_smoke -- --include-ignored + build-macos: + # Self-hosted Apple Silicon runner. minvmd links libkrun + # (`#[link(name = "krun")]`) and only compiles on macOS — the Linux CI + # builds it as a stub. Real hardware, so the hypervisor-backed FFI smoke + # runs here; GitHub-hosted macOS VMs can't. Scoped to minvmd, the only + # macOS-gated crate; widen to --workspace once the tree is mac-buildable. + # + # Gated on the RUN_MACOS_CI repo variable (defaults to enabled when unset). + # `timeout-minutes` counts time queued for a runner, so set + # RUN_MACOS_CI=false to skip while the runner is offline. + if: ${{ vars.RUN_MACOS_CI != 'false' }} + runs-on: [self-hosted, macOS, ARM64] + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + # Don't leave git credentials on the persistent self-hosted runner. + persist-credentials: false + - name: Toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - name: Provision libkrun (slp/krun tap) + # Self-install libkrun from the slp/krun tap so this job no longer depends + # on hand-provisioned runner state. Idempotent (a no-op when already + # present); same tap the runner setup used, so the supply-chain surface + # is unchanged. + run: brew install slp/krun/libkrun + - name: Verify libkrun is available + run: | + if [ ! -f /opt/homebrew/lib/libkrun.dylib ]; then + echo "::error::libkrun.dylib not found under /opt/homebrew/lib. Provision the runner with: brew install slp/krun/libkrun" >&2 + exit 1 + fi + ls -l /opt/homebrew/lib/libkrun*.dylib + - name: Clippy (minvmd) + run: cargo clippy -p minvmd --all-targets -- -D warnings + - name: Test (minvmd) + run: cargo test -p minvmd + - name: Test (sessions) + # Platform-sensitive surface: std::fs::canonicalize behavior on + # prefix-symlinked roots (/tmp → /private/tmp on macOS) and the + # follow_symlinks dual-path policy logic. Reuses the build + # cache from the minvmd job above. + run: cargo test -p sessions + - name: FFI smoke against real libkrun (no VM boot) + # Exercises create_ctx -> set_vm_config -> set_exec through the safe + # wrappers. Skips krun_start_enter (no kernel/rootfs), so it needs no + # codesigned hypervisor entitlement. + run: MINVMD_E2E=1 cargo test -p minvmd --test krun_smoke -- --include-ignored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d018dcf2..73d82648a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,290 +1,303 @@ name: CI on: - push: - branches: ["main"] - paths-ignore: - - "**.md" - - "docs/**" - - "LICENSE" - pull_request: - branches: ["main"] - paths-ignore: - - "**.md" - - "docs/**" - - "LICENSE" - workflow_dispatch: + push: + branches: ["main"] + paths-ignore: + - "**.md" + - "docs/**" + - "LICENSE" + pull_request: + branches: ["main"] + paths-ignore: + - "**.md" + - "docs/**" + - "LICENSE" + workflow_dispatch: env: - CARGO_TERM_COLOR: always + CARGO_TERM_COLOR: always # Cancel an in-progress run when a newer commit is pushed to the same ref. # main is excluded so release/promote on main never get cancelled mid-flight. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} permissions: - contents: write + contents: write jobs: - fmt: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - name: Run rustfmt - run: cargo fmt --all -- --check + fmt: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Run rustfmt + run: cargo fmt --all -- --check - clippy: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Free Disk Space - uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable - with: - remove_android: true - remove_dotnet: true - remove_haskell: true - remove_tool_cache: true - - uses: actions/checkout@v6 - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Cache cargo registry and build artifacts - uses: Swatinem/rust-cache@v2 - - name: Run Clippy - run: cargo clippy --workspace --all-targets -- -D warnings + clippy: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Free Disk Space + uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable + with: + remove_android: true + remove_dotnet: true + remove_haskell: true + remove_tool_cache: true + - uses: actions/checkout@v6 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Cache cargo registry and build artifacts + uses: Swatinem/rust-cache@v2 + - name: Run Clippy + run: cargo clippy --workspace --all-targets -- -D warnings - test: - runs-on: ubuntu-latest - timeout-minutes: 40 - steps: - - name: Free Disk Space - uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable - with: - remove_android: true - remove_dotnet: true - remove_haskell: true - remove_tool_cache: true - - uses: actions/checkout@v6 - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - - name: Cache cargo registry and build artifacts - uses: Swatinem/rust-cache@v2 - - name: Install nextest - uses: taiki-e/install-action@v2 - with: - tool: nextest - - name: Check Cargo.lock is up-to-date - run: cargo fetch --locked - - name: Run tests - run: cargo nextest run --workspace - - name: Run HTTPS/mTLS proxy tests - # The mTLS reverse proxy is behind the non-default `networking-proxy` - # feature, so `--workspace` above does not compile or run its proofs - # (R4.5 mtls_missing_cert_returns_401_with_no_topology, UC2b - # mtls_valid_cert_routes_to_backend). Exercise them explicitly. - run: cargo nextest run -p minimald --features networking-proxy - - name: Run WireGuard mesh tests - # The WireGuard mesh peer is behind the non-default `networking-wg` - # feature, so `--workspace` above does not compile or run its proofs. - # Exercise them explicitly (R-WG: two_meshes_handshake_and_relay_a_packet, - # rpc get_mesh_status). The `#[ignore]` two-namespace mesh_uc7 proof runs - # in the netns lane. - run: cargo test -p minimald --features networking-wg - - name: Run doctests - run: cargo test --workspace --doc + test: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Free Disk Space + uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable + with: + remove_android: true + remove_dotnet: true + remove_haskell: true + remove_tool_cache: true + - uses: actions/checkout@v6 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Cache cargo registry and build artifacts + uses: Swatinem/rust-cache@v2 + - name: Install nextest + uses: taiki-e/install-action@v2 + with: + tool: nextest + - name: Check Cargo.lock is up-to-date + run: cargo fetch --locked + - name: Run tests + run: cargo nextest run --workspace + - name: Run HTTPS/mTLS proxy tests + # The mTLS reverse proxy is behind the non-default `networking-proxy` + # feature, so `--workspace` above does not compile or run its proofs + # (R4.5 mtls_missing_cert_returns_401_with_no_topology, UC2b + # mtls_valid_cert_routes_to_backend). Exercise them explicitly. + run: cargo nextest run -p minimald --features networking-proxy + - name: Run WireGuard mesh tests + # The WireGuard mesh peer is behind the non-default `networking-wg` + # feature, so `--workspace` above does not compile or run its proofs. + # Exercise them explicitly (R-WG: two_meshes_handshake_and_relay_a_packet, + # rpc get_mesh_status). The `#[ignore]` two-namespace mesh_uc7 proof runs + # in the netns lane. + run: cargo test -p minimald --features networking-wg + - name: Run doctests + run: cargo test --workspace --doc - dogfood: - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-minimal - - name: Smoke-test `minimal run` - run: minimal run build-smoke + dogfood: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-minimal + - name: Smoke-test `minimal run` + run: minimal run build-smoke - build-release-amd64: - runs-on: ubuntu-latest - timeout-minutes: 45 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - name: Free Disk Space - # Static release build of the full workspace; the cargo target dir - # is the largest in any of the linux jobs. `remove_tool_cache: true` - # is safe even without a `dtolnay/rust-toolchain` step — the - # pre-installed Rust toolchain on ubuntu-latest lives in - # `~/.rustup` + `~/.cargo`, not `/opt/hostedtoolcache`, so - # `rustup target add` below still resolves. - uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable - with: - remove_android: true - remove_dotnet: true - remove_haskell: true - remove_tool_cache: true - - uses: actions/checkout@v6 - - name: Install dependencies - run: sudo apt-get update && sudo apt-get install -y musl-tools protobuf-compiler - - name: Add Rust target - run: rustup target add x86_64-unknown-linux-musl - - name: Configure musl linker - run: | - echo '' >> .cargo/config.toml - echo '[target.x86_64-unknown-linux-musl]' >> .cargo/config.toml - echo 'linker = "musl-gcc"' >> .cargo/config.toml - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-amd64-cargo-release-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-amd64-cargo-release- - - name: Build static release binary - run: cargo build --release --package minimal --target x86_64-unknown-linux-musl - - name: Upload binary artifact - uses: actions/upload-artifact@v7 - with: - name: minimal-linux-amd64 - path: target/x86_64-unknown-linux-musl/release/minimal - retention-days: 7 + build-release-linux-amd64: + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - name: Free Disk Space + # Static release build of the full workspace; the cargo target dir + # is the largest in any of the linux jobs. `remove_tool_cache: true` + # is safe even without a `dtolnay/rust-toolchain` step — the + # pre-installed Rust toolchain on ubuntu-latest lives in + # `~/.rustup` + `~/.cargo`, not `/opt/hostedtoolcache`, so + # `rustup target add` below still resolves. + uses: endersonmenezes/free-disk-space@v3 # Use @main for latest, @v3 for stable + with: + remove_android: true + remove_dotnet: true + remove_haskell: true + remove_tool_cache: true + - uses: actions/checkout@v6 + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y musl-tools protobuf-compiler + - name: Add Rust target + run: rustup target add x86_64-unknown-linux-musl + - name: Configure musl linker + run: | + echo '' >> .cargo/config.toml + echo '[target.x86_64-unknown-linux-musl]' >> .cargo/config.toml + echo 'linker = "musl-gcc"' >> .cargo/config.toml + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-amd64-cargo-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-amd64-cargo-release- + - name: Build static release binaries + run: | + cargo build --release --target x86_64-unknown-linux-musl \ + --package mip \ + --package minimal \ + --package minimald + - name: Upload binary (mip) + uses: actions/upload-artifact@v7 + with: + name: mip-linux-amd64 + path: target/x86_64-unknown-linux-musl/release/mip + retention-days: 7 + - name: Upload binary (minimal) + uses: actions/upload-artifact@v7 + with: + name: minimal-linux-amd64 + path: target/x86_64-unknown-linux-musl/release/minimal + retention-days: 7 + - name: Upload binary (minimald) + uses: actions/upload-artifact@v7 + with: + name: minimald-linux-amd64 + path: target/x86_64-unknown-linux-musl/release/minimald + retention-days: 7 - build-release-arm64: - runs-on: ubuntu-latest - timeout-minutes: 45 - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - steps: - - uses: actions/checkout@v6 - - name: Install cross - uses: taiki-e/install-action@v2 - with: - tool: cross - - uses: actions/cache@v5 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-arm64-cargo-release-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-arm64-cargo-release- - - name: Build static release binary - run: cross build --release --package minimal --target aarch64-unknown-linux-musl - - name: Upload binary artifact - uses: actions/upload-artifact@v7 - with: - name: minimal-linux-arm64 - path: target/aarch64-unknown-linux-musl/release/minimal - retention-days: 7 + build-release-linux-arm64: + runs-on: ubuntu-latest + timeout-minutes: 45 + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + steps: + - uses: actions/checkout@v6 + - name: Install cross + uses: taiki-e/install-action@v2 + with: + tool: cross + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-arm64-cargo-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-arm64-cargo-release- + - name: Build static release binaries + run: | + cross build --release \ + --package mip \ + --package minimal \ + --package minimald \ + --target aarch64-unknown-linux-musl + - name: Upload binary (mip) + uses: actions/upload-artifact@v7 + with: + name: mip-linux-arm64 + path: target/aarch64-unknown-linux-musl/release/mip + retention-days: 7 + - name: Upload binary (minimal) + uses: actions/upload-artifact@v7 + with: + name: minimal-linux-arm64 + path: target/aarch64-unknown-linux-musl/release/minimal + retention-days: 7 + - name: Upload binary (minimald) + uses: actions/upload-artifact@v7 + with: + name: minimald-linux-arm64 + path: target/aarch64-unknown-linux-musl/release/minimald + retention-days: 7 - cargo-deny: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - uses: EmbarkStudios/cargo-deny-action@v2 + cargo-deny: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: EmbarkStudios/cargo-deny-action@v2 - release: - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [ci-success, build-release-amd64, build-release-arm64] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: - contents: write - id-token: write - steps: - - uses: actions/checkout@v6 - - name: Download amd64 binary - uses: actions/download-artifact@v8 - with: - name: minimal-linux-amd64 - path: artifacts/amd64 - - name: Download arm64 binary - uses: actions/download-artifact@v8 - with: - name: minimal-linux-arm64 - path: artifacts/arm64 - - name: Generate release info - id: release_info - run: | - SHORT_SHA=$(git rev-parse --short HEAD) - echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT - echo "tag=release-$SHORT_SHA" >> $GITHUB_OUTPUT - echo "name=minimal-$SHORT_SHA" >> $GITHUB_OUTPUT - - name: Prepare release assets - run: | - cp artifacts/amd64/minimal minimal-linux-amd64 - cp artifacts/arm64/minimal minimal-linux-arm64 - cp artifacts/amd64/minimal minimal - chmod +x minimal-linux-* minimal - - name: Create Release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${{ steps.release_info.outputs.tag }}" \ - minimal \ - minimal-linux-amd64 \ - minimal-linux-arm64 \ - --repo="${GITHUB_REPOSITORY}" \ - --title="${{ steps.release_info.outputs.name }}" - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - project_id: "289724348228" - workload_identity_provider: "projects/289724348228/locations/global/workloadIdentityPools/github/providers/gominimal" - - name: Generate completions - run: | - chmod +x artifacts/amd64/minimal - mkdir -p completions/{bash,zsh,fish} - artifacts/amd64/minimal completions bash > completions/bash/minimal 2>/dev/null || true - artifacts/amd64/minimal completions zsh > completions/zsh/_minimal 2>/dev/null || true - artifacts/amd64/minimal completions fish > completions/fish/minimal.fish 2>/dev/null || true - - name: Package and upload CLI archives to GCS - run: | - SHA=$(git rev-parse --short HEAD) + release: + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: + [ci-success, build-release-linux-amd64, build-release-linux-arm64] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v6 + - name: Generate release info + id: release_info + run: | + SHORT_SHA=$(git rev-parse --short=8 HEAD) + echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT + echo "tag=release-$SHORT_SHA" >> $GITHUB_OUTPUT + echo "name=minimal-$SHORT_SHA" >> $GITHUB_OUTPUT + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + pattern: "*-linux-*" + path: artifacts/ + - name: Make binaries executable + run: chmod +x artifacts/*-{linux,macos}-* + - name: Generate completions + run: | + mkdir -p artifacts/completions/{bash,zsh,fish} + artifacts/mip-linux-amd64 completions bash > artifacts/completions/bash/mip 2>/dev/null || true + artifacts/mip-linux-amd64 completions zsh > artifacts/completions/zsh/_mip 2>/dev/null || true + artifacts/mip-linux-amd64 completions fish > artifacts/completions/fish/mip.fish 2>/dev/null || true + artifacts/minimal-linux-amd64 completions bash > artifacts/completions/bash/minimal 2>/dev/null || true + artifacts/minimal-linux-amd64 completions zsh > artifacts/completions/zsh/_minimal 2>/dev/null || true + artifacts/minimal-linux-amd64 completions fish > artifacts/completions/fish/minimal.fish 2>/dev/null || true + artifacts/minimald-linux-amd64 completions bash > artifacts/completions/bash/minimald 2>/dev/null || true + artifacts/minimald-linux-amd64 completions zsh > artifacts/completions/zsh/_minimald 2>/dev/null || true + artifacts/minimald-linux-amd64 completions fish > artifacts/completions/fish/minimald.fish 2>/dev/null || true + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + cd artifacts/ + gh release create "${{ steps.release_info.outputs.tag }}" \ + $(ls) \ + --repo="${GITHUB_REPOSITORY}" \ + --title="${{ steps.release_info.outputs.name }}" + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + project_id: "289724348228" + workload_identity_provider: "projects/289724348228/locations/global/workloadIdentityPools/github/providers/gominimal" + - name: Package and upload archive to GCS + run: | + SHA=$(git rev-parse --short=8 HEAD) + tar --zstd -cf "minimalone-${SHA}.tar.zst" -C artifacts . - # amd64-linux - mkdir -p pkg-amd64/bin - cp artifacts/amd64/minimal pkg-amd64/bin/ - chmod +x pkg-amd64/bin/minimal - cp -r completions pkg-amd64/ - tar --zstd -cf "cli-amd64-linux-${SHA}.tar.zst" -C pkg-amd64 . + gcloud storage cp \ + --cache-control="public, max-age=31536000, immutable" \ + "minimalone-${SHA}.tar.zst" gs://minimal-shim/archives/ - # arm64-linux (completions are text, same for both arches) - mkdir -p pkg-arm64/bin - cp artifacts/arm64/minimal pkg-arm64/bin/ - chmod +x pkg-arm64/bin/minimal - cp -r completions pkg-arm64/ - tar --zstd -cf "cli-arm64-linux-${SHA}.tar.zst" -C pkg-arm64 . + minimal-check: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/setup-minimal + - name: Check + run: minimal --no-fetch check - gcloud storage cp \ - --cache-control="public, max-age=31536000, immutable" \ - "cli-amd64-linux-${SHA}.tar.zst" gs://minimal-shim/archives/ - gcloud storage cp \ - --cache-control="public, max-age=31536000, immutable" \ - "cli-arm64-linux-${SHA}.tar.zst" gs://minimal-shim/archives/ - - minimal-check: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-minimal - - name: Check - run: minimal --no-fetch check - - # Single required status check for branch protection. Stays green only when - # every gating job below succeeds. - ci-success: - if: always() - needs: [fmt, clippy, test, dogfood, cargo-deny, minimal-check] - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Verify all gating jobs succeeded - if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') - run: exit 1 - - run: echo "All CI gates passed" + # Single required status check for branch protection. Stays green only when + # every gating job below succeeds. + ci-success: + if: always() + needs: [fmt, clippy, test, dogfood, cargo-deny, minimal-check] + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Verify all gating jobs succeeded + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: exit 1 + - run: echo "All CI gates passed" diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index e2f00db06..9d7df82e4 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -1,274 +1,229 @@ name: promote-cli on: - workflow_dispatch: - inputs: - sha: - description: "Short SHA to promote (leave empty for latest in bucket)" - required: false - type: string - platforms: - description: "Comma-separated platform list or 'all'" - required: false - default: "amd64-linux,arm64-linux" - type: string - dry_run: - description: "Dry run — open issue + wait for approval but skip the GCS write" - required: false - type: boolean - default: false + workflow_dispatch: + inputs: + sha: + description: "Short SHA to promote (leave empty for latest in bucket)" + required: false + type: string + platforms: + description: "Comma-separated platform list or 'all'" + required: false + default: "amd64-linux,arm64-linux" + type: string + dry_run: + description: "Dry run — open issue + wait for approval but skip the GCS write" + required: false + type: boolean + default: false env: - # Single source of truth for the gate's approver allowlist. - # Comma-separated, no spaces (the polling step splits on `,`). - APPROVERS: bryan-minimal,evanspearman,jessie-minimal,jtnkminimal,Max-minimal,mitodrummer,msample,norrietaylor,twitchyliquid64 + # Single source of truth for the gate's approver allowlist. + # Comma-separated, no spaces (the polling step splits on `,`). + APPROVERS: bryan-minimal,evanspearman,jessie-minimal,jtnkminimal,Max-minimal,mitodrummer,msample,norrietaylor,twitchyliquid64 jobs: - gate: - runs-on: ubuntu-latest - permissions: - issues: write - # 6h matches the GitHub-hosted runner hard limit. A higher value is - # misleading — `ubuntu-latest` jobs are killed at 6h regardless. - timeout-minutes: 360 - outputs: - issue_url: ${{ steps.issue.outputs.url }} - steps: - - name: Open approval issue - id: issue - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - WORKFLOW: ${{ github.workflow }} - RUN_NUMBER: ${{ github.run_number }} - # triggering_actor (NOT actor) — actor is frozen on rerun, so a rerun - # by a different user would still let the original initiator self-approve. - ACTOR: ${{ github.triggering_actor }} - SERVER_URL: ${{ github.server_url }} - RUN_ID: ${{ github.run_id }} - SHA_INPUT: ${{ inputs.sha }} - PLATFORMS_INPUT: ${{ inputs.platforms }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - BODY="**Repo:** \`${REPO}\` - **Workflow:** \`${WORKFLOW}\` - **Requested by:** @${ACTOR} - **Run:** ${SERVER_URL}/${REPO}/actions/runs/${RUN_ID} - **SHA input:** \`${SHA_INPUT:-}\` - **Platforms:** \`${PLATFORMS_INPUT}\` - **Dry run:** \`${DRY_RUN}\` - - Comment **\`approved\`** to proceed or **\`denied\`** to cancel. - Closing this issue without an approval comment counts as denial. - - Approver list (initiator excluded at runtime): - ${APPROVERS//,/, }" - - URL=$(gh issue create \ - --repo "$REPO" \ - --title "Promotion approval required: ${WORKFLOW} run ${RUN_NUMBER}" \ - --label "promotion-approval" \ - --body "$BODY") - NUM="${URL##*/}" - echo "url=$URL" >> "$GITHUB_OUTPUT" - echo "number=$NUM" >> "$GITHUB_OUTPUT" - echo "Opened approval issue: $URL" - - - name: Notify Slack — approval required - continue-on-error: true - timeout-minutes: 5 - uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 - with: - webhook: ${{ secrets.SLACK_WEBHOOK_URL }} - webhook-type: incoming-webhook - payload: | - { - "text": "*Promotion approval required:* `${{ github.repository }}`\nRequested by: ${{ github.triggering_actor }}\nApprove here: ${{ steps.issue.outputs.url }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } - - - name: Wait for approver decision - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ steps.issue.outputs.number }} - REPO: ${{ github.repository }} - # triggering_actor reflects the user who triggered THIS run (incl. reruns), - # so rerunning the workflow as a different user correctly excludes them. - INITIATOR: ${{ github.triggering_actor }} - POLL_INTERVAL: "30" - run: | - set -euo pipefail - - INITIATOR_LC=$(printf '%s' "$INITIATOR" | tr '[:upper:]' '[:lower:]') - ALLOWED_JSON=$(printf '%s' "$APPROVERS" | tr ',' '\n' \ - | tr '[:upper:]' '[:lower:]' \ - | grep -vx "$INITIATOR_LC" \ - | jq -R . | jq -s .) - - echo "Polling issue #${ISSUE_NUMBER}. Allowed approvers (initiator ${INITIATOR} excluded):" - echo "$ALLOWED_JSON" | jq -r '.[]' - - while true; do - STATE=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq '.state') - - DECISION=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUMBER}/comments?per_page=100" \ - | jq -s 'add // []' \ - | jq -c --argjson allowed "$ALLOWED_JSON" ' - map(select(.user.login | ascii_downcase as $u | $allowed | index($u))) - | sort_by(.created_at) - | map( - (.body | ascii_downcase | gsub("[[:space:]]"; "")) as $b - | if ($b | test("^(approved|approve|lgtm)$")) then {decision: "approved", user: .user.login} - elif ($b | test("^(denied|deny|reject|rejected)$")) then {decision: "denied", user: .user.login} - else empty end - ) - | first // empty') - - if [ -n "$DECISION" ] && [ "$DECISION" != "null" ]; then - DEC=$(echo "$DECISION" | jq -r '.decision') - USR=$(echo "$DECISION" | jq -r '.user') - if [ "$DEC" = "approved" ]; then - echo "Approved by @${USR}." - gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "Approved by @${USR}. Proceeding." || true - exit 0 - else - echo "Denied by @${USR}." - gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "Denied by @${USR}. Cancelling promotion." || true - exit 1 - fi - fi - - if [ "$STATE" = "closed" ]; then - echo "Issue closed without an approval comment — treating as denial." - exit 1 - fi - - sleep "$POLL_INTERVAL" - done - - promote: - needs: gate - runs-on: ubuntu-latest - permissions: - contents: "read" - id-token: "write" - steps: - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v3 - with: - project_id: "289724348228" - workload_identity_provider: "projects/289724348228/locations/global/workloadIdentityPools/github/providers/gominimal" - - - name: Promote CLI version - id: promote - env: - DRY_RUN: ${{ inputs.dry_run }} - # Pass dispatch inputs via env (NOT inline ${{ inputs.* }} in `run:`) - # to avoid shell template injection — see GitHub's hardening guide. - INPUT_SHA: ${{ inputs.sha }} - INPUT_PLATFORMS: ${{ inputs.platforms }} - run: | - # Trim leading/trailing whitespace; users often paste SHAs with stray spaces. - SHA="$(printf '%s' "$INPUT_SHA" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - PLATFORMS="$(printf '%s' "$INPUT_PLATFORMS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" - - if [ "$PLATFORMS" = "all" ]; then - PLATFORMS="amd64-linux,arm64-linux" - fi - - if [ -z "$SHA" ]; then - echo "No SHA provided, finding latest CLI archive in bucket..." - LATEST=$(gcloud storage ls -l "gs://minimal-shim/archives/cli-amd64-linux-*.tar.zst" \ - | grep -v "^TOTAL:" \ - | sort -k2 \ - | tail -1 \ - | awk '{print $NF}') - if [ -z "$LATEST" ]; then - echo "ERROR: No CLI archives found in bucket" - exit 1 - fi - # Extract SHA: archives/cli-amd64-linux-.tar.zst → - SHA="$(basename "$LATEST" | sed 's/^cli-amd64-linux-//;s/\.tar\.zst$//')" - echo "Latest SHA: ${SHA}" - fi - - IFS=',' read -ra PLATFORM_LIST <<< "$PLATFORMS" - for plat in "${PLATFORM_LIST[@]}"; do - plat="$(echo "$plat" | tr -d ' ')" - echo "Checking cli-${plat}-${SHA}.tar.zst exists..." - if ! gcloud storage ls "gs://minimal-shim/archives/cli-${plat}-${SHA}.tar.zst" > /dev/null 2>&1; then - echo "ERROR: cli-${plat}-${SHA}.tar.zst not found in bucket" - exit 1 - fi - done - - for plat in "${PLATFORM_LIST[@]}"; do - plat="$(echo "$plat" | tr -d ' ')" - echo "Promoting CLI to ${SHA} for ${plat}..." - echo "{\"version\":\"${SHA}\"}" > "cli-${plat}.json" - if [ "$DRY_RUN" = "true" ]; then - echo "[dry-run] would: gcloud storage cp --cache-control=max-age=300 cli-${plat}.json gs://minimal-shim/config/cli-${plat}.json" - echo "[dry-run] config payload:" - cat "cli-${plat}.json" - else - gcloud storage cp \ - --cache-control="max-age=300" \ - "cli-${plat}.json" \ - "gs://minimal-shim/config/cli-${plat}.json" - fi - done - - if [ "$DRY_RUN" = "true" ]; then - echo "[dry-run] CLI would have been promoted to ${SHA} for platforms: ${PLATFORMS}" - else - echo "CLI promoted to ${SHA} for platforms: ${PLATFORMS}" - fi - - # Pin reference docs to the promoted SHA so docs.minimal.dev/reference - # tracks the promoted CLI version rather than docs-repo main. The docs - # build reads this pointer to decide which minimal SHA to fetch - # docs/reference/*.md from. - echo "{\"version\":\"${SHA}\"}" > docs.json - if [ "$DRY_RUN" = "true" ]; then - echo "[dry-run] would: gcloud storage cp --cache-control=max-age=300 docs.json gs://minimal-shim/config/docs.json" - else - gcloud storage cp \ - --cache-control="max-age=300" \ - docs.json \ - gs://minimal-shim/config/docs.json - echo "Reference docs pinned to ${SHA}" - fi - - # Export resolved SHA for the docs-rebuild dispatch step below. - echo "sha=${SHA}" >> "$GITHUB_OUTPUT" - - # Mint a short-lived token scoped to gominimal/docs via the - # gominimal-aw-bot GitHub App (org standard — App tokens over PATs, see - # gominimal/min-aw ADR-0002). The default GITHUB_TOKEN cannot dispatch - # other repos; this token carries the App's contents:write permission - # narrowed to the docs repo only. - - name: Mint docs-repo token (gominimal-aw-bot) - id: docs-token - if: ${{ inputs.dry_run != true }} - uses: actions/create-github-app-token@v3 - with: - app-id: ${{ vars.AW_BOT_APP_ID }} - private-key: ${{ secrets.AW_BOT_PRIVATE_KEY }} - owner: gominimal - repositories: docs - - - name: Trigger docs rebuild - env: - GH_TOKEN: ${{ steps.docs-token.outputs.token }} - DRY_RUN: ${{ inputs.dry_run }} - SHA: ${{ steps.promote.outputs.sha }} - run: | - set -euo pipefail - if [ "$DRY_RUN" = "true" ]; then - echo "[dry-run] would dispatch reference-docs-promoted to gominimal/docs for ${SHA}" - else - gh api repos/gominimal/docs/dispatches \ - -f event_type=reference-docs-promoted \ - -F "client_payload[sha]=${SHA}" - echo "Dispatched reference-docs-promoted to gominimal/docs for ${SHA}" - fi + gate: + runs-on: ubuntu-latest + permissions: + issues: write + # 6h matches the GitHub-hosted runner hard limit. A higher value is + # misleading — `ubuntu-latest` jobs are killed at 6h regardless. + timeout-minutes: 360 + outputs: + issue_url: ${{ steps.issue.outputs.url }} + steps: + - name: Open approval issue + id: issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + WORKFLOW: ${{ github.workflow }} + RUN_NUMBER: ${{ github.run_number }} + # triggering_actor (NOT actor) — actor is frozen on rerun, so a rerun + # by a different user would still let the original initiator self-approve. + ACTOR: ${{ github.triggering_actor }} + SERVER_URL: ${{ github.server_url }} + RUN_ID: ${{ github.run_id }} + SHA_INPUT: ${{ inputs.sha }} + PLATFORMS_INPUT: ${{ inputs.platforms }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + BODY="**Repo:** \`${REPO}\` + **Workflow:** \`${WORKFLOW}\` + **Requested by:** @${ACTOR} + **Run:** ${SERVER_URL}/${REPO}/actions/runs/${RUN_ID} + **SHA input:** \`${SHA_INPUT:-}\` + **Platforms:** \`${PLATFORMS_INPUT}\` + **Dry run:** \`${DRY_RUN}\` + + Comment **\`approved\`** to proceed or **\`denied\`** to cancel. + Closing this issue without an approval comment counts as denial. + + Approver list (initiator excluded at runtime): + ${APPROVERS//,/, }" + + URL=$(gh issue create \ + --repo "$REPO" \ + --title "Promotion approval required: ${WORKFLOW} run ${RUN_NUMBER}" \ + --label "promotion-approval" \ + --body "$BODY") + NUM="${URL##*/}" + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "number=$NUM" >> "$GITHUB_OUTPUT" + echo "Opened approval issue: $URL" + + - name: Notify Slack — approval required + continue-on-error: true + timeout-minutes: 5 + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + with: + webhook: ${{ secrets.SLACK_WEBHOOK_URL }} + webhook-type: incoming-webhook + payload: | + { + "text": "*Promotion approval required:* `${{ github.repository }}`\nRequested by: ${{ github.triggering_actor }}\nApprove here: ${{ steps.issue.outputs.url }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + + - name: Wait for approver decision + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ISSUE_NUMBER: ${{ steps.issue.outputs.number }} + REPO: ${{ github.repository }} + # triggering_actor reflects the user who triggered THIS run (incl. reruns), + # so rerunning the workflow as a different user correctly excludes them. + INITIATOR: ${{ github.triggering_actor }} + POLL_INTERVAL: "30" + run: | + set -euo pipefail + + INITIATOR_LC=$(printf '%s' "$INITIATOR" | tr '[:upper:]' '[:lower:]') + ALLOWED_JSON=$(printf '%s' "$APPROVERS" | tr ',' '\n' \ + | tr '[:upper:]' '[:lower:]' \ + | grep -vx "$INITIATOR_LC" \ + | jq -R . | jq -s .) + + echo "Polling issue #${ISSUE_NUMBER}. Allowed approvers (initiator ${INITIATOR} excluded):" + echo "$ALLOWED_JSON" | jq -r '.[]' + + while true; do + STATE=$(gh api "repos/${REPO}/issues/${ISSUE_NUMBER}" --jq '.state') + + DECISION=$(gh api --paginate "repos/${REPO}/issues/${ISSUE_NUMBER}/comments?per_page=100" \ + | jq -s 'add // []' \ + | jq -c --argjson allowed "$ALLOWED_JSON" ' + map(select(.user.login | ascii_downcase as $u | $allowed | index($u))) + | sort_by(.created_at) + | map( + (.body | ascii_downcase | gsub("[[:space:]]"; "")) as $b + | if ($b | test("^(approved|approve|lgtm)$")) then {decision: "approved", user: .user.login} + elif ($b | test("^(denied|deny|reject|rejected)$")) then {decision: "denied", user: .user.login} + else empty end + ) + | first // empty') + + if [ -n "$DECISION" ] && [ "$DECISION" != "null" ]; then + DEC=$(echo "$DECISION" | jq -r '.decision') + USR=$(echo "$DECISION" | jq -r '.user') + if [ "$DEC" = "approved" ]; then + echo "Approved by @${USR}." + gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "Approved by @${USR}. Proceeding." || true + exit 0 + else + echo "Denied by @${USR}." + gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "Denied by @${USR}. Cancelling promotion." || true + exit 1 + fi + fi + + if [ "$STATE" = "closed" ]; then + echo "Issue closed without an approval comment — treating as denial." + exit 1 + fi + + sleep "$POLL_INTERVAL" + done + + promote: + needs: gate + runs-on: ubuntu-latest + permissions: + contents: "read" + id-token: "write" + steps: + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v3 + with: + project_id: "289724348228" + workload_identity_provider: "projects/289724348228/locations/global/workloadIdentityPools/github/providers/gominimal" + + - name: Promote CLI version + id: promote + env: + DRY_RUN: ${{ inputs.dry_run }} + # Pass dispatch inputs via env (NOT inline ${{ inputs.* }} in `run:`) + # to avoid shell template injection — see GitHub's hardening guide. + INPUT_SHA: ${{ inputs.sha }} + INPUT_PLATFORMS: ${{ inputs.platforms }} + run: | + # Trim leading/trailing whitespace; users often paste SHAs with stray spaces. + SHA="$(printf '%s' "$INPUT_SHA" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + PLATFORMS="$(printf '%s' "$INPUT_PLATFORMS" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + + if [ "$PLATFORMS" = "all" ]; then + PLATFORMS="amd64-linux,arm64-linux" + fi + + if [ -z "$SHA" ]; then + echo "No SHA provided, finding latest archive in bucket..." + LATEST=$(gcloud storage ls -l "gs://minimal-shim/archives/minimalone-*.tar.zst" \ + | grep -v "^TOTAL:" \ + | sort -k2 \ + | tail -1 \ + | awk '{print $NF}') + if [ -z "$LATEST" ]; then + echo "ERROR: No archive found in bucket" + exit 1 + fi + # Extract SHA: archives/minimalone-.tar.zst → + SHA="$(basename "$LATEST" | sed 's/^minimalone-//;s/\.tar\.zst$//')" + echo "Latest SHA: ${SHA}" + fi + + # TODO: implement minimal-one-specific promotion process!! + + # Export resolved SHA for the docs-rebuild dispatch step below. + echo "sha=${SHA}" >> "$GITHUB_OUTPUT" + + # Mint a short-lived token scoped to gominimal/docs via the + # gominimal-aw-bot GitHub App (org standard — App tokens over PATs, see + # gominimal/min-aw ADR-0002). The default GITHUB_TOKEN cannot dispatch + # other repos; this token carries the App's contents:write permission + # narrowed to the docs repo only. + - name: Mint docs-repo token (gominimal-aw-bot) + id: docs-token + if: ${{ inputs.dry_run != true }} + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ vars.AW_BOT_APP_ID }} + private-key: ${{ secrets.AW_BOT_PRIVATE_KEY }} + owner: gominimal + repositories: docs + + - name: Trigger docs rebuild + env: + GH_TOKEN: ${{ steps.docs-token.outputs.token }} + DRY_RUN: ${{ inputs.dry_run }} + SHA: ${{ steps.promote.outputs.sha }} + run: | + set -euo pipefail + if [ "$DRY_RUN" = "true" ]; then + echo "[dry-run] would dispatch reference-docs-promoted to gominimal/docs for ${SHA}" + else + gh api repos/gominimal/docs/dispatches \ + -f event_type=reference-docs-promoted \ + -F "client_payload[sha]=${SHA}" + echo "Dispatched reference-docs-promoted to gominimal/docs for ${SHA}" + fi diff --git a/.minimal/minimal.toml b/.minimal/minimal.toml index 90239da75..337275db7 100644 --- a/.minimal/minimal.toml +++ b/.minimal/minimal.toml @@ -21,8 +21,8 @@ exec = "cargo build --verbose" inherit_cwd = true [tasks.build-smoke] -description = "Smoke-test that `minimal run` can build the CLI" -exec = "cargo build -p minimal" +description = "Smoke-test that `mip run` can build the CLI" +exec = "cargo build -p mip" inherit_cwd = true [tasks.test] diff --git a/Cargo.lock b/Cargo.lock index 9fd33172d..7e44f31bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3479,51 +3479,6 @@ dependencies = [ [[package]] name = "minimal" -version = "0.4.1" -dependencies = [ - "anyhow", - "args", - "blake3", - "check", - "checkouts", - "clap", - "clap_complete", - "codespan-reporting", - "common", - "decode", - "dirs", - "futures", - "graph", - "lcache", - "mctx", - "mfile", - "nickel-lang-core", - "op", - "orchestrator", - "ot", - "petgraph", - "rayon", - "rcache", - "remote-client", - "serde", - "serde_json", - "serial_test", - "smallvec", - "tokio", - "tokio-util", - "toml_edit", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "minimal2" version = "0.0.1" dependencies = [ "camino", @@ -3544,6 +3499,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minimald" version = "0.0.1" @@ -3653,6 +3614,45 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mip" +version = "0.4.1" +dependencies = [ + "anyhow", + "args", + "blake3", + "check", + "checkouts", + "clap", + "clap_complete", + "codespan-reporting", + "common", + "decode", + "dirs", + "futures", + "graph", + "lcache", + "mctx", + "mfile", + "nickel-lang-core", + "op", + "orchestrator", + "ot", + "petgraph", + "rayon", + "rcache", + "remote-client", + "serde", + "serde_json", + "serial_test", + "smallvec", + "tokio", + "tokio-util", + "toml_edit", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ml-kem" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 6429a1e96..2e92a541c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,8 @@ members = [ "crates/ot", "crates/orchestrator", "crates/paths", + "crates/mip", "crates/minimal", - "crates/minimal2", "crates/minimald", "crates/minimald-rpc", "crates/minvmd", diff --git a/crates/minimal/Cargo.toml b/crates/minimal/Cargo.toml index b1714a157..af9b9c067 100644 --- a/crates/minimal/Cargo.toml +++ b/crates/minimal/Cargo.toml @@ -1,50 +1,24 @@ [package] name = "minimal" -version = "0.4.1" +version = "0.0.1" edition.workspace = true publish.workspace = true [dependencies] -args.workspace = true -graph.workspace = true -lcache.workspace = true -rcache.workspace = true -check.workspace = true -common.workspace = true -checkouts.workspace = true -decode.workspace = true -mctx.workspace = true -mfile.workspace = true -op.workspace = true -ot.workspace = true -orchestrator.workspace = true -remote-client.workspace = true - -nickel-lang-core.workspace = true -codespan-reporting.workspace = true - -tracing.workspace = true -tracing-subscriber.workspace = true - clap.workspace = true clap_complete.workspace = true +camino.workspace = true +chrono.workspace = true dirs.workspace = true -blake3.workspace = true +minimald-rpc.workspace = true +paths.workspace = true +russh.workspace = true serde.workspace = true serde_json.workspace = true - -rayon.workspace = true +sessions.workspace = true tokio.workspace = true -tokio-util.workspace = true -futures.workspace = true - -anyhow.workspace = true - -toml_edit.workspace = true - -petgraph.workspace = true -smallvec.workspace = true - -[dev-dependencies] -serial_test.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +minvmd = { path = "../minvmd" } +ot.workspace = true diff --git a/crates/minimal2/src/autospawn.rs b/crates/minimal/src/autospawn.rs similarity index 100% rename from crates/minimal2/src/autospawn.rs rename to crates/minimal/src/autospawn.rs diff --git a/crates/minimal2/src/client.rs b/crates/minimal/src/client.rs similarity index 100% rename from crates/minimal2/src/client.rs rename to crates/minimal/src/client.rs diff --git a/crates/minimal/src/main.rs b/crates/minimal/src/main.rs index 70f63bc8f..797b1030f 100644 --- a/crates/minimal/src/main.rs +++ b/crates/minimal/src/main.rs @@ -1,47 +1,14 @@ -#![allow(clippy::result_large_err)] +//! The minimal CLI which pairs/talks-with minimald. -use anyhow::anyhow; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::Shell; -use mctx::{ConfigBuilder, Context, Error}; -use std::io; +use std::os::unix::process::CommandExt as _; use std::path::PathBuf; +use tokio::io::AsyncWriteExt as _; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; -mod cmd_pkg; -use cmd_pkg::{PkgArgs, cmd_pkg}; -mod cmd_check; -use cmd_check::{CheckArgs, cmd_check}; -mod cmd_plan; -use cmd_plan::{PlanArgs, cmd_plan}; -mod cmd_materialize; -use cmd_materialize::{MaterializeArgs, cmd_materialize}; -mod cmd_upload_cache; -use cmd_upload_cache::{UploadArgs, cmd_upload_cache}; -mod cmd_patched_build; -use cmd_patched_build::{PatchedBuildArgs, cmd_patched_build}; -#[cfg(target_os = "linux")] -mod cmd_run; -#[cfg(target_os = "linux")] -use cmd_run::{RunArgs, cmd_run, cmd_run_by_spec}; -mod cmd_dep; -use cmd_dep::{DepArgs, cmd_dep}; -mod cmd_update; -use cmd_update::{UpdateArgs, cmd_update}; -mod cmd_init; -use cmd_init::{InitArgs, cmd_init}; -mod cmd_add; -use cmd_add::{AddArgs, cmd_add}; -mod cmd_dump; -use cmd_dump::{DumpArgs, cmd_dump}; -mod cmd_status; -use cmd_status::{StatusArgs, cmd_status}; -mod cmd_cache; -use cmd_cache::{CacheArgs, cmd_cache}; -mod cmd_rexec; -use cmd_rexec::{RexecArgs, cmd_rexec}; -mod cmd_remote_build; -use cmd_remote_build::{RemoteBuildArgs, cmd_remote_build}; +mod autospawn; +mod client; #[derive(Parser)] #[command(name = "minimal", version = env!("CARGO_PKG_VERSION"), long_version = env!("LONG_VERSION"))] @@ -56,142 +23,281 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Runs a task, such as one specified in `minimal.toml`. - #[cfg(target_os = "linux")] - Run(RunArgs), - /// Refreshes local checkouts of upstream packages & the standard library. - Update(UpdateArgs), - /// Add a new tool or dependency. - Add(AddArgs), - /// Automatically initialize minimal configuration based on your source tree. - Init(InitArgs), - /// Shows the status of Minimal in this codebase. - Status(StatusArgs), - /// Launches a development shell. Shorthand for `minimal run shell`. - Shell, - /// Runs the build task. Shorthand for `minimal run build`. - Build, - /// Runs the test task. Shorthand for `minimal run test`. - Test, - /// Materializes an output specified in `minimal.toml`. - Materialize(MaterializeArgs), - /// Builds the specified package(s) in a clean room, making them available in the local cache. - #[clap(alias = "pkg")] - Package(PkgArgs), - /// Execute a command on a remote build server. - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - Rexec(RexecArgs), - /// Build packages on a remote build server. - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - RemoteBuild(RemoteBuildArgs), - /// Manipulate the local cache. - #[clap(subcommand)] - Cache(CacheArgs), - - /// Validates minimal configuration including packages, stacks, and profiles - Check(CheckArgs), - /// Prints the build plan for the specified package(s) - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - Plan(PlanArgs), - /// Uploads the specified packages and their transitive needs to the cache. - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - UploadCache(UploadArgs), - /// Executes the build for a package, using stale dependencies. - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - PatchedBuild(PatchedBuildArgs), - /// Dumps out information about the supply chain in a machine-readable format. - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - Dump(DumpArgs), - /// Generates Graphviz source code of the dependency graph - #[command( - long_about = "Generate an image of the dependency graph using graphviz's \"dot\" program.\n\n minimal dep --input_deps_depth=0 -p file | dot -Tpng > deps.png" - )] - Dep(DepArgs), - + /// List sessions + Ls(LsArgs), + /// Activate (create) a new session + Activate(ActivateArgs), + /// Attach to an existing session + Attach(AttachArgs), + /// Destroy (terminate) a session + Destroy(DestroyArgs), + /// Session inspection subcommands + Session(SessionArgs), + /// WireGuard mesh: join, leave, and inspect remote-access state + Mesh(MeshArgs), + /// Proxy stdio to a daemon UDS socket (used as an SSH ProxyCommand). + #[command(hide = true)] + Proxy(ProxyArgs), + /// Forward a local TCP port to a remote address inside a PTask via SSH + /// (R4.8, R4.9). + /// + /// Sets up an SSH `LocalForward` (`-L`) tunnel through the minimald SSH + /// server so traffic sent to `` on the host is relayed to + /// `:` from inside the named PTask's network + /// namespace. Useful when WireGuard (`networking-wg` feature) is + /// unavailable (e.g., on corporate networks that block UDP). + /// + /// Examples: + /// + /// # Forward host port 18080 to the webserver inside the "dev" session: + /// minimal ssh-forward dev 18080:127.0.0.1:80 + /// + /// # Then access it from the host: + /// curl http://localhost:18080/ + #[command(name = "ssh-forward", visible_alias = "forward")] + SshForward(SshForwardArgs), + /// Obtain an mTLS client certificate from minimald for use with the HTTPS + /// reverse proxy (R4.4, R4.5). + /// + /// Connects to minimald, generates a fresh client certificate signed by + /// the daemon's internal CA, and saves the certificate and private key to + /// `~/.config/minimal/client.pem` / `~/.config/minimal/client.key`. Also + /// saves the CA certificate to `~/.config/minimal/ca.pem` so tools like + /// `curl` can trust the HTTPS proxy. + /// + /// Example: + /// + /// minimal login + /// curl --cacert ~/.config/minimal/ca.pem \ + /// --cert ~/.config/minimal/client.pem \ + /// --key ~/.config/minimal/client.key \ + /// https://localhost:7655/ + Login(LoginArgs), /// Generate shell completion script #[command( - long_about = "Generate a shell tab-completion script for the minimal CLI for your shell.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(minimal completions bash)" + long_about = "Generate a shell tab-completion script for the minimal CLI.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(minimal completions bash)" )] Completions(CompletionsArgs), } -#[derive(Debug, clap::Args)] -struct CompletionsArgs { - /// The shell type for a CLI completion script should be printed - #[arg(value_parser)] - shell: Shell, +#[derive(Debug, Args)] +struct SessionArgs { + #[command(subcommand)] + command: SessionCommand, +} + +#[derive(Debug, Subcommand)] +enum SessionCommand { + /// Print the effective networking policy for a session as JSON + Policy(PolicyArgs), } -/// Shared arguments and builders across all subcommands #[derive(Debug, Args)] -pub struct GlobalArgs { - /// Use the given directory as the repository root, instead of searching from the current working directory. - #[arg(long, short = 'C')] - repo_dir: Option, +struct PolicyArgs { + /// Session identifier (UUID or session name) + session: String, +} + +/// WireGuard mesh subcommands for authenticated remote PTask access (UC7 / +/// UC2b). The mesh lets a laptop, or another host's PTasks, reach this host's +/// PTasks over an encrypted tunnel. +#[derive(Debug, Args)] +struct MeshArgs { + #[command(subcommand)] + command: MeshCommand, +} +#[derive(Debug, Subcommand)] +enum MeshCommand { + /// Enrol this machine into a remote minimald's WireGuard mesh + /// + /// v1 uses manual key exchange: this records the target and prints the + /// steps to swap public keys. Once enrolled you can reach the remote + /// host's own-IP PTasks by their switch IPs over the tunnel. + /// + /// Example: + /// + /// minimal mesh join mesh.example.com:51820 + #[command(verbatim_doc_comment)] + Join(MeshJoinArgs), + /// Leave the WireGuard mesh and drop this machine's local enrolment + /// + /// Removes the local enrolment record written by `minimal mesh join`. + /// Peer entries on the remote minimald must be removed there (manual v1). + /// + /// Example: + /// + /// minimal mesh leave + #[command(verbatim_doc_comment)] + Leave, + /// Show this minimald's mesh status: public key, advertised subnets, peers + /// + /// Queries the local minimald for its WireGuard public key, the switch + /// subnets it advertises to the mesh, and each peer's last handshake. + /// + /// Example: + /// + /// minimal mesh status + #[command(verbatim_doc_comment)] + Status, +} + +#[derive(Debug, Args)] +struct MeshJoinArgs { + /// Address of the remote minimald exposing the mesh (`host:port`) + address: String, +} + +/// Shared arguments all subcommands +#[derive(Debug, Args)] +pub struct GlobalArgs { /// Override the base directory used for operations (default: ~/.cache/minimal) #[arg(long)] minimal_dir: Option, - /// Load the minimal standard library from the given path instead + /// Linux: run minimald inside the minvmd microVM (DM1) instead of natively + /// on the host (DM2, the default). No effect on macOS, where minvmd is the + /// only backend. + #[arg(long, global = true)] + minvmd: bool, +} + +#[derive(Debug, Args)] +struct ActivateArgs { + /// Optional session name + #[arg(long, short)] + name: Option, + /// Project path to activate (defaults to current directory) + #[arg(default_value = ".")] + path: String, + /// Network mode: no-net, host-net (default), or own-ip. + #[arg(long, value_enum, default_value_t = CliNetworkMode::HostNet)] + network: CliNetworkMode, + /// Static ingress port mapping `EXT:INT[/PROTO]` (PROTO = tcp|udp, default + /// tcp). Repeatable. Requires `--network own-ip`. + #[arg(long = "ingress", value_name = "EXT:INT[/PROTO]")] + ingress: Vec, + /// Automatically attach after creation #[arg(long)] - #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] - stdlib_dir: Option, + attach: bool, +} - /// Ignore locally-available binary artifacts (results in rebuilds unless present in a remote cache) - #[arg(long, default_value_t = false, global = true)] - no_cache: bool, +/// CLI surface for [`sessions::NetworkMode`]. A local `ValueEnum` keeps the +/// `sessions` crate free of a clap dependency. +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum CliNetworkMode { + NoNet, + HostNet, + OwnIp, +} - /// Do not fetch binary artifacts from the internet - #[arg(long, default_value_t = false, global = true)] - no_fetch: bool, +impl From for sessions::NetworkMode { + fn from(m: CliNetworkMode) -> Self { + match m { + CliNetworkMode::NoNet => sessions::NetworkMode::NoNet, + CliNetworkMode::HostNet => sessions::NetworkMode::HostNet, + CliNetworkMode::OwnIp => sessions::NetworkMode::OwnIp, + } + } +} - /// Use only what's already in the local cache for sources, VCS checkouts, - /// and the remote artifact cache. On cache miss, fail with a clear error - /// instead of attempting any network call. Useful for builds in - /// network-isolated environments where every input is pre-staged. - /// - /// Composes with the other cache flags: - /// - implies the remote-artifact-cache-skip half of --no-fetch (you - /// can't reach the artifact cache when offline anyway), so - /// --offline alone is sufficient — no need for --offline --no-fetch - /// - orthogonal to --no-cache and --rebuild, which control whether to - /// use locally-built artifacts (--offline doesn't force a rebuild; - /// it just gates the network) - #[arg(long, default_value_t = false, global = true)] - offline: bool, - - /// Configure the number of parallel builds - #[arg(short, long, global = true)] - num_parallel_builds: Option, -} - -pub(crate) fn enforce_science_mode() -> Result<(), Error> { - if std::env::var("MINIMAL_SCIENCE_MODE").unwrap_or("".to_string()) != "yeppers" { - eprintln!("You are using a command that is experimental or very unsafe!!"); - eprintln!( - "No guarantees are given about the consistency of your minimal install following the execution of such commands, nor the stability of any such commands." - ); - eprintln!( - "If you are sure you want to continue, set the environment variable MINIMAL_SCIENCE_MODE=yeppers before continuing." - ); - eprintln!(); +/// Parse an `--ingress EXT:INT[/PROTO]` spec into a [`sessions::PortMapping`]. +/// PROTO defaults to tcp; only tcp/udp are accepted (gvproxy's static forwarder +/// exposes no other transport). +fn parse_ingress_mapping(spec: &str) -> Result { + let (ports, proto) = match spec.split_once('/') { + Some((ports, proto)) => (ports, parse_ingress_proto(proto)?), + None => (spec, sessions::IpProto::Tcp), + }; + let (ext, int) = ports + .split_once(':') + .ok_or_else(|| format!("ingress '{spec}': expected EXT:INT[/PROTO]"))?; + let external_port = ext + .parse::() + .map_err(|_| format!("ingress '{spec}': invalid external port '{ext}'"))?; + let internal_port = int + .parse::() + .map_err(|_| format!("ingress '{spec}': invalid internal port '{int}'"))?; + Ok(sessions::PortMapping { + external_port, + internal_port, + proto, + }) +} - Err(Error::Other(anyhow!( - "Aborting execution of unsafe command outside of science mode" - ))) - } else { - Ok(()) +fn parse_ingress_proto(proto: &str) -> Result { + match proto.to_ascii_lowercase().as_str() { + "tcp" => Ok(sessions::IpProto::Tcp), + "udp" => Ok(sessions::IpProto::Udp), + other => Err(format!( + "ingress: unsupported protocol '{other}' (use tcp or udp)" + )), } } +#[derive(Debug, Args)] +struct AttachArgs { + /// Session identifier (UUID or session name) + session: String, + /// Command to exec in the session context (non-interactive) + #[arg(long, short)] + command: Option, +} + +#[derive(Debug, Args)] +struct LsArgs { + /// Output raw session IDs (one per line) for piping into scripts + #[arg(long)] + raw: bool, +} + +#[derive(Debug, Args)] +struct DestroyArgs { + /// Session identifier (UUID or session name) + session: String, +} + +#[derive(Debug, Args)] +struct ProxyArgs { + /// UDS socket path to connect to + #[arg(long)] + socket: String, +} + +/// Arguments for `minimal ssh-forward`. +#[derive(Debug, Args)] +struct SshForwardArgs { + /// Session identifier (UUID or session name) + session: String, + /// Port-forward specification: `::` + /// + /// Example: `18080:127.0.0.1:80` to forward local port 18080 to port 80 + /// on the loopback address as seen from inside the session. + #[arg(value_name = "LOCAL:REMOTE_HOST:REMOTE_PORT")] + forward: String, +} + +/// Arguments for `minimal login`. +#[derive(Debug, Args)] +struct LoginArgs { + /// Override the directory where client cert files are written + /// (default: `~/.config/minimal/`). + #[arg(long)] + cert_dir: Option, +} + +#[derive(Debug, clap::Args)] +struct CompletionsArgs { + /// The shell type for a CLI completion script should be printed + #[arg(value_parser)] + shell: Shell, +} + #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), ()> { let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { EnvFilter::new("info") .add_directive("topiary=off".parse().unwrap()) .add_directive("libcgroups=off".parse().unwrap()) - .add_directive("build_events=off".parse().unwrap()) - .add_directive("build_events_proto=off".parse().unwrap()) }); tracing_subscriber::registry() @@ -201,122 +307,722 @@ async fn main() -> Result<(), Error> { let cli = Cli::parse(); - let result = run_cli(cli).await; + match cli.command { + Command::Ls(args) => cmd_ls(&cli.global_args, args).await, + Command::Activate(args) => cmd_activate(&cli.global_args, args).await, + Command::Attach(args) => cmd_attach(&cli.global_args, args).await, + Command::Destroy(args) => cmd_destroy(&cli.global_args, args).await, + Command::Session(SessionArgs { + command: SessionCommand::Policy(args), + }) => cmd_session_policy(&cli.global_args, args).await, + Command::Mesh(MeshArgs { command }) => match command { + MeshCommand::Status => cmd_mesh_status(&cli.global_args).await, + MeshCommand::Join(args) => cmd_mesh_join(&cli.global_args, args), + MeshCommand::Leave => cmd_mesh_leave(&cli.global_args), + }, + Command::Proxy(args) => cmd_proxy(args).await, + Command::SshForward(args) => cmd_ssh_forward(&cli.global_args, args).await, + Command::Login(args) => cmd_login(&cli.global_args, args).await, + Command::Completions(CompletionsArgs { shell }) => { + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout()); + Ok(()) + } + } +} + +/// Connect to the daemon, resolving the socket path from global args. +async fn connect_daemon(global: &GlobalArgs) -> Result { + let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) + .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; + + client::Client::connect(&sock) + .await + .map_err(|e| eprintln!("Failed to connect to minimald: {e}")) +} + +/// Bidirectionally pipe stdio to a daemon UDS socket. +/// +/// Intended for use as an SSH `ProxyCommand`: ssh writes to our stdin and +/// reads from our stdout, while we bridge both directions to the UDS. +async fn cmd_proxy(args: ProxyArgs) -> Result<(), ()> { + let stream = tokio::net::UnixStream::connect(&args.socket) + .await + .map_err(|e| eprintln!("connect to {}: {e}", args.socket))?; + + let (mut rx, mut tx) = stream.into_split(); + let mut stdin = tokio::io::stdin(); + let mut stdout = tokio::io::stdout(); - if let Err(e) = result { - e.report_to_stderr(); - std::process::exit(1); + let to_sock = async { + tokio::io::copy(&mut stdin, &mut tx).await?; + tx.shutdown().await }; + let from_sock = tokio::io::copy(&mut rx, &mut stdout); + + tokio::try_join!(to_sock, from_sock).map_err(|e| eprintln!("proxy: {e}"))?; Ok(()) } -async fn run_cli(cli: Cli) -> Result<(), Error> { - let Cli { - command, - global_args, - } = cli; +/// List sessions via the `ListSessions` RPC. +async fn cmd_ls(global: &GlobalArgs, args: LsArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let mut client = connect_daemon(global).await?; - // One operation tree for this CLI invocation, rendered to stderr. Threaded - // into the Context so all operations attach to it (replacing the former - // process-global root). - let ot_root = ot::OpTracker::new_root(); - ot::render_to_stderr(ot_root.clone()); + use minimald_rpc::ListSessions; + let resp = client + .oneshot_rpc::(()) + .await + .map_err(|e| eprintln!("ListSessions RPC failed: {e}"))?; - let mut config = ConfigBuilder::new() - .with_operation_tracker(ot_root) - .with_no_cache(global_args.no_cache) - .with_no_fetch(global_args.no_fetch) - .with_offline(global_args.offline); - if let Some(num_parallel_builds) = global_args.num_parallel_builds { - config = config.with_num_parallel_builds(num_parallel_builds); + if resp.sessions.is_empty() { + if !args.raw { + println!("No active sessions."); + } + return Ok(()); } - if let Some(repo_dir) = global_args.repo_dir { - config = config.with_repo_dir(repo_dir); + + if args.raw { + for entry in &resp.sessions { + println!("{}", entry.id); + } + return Ok(()); } - if let Some(minimal_dir) = global_args.minimal_dir { - config = config.with_state_dir(minimal_dir); + + // Format as a table: ID, Name, Title, Last Activity. + // Widths chosen to fit a standard 80-col terminal. + println!( + "{:<36} {:<20} {:<20} LAST ACTIVITY", + "SESSION ID", "NAME", "TITLE" + ); + println!("{:-<36} {:-<20} {:-<20} {:-<24}", "", "", "", ""); + + for entry in &resp.sessions { + let id = entry.id.to_string(); + let name = entry.name.as_deref().unwrap_or("-"); + let (title, last_activity) = match &entry.attrs { + Some(attrs) => { + let title = attrs + .title + .as_ref() + .map(|t| t.value.as_str()) + .unwrap_or("-"); + let last = attrs + .last_stdout + .or(attrs.last_stdin) + .map(|dt| { + let local = dt.with_timezone(&chrono::Local); + local.format("%Y-%m-%d %H:%M:%S").to_string() + }) + .unwrap_or_else(|| "-".to_string()); + (title, last) + } + None => ("-", "-".to_string()), + }; + println!("{id:<36} {name:<20} {title:<20} {last_activity}"); } - if let Some(stdlib_dir) = global_args.stdlib_dir { - config = config.with_stdlib_dir(stdlib_dir); + + Ok(()) +} + +/// Create a new session via the `CreateSession` RPC. +async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); } - let config = config.build()?; - // Commands that don't need a minimal.toml / full Context. - match command { - Command::Completions(CompletionsArgs { shell }) => { - let mut cmd = Cli::command(); - let name = cmd.get_name().to_string(); - clap_complete::generate(shell, &mut cmd, name, &mut io::stdout()); - return Ok(()); + let project_path = std::fs::canonicalize(&args.path) + .map_err(|e| eprintln!("Cannot resolve project path '{}': {e}", args.path))?; + + let utf8_path = camino::Utf8PathBuf::from_path_buf(project_path) + .map_err(|_| eprintln!("Project path is not valid UTF-8"))?; + let abs_path = paths::HostAbsPath::try_new(utf8_path) + .map_err(|e| eprintln!("Invalid project path: {e}"))?; + + let mut port_mappings = Vec::with_capacity(args.ingress.len()); + for spec in &args.ingress { + match parse_ingress_mapping(spec) { + Ok(mapping) => port_mappings.push(mapping), + Err(e) => { + eprintln!("{e}"); + return Err(()); + } + } + } + let policy = sessions::SessionPolicy { + egress: None, + ingress: (!port_mappings.is_empty()).then_some(sessions::IngressPolicy { + port_mappings, + dynamic_allowed_range: None, + }), + }; + + // The daemon sources `username` from the authenticated SSH + // connection context; the client doesn't send it. + let config = minimald_rpc::SessionConfig { + name: args.name.clone(), + project_path: abs_path, + network: args.network.into(), + policy, + attrs: Default::default(), + }; + + let mut client = connect_daemon(global).await?; + + use minimald_rpc::{CreateSession, CreateSessionRequest}; + let req = CreateSessionRequest { + config, + contribution: Default::default(), + }; + let resp = client + .oneshot_rpc::(req) + .await + .map_err(|e| eprintln!("CreateSession RPC failed: {e}"))?; + + // Surface the daemon's typed policy/network-mode validation error (e.g. + // ingress on a non-own-ip session, privileged host port) rather than a + // generic failure line. + let created = match resp { + minimald_rpc::Errorable::Ok(r) => r, + minimald_rpc::Errorable::Err { error } => { + eprintln!("CreateSession failed: {error}"); + return Err(()); + } + }; + // Today the daemon only ever produces `Ready` (the empty- + // contribution fast path). `Pending` lights up when Phase 2 + // routing lands. + let id = match created { + minimald_rpc::CreateSessionResponse::Ready { id } => id, + minimald_rpc::CreateSessionResponse::Pending { .. } => { + eprintln!( + "CreateSession returned Pending, but the composition pipeline \ + is not wired in this client yet", + ); + return Err(()); + } + }; + + println!("{id}"); + + if args.attach { + // Chain into attach. + let attach_args = AttachArgs { + session: id.to_string(), + command: None, + }; + return cmd_attach(global, attach_args).await; + } + + Ok(()) +} + +/// Shell-quote a string for safe interpolation into `sh -c`. +fn shell_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "'\"'\"'")) +} + +/// Attach to an existing session. Both interactive and `--command` paths +/// shell out to `ssh` — the daemon's shell_request handler mints a PTY-backed +/// session shell, and ssh handles termios/PTY management for us. +async fn cmd_attach(global: &GlobalArgs, args: AttachArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) + .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; + + // Resolve the session: if it looks like a UUID, query by ID; otherwise by name. + use minimald_rpc::{GetSessionRecord, GetSessionRecordRequest}; + let mut client = client::Client::connect(&sock) + .await + .map_err(|e| eprintln!("Failed to connect to minimald: {e}"))?; + + let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { + GetSessionRecordRequest::Id(id) + } else { + GetSessionRecordRequest::Name(args.session.clone()) + }; + + let resp = client + .oneshot_rpc::(lookup) + .await + .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; + + let record = match resp.record { + Some(r) => r, + None => { + eprintln!("No session found matching '{}'", args.session); + return Err(()); } - Command::Init(args) => return cmd_init(args, config).await, - #[cfg(target_os = "linux")] - Command::Run(RunArgs { - variant: - cmd_run::RunVariant::BySpec { - upstream, - task_spec, - }, - task_args, - }) => return cmd_run_by_spec(upstream, task_spec, task_args, config).await, - _ => {} - } - let mut ctx = Context::new(config)?; - - match command { - Command::Package(args) => cmd_pkg(args, &mut ctx).await, - Command::Check(args) => cmd_check(args, &mut ctx).await, - Command::Plan(args) => cmd_plan(args, &mut ctx).await, - Command::Add(args) => cmd_add(args, &mut ctx).await, - Command::UploadCache(args) => cmd_upload_cache(args, &mut ctx).await, - Command::Materialize(args) => cmd_materialize(args, &mut ctx).await, - Command::PatchedBuild(args) => cmd_patched_build(args, &mut ctx).await, - #[cfg(target_os = "linux")] - Command::Run(args) => cmd_run(args, &mut ctx).await, - Command::Shell => { - cmd_run( - RunArgs { - variant: cmd_run::RunVariant::ByName { - task_name: "shell".to_string(), - }, - task_args: vec![], - }, - &mut ctx, - ) - .await + }; + + tracing::info!( + session_id = %record.id, + session_name = ?record.name, + "found session" + ); + + // Shell out to ssh for both interactive and --command attachment. + // ProxyCommand points at our own `proxy` subcommand so we don't + // depend on socat or nc being installed. + let exe = + std::env::current_exe().map_err(|e| eprintln!("cannot determine current exe: {e}"))?; + let proxy_cmd = format!( + "{} proxy --socket {}", + shell_quote(&exe.display().to_string()), + shell_quote(&sock.display().to_string()), + ); + + let mut ssh = std::process::Command::new("ssh"); + ssh.env("MINIMAL_SESSION_ID", record.id.to_string()).args([ + "-o", + "SendEnv=MINIMAL_SESSION_ID", + "-o", + &format!("ProxyCommand={proxy_cmd}"), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + ]); + + // The interactive path opens the in-sandbox session shell via the daemon's + // `shell_request`, which requires a PTY. Force one with `-tt` so the shell + // works even when our stdin is not a tty (e.g. driven from a script for + // automated networking tests); without it ssh skips the PTY and the daemon + // rejects the shell. The `--command` path is a non-interactive exec and + // needs no PTY. + if args.command.is_none() { + ssh.arg("-tt"); + } + ssh.arg("local-0"); + + // If a command was provided, pass it to ssh (non-interactive exec). + // Otherwise, ssh opens an interactive shell via shell_request. + if let Some(ref cmd) = args.command { + ssh.arg(cmd); + } + + let err = ssh.exec(); + // exec() only returns on failure + eprintln!("failed to exec ssh: {err}"); + Err(()) +} + +/// Print the effective networking policy for a session as JSON. +async fn cmd_session_policy(global: &GlobalArgs, args: PolicyArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let mut client = connect_daemon(global).await?; + + use minimald_rpc::{GetSessionPolicy, GetSessionPolicyRequest}; + let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { + GetSessionPolicyRequest::Id(id) + } else { + GetSessionPolicyRequest::Name(args.session.clone()) + }; + + let resp = client + .oneshot_rpc::(lookup) + .await + .map_err(|e| eprintln!("GetSessionPolicy RPC failed: {e}"))?; + + match resp { + minimald_rpc::Errorable::Ok(policy) => { + let json = serde_json::to_string(&policy) + .map_err(|e| eprintln!("Failed to serialize policy: {e}"))?; + println!("{json}"); + Ok(()) + } + minimald_rpc::Errorable::Err { error } => { + eprintln!("{error}"); + Err(()) + } + } +} + +/// The local mesh-enrolment record path. Honors `--minimal-dir`, else falls +/// back to the user config dir. +fn mesh_enrolment_path(global: &GlobalArgs) -> Result { + let base = match &global.minimal_dir { + Some(dir) => dir.clone(), + None => dirs::config_dir() + .map(|c| c.join("minimal")) + .ok_or_else(|| eprintln!("cannot determine config directory; set --minimal-dir"))?, + }; + Ok(base.join("mesh-enrolment")) +} + +/// Show this minimald's WireGuard mesh status (R4.6): own public key, the +/// switch subnets it advertises, and each peer's last handshake. +async fn cmd_mesh_status(global: &GlobalArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let mut client = connect_daemon(global).await?; + + use minimald_rpc::GetMeshStatus; + let resp = client + .oneshot_rpc::(()) + .await + .map_err(|e| eprintln!("GetMeshStatus RPC failed: {e}"))?; + + if !resp.configured { + println!("No WireGuard mesh is configured on this minimald."); + return Ok(()); + } + + println!( + "public key: {}", + resp.own_public_key.as_deref().unwrap_or("-") + ); + if resp.advertised_subnets.is_empty() { + println!("advertised: (none)"); + } else { + println!("advertised: {}", resp.advertised_subnets.join(", ")); + } + + if resp.peers.is_empty() { + println!("peers: (none)"); + return Ok(()); + } + + println!("peers:"); + println!(" {:<20} {:<46} LAST HANDSHAKE", "NAME", "PUBLIC KEY"); + for p in &resp.peers { + let handshake = match p.last_handshake_secs { + Some(secs) => format!("{secs}s ago"), + None => "never".to_string(), + }; + println!(" {:<20} {:<46} {handshake}", p.name, p.public_key); + } + + Ok(()) +} + +/// Record this machine's enrolment into a remote minimald's mesh (R4.3, v1 +/// manual key exchange) and print the steps to complete the key swap. +fn cmd_mesh_join(global: &GlobalArgs, args: MeshJoinArgs) -> Result<(), ()> { + // Validate the endpoint at the point of entry so a typo never lands a bad + // enrolment on disk for a later consumer to choke on. The CLI contract is + // `host:port`; require a non-empty host and a parseable u16 port. + let Some((host, port)) = args.address.rsplit_once(':') else { + eprintln!("mesh join address must be host:port, e.g. mesh.example.com:51820"); + return Err(()); + }; + if host.is_empty() || port.parse::().map(|p| p == 0).unwrap_or(true) { + eprintln!("mesh join address must include a non-empty host and a valid non-zero port"); + return Err(()); + } + + let path = mesh_enrolment_path(global)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| eprintln!("creating {}: {e}", parent.display()))?; + } + std::fs::write(&path, format!("{}\n", args.address)) + .map_err(|e| eprintln!("writing {}: {e}", path.display()))?; + + println!( + "Recorded mesh enrolment for {} at {}.", + args.address, + path.display() + ); + println!(); + println!("v1 uses manual key exchange. To complete the join:"); + println!(" 1. Run `minimal mesh status` on the remote host to read its public key."); + println!(" 2. Add this machine's WireGuard public key to the remote minimald's peers."); + println!(" 3. Add the remote's public key and endpoint to this machine's mesh config."); + Ok(()) +} + +/// Drop this machine's local mesh enrolment (R4.3). Remote peer entries are +/// removed on the remote host (manual v1). +fn cmd_mesh_leave(global: &GlobalArgs) -> Result<(), ()> { + let path = mesh_enrolment_path(global)?; + match std::fs::remove_file(&path) { + Ok(()) => { + println!( + "Left the mesh; removed local enrolment at {}.", + path.display() + ); + Ok(()) } - Command::Build => { - cmd_run( - RunArgs { - variant: cmd_run::RunVariant::ByName { - task_name: "build".to_string(), - }, - task_args: vec![], - }, - &mut ctx, - ) - .await + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("No local mesh enrolment to remove."); + Ok(()) } - Command::Test => { - cmd_run( - RunArgs { - variant: cmd_run::RunVariant::ByName { - task_name: "test".to_string(), - }, - task_args: vec![], - }, - &mut ctx, - ) - .await + Err(e) => { + eprintln!("removing {}: {e}", path.display()); + Err(()) } - Command::Update(args) => cmd_update(args, &mut ctx).await, - Command::Dep(args) => cmd_dep(args, &mut ctx).await, - Command::Dump(args) => cmd_dump(args, &mut ctx).await, - Command::Status(args) => cmd_status(args, &mut ctx).await, - Command::Rexec(args) => cmd_rexec(args, &mut ctx).await, - Command::RemoteBuild(args) => cmd_remote_build(args, &mut ctx).await, - Command::Cache(args) => cmd_cache(args, &mut ctx).await, - // Handled before Context::new - Command::Completions(_) | Command::Init(_) => unreachable!(), + } +} + +/// Destroy (terminate) a session. +async fn cmd_destroy(global: &GlobalArgs, args: DestroyArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let mut client = connect_daemon(global).await?; + + // Resolve the session: if it looks like a UUID, query by ID; otherwise by name. + use minimald_rpc::{ + DestroySession, DestroySessionRequest, GetSessionRecord, GetSessionRecordRequest, + }; + let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { + GetSessionRecordRequest::Id(id) + } else { + GetSessionRecordRequest::Name(args.session.clone()) + }; + + let resp = client + .oneshot_rpc::(lookup) + .await + .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; + + let record = match resp.record { + Some(r) => r, + None => { + eprintln!("No session found matching '{}'", args.session); + return Err(()); + } + }; + + let resp = client + .oneshot_rpc::(DestroySessionRequest { id: record.id }) + .await + .map_err(|e| eprintln!("DestroySession RPC failed: {e}"))?; + + if resp.ok().is_some() { + println!( + "Destroyed session {} ({})", + record.id, + record.name.as_deref().unwrap_or("-") + ); + } else { + eprintln!("DestroySession returned an error from the daemon"); + return Err(()); + } + + Ok(()) +} + +/// Establish an SSH `LocalForward` tunnel from a local port to a remote +/// address inside the named PTask's network namespace (R4.8, R4.9). +/// +/// The forward spec is `::`. The +/// command shells out to `ssh -L` (the same mechanism as `cmd_attach`). +/// The `-N` flag keeps the tunnel alive without opening an interactive +/// shell. +async fn cmd_ssh_forward(global: &GlobalArgs, args: SshForwardArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) + .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; + + // Look up the session to validate it exists and to obtain its UUID for the + // server-side auth gate (passed as the SSH username so `direct-tcpip` can + // verify the session without a per-channel env handshake). + use minimald_rpc::{GetSessionRecord, GetSessionRecordRequest}; + let mut daemon_client = client::Client::connect(&sock) + .await + .map_err(|e| eprintln!("Failed to connect to minimald: {e}"))?; + + let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { + GetSessionRecordRequest::Id(id) + } else { + GetSessionRecordRequest::Name(args.session.clone()) + }; + + let resp = daemon_client + .oneshot_rpc::(lookup) + .await + .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; + + let record = match resp.record { + Some(r) => r, + None => { + eprintln!("No session found matching '{}'", args.session); + return Err(()); + } + }; + + // Validate the forward spec format: local:remote_host:remote_port. + // We accept either `local_port:host:port` (3 components, last two joined by + // the final colon) or the more compact form where host is an IPv4 address. + let parts: Vec<&str> = args.forward.splitn(3, ':').collect(); + if parts.len() != 3 { + eprintln!( + "invalid forward spec {:?}: expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT", + args.forward + ); + return Err(()); + } + let local_port = parts[0]; + let remote_host = parts[1]; + let remote_port = parts[2]; + let forward_arg = format!("{local_port}:{remote_host}:{remote_port}"); + + let exe = + std::env::current_exe().map_err(|e| eprintln!("cannot determine current exe: {e}"))?; + let proxy_cmd = format!( + "{} proxy --socket {}", + shell_quote(&exe.display().to_string()), + shell_quote(&sock.display().to_string()), + ); + + let session_id = record.id.to_string(); + // Use `-N` (no command) so the foreground ssh keeps the tunnel alive after + // `exec()` replaces this process. `-o ExitOnForwardFailure=yes` makes ssh + // exit immediately if the local port cannot be bound rather than silently + // succeeding without a tunnel. + let mut ssh = std::process::Command::new("ssh"); + ssh.args([ + "-L", + &forward_arg, + "-N", + "-l", + &session_id, + "-o", + "ExitOnForwardFailure=yes", + "-o", + &format!("ProxyCommand={proxy_cmd}"), + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "local-0", + ]); + + // exec() replaces the process, so this call only returns on failure. + let err = std::os::unix::process::CommandExt::exec(&mut ssh); + eprintln!("failed to exec ssh: {err}"); + Err(()) +} + +/// Obtain an mTLS client certificate from minimald (R4.4). +/// +/// Calls the `IssueClientCert` RPC, which has minimald generate a key pair, +/// sign the certificate with its internal CA, and return both. The cert, key, +/// and CA cert are written to `/{client.pem,client.key,ca.pem}`. +async fn cmd_login(global: &GlobalArgs, args: LoginArgs) -> Result<(), ()> { + if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { + eprintln!("Failed to ensure the minimald daemon is running: {e}"); + return Err(()); + } + + let mut client = connect_daemon(global).await?; + + let subject_cn = std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "minimal-client".to_string()); + + use minimald_rpc::{IssueClientCert, IssueClientCertRequest}; + let resp = client + .oneshot_rpc::(IssueClientCertRequest { subject_cn }) + .await + .map_err(|e| eprintln!("IssueClientCert RPC failed: {e}"))?; + + let cert_resp = match resp { + minimald_rpc::Errorable::Ok(r) => r, + minimald_rpc::Errorable::Err { error } => { + eprintln!("IssueClientCert failed: {error}"); + return Err(()); + } + }; + + // Determine the cert directory. + let cert_dir = match args.cert_dir { + Some(d) => d, + None => { + let config_dir = + dirs::config_dir().ok_or_else(|| eprintln!("cannot determine config directory"))?; + config_dir.join("minimal") + } + }; + std::fs::create_dir_all(&cert_dir) + .map_err(|e| eprintln!("cannot create cert dir {}: {e}", cert_dir.display()))?; + + let client_cert_path = cert_dir.join("client.pem"); + let client_key_path = cert_dir.join("client.key"); + let ca_cert_path = cert_dir.join("ca.pem"); + + std::fs::write(&client_cert_path, cert_resp.cert_pem.as_bytes()) + .map_err(|e| eprintln!("writing {}: {e}", client_cert_path.display()))?; + { + use std::io::Write as _; + #[cfg(unix)] + use std::os::unix::fs::OpenOptionsExt as _; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut f = opts + .open(&client_key_path) + .map_err(|e| eprintln!("writing {}: {e}", client_key_path.display()))?; + f.write_all(cert_resp.key_pem.as_bytes()) + .map_err(|e| eprintln!("writing {}: {e}", client_key_path.display()))?; + } + std::fs::write(&ca_cert_path, cert_resp.ca_cert_pem.as_bytes()) + .map_err(|e| eprintln!("writing {}: {e}", ca_cert_path.display()))?; + + println!("Saved client certificate to {}", client_cert_path.display()); + println!("Saved client key to {}", client_key_path.display()); + println!("Saved CA certificate to {}", ca_cert_path.display()); + println!(); + println!( + "To use the HTTPS proxy:\n curl --cacert {} --cert {} --key {} https://localhost:7655/", + ca_cert_path.display(), + client_cert_path.display(), + client_key_path.display(), + ); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ingress_spec_defaults_to_tcp() { + let m = parse_ingress_mapping("18080:80").unwrap(); + assert_eq!(m.external_port, 18080); + assert_eq!(m.internal_port, 80); + assert_eq!(m.proto, sessions::IpProto::Tcp); + } + + #[test] + fn ingress_spec_parses_explicit_proto() { + let m = parse_ingress_mapping("5353:53/udp").unwrap(); + assert_eq!(m.external_port, 5353); + assert_eq!(m.internal_port, 53); + assert_eq!(m.proto, sessions::IpProto::Udp); + } + + #[test] + fn ingress_spec_rejects_malformed_and_bad_proto() { + assert!(parse_ingress_mapping("18080").is_err()); + assert!(parse_ingress_mapping("notaport:80").is_err()); + assert!(parse_ingress_mapping("18080:80/icmp").is_err()); } } diff --git a/crates/minimal2/Cargo.toml b/crates/minimal2/Cargo.toml deleted file mode 100644 index a7115a084..000000000 --- a/crates/minimal2/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "minimal2" -version = "0.0.1" -edition.workspace = true -publish.workspace = true - -[dependencies] -clap.workspace = true -clap_complete.workspace = true -camino.workspace = true -chrono.workspace = true -dirs.workspace = true -minimald-rpc.workspace = true -paths.workspace = true -russh.workspace = true -serde.workspace = true -serde_json.workspace = true -sessions.workspace = true -tokio.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true - -minvmd = { path = "../minvmd" } -ot.workspace = true diff --git a/crates/minimal2/src/main.rs b/crates/minimal2/src/main.rs deleted file mode 100644 index 797b1030f..000000000 --- a/crates/minimal2/src/main.rs +++ /dev/null @@ -1,1028 +0,0 @@ -//! The minimal CLI which pairs/talks-with minimald. - -use clap::{Args, CommandFactory, Parser, Subcommand}; -use clap_complete::Shell; -use std::os::unix::process::CommandExt as _; -use std::path::PathBuf; -use tokio::io::AsyncWriteExt as _; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - -mod autospawn; -mod client; - -#[derive(Parser)] -#[command(name = "minimal", version = env!("CARGO_PKG_VERSION"), long_version = env!("LONG_VERSION"))] -#[command(about = "The Minimal CLI")] -struct Cli { - #[command(subcommand)] - command: Command, - - #[command(flatten)] - global_args: GlobalArgs, -} - -#[derive(Subcommand)] -enum Command { - /// List sessions - Ls(LsArgs), - /// Activate (create) a new session - Activate(ActivateArgs), - /// Attach to an existing session - Attach(AttachArgs), - /// Destroy (terminate) a session - Destroy(DestroyArgs), - /// Session inspection subcommands - Session(SessionArgs), - /// WireGuard mesh: join, leave, and inspect remote-access state - Mesh(MeshArgs), - /// Proxy stdio to a daemon UDS socket (used as an SSH ProxyCommand). - #[command(hide = true)] - Proxy(ProxyArgs), - /// Forward a local TCP port to a remote address inside a PTask via SSH - /// (R4.8, R4.9). - /// - /// Sets up an SSH `LocalForward` (`-L`) tunnel through the minimald SSH - /// server so traffic sent to `` on the host is relayed to - /// `:` from inside the named PTask's network - /// namespace. Useful when WireGuard (`networking-wg` feature) is - /// unavailable (e.g., on corporate networks that block UDP). - /// - /// Examples: - /// - /// # Forward host port 18080 to the webserver inside the "dev" session: - /// minimal ssh-forward dev 18080:127.0.0.1:80 - /// - /// # Then access it from the host: - /// curl http://localhost:18080/ - #[command(name = "ssh-forward", visible_alias = "forward")] - SshForward(SshForwardArgs), - /// Obtain an mTLS client certificate from minimald for use with the HTTPS - /// reverse proxy (R4.4, R4.5). - /// - /// Connects to minimald, generates a fresh client certificate signed by - /// the daemon's internal CA, and saves the certificate and private key to - /// `~/.config/minimal/client.pem` / `~/.config/minimal/client.key`. Also - /// saves the CA certificate to `~/.config/minimal/ca.pem` so tools like - /// `curl` can trust the HTTPS proxy. - /// - /// Example: - /// - /// minimal login - /// curl --cacert ~/.config/minimal/ca.pem \ - /// --cert ~/.config/minimal/client.pem \ - /// --key ~/.config/minimal/client.key \ - /// https://localhost:7655/ - Login(LoginArgs), - /// Generate shell completion script - #[command( - long_about = "Generate a shell tab-completion script for the minimal CLI.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(minimal completions bash)" - )] - Completions(CompletionsArgs), -} - -#[derive(Debug, Args)] -struct SessionArgs { - #[command(subcommand)] - command: SessionCommand, -} - -#[derive(Debug, Subcommand)] -enum SessionCommand { - /// Print the effective networking policy for a session as JSON - Policy(PolicyArgs), -} - -#[derive(Debug, Args)] -struct PolicyArgs { - /// Session identifier (UUID or session name) - session: String, -} - -/// WireGuard mesh subcommands for authenticated remote PTask access (UC7 / -/// UC2b). The mesh lets a laptop, or another host's PTasks, reach this host's -/// PTasks over an encrypted tunnel. -#[derive(Debug, Args)] -struct MeshArgs { - #[command(subcommand)] - command: MeshCommand, -} - -#[derive(Debug, Subcommand)] -enum MeshCommand { - /// Enrol this machine into a remote minimald's WireGuard mesh - /// - /// v1 uses manual key exchange: this records the target and prints the - /// steps to swap public keys. Once enrolled you can reach the remote - /// host's own-IP PTasks by their switch IPs over the tunnel. - /// - /// Example: - /// - /// minimal mesh join mesh.example.com:51820 - #[command(verbatim_doc_comment)] - Join(MeshJoinArgs), - /// Leave the WireGuard mesh and drop this machine's local enrolment - /// - /// Removes the local enrolment record written by `minimal mesh join`. - /// Peer entries on the remote minimald must be removed there (manual v1). - /// - /// Example: - /// - /// minimal mesh leave - #[command(verbatim_doc_comment)] - Leave, - /// Show this minimald's mesh status: public key, advertised subnets, peers - /// - /// Queries the local minimald for its WireGuard public key, the switch - /// subnets it advertises to the mesh, and each peer's last handshake. - /// - /// Example: - /// - /// minimal mesh status - #[command(verbatim_doc_comment)] - Status, -} - -#[derive(Debug, Args)] -struct MeshJoinArgs { - /// Address of the remote minimald exposing the mesh (`host:port`) - address: String, -} - -/// Shared arguments all subcommands -#[derive(Debug, Args)] -pub struct GlobalArgs { - /// Override the base directory used for operations (default: ~/.cache/minimal) - #[arg(long)] - minimal_dir: Option, - /// Linux: run minimald inside the minvmd microVM (DM1) instead of natively - /// on the host (DM2, the default). No effect on macOS, where minvmd is the - /// only backend. - #[arg(long, global = true)] - minvmd: bool, -} - -#[derive(Debug, Args)] -struct ActivateArgs { - /// Optional session name - #[arg(long, short)] - name: Option, - /// Project path to activate (defaults to current directory) - #[arg(default_value = ".")] - path: String, - /// Network mode: no-net, host-net (default), or own-ip. - #[arg(long, value_enum, default_value_t = CliNetworkMode::HostNet)] - network: CliNetworkMode, - /// Static ingress port mapping `EXT:INT[/PROTO]` (PROTO = tcp|udp, default - /// tcp). Repeatable. Requires `--network own-ip`. - #[arg(long = "ingress", value_name = "EXT:INT[/PROTO]")] - ingress: Vec, - /// Automatically attach after creation - #[arg(long)] - attach: bool, -} - -/// CLI surface for [`sessions::NetworkMode`]. A local `ValueEnum` keeps the -/// `sessions` crate free of a clap dependency. -#[derive(Debug, Clone, Copy, clap::ValueEnum)] -enum CliNetworkMode { - NoNet, - HostNet, - OwnIp, -} - -impl From for sessions::NetworkMode { - fn from(m: CliNetworkMode) -> Self { - match m { - CliNetworkMode::NoNet => sessions::NetworkMode::NoNet, - CliNetworkMode::HostNet => sessions::NetworkMode::HostNet, - CliNetworkMode::OwnIp => sessions::NetworkMode::OwnIp, - } - } -} - -/// Parse an `--ingress EXT:INT[/PROTO]` spec into a [`sessions::PortMapping`]. -/// PROTO defaults to tcp; only tcp/udp are accepted (gvproxy's static forwarder -/// exposes no other transport). -fn parse_ingress_mapping(spec: &str) -> Result { - let (ports, proto) = match spec.split_once('/') { - Some((ports, proto)) => (ports, parse_ingress_proto(proto)?), - None => (spec, sessions::IpProto::Tcp), - }; - let (ext, int) = ports - .split_once(':') - .ok_or_else(|| format!("ingress '{spec}': expected EXT:INT[/PROTO]"))?; - let external_port = ext - .parse::() - .map_err(|_| format!("ingress '{spec}': invalid external port '{ext}'"))?; - let internal_port = int - .parse::() - .map_err(|_| format!("ingress '{spec}': invalid internal port '{int}'"))?; - Ok(sessions::PortMapping { - external_port, - internal_port, - proto, - }) -} - -fn parse_ingress_proto(proto: &str) -> Result { - match proto.to_ascii_lowercase().as_str() { - "tcp" => Ok(sessions::IpProto::Tcp), - "udp" => Ok(sessions::IpProto::Udp), - other => Err(format!( - "ingress: unsupported protocol '{other}' (use tcp or udp)" - )), - } -} - -#[derive(Debug, Args)] -struct AttachArgs { - /// Session identifier (UUID or session name) - session: String, - /// Command to exec in the session context (non-interactive) - #[arg(long, short)] - command: Option, -} - -#[derive(Debug, Args)] -struct LsArgs { - /// Output raw session IDs (one per line) for piping into scripts - #[arg(long)] - raw: bool, -} - -#[derive(Debug, Args)] -struct DestroyArgs { - /// Session identifier (UUID or session name) - session: String, -} - -#[derive(Debug, Args)] -struct ProxyArgs { - /// UDS socket path to connect to - #[arg(long)] - socket: String, -} - -/// Arguments for `minimal ssh-forward`. -#[derive(Debug, Args)] -struct SshForwardArgs { - /// Session identifier (UUID or session name) - session: String, - /// Port-forward specification: `::` - /// - /// Example: `18080:127.0.0.1:80` to forward local port 18080 to port 80 - /// on the loopback address as seen from inside the session. - #[arg(value_name = "LOCAL:REMOTE_HOST:REMOTE_PORT")] - forward: String, -} - -/// Arguments for `minimal login`. -#[derive(Debug, Args)] -struct LoginArgs { - /// Override the directory where client cert files are written - /// (default: `~/.config/minimal/`). - #[arg(long)] - cert_dir: Option, -} - -#[derive(Debug, clap::Args)] -struct CompletionsArgs { - /// The shell type for a CLI completion script should be printed - #[arg(value_parser)] - shell: Shell, -} - -#[tokio::main] -async fn main() -> Result<(), ()> { - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - EnvFilter::new("info") - .add_directive("topiary=off".parse().unwrap()) - .add_directive("libcgroups=off".parse().unwrap()) - }); - - tracing_subscriber::registry() - .with(fmt::layer().with_writer(ot::StdoutWriter::new)) - .with(filter) - .init(); - - let cli = Cli::parse(); - - match cli.command { - Command::Ls(args) => cmd_ls(&cli.global_args, args).await, - Command::Activate(args) => cmd_activate(&cli.global_args, args).await, - Command::Attach(args) => cmd_attach(&cli.global_args, args).await, - Command::Destroy(args) => cmd_destroy(&cli.global_args, args).await, - Command::Session(SessionArgs { - command: SessionCommand::Policy(args), - }) => cmd_session_policy(&cli.global_args, args).await, - Command::Mesh(MeshArgs { command }) => match command { - MeshCommand::Status => cmd_mesh_status(&cli.global_args).await, - MeshCommand::Join(args) => cmd_mesh_join(&cli.global_args, args), - MeshCommand::Leave => cmd_mesh_leave(&cli.global_args), - }, - Command::Proxy(args) => cmd_proxy(args).await, - Command::SshForward(args) => cmd_ssh_forward(&cli.global_args, args).await, - Command::Login(args) => cmd_login(&cli.global_args, args).await, - Command::Completions(CompletionsArgs { shell }) => { - let mut cmd = Cli::command(); - let name = cmd.get_name().to_string(); - clap_complete::generate(shell, &mut cmd, name, &mut std::io::stdout()); - Ok(()) - } - } -} - -/// Connect to the daemon, resolving the socket path from global args. -async fn connect_daemon(global: &GlobalArgs) -> Result { - let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) - .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; - - client::Client::connect(&sock) - .await - .map_err(|e| eprintln!("Failed to connect to minimald: {e}")) -} - -/// Bidirectionally pipe stdio to a daemon UDS socket. -/// -/// Intended for use as an SSH `ProxyCommand`: ssh writes to our stdin and -/// reads from our stdout, while we bridge both directions to the UDS. -async fn cmd_proxy(args: ProxyArgs) -> Result<(), ()> { - let stream = tokio::net::UnixStream::connect(&args.socket) - .await - .map_err(|e| eprintln!("connect to {}: {e}", args.socket))?; - - let (mut rx, mut tx) = stream.into_split(); - let mut stdin = tokio::io::stdin(); - let mut stdout = tokio::io::stdout(); - - let to_sock = async { - tokio::io::copy(&mut stdin, &mut tx).await?; - tx.shutdown().await - }; - let from_sock = tokio::io::copy(&mut rx, &mut stdout); - - tokio::try_join!(to_sock, from_sock).map_err(|e| eprintln!("proxy: {e}"))?; - Ok(()) -} - -/// List sessions via the `ListSessions` RPC. -async fn cmd_ls(global: &GlobalArgs, args: LsArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let mut client = connect_daemon(global).await?; - - use minimald_rpc::ListSessions; - let resp = client - .oneshot_rpc::(()) - .await - .map_err(|e| eprintln!("ListSessions RPC failed: {e}"))?; - - if resp.sessions.is_empty() { - if !args.raw { - println!("No active sessions."); - } - return Ok(()); - } - - if args.raw { - for entry in &resp.sessions { - println!("{}", entry.id); - } - return Ok(()); - } - - // Format as a table: ID, Name, Title, Last Activity. - // Widths chosen to fit a standard 80-col terminal. - println!( - "{:<36} {:<20} {:<20} LAST ACTIVITY", - "SESSION ID", "NAME", "TITLE" - ); - println!("{:-<36} {:-<20} {:-<20} {:-<24}", "", "", "", ""); - - for entry in &resp.sessions { - let id = entry.id.to_string(); - let name = entry.name.as_deref().unwrap_or("-"); - let (title, last_activity) = match &entry.attrs { - Some(attrs) => { - let title = attrs - .title - .as_ref() - .map(|t| t.value.as_str()) - .unwrap_or("-"); - let last = attrs - .last_stdout - .or(attrs.last_stdin) - .map(|dt| { - let local = dt.with_timezone(&chrono::Local); - local.format("%Y-%m-%d %H:%M:%S").to_string() - }) - .unwrap_or_else(|| "-".to_string()); - (title, last) - } - None => ("-", "-".to_string()), - }; - println!("{id:<36} {name:<20} {title:<20} {last_activity}"); - } - - Ok(()) -} - -/// Create a new session via the `CreateSession` RPC. -async fn cmd_activate(global: &GlobalArgs, args: ActivateArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let project_path = std::fs::canonicalize(&args.path) - .map_err(|e| eprintln!("Cannot resolve project path '{}': {e}", args.path))?; - - let utf8_path = camino::Utf8PathBuf::from_path_buf(project_path) - .map_err(|_| eprintln!("Project path is not valid UTF-8"))?; - let abs_path = paths::HostAbsPath::try_new(utf8_path) - .map_err(|e| eprintln!("Invalid project path: {e}"))?; - - let mut port_mappings = Vec::with_capacity(args.ingress.len()); - for spec in &args.ingress { - match parse_ingress_mapping(spec) { - Ok(mapping) => port_mappings.push(mapping), - Err(e) => { - eprintln!("{e}"); - return Err(()); - } - } - } - let policy = sessions::SessionPolicy { - egress: None, - ingress: (!port_mappings.is_empty()).then_some(sessions::IngressPolicy { - port_mappings, - dynamic_allowed_range: None, - }), - }; - - // The daemon sources `username` from the authenticated SSH - // connection context; the client doesn't send it. - let config = minimald_rpc::SessionConfig { - name: args.name.clone(), - project_path: abs_path, - network: args.network.into(), - policy, - attrs: Default::default(), - }; - - let mut client = connect_daemon(global).await?; - - use minimald_rpc::{CreateSession, CreateSessionRequest}; - let req = CreateSessionRequest { - config, - contribution: Default::default(), - }; - let resp = client - .oneshot_rpc::(req) - .await - .map_err(|e| eprintln!("CreateSession RPC failed: {e}"))?; - - // Surface the daemon's typed policy/network-mode validation error (e.g. - // ingress on a non-own-ip session, privileged host port) rather than a - // generic failure line. - let created = match resp { - minimald_rpc::Errorable::Ok(r) => r, - minimald_rpc::Errorable::Err { error } => { - eprintln!("CreateSession failed: {error}"); - return Err(()); - } - }; - // Today the daemon only ever produces `Ready` (the empty- - // contribution fast path). `Pending` lights up when Phase 2 - // routing lands. - let id = match created { - minimald_rpc::CreateSessionResponse::Ready { id } => id, - minimald_rpc::CreateSessionResponse::Pending { .. } => { - eprintln!( - "CreateSession returned Pending, but the composition pipeline \ - is not wired in this client yet", - ); - return Err(()); - } - }; - - println!("{id}"); - - if args.attach { - // Chain into attach. - let attach_args = AttachArgs { - session: id.to_string(), - command: None, - }; - return cmd_attach(global, attach_args).await; - } - - Ok(()) -} - -/// Shell-quote a string for safe interpolation into `sh -c`. -fn shell_quote(s: &str) -> String { - format!("'{}'", s.replace('\'', "'\"'\"'")) -} - -/// Attach to an existing session. Both interactive and `--command` paths -/// shell out to `ssh` — the daemon's shell_request handler mints a PTY-backed -/// session shell, and ssh handles termios/PTY management for us. -async fn cmd_attach(global: &GlobalArgs, args: AttachArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) - .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; - - // Resolve the session: if it looks like a UUID, query by ID; otherwise by name. - use minimald_rpc::{GetSessionRecord, GetSessionRecordRequest}; - let mut client = client::Client::connect(&sock) - .await - .map_err(|e| eprintln!("Failed to connect to minimald: {e}"))?; - - let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { - GetSessionRecordRequest::Id(id) - } else { - GetSessionRecordRequest::Name(args.session.clone()) - }; - - let resp = client - .oneshot_rpc::(lookup) - .await - .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; - - let record = match resp.record { - Some(r) => r, - None => { - eprintln!("No session found matching '{}'", args.session); - return Err(()); - } - }; - - tracing::info!( - session_id = %record.id, - session_name = ?record.name, - "found session" - ); - - // Shell out to ssh for both interactive and --command attachment. - // ProxyCommand points at our own `proxy` subcommand so we don't - // depend on socat or nc being installed. - let exe = - std::env::current_exe().map_err(|e| eprintln!("cannot determine current exe: {e}"))?; - let proxy_cmd = format!( - "{} proxy --socket {}", - shell_quote(&exe.display().to_string()), - shell_quote(&sock.display().to_string()), - ); - - let mut ssh = std::process::Command::new("ssh"); - ssh.env("MINIMAL_SESSION_ID", record.id.to_string()).args([ - "-o", - "SendEnv=MINIMAL_SESSION_ID", - "-o", - &format!("ProxyCommand={proxy_cmd}"), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - ]); - - // The interactive path opens the in-sandbox session shell via the daemon's - // `shell_request`, which requires a PTY. Force one with `-tt` so the shell - // works even when our stdin is not a tty (e.g. driven from a script for - // automated networking tests); without it ssh skips the PTY and the daemon - // rejects the shell. The `--command` path is a non-interactive exec and - // needs no PTY. - if args.command.is_none() { - ssh.arg("-tt"); - } - ssh.arg("local-0"); - - // If a command was provided, pass it to ssh (non-interactive exec). - // Otherwise, ssh opens an interactive shell via shell_request. - if let Some(ref cmd) = args.command { - ssh.arg(cmd); - } - - let err = ssh.exec(); - // exec() only returns on failure - eprintln!("failed to exec ssh: {err}"); - Err(()) -} - -/// Print the effective networking policy for a session as JSON. -async fn cmd_session_policy(global: &GlobalArgs, args: PolicyArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let mut client = connect_daemon(global).await?; - - use minimald_rpc::{GetSessionPolicy, GetSessionPolicyRequest}; - let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { - GetSessionPolicyRequest::Id(id) - } else { - GetSessionPolicyRequest::Name(args.session.clone()) - }; - - let resp = client - .oneshot_rpc::(lookup) - .await - .map_err(|e| eprintln!("GetSessionPolicy RPC failed: {e}"))?; - - match resp { - minimald_rpc::Errorable::Ok(policy) => { - let json = serde_json::to_string(&policy) - .map_err(|e| eprintln!("Failed to serialize policy: {e}"))?; - println!("{json}"); - Ok(()) - } - minimald_rpc::Errorable::Err { error } => { - eprintln!("{error}"); - Err(()) - } - } -} - -/// The local mesh-enrolment record path. Honors `--minimal-dir`, else falls -/// back to the user config dir. -fn mesh_enrolment_path(global: &GlobalArgs) -> Result { - let base = match &global.minimal_dir { - Some(dir) => dir.clone(), - None => dirs::config_dir() - .map(|c| c.join("minimal")) - .ok_or_else(|| eprintln!("cannot determine config directory; set --minimal-dir"))?, - }; - Ok(base.join("mesh-enrolment")) -} - -/// Show this minimald's WireGuard mesh status (R4.6): own public key, the -/// switch subnets it advertises, and each peer's last handshake. -async fn cmd_mesh_status(global: &GlobalArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let mut client = connect_daemon(global).await?; - - use minimald_rpc::GetMeshStatus; - let resp = client - .oneshot_rpc::(()) - .await - .map_err(|e| eprintln!("GetMeshStatus RPC failed: {e}"))?; - - if !resp.configured { - println!("No WireGuard mesh is configured on this minimald."); - return Ok(()); - } - - println!( - "public key: {}", - resp.own_public_key.as_deref().unwrap_or("-") - ); - if resp.advertised_subnets.is_empty() { - println!("advertised: (none)"); - } else { - println!("advertised: {}", resp.advertised_subnets.join(", ")); - } - - if resp.peers.is_empty() { - println!("peers: (none)"); - return Ok(()); - } - - println!("peers:"); - println!(" {:<20} {:<46} LAST HANDSHAKE", "NAME", "PUBLIC KEY"); - for p in &resp.peers { - let handshake = match p.last_handshake_secs { - Some(secs) => format!("{secs}s ago"), - None => "never".to_string(), - }; - println!(" {:<20} {:<46} {handshake}", p.name, p.public_key); - } - - Ok(()) -} - -/// Record this machine's enrolment into a remote minimald's mesh (R4.3, v1 -/// manual key exchange) and print the steps to complete the key swap. -fn cmd_mesh_join(global: &GlobalArgs, args: MeshJoinArgs) -> Result<(), ()> { - // Validate the endpoint at the point of entry so a typo never lands a bad - // enrolment on disk for a later consumer to choke on. The CLI contract is - // `host:port`; require a non-empty host and a parseable u16 port. - let Some((host, port)) = args.address.rsplit_once(':') else { - eprintln!("mesh join address must be host:port, e.g. mesh.example.com:51820"); - return Err(()); - }; - if host.is_empty() || port.parse::().map(|p| p == 0).unwrap_or(true) { - eprintln!("mesh join address must include a non-empty host and a valid non-zero port"); - return Err(()); - } - - let path = mesh_enrolment_path(global)?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| eprintln!("creating {}: {e}", parent.display()))?; - } - std::fs::write(&path, format!("{}\n", args.address)) - .map_err(|e| eprintln!("writing {}: {e}", path.display()))?; - - println!( - "Recorded mesh enrolment for {} at {}.", - args.address, - path.display() - ); - println!(); - println!("v1 uses manual key exchange. To complete the join:"); - println!(" 1. Run `minimal mesh status` on the remote host to read its public key."); - println!(" 2. Add this machine's WireGuard public key to the remote minimald's peers."); - println!(" 3. Add the remote's public key and endpoint to this machine's mesh config."); - Ok(()) -} - -/// Drop this machine's local mesh enrolment (R4.3). Remote peer entries are -/// removed on the remote host (manual v1). -fn cmd_mesh_leave(global: &GlobalArgs) -> Result<(), ()> { - let path = mesh_enrolment_path(global)?; - match std::fs::remove_file(&path) { - Ok(()) => { - println!( - "Left the mesh; removed local enrolment at {}.", - path.display() - ); - Ok(()) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - println!("No local mesh enrolment to remove."); - Ok(()) - } - Err(e) => { - eprintln!("removing {}: {e}", path.display()); - Err(()) - } - } -} - -/// Destroy (terminate) a session. -async fn cmd_destroy(global: &GlobalArgs, args: DestroyArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let mut client = connect_daemon(global).await?; - - // Resolve the session: if it looks like a UUID, query by ID; otherwise by name. - use minimald_rpc::{ - DestroySession, DestroySessionRequest, GetSessionRecord, GetSessionRecordRequest, - }; - let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { - GetSessionRecordRequest::Id(id) - } else { - GetSessionRecordRequest::Name(args.session.clone()) - }; - - let resp = client - .oneshot_rpc::(lookup) - .await - .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; - - let record = match resp.record { - Some(r) => r, - None => { - eprintln!("No session found matching '{}'", args.session); - return Err(()); - } - }; - - let resp = client - .oneshot_rpc::(DestroySessionRequest { id: record.id }) - .await - .map_err(|e| eprintln!("DestroySession RPC failed: {e}"))?; - - if resp.ok().is_some() { - println!( - "Destroyed session {} ({})", - record.id, - record.name.as_deref().unwrap_or("-") - ); - } else { - eprintln!("DestroySession returned an error from the daemon"); - return Err(()); - } - - Ok(()) -} - -/// Establish an SSH `LocalForward` tunnel from a local port to a remote -/// address inside the named PTask's network namespace (R4.8, R4.9). -/// -/// The forward spec is `::`. The -/// command shells out to `ssh -L` (the same mechanism as `cmd_attach`). -/// The `-N` flag keeps the tunnel alive without opening an interactive -/// shell. -async fn cmd_ssh_forward(global: &GlobalArgs, args: SshForwardArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let sock = client::resolve_socket_path(global.minimal_dir.as_deref(), global.minvmd) - .map_err(|e| eprintln!("Failed to resolve daemon socket path: {e}"))?; - - // Look up the session to validate it exists and to obtain its UUID for the - // server-side auth gate (passed as the SSH username so `direct-tcpip` can - // verify the session without a per-channel env handshake). - use minimald_rpc::{GetSessionRecord, GetSessionRecordRequest}; - let mut daemon_client = client::Client::connect(&sock) - .await - .map_err(|e| eprintln!("Failed to connect to minimald: {e}"))?; - - let lookup = if let Ok(id) = sessions::SessionId::parse_str(&args.session) { - GetSessionRecordRequest::Id(id) - } else { - GetSessionRecordRequest::Name(args.session.clone()) - }; - - let resp = daemon_client - .oneshot_rpc::(lookup) - .await - .map_err(|e| eprintln!("GetSessionRecord RPC failed: {e}"))?; - - let record = match resp.record { - Some(r) => r, - None => { - eprintln!("No session found matching '{}'", args.session); - return Err(()); - } - }; - - // Validate the forward spec format: local:remote_host:remote_port. - // We accept either `local_port:host:port` (3 components, last two joined by - // the final colon) or the more compact form where host is an IPv4 address. - let parts: Vec<&str> = args.forward.splitn(3, ':').collect(); - if parts.len() != 3 { - eprintln!( - "invalid forward spec {:?}: expected LOCAL_PORT:REMOTE_HOST:REMOTE_PORT", - args.forward - ); - return Err(()); - } - let local_port = parts[0]; - let remote_host = parts[1]; - let remote_port = parts[2]; - let forward_arg = format!("{local_port}:{remote_host}:{remote_port}"); - - let exe = - std::env::current_exe().map_err(|e| eprintln!("cannot determine current exe: {e}"))?; - let proxy_cmd = format!( - "{} proxy --socket {}", - shell_quote(&exe.display().to_string()), - shell_quote(&sock.display().to_string()), - ); - - let session_id = record.id.to_string(); - // Use `-N` (no command) so the foreground ssh keeps the tunnel alive after - // `exec()` replaces this process. `-o ExitOnForwardFailure=yes` makes ssh - // exit immediately if the local port cannot be bound rather than silently - // succeeding without a tunnel. - let mut ssh = std::process::Command::new("ssh"); - ssh.args([ - "-L", - &forward_arg, - "-N", - "-l", - &session_id, - "-o", - "ExitOnForwardFailure=yes", - "-o", - &format!("ProxyCommand={proxy_cmd}"), - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "local-0", - ]); - - // exec() replaces the process, so this call only returns on failure. - let err = std::os::unix::process::CommandExt::exec(&mut ssh); - eprintln!("failed to exec ssh: {err}"); - Err(()) -} - -/// Obtain an mTLS client certificate from minimald (R4.4). -/// -/// Calls the `IssueClientCert` RPC, which has minimald generate a key pair, -/// sign the certificate with its internal CA, and return both. The cert, key, -/// and CA cert are written to `/{client.pem,client.key,ca.pem}`. -async fn cmd_login(global: &GlobalArgs, args: LoginArgs) -> Result<(), ()> { - if let Err(e) = autospawn::ensure_daemon_running(global.minvmd, global.minimal_dir.as_deref()) { - eprintln!("Failed to ensure the minimald daemon is running: {e}"); - return Err(()); - } - - let mut client = connect_daemon(global).await?; - - let subject_cn = std::env::var("USER") - .or_else(|_| std::env::var("LOGNAME")) - .unwrap_or_else(|_| "minimal-client".to_string()); - - use minimald_rpc::{IssueClientCert, IssueClientCertRequest}; - let resp = client - .oneshot_rpc::(IssueClientCertRequest { subject_cn }) - .await - .map_err(|e| eprintln!("IssueClientCert RPC failed: {e}"))?; - - let cert_resp = match resp { - minimald_rpc::Errorable::Ok(r) => r, - minimald_rpc::Errorable::Err { error } => { - eprintln!("IssueClientCert failed: {error}"); - return Err(()); - } - }; - - // Determine the cert directory. - let cert_dir = match args.cert_dir { - Some(d) => d, - None => { - let config_dir = - dirs::config_dir().ok_or_else(|| eprintln!("cannot determine config directory"))?; - config_dir.join("minimal") - } - }; - std::fs::create_dir_all(&cert_dir) - .map_err(|e| eprintln!("cannot create cert dir {}: {e}", cert_dir.display()))?; - - let client_cert_path = cert_dir.join("client.pem"); - let client_key_path = cert_dir.join("client.key"); - let ca_cert_path = cert_dir.join("ca.pem"); - - std::fs::write(&client_cert_path, cert_resp.cert_pem.as_bytes()) - .map_err(|e| eprintln!("writing {}: {e}", client_cert_path.display()))?; - { - use std::io::Write as _; - #[cfg(unix)] - use std::os::unix::fs::OpenOptionsExt as _; - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create(true).truncate(true); - #[cfg(unix)] - opts.mode(0o600); - let mut f = opts - .open(&client_key_path) - .map_err(|e| eprintln!("writing {}: {e}", client_key_path.display()))?; - f.write_all(cert_resp.key_pem.as_bytes()) - .map_err(|e| eprintln!("writing {}: {e}", client_key_path.display()))?; - } - std::fs::write(&ca_cert_path, cert_resp.ca_cert_pem.as_bytes()) - .map_err(|e| eprintln!("writing {}: {e}", ca_cert_path.display()))?; - - println!("Saved client certificate to {}", client_cert_path.display()); - println!("Saved client key to {}", client_key_path.display()); - println!("Saved CA certificate to {}", ca_cert_path.display()); - println!(); - println!( - "To use the HTTPS proxy:\n curl --cacert {} --cert {} --key {} https://localhost:7655/", - ca_cert_path.display(), - client_cert_path.display(), - client_key_path.display(), - ); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ingress_spec_defaults_to_tcp() { - let m = parse_ingress_mapping("18080:80").unwrap(); - assert_eq!(m.external_port, 18080); - assert_eq!(m.internal_port, 80); - assert_eq!(m.proto, sessions::IpProto::Tcp); - } - - #[test] - fn ingress_spec_parses_explicit_proto() { - let m = parse_ingress_mapping("5353:53/udp").unwrap(); - assert_eq!(m.external_port, 5353); - assert_eq!(m.internal_port, 53); - assert_eq!(m.proto, sessions::IpProto::Udp); - } - - #[test] - fn ingress_spec_rejects_malformed_and_bad_proto() { - assert!(parse_ingress_mapping("18080").is_err()); - assert!(parse_ingress_mapping("notaport:80").is_err()); - assert!(parse_ingress_mapping("18080:80/icmp").is_err()); - } -} diff --git a/crates/mip/Cargo.toml b/crates/mip/Cargo.toml new file mode 100644 index 000000000..ebb1a2364 --- /dev/null +++ b/crates/mip/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "mip" +version = "0.4.1" +edition.workspace = true +publish.workspace = true + +[dependencies] +args.workspace = true +graph.workspace = true +lcache.workspace = true +rcache.workspace = true +check.workspace = true +common.workspace = true +checkouts.workspace = true +decode.workspace = true +mctx.workspace = true +mfile.workspace = true +op.workspace = true +ot.workspace = true +orchestrator.workspace = true +remote-client.workspace = true + +nickel-lang-core.workspace = true +codespan-reporting.workspace = true + +tracing.workspace = true +tracing-subscriber.workspace = true + +clap.workspace = true +clap_complete.workspace = true +dirs.workspace = true +blake3.workspace = true +serde.workspace = true +serde_json.workspace = true + +rayon.workspace = true +tokio.workspace = true +tokio-util.workspace = true +futures.workspace = true + +anyhow.workspace = true + +toml_edit.workspace = true + +petgraph.workspace = true +smallvec.workspace = true + +[dev-dependencies] +serial_test.workspace = true diff --git a/crates/minimal2/build.rs b/crates/mip/build.rs similarity index 100% rename from crates/minimal2/build.rs rename to crates/mip/build.rs diff --git a/crates/minimal/src/cmd_add.rs b/crates/mip/src/cmd_add.rs similarity index 100% rename from crates/minimal/src/cmd_add.rs rename to crates/mip/src/cmd_add.rs diff --git a/crates/minimal/src/cmd_cache.rs b/crates/mip/src/cmd_cache.rs similarity index 100% rename from crates/minimal/src/cmd_cache.rs rename to crates/mip/src/cmd_cache.rs diff --git a/crates/minimal/src/cmd_check.rs b/crates/mip/src/cmd_check.rs similarity index 100% rename from crates/minimal/src/cmd_check.rs rename to crates/mip/src/cmd_check.rs diff --git a/crates/minimal/src/cmd_dep.rs b/crates/mip/src/cmd_dep.rs similarity index 100% rename from crates/minimal/src/cmd_dep.rs rename to crates/mip/src/cmd_dep.rs diff --git a/crates/minimal/src/cmd_dump.rs b/crates/mip/src/cmd_dump.rs similarity index 100% rename from crates/minimal/src/cmd_dump.rs rename to crates/mip/src/cmd_dump.rs diff --git a/crates/minimal/src/cmd_init.rs b/crates/mip/src/cmd_init.rs similarity index 100% rename from crates/minimal/src/cmd_init.rs rename to crates/mip/src/cmd_init.rs diff --git a/crates/minimal/src/cmd_materialize.rs b/crates/mip/src/cmd_materialize.rs similarity index 100% rename from crates/minimal/src/cmd_materialize.rs rename to crates/mip/src/cmd_materialize.rs diff --git a/crates/minimal/src/cmd_patched_build.rs b/crates/mip/src/cmd_patched_build.rs similarity index 100% rename from crates/minimal/src/cmd_patched_build.rs rename to crates/mip/src/cmd_patched_build.rs diff --git a/crates/minimal/src/cmd_pkg.rs b/crates/mip/src/cmd_pkg.rs similarity index 100% rename from crates/minimal/src/cmd_pkg.rs rename to crates/mip/src/cmd_pkg.rs diff --git a/crates/minimal/src/cmd_plan.rs b/crates/mip/src/cmd_plan.rs similarity index 100% rename from crates/minimal/src/cmd_plan.rs rename to crates/mip/src/cmd_plan.rs diff --git a/crates/minimal/src/cmd_remote_build.rs b/crates/mip/src/cmd_remote_build.rs similarity index 100% rename from crates/minimal/src/cmd_remote_build.rs rename to crates/mip/src/cmd_remote_build.rs diff --git a/crates/minimal/src/cmd_rexec.rs b/crates/mip/src/cmd_rexec.rs similarity index 100% rename from crates/minimal/src/cmd_rexec.rs rename to crates/mip/src/cmd_rexec.rs diff --git a/crates/minimal/src/cmd_run.rs b/crates/mip/src/cmd_run.rs similarity index 100% rename from crates/minimal/src/cmd_run.rs rename to crates/mip/src/cmd_run.rs diff --git a/crates/minimal/src/cmd_status.rs b/crates/mip/src/cmd_status.rs similarity index 100% rename from crates/minimal/src/cmd_status.rs rename to crates/mip/src/cmd_status.rs diff --git a/crates/minimal/src/cmd_update.rs b/crates/mip/src/cmd_update.rs similarity index 100% rename from crates/minimal/src/cmd_update.rs rename to crates/mip/src/cmd_update.rs diff --git a/crates/minimal/src/cmd_upload_cache.rs b/crates/mip/src/cmd_upload_cache.rs similarity index 100% rename from crates/minimal/src/cmd_upload_cache.rs rename to crates/mip/src/cmd_upload_cache.rs diff --git a/crates/mip/src/main.rs b/crates/mip/src/main.rs new file mode 100644 index 000000000..70f63bc8f --- /dev/null +++ b/crates/mip/src/main.rs @@ -0,0 +1,322 @@ +#![allow(clippy::result_large_err)] + +use anyhow::anyhow; +use clap::{Args, CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; +use mctx::{ConfigBuilder, Context, Error}; +use std::io; +use std::path::PathBuf; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +mod cmd_pkg; +use cmd_pkg::{PkgArgs, cmd_pkg}; +mod cmd_check; +use cmd_check::{CheckArgs, cmd_check}; +mod cmd_plan; +use cmd_plan::{PlanArgs, cmd_plan}; +mod cmd_materialize; +use cmd_materialize::{MaterializeArgs, cmd_materialize}; +mod cmd_upload_cache; +use cmd_upload_cache::{UploadArgs, cmd_upload_cache}; +mod cmd_patched_build; +use cmd_patched_build::{PatchedBuildArgs, cmd_patched_build}; +#[cfg(target_os = "linux")] +mod cmd_run; +#[cfg(target_os = "linux")] +use cmd_run::{RunArgs, cmd_run, cmd_run_by_spec}; +mod cmd_dep; +use cmd_dep::{DepArgs, cmd_dep}; +mod cmd_update; +use cmd_update::{UpdateArgs, cmd_update}; +mod cmd_init; +use cmd_init::{InitArgs, cmd_init}; +mod cmd_add; +use cmd_add::{AddArgs, cmd_add}; +mod cmd_dump; +use cmd_dump::{DumpArgs, cmd_dump}; +mod cmd_status; +use cmd_status::{StatusArgs, cmd_status}; +mod cmd_cache; +use cmd_cache::{CacheArgs, cmd_cache}; +mod cmd_rexec; +use cmd_rexec::{RexecArgs, cmd_rexec}; +mod cmd_remote_build; +use cmd_remote_build::{RemoteBuildArgs, cmd_remote_build}; + +#[derive(Parser)] +#[command(name = "minimal", version = env!("CARGO_PKG_VERSION"), long_version = env!("LONG_VERSION"))] +#[command(about = "The Minimal CLI")] +struct Cli { + #[command(subcommand)] + command: Command, + + #[command(flatten)] + global_args: GlobalArgs, +} + +#[derive(Subcommand)] +enum Command { + /// Runs a task, such as one specified in `minimal.toml`. + #[cfg(target_os = "linux")] + Run(RunArgs), + /// Refreshes local checkouts of upstream packages & the standard library. + Update(UpdateArgs), + /// Add a new tool or dependency. + Add(AddArgs), + /// Automatically initialize minimal configuration based on your source tree. + Init(InitArgs), + /// Shows the status of Minimal in this codebase. + Status(StatusArgs), + /// Launches a development shell. Shorthand for `minimal run shell`. + Shell, + /// Runs the build task. Shorthand for `minimal run build`. + Build, + /// Runs the test task. Shorthand for `minimal run test`. + Test, + /// Materializes an output specified in `minimal.toml`. + Materialize(MaterializeArgs), + /// Builds the specified package(s) in a clean room, making them available in the local cache. + #[clap(alias = "pkg")] + Package(PkgArgs), + /// Execute a command on a remote build server. + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + Rexec(RexecArgs), + /// Build packages on a remote build server. + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + RemoteBuild(RemoteBuildArgs), + /// Manipulate the local cache. + #[clap(subcommand)] + Cache(CacheArgs), + + /// Validates minimal configuration including packages, stacks, and profiles + Check(CheckArgs), + /// Prints the build plan for the specified package(s) + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + Plan(PlanArgs), + /// Uploads the specified packages and their transitive needs to the cache. + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + UploadCache(UploadArgs), + /// Executes the build for a package, using stale dependencies. + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + PatchedBuild(PatchedBuildArgs), + /// Dumps out information about the supply chain in a machine-readable format. + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + Dump(DumpArgs), + /// Generates Graphviz source code of the dependency graph + #[command( + long_about = "Generate an image of the dependency graph using graphviz's \"dot\" program.\n\n minimal dep --input_deps_depth=0 -p file | dot -Tpng > deps.png" + )] + Dep(DepArgs), + + /// Generate shell completion script + #[command( + long_about = "Generate a shell tab-completion script for the minimal CLI for your shell.\nSupported shells include bash, zsh, elvish and fish.\n\n source <(minimal completions bash)" + )] + Completions(CompletionsArgs), +} + +#[derive(Debug, clap::Args)] +struct CompletionsArgs { + /// The shell type for a CLI completion script should be printed + #[arg(value_parser)] + shell: Shell, +} + +/// Shared arguments and builders across all subcommands +#[derive(Debug, Args)] +pub struct GlobalArgs { + /// Use the given directory as the repository root, instead of searching from the current working directory. + #[arg(long, short = 'C')] + repo_dir: Option, + + /// Override the base directory used for operations (default: ~/.cache/minimal) + #[arg(long)] + minimal_dir: Option, + /// Load the minimal standard library from the given path instead + #[arg(long)] + #[clap(hide = !std::env::var("MINIMAL_SCIENCE_MODE").is_ok())] + stdlib_dir: Option, + + /// Ignore locally-available binary artifacts (results in rebuilds unless present in a remote cache) + #[arg(long, default_value_t = false, global = true)] + no_cache: bool, + + /// Do not fetch binary artifacts from the internet + #[arg(long, default_value_t = false, global = true)] + no_fetch: bool, + + /// Use only what's already in the local cache for sources, VCS checkouts, + /// and the remote artifact cache. On cache miss, fail with a clear error + /// instead of attempting any network call. Useful for builds in + /// network-isolated environments where every input is pre-staged. + /// + /// Composes with the other cache flags: + /// - implies the remote-artifact-cache-skip half of --no-fetch (you + /// can't reach the artifact cache when offline anyway), so + /// --offline alone is sufficient — no need for --offline --no-fetch + /// - orthogonal to --no-cache and --rebuild, which control whether to + /// use locally-built artifacts (--offline doesn't force a rebuild; + /// it just gates the network) + #[arg(long, default_value_t = false, global = true)] + offline: bool, + + /// Configure the number of parallel builds + #[arg(short, long, global = true)] + num_parallel_builds: Option, +} + +pub(crate) fn enforce_science_mode() -> Result<(), Error> { + if std::env::var("MINIMAL_SCIENCE_MODE").unwrap_or("".to_string()) != "yeppers" { + eprintln!("You are using a command that is experimental or very unsafe!!"); + eprintln!( + "No guarantees are given about the consistency of your minimal install following the execution of such commands, nor the stability of any such commands." + ); + eprintln!( + "If you are sure you want to continue, set the environment variable MINIMAL_SCIENCE_MODE=yeppers before continuing." + ); + eprintln!(); + + Err(Error::Other(anyhow!( + "Aborting execution of unsafe command outside of science mode" + ))) + } else { + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + EnvFilter::new("info") + .add_directive("topiary=off".parse().unwrap()) + .add_directive("libcgroups=off".parse().unwrap()) + .add_directive("build_events=off".parse().unwrap()) + .add_directive("build_events_proto=off".parse().unwrap()) + }); + + tracing_subscriber::registry() + .with(fmt::layer().with_writer(ot::StdoutWriter::new)) + .with(filter) + .init(); + + let cli = Cli::parse(); + + let result = run_cli(cli).await; + + if let Err(e) = result { + e.report_to_stderr(); + std::process::exit(1); + }; + Ok(()) +} + +async fn run_cli(cli: Cli) -> Result<(), Error> { + let Cli { + command, + global_args, + } = cli; + + // One operation tree for this CLI invocation, rendered to stderr. Threaded + // into the Context so all operations attach to it (replacing the former + // process-global root). + let ot_root = ot::OpTracker::new_root(); + ot::render_to_stderr(ot_root.clone()); + + let mut config = ConfigBuilder::new() + .with_operation_tracker(ot_root) + .with_no_cache(global_args.no_cache) + .with_no_fetch(global_args.no_fetch) + .with_offline(global_args.offline); + if let Some(num_parallel_builds) = global_args.num_parallel_builds { + config = config.with_num_parallel_builds(num_parallel_builds); + } + if let Some(repo_dir) = global_args.repo_dir { + config = config.with_repo_dir(repo_dir); + } + if let Some(minimal_dir) = global_args.minimal_dir { + config = config.with_state_dir(minimal_dir); + } + if let Some(stdlib_dir) = global_args.stdlib_dir { + config = config.with_stdlib_dir(stdlib_dir); + } + let config = config.build()?; + + // Commands that don't need a minimal.toml / full Context. + match command { + Command::Completions(CompletionsArgs { shell }) => { + let mut cmd = Cli::command(); + let name = cmd.get_name().to_string(); + clap_complete::generate(shell, &mut cmd, name, &mut io::stdout()); + return Ok(()); + } + Command::Init(args) => return cmd_init(args, config).await, + #[cfg(target_os = "linux")] + Command::Run(RunArgs { + variant: + cmd_run::RunVariant::BySpec { + upstream, + task_spec, + }, + task_args, + }) => return cmd_run_by_spec(upstream, task_spec, task_args, config).await, + _ => {} + } + let mut ctx = Context::new(config)?; + + match command { + Command::Package(args) => cmd_pkg(args, &mut ctx).await, + Command::Check(args) => cmd_check(args, &mut ctx).await, + Command::Plan(args) => cmd_plan(args, &mut ctx).await, + Command::Add(args) => cmd_add(args, &mut ctx).await, + Command::UploadCache(args) => cmd_upload_cache(args, &mut ctx).await, + Command::Materialize(args) => cmd_materialize(args, &mut ctx).await, + Command::PatchedBuild(args) => cmd_patched_build(args, &mut ctx).await, + #[cfg(target_os = "linux")] + Command::Run(args) => cmd_run(args, &mut ctx).await, + Command::Shell => { + cmd_run( + RunArgs { + variant: cmd_run::RunVariant::ByName { + task_name: "shell".to_string(), + }, + task_args: vec![], + }, + &mut ctx, + ) + .await + } + Command::Build => { + cmd_run( + RunArgs { + variant: cmd_run::RunVariant::ByName { + task_name: "build".to_string(), + }, + task_args: vec![], + }, + &mut ctx, + ) + .await + } + Command::Test => { + cmd_run( + RunArgs { + variant: cmd_run::RunVariant::ByName { + task_name: "test".to_string(), + }, + task_args: vec![], + }, + &mut ctx, + ) + .await + } + Command::Update(args) => cmd_update(args, &mut ctx).await, + Command::Dep(args) => cmd_dep(args, &mut ctx).await, + Command::Dump(args) => cmd_dump(args, &mut ctx).await, + Command::Status(args) => cmd_status(args, &mut ctx).await, + Command::Rexec(args) => cmd_rexec(args, &mut ctx).await, + Command::RemoteBuild(args) => cmd_remote_build(args, &mut ctx).await, + Command::Cache(args) => cmd_cache(args, &mut ctx).await, + // Handled before Context::new + Command::Completions(_) | Command::Init(_) => unreachable!(), + } +} diff --git a/docs/specs/01-spec-minvmd-host-daemon/01-spec-minvmd-host-daemon.md b/docs/specs/01-spec-minvmd-host-daemon/01-spec-minvmd-host-daemon.md index 6ce32199a..927606e67 100644 --- a/docs/specs/01-spec-minvmd-host-daemon/01-spec-minvmd-host-daemon.md +++ b/docs/specs/01-spec-minvmd-host-daemon/01-spec-minvmd-host-daemon.md @@ -265,7 +265,7 @@ auto-spawns it; subsequent calls reuse; `minvmd status` introspects; **Affected areas:** `crates/minvmd/src/cmd/{run,status,stop}.rs` (new), `crates/minvmd/src/state.rs` (new), `crates/minvmd/src/lifecycle.rs` -(new), `crates/minimal2/src/main.rs` (extend) +(new), `crates/minimal/src/main.rs` (extend) **Functional Requirements:** @@ -288,7 +288,7 @@ auto-spawns it; subsequent calls reuse; `minvmd status` introspects; SIGKILL on timeout, then remove `vmm.pid` and reset `state.toml` to `Stopped`. The command shall be idempotent. (translated from plan: step R4.4) -- **R4.5**: On macOS and Linux, `crates/minimal2` shall check `state.toml` +- **R4.5**: On macOS and Linux, `crates/minimal` shall check `state.toml` before connecting to the UDS; if no `minvmd` is running it shall spawn `minvmd run --detach` and wait (with timeout) for the UDS. On targets with no minvmd backend this path shall be a no-op. (translated from @@ -433,8 +433,8 @@ hold under this model. - **State file format** — TOML with serde; one file per VM (in v0.1, only `default`). Lifecycle enum is `NotProvisioned | Stopped | Starting | Running | Stopping`. -- **Auto-spawn from `minimal2`** — implementation lives in - `crates/minimal2/src/autospawn.rs`, gated +- **Auto-spawn from `minimal`** — implementation lives in + `crates/minimal/src/autospawn.rs`, gated `#[cfg(any(target_os = "macos", target_os = "linux"))]` and invoked from `main.rs`. Enabled on both macOS and Linux. diff --git a/docs/specs/01-spec-minvmd-host-daemon/architecture.md b/docs/specs/01-spec-minvmd-host-daemon/architecture.md index 675371b2f..c3d64a3d4 100644 --- a/docs/specs/01-spec-minvmd-host-daemon/architecture.md +++ b/docs/specs/01-spec-minvmd-host-daemon/architecture.md @@ -89,9 +89,9 @@ The existing codebase already implements this: `lib.rs` gates `pub mod krun` on `target_os = "macos"`, and `build.rs` only emits link directives on macOS. -### Auto-spawn from `minimal2` +### Auto-spawn from `minimal` -On macOS, `crates/minimal2` checks `state.toml` before connecting to the +On macOS, `crates/minimal` checks `state.toml` before connecting to the provider UDS. If no `minvmd` is running, it spawns `minvmd run --detach` and waits (with timeout, default 8 s) for the UDS to accept connections. On Linux this path is a no-op — `minimald` runs @@ -125,7 +125,7 @@ is the guest-initiated variant. `fd-lock` — file-descriptor-based advisory locking for `lifecycle.lock`. Workspace-pinned in `Cargo.toml`. -### Changes to `crates/minimal2` +### Changes to `crates/minimal` A `#[cfg(target_os = "macos")]` block in `src/main.rs` adds the auto-spawn check: read `state.toml`, conditionally spawn diff --git a/docs/specs/02-spec-minvmd-linux-kvm/02-spec-minvmd-linux-kvm.md b/docs/specs/02-spec-minvmd-linux-kvm/02-spec-minvmd-linux-kvm.md index b1168f36e..cbc72ae7b 100644 --- a/docs/specs/02-spec-minvmd-linux-kvm/02-spec-minvmd-linux-kvm.md +++ b/docs/specs/02-spec-minvmd-linux-kvm/02-spec-minvmd-linux-kvm.md @@ -46,7 +46,7 @@ session path from #374. isolation over namespace isolation) is treated as an open question (see Open Questions below). For this spec, VM isolation on Linux requires manually running `minvmd run --detach` before using `minimal`; the auto-spawn path in -`minimal2` stays a no-op on Linux. The full selection surface — per-session +`minimal` stays a no-op on Linux. The full selection surface — per-session flag, loadout field, or policy — is deferred to a follow-up under #396. ## Introduction/Overview @@ -262,7 +262,7 @@ establish the Linux/KVM baseline. ## Non-Goals -- **Selection surface / auto-spawn on Linux.** `minimal2`'s +- **Selection surface / auto-spawn on Linux.** `minimal`'s `ensure_minvmd_running()` on Linux remains a no-op in this spec. A user opts into VM isolation by running `minvmd run --detach` manually. The per-session flag, loadout field, or policy mechanism is deferred to a @@ -314,7 +314,7 @@ runners provisioned via the infrastructure team use the configured path. models Linux as a coexistence scenario: `minimald` (direct namespace provider) and `minvmd` (VM provider) each expose their own UDS; `minimal` discovers both. This spec realizes the `minvmd` side of the Linux deployment diagram without -changing the `minimal`/`minimal2` discovery logic. +changing the `minimal`/`minimal` discovery logic. ## Repository Standards diff --git a/docs/specs/03-spec-networking/03-spec-networking.md b/docs/specs/03-spec-networking/03-spec-networking.md index 3b5121f33..6a619e1d9 100644 --- a/docs/specs/03-spec-networking/03-spec-networking.md +++ b/docs/specs/03-spec-networking/03-spec-networking.md @@ -311,7 +311,7 @@ where WireGuard is blocked. **Affected areas:** `crates/minimald/` (new `wg` module: wireguard-go/boringtun peer lifecycle, subnet-router advertisement; new `proxy` module: HTTPS TLS termination, mTLS/OIDC auth, reverse-proxy to gvproxy switch), -`crates/minimal2/` (new `mesh` subcommand: CLI peer join/leave, peer-key +`crates/minimal/` (new `mesh` subcommand: CLI peer join/leave, peer-key management) **Functional Requirements:** @@ -462,7 +462,7 @@ access in restricted environments. code; structured `tracing` fields, not interpolated strings. - Commit messages: Conventional Commits (`docs/commit-conventions.md`); imperative mood, lower-case, no trailing period; `feat(minimald):`, `feat(minvmd):`, - `feat(minimal2):` scopes. + `feat(minimal):` scopes. - Rust coding standards (`docs/rust-coding-standards.md`): functional over imperative; cheapest reference (`&str`, `&Path`); make illegal states unrepresentable; typed errors (`thiserror`) in library crates, `anyhow` at @@ -549,6 +549,6 @@ access in restricted environments. | Check | Command | |---|---| | Lint | `cargo clippy --allow-dirty --fix --all-targets -- -D warnings` | -| Build | `cargo build -p minimald -p minvmd -p minimal2` | -| Unit + integration | `cargo test -p minimald -p minvmd -p minimal2` | +| Build | `cargo build -p minimald -p minvmd -p minimal` | +| Unit + integration | `cargo test -p minimald -p minvmd -p minimal` | | Full (hardware) | `cargo test -- --include-ignored` (requires `/dev/kvm`) | diff --git a/docs/specs/03-spec-networking/architecture.md b/docs/specs/03-spec-networking/architecture.md index 30eb3fc51..c4e1b9d4d 100644 --- a/docs/specs/03-spec-networking/architecture.md +++ b/docs/specs/03-spec-networking/architecture.md @@ -197,7 +197,7 @@ switch for all PTask attachments inside that VM. New `net.rs`: `NetworkMode`, `spawn_gvproxy`, `VmEgressPolicy` — aligned with the stub design from spec #404 but extended for the full switch role. -### `crates/minimal2/src/` +### `crates/minimal/src/` New subcommand group `mesh`: `join`, `leave`, `status` (Unit 4). New subcommand `ssh-forward` (Unit 4). diff --git a/docs/specs/03-spec-networking/test-plan.md b/docs/specs/03-spec-networking/test-plan.md index c3ee044b1..cb8152653 100644 --- a/docs/specs/03-spec-networking/test-plan.md +++ b/docs/specs/03-spec-networking/test-plan.md @@ -5,7 +5,7 @@ interactive session path. **`attach -c` is never used** — that exec path runs the daemon host in the guest **root** netns and bypasses the sandbox, so it can never prove a session's isolation or own-ip behaviour. -Run from the repo root with the daemon up. `M=target/debug/minimal2`. +Run from the repo root with the daemon up. `M=target/debug/minimal`. **Portability:** targets the minvmd VM deployment (DM1) — macOS/HVF and Linux/KVM are identical (the sandbox guest is Linux on every host; the CLI, `expect`, and @@ -17,7 +17,7 @@ proxies bind host loopback directly (no gvproxy host-expose). ## Environment & driver -- **The full session path:** `minimal2 attach ` (no `-c`) opens an +- **The full session path:** `minimal attach ` (no `-c`) opens an interactive PTY shell — `shell_request` → `SandboxLauncher` → a hakoniwa sandbox running `bash --noprofile -l` with the session's `NetworkMode`. Commands typed there run in the sandbox netns. This is the only faithful path. @@ -152,7 +152,7 @@ Status today: `login` **PASS** (correct macOS cert path); proxy legs **BLOCKED** ```bash $M activate -n dev --network own-ip --ingress 18080:80 . # dev shell: socat TCP-LISTEN:80,reuseaddr,fork SYSTEM:'printf "HTTP/1.0 200 OK\r\n\r\nFORWARD_OK"' & (keep attached) -# host: minimal2 ssh-forward dev 18080:127.0.0.1:80 & +# host: minimal ssh-forward dev 18080:127.0.0.1:80 & # curl http://localhost:18080/ -> 200 FORWARD_OK $M destroy dev ``` diff --git a/docs/specs/03-spec-networking/test-plan.sh b/docs/specs/03-spec-networking/test-plan.sh index 5f71edef1..45f5de97f 100755 --- a/docs/specs/03-spec-networking/test-plan.sh +++ b/docs/specs/03-spec-networking/test-plan.sh @@ -2,7 +2,7 @@ # minimald Networking Epic (#478) — CLI test plan, executable form. # # RULE: never use `attach -c`. Every per-session networking assertion runs INSIDE -# the sandbox via the full interactive session path (`minimal2 attach `), +# the sandbox via the full interactive session path (`minimal attach `), # driven with a real PTY via expect. attach -c / the exec path runs on the daemon # host in the guest ROOT netns and bypasses the sandbox, so it cannot prove a # session's isolation or own-ip behaviour. @@ -18,9 +18,9 @@ # In-session tools: sh bash curl getent coreutils socat. NOT present: ip nc wget python. # Switch-IP discovery therefore uses /proc/net/fib_trie, not `ip`. set -u -M="$PWD/target/debug/minimal2" +M="$PWD/target/debug/minimal" ATTACH_TIMEOUT=180 # first attach builds the sandbox; be generous -# mTLS client-cert dir written by `minimal2 login` (dirs crate): macOS uses +# mTLS client-cert dir written by `minimal login` (dirs crate): macOS uses # Application Support, Linux/XDG uses ~/.config. case "$(uname -s)" in Darwin) CERT_DIR="$HOME/Library/Application Support/minimal" ;; diff --git a/docs/specs/06-spec-ssh-host-key-in-beacon/06-spec-ssh-host-key-in-beacon.md b/docs/specs/06-spec-ssh-host-key-in-beacon/06-spec-ssh-host-key-in-beacon.md index abb40a2ef..5ef3219e3 100644 --- a/docs/specs/06-spec-ssh-host-key-in-beacon/06-spec-ssh-host-key-in-beacon.md +++ b/docs/specs/06-spec-ssh-host-key-in-beacon/06-spec-ssh-host-key-in-beacon.md @@ -107,7 +107,7 @@ in `minvmd/Cargo.toml` so the production code can call instance 0 per the hardcoded `is_minimal_microvm()` config). - Rotating or re-verifying the key across reboots (`learn_known_hosts_path` is idempotent for the same key and updates the entry on key change). -- Updating `MinimalClientHandler::check_server_key` in `minimal2/src/client.rs` +- Updating `MinimalClientHandler::check_server_key` in `minimal/src/client.rs` (the internal RPC client already accepts any key unconditionally; this spec targets user-facing SSH warnings on first connect). diff --git a/justfile b/justfile index b2eaff0ed..bc93d5949 100644 --- a/justfile +++ b/justfile @@ -17,7 +17,7 @@ rootfs := scratch / "rootfs.img" initramfs := scratch / "initramfs.cpio" gvproxy := scratch / "gvproxy" minvmd-bin := justfile_directory() / "target/debug/minvmd" -minimal := justfile_directory() / "target/debug/minimal2" +minimal := justfile_directory() / "target/debug/minimal" # Re-run any time the entitlements file or binary changes. Ad-hoc signing # (`-s -`) requires no Apple Developer membership; the binary only runs on the @@ -28,7 +28,7 @@ codesign-minvmd: cargo build -p minvmd --release codesign --entitlements crates/minvmd/minvmd.entitlements --force -s - {{justfile_directory()}}/target/release/minvmd -# ── minimal2 → minvmd → minimald bring-up (macOS/HVF or Linux/KVM) ─────────── +# ── minimal → minvmd → minimald bring-up (macOS/HVF or Linux/KVM) ─────────── # # `just up` brings the whole stack up with full guest networking: # 1. materialize the guest kernel + generic rootfs into .scratch @@ -113,9 +113,9 @@ minvmd-build: libkrun *) echo "unsupported host $(uname -s)" >&2; exit 1 ;; esac -# Build the `minimal` CLI (minimal2 crate). +# Build the `minimal` CLI (minimal crate). minimal-cli: - cargo build -p minimal2 + cargo build -p minimal # minimal auto-spawns `minvmd run --detach`, so minvmd must be on PATH and the # MINVMD_* artifact paths exported; both are set here and inherited by the diff --git a/scripts/fetch-artifact.sh b/scripts/fetch-artifact.sh index 53e90d84c..ea2c146e1 100755 --- a/scripts/fetch-artifact.sh +++ b/scripts/fetch-artifact.sh @@ -35,9 +35,9 @@ cd "$ROOT" # Build the CLI from source (debug: this drives a cache fetch, so CLI CPU is not # the bottleneck). Incremental, so a second invocation in the same job is cheap. -cargo build -p minimal -MINIMAL="$ROOT/target/debug/minimal" +cargo build -p mip +MIP="$ROOT/target/debug/mip" mkdir -p "$(dirname "$DEST")" -"$MINIMAL" materialize --output "$DEST" --arch "$ARCH" "$OUTPUT" +"$MIP" materialize --output "$DEST" --arch "$ARCH" "$OUTPUT" echo "fetched $OUTPUT -> $DEST ($(wc -c < "$DEST" | tr -d ' ') bytes)" diff --git a/scripts/fetch-libkrun.sh b/scripts/fetch-libkrun.sh index a55a997d8..bd672cf82 100755 --- a/scripts/fetch-libkrun.sh +++ b/scripts/fetch-libkrun.sh @@ -38,15 +38,15 @@ cd "$ROOT" # Build the CLI from source (debug: this drives a cache fetch, so CLI CPU is not # the bottleneck). Incremental, so a second invocation in the same job is cheap. -cargo build -p minimal -MINIMAL="$ROOT/target/debug/minimal" +cargo build -p mip +MIP="$ROOT/target/debug/mip" WORK="$(mktemp -d)" trap 'rm -rf "$WORK"' EXIT # Materialize the libkrun OCI image (cache fetch keyed by the pinned commit). IMG="$WORK/libkrun-oci.tar" -"$MINIMAL" materialize --output "$IMG" --arch "$ARCH" libkrun +"$MIP" materialize --output "$IMG" --arch "$ARCH" libkrun # Unpack the OCI layout and replay its layers (in manifest order) into a rootfs. # The image is a standard OCI archive: index.json -> manifest blob -> gzipped