diff --git a/.github/workflows/apt-canary.yml b/.github/workflows/apt-canary.yml new file mode 100644 index 000000000..f654abc7c --- /dev/null +++ b/.github/workflows/apt-canary.yml @@ -0,0 +1,35 @@ +name: APT Canary + +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +jobs: + canary: + name: APT Canary Smoke Test + if: vars.APT_CANARY_ENABLED == 'true' || github.event_name == 'workflow_dispatch' + runs-on: self-hosted-ubuntu-2404-x64 + env: + APT_REPO_URL: ${{ vars.APT_REPO_URL }} + APT_REPO_KEY_URL: ${{ vars.APT_REPO_KEY_URL }} + DEFAULT_APT_REPO_URL: https://apt.microsandbox.dev + DEFAULT_APT_REPO_KEY_URL: https://apt.microsandbox.dev/microsandbox-archive-keyring.gpg + steps: + - uses: actions/checkout@v4 + + - name: Clean previous apt state + run: | + sudo apt-get remove -y microsandbox || true + sudo rm -f /etc/apt/sources.list.d/microsandbox.list + sudo rm -f /usr/share/keyrings/microsandbox-archive-keyring.gpg + rm -rf ~/.microsandbox + + - name: Install and smoke test from published APT repository + run: | + REPO_URL="${APT_REPO_URL:-$DEFAULT_APT_REPO_URL}" + KEY_URL="${APT_REPO_KEY_URL:-$DEFAULT_APT_REPO_KEY_URL}" + + bash scripts/apt-smoke-test.sh \ + --repo-url "$REPO_URL" \ + --key-url "$KEY_URL" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b01777492..05c97bcf8 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -34,6 +34,41 @@ jobs: code: - '!docs/**' + # --------------------------------------------------------------------------- + # Lint workflow and packaging tooling + # --------------------------------------------------------------------------- + tooling-lint: + name: Tooling Lint + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Install workflow and shell lint dependencies + run: sudo apt-get update && sudo apt-get install -y python3-yaml shellcheck + + - name: Install actionlint + run: go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.12 + + - name: Run shared packaging helper regression test + run: bash scripts/tests/test-apt-common.sh + + - name: Run workflow structure regression test + run: python3 scripts/tests/test-workflows.py + + - name: Lint shell scripts + run: PATH="$HOME/go/bin:$PATH" shellcheck -x scripts/*.sh scripts/lib/*.sh scripts/tests/*.sh + + - name: Lint GitHub Actions workflows + run: PATH="$HOME/go/bin:$PATH" actionlint + # --------------------------------------------------------------------------- # Build kernel.c on Linux for macOS libkrunfw linking # --------------------------------------------------------------------------- @@ -108,7 +143,7 @@ jobs: # --------------------------------------------------------------------------- check: name: Check (${{ matrix.target }}) - needs: [build-kernel, build-agentd-aarch64, changes] + needs: [tooling-lint, build-kernel, build-agentd-aarch64, changes] if: always() && needs.changes.outputs.code == 'true' runs-on: ${{ matrix.runner }} strategy: @@ -317,6 +352,114 @@ jobs: npm install --no-package-lock --ignore-scripts npm run build + # --------------------------------------------------------------------------- + # Validate APT packages and repository + # --------------------------------------------------------------------------- + apt-package-test: + name: APT Package Validation (${{ matrix.target }}) + needs: check + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: linux-x86_64 + runner: ubuntu-latest + arch: x86_64 + deb_arch: amd64 + upload_kvm_repo: true + - target: linux-aarch64 + runner: ubuntu-24.04-arm + arch: aarch64 + deb_arch: arm64 + upload_kvm_repo: false + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Install APT packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils lintian gnupg + + - name: Build baseline APT artifacts + run: | + bash scripts/build-apt-baseline-artifacts.sh \ + --output-dir build/apt/${{ matrix.arch }} + + - name: Build and validate .deb packages + run: | + VERSION="$(sed -n 's/^version = \"\\(.*\\)\"$/\\1/p' Cargo.toml | head -n1)" + + bash scripts/package-deb.sh \ + --arch "${{ matrix.arch }}" \ + --version "$VERSION" \ + --revision 1 \ + --msb build/apt/${{ matrix.arch }}/msb \ + --libkrunfw build/apt/${{ matrix.arch }}/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} \ + --output-dir dist/v1 + + bash scripts/package-deb.sh \ + --arch "${{ matrix.arch }}" \ + --version "$VERSION" \ + --revision 2 \ + --msb build/apt/${{ matrix.arch }}/msb \ + --libkrunfw build/apt/${{ matrix.arch }}/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} \ + --output-dir dist/v2 + + bash scripts/validate-deb.sh \ + --deb dist/v1/microsandbox_*_${{ matrix.deb_arch }}.deb \ + --arch "${{ matrix.deb_arch }}" \ + --version "$VERSION" \ + --revision 1 + + bash scripts/validate-deb.sh \ + --deb dist/v2/microsandbox_*_${{ matrix.deb_arch }}.deb \ + --arch "${{ matrix.deb_arch }}" \ + --version "$VERSION" \ + --revision 2 + + - name: Build signed test repositories + env: + GNUPGHOME: ${{ runner.temp }}/apt-gnupg + run: | + mkdir -p "$GNUPGHOME" + chmod 700 "$GNUPGHOME" + + GOOD_KEY_ID="$(bash scripts/generate-apt-test-key.sh --gnupg-home "$GNUPGHOME")" + BAD_KEY_ID="$(bash scripts/generate-apt-test-key.sh \ + --gnupg-home "$GNUPGHOME" \ + --name-real "Microsandbox Wrong Repository" \ + --name-email "wrong@microsandbox.dev")" + + bash scripts/build-apt-repo.sh \ + --input-dir dist/v1 \ + --output-dir apt-repo-v1 \ + --gpg-key-id "$GOOD_KEY_ID" + + bash scripts/build-apt-repo.sh \ + --input-dir dist/v2 \ + --output-dir apt-repo-v2 \ + --gpg-key-id "$GOOD_KEY_ID" + + gpg --batch --yes --output wrong-repo-keyring.gpg --export "$BAD_KEY_ID" + + - name: Test APT install and upgrade in containers + run: | + bash scripts/test-apt-repo.sh \ + --repo-v1 apt-repo-v1 \ + --repo-v2 apt-repo-v2 \ + --keyring apt-repo-v1/microsandbox-archive-keyring.gpg \ + --bad-keyring wrong-repo-keyring.gpg + + - name: Upload signed APT repository artifact + if: matrix.upload_kvm_repo + uses: actions/upload-artifact@v4 + with: + name: apt-repo-v1-${{ matrix.target }} + path: apt-repo-v1/ + # --------------------------------------------------------------------------- # Integration tests (requires KVM) # --------------------------------------------------------------------------- @@ -433,3 +576,33 @@ jobs: export PATH="$HOME/.microsandbox/bin:$PATH" export LD_LIBRARY_PATH="${{ github.workspace }}/build:$HOME/.microsandbox/lib" npm test + + # --------------------------------------------------------------------------- + # APT smoke tests (requires KVM) + # --------------------------------------------------------------------------- + apt-kvm-smoke: + name: APT KVM Smoke Test + needs: apt-package-test + runs-on: self-hosted-ubuntu-2404-x64 + steps: + - uses: actions/checkout@v4 + + - name: Clean workspace + run: | + rm -rf "${{ github.workspace }}"/apt-repo + rm -rf ~/.microsandbox + sudo apt-get remove -y microsandbox || true + sudo rm -f /etc/apt/sources.list.d/microsandbox.list + sudo rm -f /usr/share/keyrings/microsandbox-archive-keyring.gpg + + - name: Download signed APT repository + uses: actions/download-artifact@v4 + with: + name: apt-repo-v1-linux-x86_64 + path: apt-repo/ + + - name: Install and smoke test microsandbox from APT + run: | + bash scripts/apt-smoke-test.sh \ + --repo-url "file://${{ github.workspace }}/apt-repo" \ + --keyring-path "${{ github.workspace }}/apt-repo/microsandbox-archive-keyring.gpg" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f767324e..638816d4f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,10 +8,13 @@ env: CARGO_TERM_COLOR: always LIBKRUNFW_VERSION: "5.2.1" LIBKRUNFW_ABI: "5" + APT_REPO_DOMAIN: "apt.microsandbox.dev" permissions: contents: write packages: write + pages: write + id-token: write jobs: # --------------------------------------------------------------------------- @@ -80,7 +83,7 @@ jobs: path: build/agentd # --------------------------------------------------------------------------- - # Build + # Build release artifacts # --------------------------------------------------------------------------- build: name: Build (${{ matrix.target }}) @@ -288,12 +291,130 @@ jobs: name: release-${{ matrix.target }} path: artifacts/ + # --------------------------------------------------------------------------- + # Package Debian artifacts for release + # --------------------------------------------------------------------------- + apt-package: + name: Package APT (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: linux-x86_64 + runner: ubuntu-latest + arch: x86_64 + - target: linux-aarch64 + runner: ubuntu-24.04-arm + arch: aarch64 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - name: Install APT packaging dependencies + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils gnupg + + - name: Build baseline APT artifacts + run: | + bash scripts/build-apt-baseline-artifacts.sh \ + --output-dir build/apt/${{ matrix.arch }} + + - name: Package Debian archive + run: | + bash scripts/package-deb.sh \ + --arch "${{ matrix.arch }}" \ + --version "${{ github.ref_name }}" \ + --revision 1 \ + --msb build/apt/${{ matrix.arch }}/msb \ + --libkrunfw build/apt/${{ matrix.arch }}/libkrunfw.so.${{ env.LIBKRUNFW_VERSION }} \ + --output-dir dist + + - name: Upload Debian package artifact + uses: actions/upload-artifact@v4 + with: + name: release-apt-${{ matrix.target }} + path: dist/*.deb + + # --------------------------------------------------------------------------- + # Build and sign the APT repository + # --------------------------------------------------------------------------- + apt-repo: + name: Build APT Repository + needs: [apt-package] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install APT repository tooling + run: | + sudo apt-get update + sudo apt-get install -y dpkg-dev apt-utils gnupg + + - name: Download Debian packages + uses: actions/download-artifact@v4 + with: + path: release-artifacts + pattern: release-apt-* + merge-multiple: true + + - name: Import APT signing key + id: apt-key + env: + GNUPGHOME: ${{ runner.temp }}/apt-gnupg + APT_GPG_PRIVATE_KEY: ${{ secrets.APT_GPG_PRIVATE_KEY }} + APT_GPG_PRIVATE_KEY_BASE64: ${{ secrets.APT_GPG_PRIVATE_KEY_BASE64 }} + run: | + mkdir -p "$GNUPGHOME" + chmod 700 "$GNUPGHOME" + KEY_ID="$(bash scripts/import-apt-signing-key.sh --gnupg-home "$GNUPGHOME")" + echo "key_id=$KEY_ID" >> "$GITHUB_OUTPUT" + + - name: Build signed APT repository + env: + GNUPGHOME: ${{ runner.temp }}/apt-gnupg + APT_GPG_PASSPHRASE: ${{ secrets.APT_GPG_PASSPHRASE }} + run: | + bash scripts/build-apt-repo.sh \ + --input-dir release-artifacts \ + --output-dir apt-repo \ + --gpg-key-id "${{ steps.apt-key.outputs.key_id }}" + + printf '%s\n' "${{ env.APT_REPO_DOMAIN }}" > apt-repo/CNAME + + - name: Upload APT repository artifact + uses: actions/upload-artifact@v4 + with: + name: apt-repo + path: apt-repo/ + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: apt-repo/ + + # --------------------------------------------------------------------------- + # Publish APT repository to GitHub Pages + # --------------------------------------------------------------------------- + deploy-apt-repo: + name: Deploy APT Repository + needs: [apt-repo] + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 + # --------------------------------------------------------------------------- # Assemble: collect all artifacts, generate checksums, create GitHub release # --------------------------------------------------------------------------- assemble: name: Assemble Release - needs: build + needs: [build, apt-package, deploy-apt-repo] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -342,7 +463,7 @@ jobs: # --------------------------------------------------------------------------- npm-publish: name: Publish npm packages - needs: build + needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -448,7 +569,7 @@ jobs: # --------------------------------------------------------------------------- mcp-publish: name: Publish MCP server - needs: npm-publish + needs: [npm-publish] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -475,7 +596,7 @@ jobs: # --------------------------------------------------------------------------- crates-publish: name: Publish crates - needs: build + needs: [build] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -509,9 +630,11 @@ jobs: # --------------------------------------------------------------------------- pypi-publish: name: Publish Python SDK - needs: build + needs: [build] runs-on: ubuntu-latest permissions: + actions: read + contents: read id-token: write environment: name: pypi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82c436fa9..5d0d3dd98 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,8 +18,33 @@ repos: - id: no-commit-to-branch args: [--branch, main] + - repo: https://github.com/koalaman/shellcheck-precommit + rev: v0.11.0 + hooks: + - id: shellcheck + args: [-x] + files: ^scripts/.*\.sh$ + + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + - repo: local hooks: + - id: apt-common-regression + name: apt common regression + entry: bash scripts/tests/test-apt-common.sh + language: system + pass_filenames: false + files: ^scripts/(lib/apt-common\.sh|tests/test-apt-common\.sh|.*\.sh)$ + - id: workflow-regressions + name: workflow regression checks + entry: python scripts/tests/test-workflows.py + language: python + additional_dependencies: [PyYAML==6.0.2] + pass_filenames: false + files: ^(\.github/workflows/.*\.ya?ml|scripts/tests/test-workflows\.py)$ - id: cargo-fmt-workspace name: cargo fmt (workspace) entry: cargo fmt --all -- --check diff --git a/README.md b/README.md index 5ed0f1441..11ff6dd99 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,16 @@ > Or install the `msb` command globally: > > ```sh +> # Debian / Ubuntu +> curl -fsSL https://apt.microsandbox.dev/microsandbox-archive-keyring.gpg \ +> | sudo tee /usr/share/keyrings/microsandbox-archive-keyring.gpg >/dev/null +> echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] https://apt.microsandbox.dev stable main" \ +> | sudo tee /etc/apt/sources.list.d/microsandbox.list >/dev/null +> sudo apt update +> sudo apt install microsandbox +> ``` +> ```sh +> # Fallback installer (other Linux / macOS) > curl -fsSL https://install.microsandbox.dev | sh > ``` > diff --git a/actionlint.yaml b/actionlint.yaml new file mode 100644 index 000000000..25b5b921a --- /dev/null +++ b/actionlint.yaml @@ -0,0 +1,3 @@ +self-hosted-runner: + labels: + - self-hosted-ubuntu-2404-x64 diff --git a/crates/cli/lib/commands/self_cmd.rs b/crates/cli/lib/commands/self_cmd.rs index 72822b53f..8356523a1 100644 --- a/crates/cli/lib/commands/self_cmd.rs +++ b/crates/cli/lib/commands/self_cmd.rs @@ -3,6 +3,11 @@ use std::io::Write; use std::path::{Path, PathBuf}; +#[cfg(target_os = "linux")] +use std::io::ErrorKind; +#[cfg(target_os = "linux")] +use std::process::Command; + use clap::{Args, Subcommand}; use console::{Key, Term, style}; @@ -14,6 +19,7 @@ use crate::ui; //-------------------------------------------------------------------------------------------------- const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const APT_PACKAGE_NAME: &str = "microsandbox"; const MARKER_START: &str = "# >>> microsandbox >>>"; const MARKER_END: &str = "# <<< microsandbox <<<"; @@ -70,6 +76,18 @@ enum UninstallCategory { Secrets, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct AptManagedInstallation { + package: String, +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum DpkgQueryError { + CommandUnavailable, + CommandFailed, +} + impl UninstallCategory { const ITEMS: &[Self] = &[ Self::All, @@ -124,6 +142,11 @@ pub async fn run(args: SelfArgs) -> anyhow::Result<()> { async fn run_update(args: SelfUpdateArgs) -> anyhow::Result<()> { info(&format!("Current version: v{CURRENT_VERSION}")); + if let Some(installation) = detect_apt_managed_installation() { + print_apt_managed_update_notice(&installation); + return Ok(()); + } + let spinner = ui::Spinner::start("Checking", "latest release"); let latest = fetch_latest_version().await?; spinner.finish_clear(); @@ -165,6 +188,11 @@ async fn run_update(args: SelfUpdateArgs) -> anyhow::Result<()> { } async fn run_uninstall(args: SelfUninstallArgs) -> anyhow::Result<()> { + if let Some(installation) = detect_apt_managed_installation() { + print_apt_managed_uninstall_notice(&installation); + return Ok(()); + } + let base_dir = resolve_base_dir()?; if !base_dir.exists() { @@ -416,6 +444,105 @@ fn done(msg: &str) { eprintln!("{} {msg}", style("done").green().bold()); } +#[cfg(target_os = "linux")] +fn detect_apt_managed_installation() -> Option { + let current_exe = std::env::current_exe().ok()?; + + detect_apt_managed_installation_with(¤t_exe, |executable_path| { + let output = Command::new("dpkg-query") + .args(["-S", &executable_path.display().to_string()]) + .output(); + + match output { + Ok(output) if output.status.success() => { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } + Ok(_) => Err(DpkgQueryError::CommandFailed), + Err(error) if error.kind() == ErrorKind::NotFound => { + Err(DpkgQueryError::CommandUnavailable) + } + Err(_) => Err(DpkgQueryError::CommandFailed), + } + }) +} + +#[cfg(not(target_os = "linux"))] +fn detect_apt_managed_installation() -> Option { + None +} + +#[cfg(target_os = "linux")] +fn detect_apt_managed_installation_with( + executable_path: &Path, + query: F, +) -> Option +where + F: FnOnce(&Path) -> Result, +{ + match query(executable_path) { + Ok(stdout) => parse_apt_managed_installation(APT_PACKAGE_NAME, executable_path, &stdout), + Err(DpkgQueryError::CommandUnavailable | DpkgQueryError::CommandFailed) => None, + } +} + +#[cfg(target_os = "linux")] +fn parse_apt_managed_installation( + package: &str, + executable_path: &Path, + stdout: &str, +) -> Option { + let expected_path = executable_path.display().to_string(); + + stdout.lines().find_map(|line| { + let (package_field, owned_path) = line.rsplit_once(": ")?; + if owned_path.trim() != expected_path { + return None; + } + + package_field.split(',').find_map(|entry| { + let package_name = entry.trim().split(':').next()?; + (package_name == package).then(|| AptManagedInstallation { + package: package.to_string(), + }) + }) + }) +} + +fn apt_upgrade_hint(installation: &AptManagedInstallation) -> String { + format!( + "sudo apt update && sudo apt upgrade {}", + installation.package + ) +} + +fn apt_remove_hint(installation: &AptManagedInstallation) -> String { + format!("sudo apt remove {}", installation.package) +} + +fn apt_purge_hint(installation: &AptManagedInstallation) -> String { + format!("sudo apt purge {}", installation.package) +} + +fn print_apt_managed_update_notice(installation: &AptManagedInstallation) { + info("This microsandbox installation is managed by APT."); + info(&format!( + "Run `{}` to upgrade the system package.", + apt_upgrade_hint(installation) + )); +} + +fn print_apt_managed_uninstall_notice(installation: &AptManagedInstallation) { + info("This microsandbox installation is managed by APT."); + info(&format!( + "Run `{}` to uninstall the system package.", + apt_remove_hint(installation) + )); + info(&format!( + "Use `{}` if you also want to remove package metadata.", + apt_purge_hint(installation) + )); +} + /// Remove a single uninstall category from the base directory. fn remove_category(base_dir: &Path, category: UninstallCategory) -> anyhow::Result<()> { match category { @@ -525,3 +652,125 @@ fn remove_marker_block(path: &Path) -> anyhow::Result { std::fs::write(path, result)?; Ok(true) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "linux")] + #[test] + fn test_parse_apt_managed_installation_accepts_package_owner_for_current_executable() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = parse_apt_managed_installation( + APT_PACKAGE_NAME, + &executable, + "microsandbox: /usr/bin/msb\n", + ); + assert_eq!( + installation, + Some(AptManagedInstallation { + package: "microsandbox".to_string(), + }) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_parse_apt_managed_installation_accepts_arch_qualified_package_owner() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = parse_apt_managed_installation( + APT_PACKAGE_NAME, + &executable, + "microsandbox:amd64: /usr/bin/msb\n", + ); + assert_eq!( + installation, + Some(AptManagedInstallation { + package: "microsandbox".to_string(), + }) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_parse_apt_managed_installation_rejects_different_package_owner() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = parse_apt_managed_installation( + APT_PACKAGE_NAME, + &executable, + "other-package: /usr/bin/msb\n", + ); + assert_eq!(installation, None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_parse_apt_managed_installation_rejects_different_owned_path() { + let executable = PathBuf::from("/home/user/.microsandbox/bin/msb"); + let installation = parse_apt_managed_installation( + APT_PACKAGE_NAME, + &executable, + "microsandbox: /usr/bin/msb\n", + ); + assert_eq!(installation, None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_apt_managed_installation_ignores_missing_dpkg_query() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = detect_apt_managed_installation_with(&executable, |_| { + Err(DpkgQueryError::CommandUnavailable) + }); + assert_eq!(installation, None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_apt_managed_installation_ignores_failed_query() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = detect_apt_managed_installation_with(&executable, |_| { + Err(DpkgQueryError::CommandFailed) + }); + assert_eq!(installation, None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_apt_managed_installation_with_successful_owner_query() { + let executable = PathBuf::from("/usr/bin/msb"); + let installation = detect_apt_managed_installation_with(&executable, |path| { + Ok(format!("microsandbox: {}\n", path.display())) + }); + assert_eq!( + installation, + Some(AptManagedInstallation { + package: "microsandbox".to_string(), + }) + ); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn test_detect_apt_managed_installation_returns_none_off_linux() { + assert_eq!(detect_apt_managed_installation(), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_apt_hints_use_expected_package_name() { + let installation = AptManagedInstallation { + package: "microsandbox".to_string(), + }; + + assert_eq!( + apt_upgrade_hint(&installation), + "sudo apt update && sudo apt upgrade microsandbox" + ); + assert_eq!( + apt_remove_hint(&installation), + "sudo apt remove microsandbox" + ); + assert_eq!(apt_purge_hint(&installation), "sudo apt purge microsandbox"); + } +} diff --git a/crates/microsandbox/lib/config/mod.rs b/crates/microsandbox/lib/config/mod.rs index 982fd5486..0c19a6892 100644 --- a/crates/microsandbox/lib/config/mod.rs +++ b/crates/microsandbox/lib/config/mod.rs @@ -667,8 +667,9 @@ pub fn resolve_msb_path() -> MicrosandboxResult { /// Resolution order: /// 1. `config().paths.libkrunfw` /// 2. A sibling of the resolved `msb` binary (for `build/msb`) -/// 3. `../lib/` next to the resolved `msb` binary (for installed layouts) -/// 4. `{home}/lib/libkrunfw.{so,dylib}` +/// 3. `../lib/microsandbox/` next to the resolved `msb` binary +/// 4. `../lib/` next to the resolved `msb` binary (for installed layouts) +/// 5. `{home}/lib/libkrunfw.{so,dylib}` pub fn resolve_libkrunfw_path() -> MicrosandboxResult { if let Some(path) = &config().paths.libkrunfw { if path.is_file() { @@ -697,9 +698,9 @@ pub fn resolve_libkrunfw_path() -> MicrosandboxResult { } candidates.push(home_fallback); - if let Some(path) = candidates.iter().find(|path| path.is_file()) { + if let Some(path) = first_existing_path(&candidates) { tracing::debug!(path = %path.display(), "resolved libkrunfw path"); - return Ok(path.clone()); + return Ok(path); } let searched = candidates @@ -718,6 +719,17 @@ fn libkrunfw_candidates_from_msb(msb_path: &Path, filename: &str) -> Vec Vec Option { + candidates.iter().find(|path| path.is_file()).cloned() +} + #[cfg(debug_assertions)] fn dev_msb_candidates_from(start: &Path) -> Vec { let mut candidates = Vec::new(); @@ -1156,6 +1172,51 @@ mod tests { assert_eq!(paths.len(), 2); } + #[test] + fn test_libkrunfw_candidates_for_packaged_msb() { + let msb = PathBuf::from("/usr/bin/msb"); + let paths = libkrunfw_candidates_from_msb(&msb, "libkrunfw.so.5.2.1"); + assert_eq!(paths[0], PathBuf::from("/usr/bin/libkrunfw.so.5.2.1")); + assert_eq!( + paths[1], + PathBuf::from("/usr/lib/microsandbox/libkrunfw.so.5.2.1") + ); + assert_eq!(paths[2], PathBuf::from("/usr/lib/libkrunfw.so.5.2.1")); + assert_eq!(paths.len(), 3); + } + + #[test] + fn test_first_existing_path_prefers_packaged_layout_candidate() { + let temp = tempfile::tempdir().unwrap(); + let packaged = temp.path().join("usr/lib/microsandbox/libkrunfw.so.5.2.1"); + let home = temp + .path() + .join("home/.microsandbox/lib/libkrunfw.so.5.2.1"); + + std::fs::create_dir_all(packaged.parent().unwrap()).unwrap(); + std::fs::create_dir_all(home.parent().unwrap()).unwrap(); + std::fs::write(&packaged, b"packaged").unwrap(); + std::fs::write(&home, b"home").unwrap(); + + let resolved = first_existing_path(&[packaged.clone(), home.clone()]); + assert_eq!(resolved, Some(packaged)); + } + + #[test] + fn test_first_existing_path_falls_back_when_packaged_candidate_missing() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("usr/lib/microsandbox/libkrunfw.so.5.2.1"); + let home = temp + .path() + .join("home/.microsandbox/lib/libkrunfw.so.5.2.1"); + + std::fs::create_dir_all(home.parent().unwrap()).unwrap(); + std::fs::write(&home, b"home").unwrap(); + + let resolved = first_existing_path(&[missing, home.clone()]); + assert_eq!(resolved, Some(home)); + } + #[test] fn test_dev_msb_candidates_from_workspace_root() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/cli/overview.mdx b/docs/cli/overview.mdx index ce7ff1252..524a09ca2 100644 --- a/docs/cli/overview.mdx +++ b/docs/cli/overview.mdx @@ -9,6 +9,17 @@ The `msb` CLI lets you create, manage, and interact with sandboxes from the term ## Install ```bash +# Debian / Ubuntu +curl -fsSL https://apt.microsandbox.dev/microsandbox-archive-keyring.gpg \ + | sudo tee /usr/share/keyrings/microsandbox-archive-keyring.gpg >/dev/null +echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] https://apt.microsandbox.dev stable main" \ + | sudo tee /etc/apt/sources.list.d/microsandbox.list >/dev/null +sudo apt update +sudo apt install microsandbox +``` + +```bash +# Fallback installer (other Linux / macOS) curl -fsSL https://install.microsandbox.dev | sh ``` @@ -59,8 +70,8 @@ msb install ubuntu # Install as 'ubuntu' command msb uninstall ubuntu # Remove installed command # Self-management -msb self update # Update msb to latest -msb self uninstall # Remove msb +msb self update # Update msb to latest (APT installs redirect to apt upgrade) +msb self uninstall # Remove msb (APT installs redirect to apt remove) ``` diff --git a/docs/cli/sandbox-commands.mdx b/docs/cli/sandbox-commands.mdx index 0a8dbabea..0825a4861 100644 --- a/docs/cli/sandbox-commands.mdx +++ b/docs/cli/sandbox-commands.mdx @@ -317,6 +317,11 @@ msb self uninstall # Remove msb (with confirmation prompt) msb self uninstall --yes # Skip confirmation ``` +> If `msb` was installed from the official APT repository, these commands do +> not modify the system package directly. They print the equivalent `apt` +> command instead: `apt upgrade microsandbox`, `apt remove microsandbox`, or +> `apt purge microsandbox`. + | Subcommand | Description | |------------|-------------| | `update` (alias: `upgrade`) | Update msb and libkrunfw to the latest release | diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index d35401633..7d8eddf30 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -34,10 +34,20 @@ icon: "bolt" ``` ```bash CLI - curl -fsSL https://install.microsandbox.dev | sh + curl -fsSL https://apt.microsandbox.dev/microsandbox-archive-keyring.gpg \ + | sudo tee /usr/share/keyrings/microsandbox-archive-keyring.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] https://apt.microsandbox.dev stable main" \ + | sudo tee /etc/apt/sources.list.d/microsandbox.list >/dev/null + sudo apt update + sudo apt install microsandbox ``` + + + On other Linux distributions and on macOS, use the fallback installer: + `curl -fsSL https://install.microsandbox.dev | sh` + diff --git a/justfile b/justfile index ff751143b..ba0a30f06 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,9 @@ # Version constants for libkrunfw. Keep in sync with microsandbox-utils/lib/lib.rs. LIBKRUNFW_ABI := "5" LIBKRUNFW_VERSION := "5.2.1" +DEFAULT_PACKAGE_FORMAT := "deb" +LOCAL_PACKAGE_DIST_DIR := "dist/packages" +LINUX_PACKAGE_NAME := "microsandbox" # Set up the development environment, build, and install. Prerequisites: just, git (+ Docker on macOS). setup: _install-dev-deps @@ -127,9 +130,35 @@ _ensure-libkrunfw: # Build libkrunfw on Linux. Requires: kernel build dependencies (gcc, make, flex, bison, etc.). [linux] -build-libkrunfw: +_require-libkrunfw-build-tools: #!/usr/bin/env bash set -euo pipefail + missing=() + + for cmd in bc bison flex gcc make python3; do + if ! command -v "$cmd" >/dev/null 2>&1; then + missing+=("$cmd") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "error: missing build tools for libkrunfw: ${missing[*]}" >&2 + echo "hint: install them with: sudo apt-get install -y bc bison flex gcc make libelf-dev python3-pyelftools libcap-ng-dev" >&2 + exit 1 + fi + +[linux] +build-libkrunfw: _require-libkrunfw-build-tools + #!/usr/bin/env bash + set -euo pipefail + kernel_version="$(sed -n 's/^KERNEL_VERSION = //p' vendor/libkrunfw/Makefile | head -n1)" + kernel_tarball="vendor/libkrunfw/tarballs/${kernel_version}.tar.xz" + + if [ -f "$kernel_tarball" ] && ! tar -tf "$kernel_tarball" >/dev/null 2>&1; then + echo "Removing corrupt kernel tarball: $kernel_tarball" + rm -f "$kernel_tarball" + fi + cd vendor/libkrunfw make -j$(nproc) cd ../.. @@ -232,6 +261,141 @@ uninstall: rm -f ~/.microsandbox/lib/libkrunfw* echo "Removed msb and libkrunfw from ~/.microsandbox/" +# Lint shell tooling and run the shared APT helper regression check. +lint-shell: + #!/usr/bin/env bash + set -euo pipefail + command -v pre-commit >/dev/null || { echo "error: pre-commit not found. Run 'just setup' first."; exit 1; } + pre-commit run shellcheck --all-files + pre-commit run apt-common-regression --all-files + +# Lint GitHub Actions workflows and run workflow regression checks. +lint-workflows: + #!/usr/bin/env bash + set -euo pipefail + command -v pre-commit >/dev/null || { echo "error: pre-commit not found. Run 'just setup' first."; exit 1; } + pre-commit run actionlint --all-files + pre-commit run workflow-regressions --all-files + +# Run the repository's shell and workflow lint suite. +lint-tooling: lint-shell lint-workflows + +# Build a local Linux package from baseline-compatible APT artifacts. +[linux] +package-local format=DEFAULT_PACKAGE_FORMAT revision="1" image="": + #!/usr/bin/env bash + set -euo pipefail + + arch="$(uname -m)" + package_format="{{ format }}" + revision="{{ revision }}" + baseline_image="{{ image }}" + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n1)" + output_dir="{{ LOCAL_PACKAGE_DIST_DIR }}/$package_format" + artifacts_dir="build/apt/$arch" + + test -n "$version" || { echo "error: could not determine workspace version from Cargo.toml"; exit 1; } + + baseline_cmd=(bash scripts/build-apt-baseline-artifacts.sh --output-dir "$artifacts_dir") + if [ -n "$baseline_image" ]; then + baseline_cmd+=(--image "$baseline_image") + fi + + echo "==> Building baseline-compatible APT artifacts for $arch..." + "${baseline_cmd[@]}" + + case "$package_format" in + deb) + echo "==> Packaging {{ LINUX_PACKAGE_NAME }} $version-$revision as $package_format..." + bash scripts/package-deb.sh \ + --arch "$arch" \ + --version "$version" \ + --revision "$revision" \ + --msb "$artifacts_dir/msb" \ + --libkrunfw "$artifacts_dir/libkrunfw.so.{{ LIBKRUNFW_VERSION }}" \ + --output-dir "$output_dir" + ;; + *) + echo "error: unsupported local package format: $package_format" >&2 + echo "supported formats: deb" >&2 + exit 1 + ;; + esac + +# Build the local Linux package and install it with the matching system tool. +[linux] +install-package-local format=DEFAULT_PACKAGE_FORMAT revision="1": (package-local format revision) + #!/usr/bin/env bash + set -euo pipefail + + package_format="{{ format }}" + revision="{{ revision }}" + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n1)" + test -n "$version" || { echo "error: could not determine workspace version from Cargo.toml"; exit 1; } + + case "$package_format" in + deb) + case "$(uname -m)" in + x86_64) + package_arch="amd64" + ;; + aarch64) + package_arch="arm64" + ;; + *) + echo "error: unsupported Debian architecture: $(uname -m)" >&2 + exit 1 + ;; + esac + package_path="$PWD/{{ LOCAL_PACKAGE_DIST_DIR }}/$package_format/{{ LINUX_PACKAGE_NAME }}_${version}-${revision}_${package_arch}.deb" + ;; + *) + echo "error: unsupported local package format: $package_format" >&2 + echo "supported formats: deb" >&2 + exit 1 + ;; + esac + + test -f "$package_path" || { echo "error: package not found: $package_path"; exit 1; } + stage_dir="$(mktemp -d /tmp/microsandbox-package.XXXXXX)" + stage_package="$stage_dir/$(basename "$package_path")" + trap 'rm -rf "$stage_dir"' EXIT + + chmod 755 "$stage_dir" + install -m644 "$package_path" "$stage_package" + + case "$package_format" in + deb) + echo "==> Installing $stage_package..." + sudo apt-get update + sudo apt install --reinstall -y "$stage_package" + ;; + esac + +# Remove the locally installed Linux package with the matching system tool. +[linux] +uninstall-package-local format=DEFAULT_PACKAGE_FORMAT: + #!/usr/bin/env bash + set -euo pipefail + + package_format="{{ format }}" + + case "$package_format" in + deb) + if dpkg-query -W -f='${Status}' "{{ LINUX_PACKAGE_NAME }}" 2>/dev/null | grep -q "install ok installed"; then + echo "==> Removing {{ LINUX_PACKAGE_NAME }}..." + sudo apt remove -y "{{ LINUX_PACKAGE_NAME }}" + else + echo "{{ LINUX_PACKAGE_NAME }} is not installed." + fi + ;; + *) + echo "error: unsupported local package format: $package_format" >&2 + echo "supported formats: deb" >&2 + exit 1 + ;; + esac + # Clean build artifacts. clean: rm -rf build diff --git a/packaging/apt/README.md b/packaging/apt/README.md new file mode 100644 index 000000000..0780ba54c --- /dev/null +++ b/packaging/apt/README.md @@ -0,0 +1,24 @@ +# APT Packaging + +This directory contains the Debian/Ubuntu packaging metadata used to build the +`microsandbox` APT package and the signed repository published at +`https://apt.microsandbox.dev`. + +## Contents + +- `control.template`: package control metadata rendered by `scripts/package-deb.sh` +- `release.conf.template`: `apt-ftparchive` release metadata rendered by + `scripts/build-apt-repo.sh` +- `copyright`: Debian machine-readable copyright file installed into the package + +## Local flow + +1. Build baseline-compatible Linux artifacts with + `scripts/build-apt-baseline-artifacts.sh --output-dir build/apt/` +2. Create `.deb` packages with `scripts/package-deb.sh` +3. Generate a signing key with `scripts/generate-apt-test-key.sh` or import the + production key with `scripts/import-apt-signing-key.sh` +4. Build the signed repository with `scripts/build-apt-repo.sh` + +The CI workflows use the same scripts for PR validation, release publication, +and canary smoke tests. diff --git a/packaging/apt/control.template b/packaging/apt/control.template new file mode 100644 index 000000000..4fa896380 --- /dev/null +++ b/packaging/apt/control.template @@ -0,0 +1,14 @@ +Package: @PACKAGE@ +Version: @VERSION@ +Section: utils +Priority: optional +Architecture: @ARCH@ +Installed-Size: @INSTALLED_SIZE@ +Maintainer: Super Rad Company +Depends: @DEPENDS@ +Homepage: https://github.com/superradcompany/microsandbox +Description: Lightweight microVM sandbox CLI + Microsandbox spins up lightweight, hardware-isolated microVMs from a local + CLI. This package installs the `msb` command and the private `libkrunfw` + runtime library used to boot and manage microsandbox environments on Debian + and Ubuntu systems. diff --git a/packaging/apt/copyright b/packaging/apt/copyright new file mode 100644 index 000000000..6ffa24f8b --- /dev/null +++ b/packaging/apt/copyright @@ -0,0 +1,12 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: microsandbox +Upstream-Contact: Super Rad Company +Source: https://github.com/superradcompany/microsandbox + +Files: * +Copyright: 2025 Super Rad Company +License: Apache-2.0 + +License: Apache-2.0 + On Debian systems, the complete text of the Apache License Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". diff --git a/packaging/apt/release.conf.template b/packaging/apt/release.conf.template new file mode 100644 index 000000000..6a7ea335c --- /dev/null +++ b/packaging/apt/release.conf.template @@ -0,0 +1,7 @@ +APT::FTPArchive::Release::Origin "@ORIGIN@"; +APT::FTPArchive::Release::Label "@LABEL@"; +APT::FTPArchive::Release::Suite "@SUITE@"; +APT::FTPArchive::Release::Codename "@CODENAME@"; +APT::FTPArchive::Release::Architectures "@ARCHITECTURES@"; +APT::FTPArchive::Release::Components "@COMPONENTS@"; +APT::FTPArchive::Release::Description "@DESCRIPTION@"; diff --git a/scripts/apt-smoke-test.sh b/scripts/apt-smoke-test.sh new file mode 100755 index 000000000..85942548b --- /dev/null +++ b/scripts/apt-smoke-test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/apt-smoke-test.sh --repo-url [--keyring-path | --key-url ] + +Install microsandbox from an APT repository and run a short end-to-end CLI smoke +test on a Linux host with KVM. +EOF +} + +REPO_URL="" +KEYRING_PATH="" +KEY_URL="" +DISTRIBUTION="stable" +PACKAGE_NAME="microsandbox" +KEYRING_DEST="/usr/share/keyrings/microsandbox-archive-keyring.gpg" +SOURCE_LIST="/etc/apt/sources.list.d/microsandbox.list" +SANDBOX_NAME="apt-smoke" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-url) + REPO_URL="$2" + shift 2 + ;; + --keyring-path) + KEYRING_PATH="$2" + shift 2 + ;; + --key-url) + KEY_URL="$2" + shift 2 + ;; + --distribution) + DISTRIBUTION="$2" + shift 2 + ;; + --package) + PACKAGE_NAME="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$REPO_URL" ]] || { + usage >&2 + exit 1 +} + +if [[ -z "$KEYRING_PATH" && -z "$KEY_URL" ]]; then + usage >&2 + exit 1 +fi + +require_cmd sudo +require_cmd apt-get +require_cmd timeout + +cleanup() { + msb stop "$SANDBOX_NAME" >/dev/null 2>&1 || true + msb rm "$SANDBOX_NAME" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +if [[ -n "$KEYRING_PATH" ]]; then + sudo install -Dm644 "$KEYRING_PATH" "$KEYRING_DEST" +else + require_cmd curl + curl -fsSL "$KEY_URL" | sudo tee "$KEYRING_DEST" >/dev/null +fi + +echo "deb [signed-by=$KEYRING_DEST] $REPO_URL $DISTRIBUTION main" | \ + sudo tee "$SOURCE_LIST" >/dev/null + +sudo apt-get update +sudo apt-get install -y "$PACKAGE_NAME" + +timeout 600 msb run --name "$SANDBOX_NAME" alpine -- sh -lc 'echo apt smoke hello' +timeout 600 msb start "$SANDBOX_NAME" +timeout 600 msb exec "$SANDBOX_NAME" -- sh -lc 'uname -a' +timeout 600 msb stop "$SANDBOX_NAME" +timeout 600 msb rm "$SANDBOX_NAME" diff --git a/scripts/build-apt-baseline-artifacts.sh b/scripts/build-apt-baseline-artifacts.sh new file mode 100644 index 000000000..dd341a28b --- /dev/null +++ b/scripts/build-apt-baseline-artifacts.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build-apt-baseline-artifacts.sh --output-dir [--image ] + +Build the Linux artifacts used for APT packaging on an older Debian baseline so +the resulting package remains compatible with the supported Debian/Ubuntu matrix. +EOF +} + +OUTPUT_DIR="" +BASELINE_IMAGE="${APT_BASELINE_IMAGE:-docker.io/library/rust:1-bullseye}" +CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" +LIBKRUNFW_VERSION="${LIBKRUNFW_VERSION:-5.2.1}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --image) + BASELINE_IMAGE="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$OUTPUT_DIR" ]] || { + usage >&2 + exit 1 +} + +require_cmd "$CONTAINER_RUNTIME" + +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +OUTPUT_DIR_ABS="$(mkdir -p "$OUTPUT_DIR" && cd "$OUTPUT_DIR" && pwd)" +HOST_UID="$(id -u)" +HOST_GID="$(id -g)" + +"$CONTAINER_RUNTIME" run --rm \ + -e DEBIAN_FRONTEND=noninteractive \ + -e HOST_UID="$HOST_UID" \ + -e HOST_GID="$HOST_GID" \ + -e LIBKRUNFW_VERSION="$LIBKRUNFW_VERSION" \ + -e OUTPUT_DIR_ABS="$OUTPUT_DIR_ABS" \ + -v "$REPO_ROOT":/workspace \ + -w /workspace \ + "$BASELINE_IMAGE" \ + bash -euo pipefail -c ' + export PATH=/usr/local/cargo/bin:$PATH + + apt-get update + apt-get install -y \ + bc \ + ca-certificates \ + flex \ + bison \ + gcc \ + libcap-ng-dev \ + libdbus-1-dev \ + libelf-dev \ + make \ + musl-tools \ + pkg-config \ + python3-pyelftools + + build_root="$(mktemp -d)" + trap '\''rm -rf "$build_root"'\'' EXIT + + export CARGO_TARGET_DIR="$build_root/target" + + case "$(uname -m)" in + x86_64) + agentd_target="x86_64-unknown-linux-musl" + ;; + aarch64) + agentd_target="aarch64-unknown-linux-musl" + ;; + *) + echo "error: unsupported architecture for agentd build: $(uname -m)" >&2 + exit 1 + ;; + esac + + libkrunfw_root="$build_root/libkrunfw" + cp -a /workspace/vendor/libkrunfw/. "$libkrunfw_root/" + + rustup target add "$agentd_target" + mkdir -p /workspace/build + cargo build --release --manifest-path crates/agentd/Cargo.toml --target "$agentd_target" + install -m755 \ + "$CARGO_TARGET_DIR/$agentd_target/release/agentd" \ + /workspace/build/agentd + + cargo build --release --no-default-features --features net -p microsandbox-cli + make -C "$libkrunfw_root" -j"$(nproc)" + + install -d "$OUTPUT_DIR_ABS" + install -m755 "$CARGO_TARGET_DIR/release/msb" "$OUTPUT_DIR_ABS/msb" + install -m644 \ + "$libkrunfw_root/libkrunfw.so.${LIBKRUNFW_VERSION}" \ + "$OUTPUT_DIR_ABS/libkrunfw.so.${LIBKRUNFW_VERSION}" + + chown -R "$HOST_UID:$HOST_GID" /workspace/build "$OUTPUT_DIR_ABS" + ' + +printf '%s\n' "$OUTPUT_DIR_ABS" diff --git a/scripts/build-apt-repo.sh b/scripts/build-apt-repo.sh new file mode 100755 index 000000000..ba67f517a --- /dev/null +++ b/scripts/build-apt-repo.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/build-apt-repo.sh --input-dir --output-dir --gpg-key-id + +Build and sign a static APT repository from one or more `.deb` files. +EOF +} + +INPUT_DIR="" +OUTPUT_DIR="" +GPG_KEY_ID="" +DISTRIBUTION="stable" +SUITE="stable" +CODENAME="stable" +COMPONENT="main" +ORIGIN="microsandbox" +LABEL="microsandbox" +DESCRIPTION="Microsandbox APT repository" + +while [[ $# -gt 0 ]]; do + case "$1" in + --input-dir) + INPUT_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --gpg-key-id) + GPG_KEY_ID="$2" + shift 2 + ;; + --distribution) + DISTRIBUTION="$2" + shift 2 + ;; + --suite) + SUITE="$2" + shift 2 + ;; + --codename) + CODENAME="$2" + shift 2 + ;; + --component) + COMPONENT="$2" + shift 2 + ;; + --origin) + ORIGIN="$2" + shift 2 + ;; + --label) + LABEL="$2" + shift 2 + ;; + --description) + DESCRIPTION="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$INPUT_DIR" && -n "$OUTPUT_DIR" && -n "$GPG_KEY_ID" ]] || { + usage >&2 + exit 1 +} + +require_cmd apt-ftparchive +require_cmd dpkg-deb +require_cmd dpkg-scanpackages +require_cmd gpg +require_cmd gzip + +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +TEMPLATE_DIR="$REPO_ROOT/packaging/apt" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct 2>/dev/null || date +%s)}" + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +POOL_DIR="$OUTPUT_DIR/pool/$COMPONENT/m/microsandbox" +DIST_DIR="$OUTPUT_DIR/dists/$DISTRIBUTION/$COMPONENT" +mkdir -p "$POOL_DIR" "$DIST_DIR" + +mapfile -t DEBS < <(find "$INPUT_DIR" -maxdepth 1 -type f -name '*.deb' | sort) +[[ ${#DEBS[@]} -gt 0 ]] || { + echo "error: no deb packages found in $INPUT_DIR" >&2 + exit 1 +} + +declare -A ARCH_SEEN=() +for deb in "${DEBS[@]}"; do + cp "$deb" "$POOL_DIR/" + arch="$(dpkg-deb -f "$deb" Architecture)" + ARCH_SEEN["$arch"]=1 +done + +mapfile -t ARCHITECTURES < <(printf '%s\n' "${!ARCH_SEEN[@]}" | sort) +ARCHITECTURE_LIST="$(printf '%s ' "${ARCHITECTURES[@]}")" +ARCHITECTURE_LIST="${ARCHITECTURE_LIST% }" + +for arch in "${ARCHITECTURES[@]}"; do + BINARY_DIR="$DIST_DIR/binary-$arch" + mkdir -p "$BINARY_DIR" + ( + cd "$OUTPUT_DIR" + dpkg-scanpackages -a "$arch" "pool/$COMPONENT/m/microsandbox" /dev/null + ) >"$BINARY_DIR/Packages" + gzip -n9 -c "$BINARY_DIR/Packages" >"$BINARY_DIR/Packages.gz" +done + +RELEASE_CONFIG="$OUTPUT_DIR/apt-ftparchive-release.conf" +render_template \ + "$TEMPLATE_DIR/release.conf.template" \ + "@ORIGIN@" "$ORIGIN" \ + "@LABEL@" "$LABEL" \ + "@SUITE@" "$SUITE" \ + "@CODENAME@" "$CODENAME" \ + "@ARCHITECTURES@" "$ARCHITECTURE_LIST" \ + "@COMPONENTS@" "$COMPONENT" \ + "@DESCRIPTION@" "$DESCRIPTION" >"$RELEASE_CONFIG" + +apt-ftparchive -c "$RELEASE_CONFIG" release "$OUTPUT_DIR/dists/$DISTRIBUTION" \ + >"$OUTPUT_DIR/dists/$DISTRIBUTION/Release" + +GPG_COMMON_ARGS=(--batch --yes --pinentry-mode loopback --local-user "$GPG_KEY_ID") +if [[ -n "${APT_GPG_PASSPHRASE:-}" ]]; then + GPG_COMMON_ARGS+=(--passphrase "$APT_GPG_PASSPHRASE") +fi + +gpg "${GPG_COMMON_ARGS[@]}" \ + --output "$OUTPUT_DIR/dists/$DISTRIBUTION/Release.gpg" \ + --detach-sign "$OUTPUT_DIR/dists/$DISTRIBUTION/Release" + +gpg "${GPG_COMMON_ARGS[@]}" \ + --output "$OUTPUT_DIR/dists/$DISTRIBUTION/InRelease" \ + --clearsign "$OUTPUT_DIR/dists/$DISTRIBUTION/Release" + +gpg --batch --yes --output "$OUTPUT_DIR/microsandbox-archive-keyring.gpg" --export "$GPG_KEY_ID" +gpg --batch --yes --armor --output "$OUTPUT_DIR/microsandbox-archive-keyring.asc" --export "$GPG_KEY_ID" + +while IFS= read -r -d '' path; do + touch -h -d "@$SOURCE_DATE_EPOCH" "$path" +done < <(find "$OUTPUT_DIR" -print0) + +printf '%s\n' "$OUTPUT_DIR" diff --git a/scripts/generate-apt-test-key.sh b/scripts/generate-apt-test-key.sh new file mode 100755 index 000000000..ece8bdaf9 --- /dev/null +++ b/scripts/generate-apt-test-key.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/generate-apt-test-key.sh --gnupg-home + +Generate an ephemeral unprotected signing key for CI/package tests. +EOF +} + +GNUPG_HOME="" +NAME_REAL="Microsandbox Test Repository" +NAME_EMAIL="ci@microsandbox.dev" + +while [[ $# -gt 0 ]]; do + case "$1" in + --gnupg-home) + GNUPG_HOME="$2" + shift 2 + ;; + --name-real) + NAME_REAL="$2" + shift 2 + ;; + --name-email) + NAME_EMAIL="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$GNUPG_HOME" ]] || { + usage >&2 + exit 1 +} + +require_cmd gpg + +mkdir -p "$GNUPG_HOME" +chmod 700 "$GNUPG_HOME" + +cat >"$GNUPG_HOME/key.conf" </dev/null 2>&1 +FINGERPRINT="$( + gpg --homedir "$GNUPG_HOME" --batch --with-colons --list-secret-keys "$NAME_EMAIL" | + awk -F: '$1 == "fpr" { print $10; exit }' +)" + +echo "$FINGERPRINT:6:" | gpg --homedir "$GNUPG_HOME" --batch --import-ownertrust >/dev/null 2>&1 +printf '%s\n' "$FINGERPRINT" diff --git a/scripts/import-apt-signing-key.sh b/scripts/import-apt-signing-key.sh new file mode 100755 index 000000000..b2107a6b0 --- /dev/null +++ b/scripts/import-apt-signing-key.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/import-apt-signing-key.sh --gnupg-home [--private-key-file ] + +Import the production APT signing key into a dedicated GnuPG home and print the +fingerprint. The private key can come from `--private-key-file`, +`APT_GPG_PRIVATE_KEY`, or `APT_GPG_PRIVATE_KEY_BASE64`. +EOF +} + +GNUPG_HOME="" +PRIVATE_KEY_FILE="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --gnupg-home) + GNUPG_HOME="$2" + shift 2 + ;; + --private-key-file) + PRIVATE_KEY_FILE="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$GNUPG_HOME" ]] || { + usage >&2 + exit 1 +} + +require_cmd gpg + +mkdir -p "$GNUPG_HOME" +chmod 700 "$GNUPG_HOME" + +TEMP_KEY_FILE="" +cleanup() { + if [[ -n "$TEMP_KEY_FILE" && -f "$TEMP_KEY_FILE" ]]; then + rm -f "$TEMP_KEY_FILE" + fi +} +trap cleanup EXIT + +if [[ -z "$PRIVATE_KEY_FILE" ]]; then + if [[ -n "${APT_GPG_PRIVATE_KEY:-}" ]]; then + TEMP_KEY_FILE="$(mktemp)" + printf '%s\n' "$APT_GPG_PRIVATE_KEY" >"$TEMP_KEY_FILE" + PRIVATE_KEY_FILE="$TEMP_KEY_FILE" + elif [[ -n "${APT_GPG_PRIVATE_KEY_BASE64:-}" ]]; then + TEMP_KEY_FILE="$(mktemp)" + printf '%s' "$APT_GPG_PRIVATE_KEY_BASE64" | base64 --decode >"$TEMP_KEY_FILE" + PRIVATE_KEY_FILE="$TEMP_KEY_FILE" + fi +fi + +[[ -n "$PRIVATE_KEY_FILE" && -f "$PRIVATE_KEY_FILE" ]] || { + echo "error: no signing key provided" >&2 + exit 1 +} + +gpg --homedir "$GNUPG_HOME" --batch --import "$PRIVATE_KEY_FILE" >/dev/null 2>&1 +FINGERPRINT="$( + gpg --homedir "$GNUPG_HOME" --batch --with-colons --list-secret-keys | + awk -F: '$1 == "fpr" { print $10; exit }' +)" + +[[ -n "$FINGERPRINT" ]] || { + echo "error: imported key fingerprint could not be determined" >&2 + exit 1 +} + +echo "$FINGERPRINT:6:" | gpg --homedir "$GNUPG_HOME" --batch --import-ownertrust >/dev/null 2>&1 +printf '%s\n' "$FINGERPRINT" diff --git a/scripts/lib/apt-common.sh b/scripts/lib/apt-common.sh new file mode 100644 index 000000000..af82b6c23 --- /dev/null +++ b/scripts/lib/apt-common.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +if [[ -n "${MICROSANDBOX_APT_COMMON_SH_LOADED:-}" ]]; then + return 0 +fi +MICROSANDBOX_APT_COMMON_SH_LOADED=1 + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "error: required command not found: $1" >&2 + exit 1 + } +} + +map_deb_arch() { + case "$1" in + amd64 | x86_64) printf '%s\n' "amd64" ;; + arm64 | aarch64) printf '%s\n' "arm64" ;; + *) + echo "error: unsupported Debian architecture: $1" >&2 + exit 1 + ;; + esac +} + +normalize_deb_version() { + local raw="$1" + local revision="${2:-1}" + local clean="${raw#v}" + + if [[ "$clean" == *-* ]]; then + printf '%s\n' "$clean" + return + fi + + printf '%s-%s\n' "$clean" "$revision" +} + +render_template() { + local template="$1" + shift + + local rendered + rendered="$(<"$template")" + + while [[ $# -gt 0 ]]; do + local key="$1" + local value="$2" + rendered="${rendered//${key}/${value}}" + shift 2 + done + + printf '%s' "$rendered" +} diff --git a/scripts/package-deb.sh b/scripts/package-deb.sh new file mode 100755 index 000000000..0c09c03a4 --- /dev/null +++ b/scripts/package-deb.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/package-deb.sh --arch --version --msb \ + --libkrunfw --output-dir [--package ] [--revision ] + +Build a Debian package for microsandbox from Linux release artifacts. +EOF +} + +PACKAGE_NAME="microsandbox" +ARCH="" +VERSION="" +REVISION="1" +MSB_PATH="" +LIBKRUNFW_PATH="" +OUTPUT_DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --arch) + ARCH="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --revision) + REVISION="$2" + shift 2 + ;; + --msb) + MSB_PATH="$2" + shift 2 + ;; + --libkrunfw) + LIBKRUNFW_PATH="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --package) + PACKAGE_NAME="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$ARCH" && -n "$VERSION" && -n "$MSB_PATH" && -n "$LIBKRUNFW_PATH" && -n "$OUTPUT_DIR" ]] || { + usage >&2 + exit 1 +} + +require_cmd dpkg-deb +require_cmd dpkg-shlibdeps +require_cmd gzip +require_cmd sed + +[[ -f "$MSB_PATH" ]] || { + echo "error: msb binary not found: $MSB_PATH" >&2 + exit 1 +} +[[ -f "$LIBKRUNFW_PATH" ]] || { + echo "error: libkrunfw library not found: $LIBKRUNFW_PATH" >&2 + exit 1 +} + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMPLATE_DIR="$REPO_ROOT/packaging/apt" +DEB_ARCH="$(map_deb_arch "$ARCH")" +DEB_VERSION="$(normalize_deb_version "$VERSION" "$REVISION")" +LIBKRUNFW_BASENAME="$(basename "$LIBKRUNFW_PATH")" +if [[ "$LIBKRUNFW_BASENAME" =~ ^libkrunfw\.so\.([0-9]+)\..+$ ]]; then + LIBKRUNFW_ABI="${BASH_REMATCH[1]}" +else + echo "error: unsupported libkrunfw filename: $LIBKRUNFW_BASENAME" >&2 + exit 1 +fi +LIBKRUNFW_SONAME_LINK="libkrunfw.so.$LIBKRUNFW_ABI" +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(git -C "$REPO_ROOT" log -1 --format=%ct 2>/dev/null || date +%s)}" + +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +PACKAGE_ROOT="$WORK_DIR/package" +DEBIAN_DIR="$PACKAGE_ROOT/DEBIAN" +DEBIAN_HELPER_DIR="$WORK_DIR/debian" +DOC_DIR="$PACKAGE_ROOT/usr/share/doc/$PACKAGE_NAME" +BIN_DIR="$PACKAGE_ROOT/usr/bin" +LIB_DIR="$PACKAGE_ROOT/usr/lib/microsandbox" + +mkdir -p "$DEBIAN_DIR" "$DEBIAN_HELPER_DIR" "$DOC_DIR" "$BIN_DIR" "$LIB_DIR" + +install -m755 "$MSB_PATH" "$BIN_DIR/msb" +install -m644 "$LIBKRUNFW_PATH" "$LIB_DIR/$LIBKRUNFW_BASENAME" +ln -s "$LIBKRUNFW_BASENAME" "$LIB_DIR/$LIBKRUNFW_SONAME_LINK" +ln -s "$LIBKRUNFW_SONAME_LINK" "$LIB_DIR/libkrunfw.so" + +install -m644 "$TEMPLATE_DIR/copyright" "$DOC_DIR/copyright" + +CHANGELOG_DATE="$(date -Ru -d "@$SOURCE_DATE_EPOCH")" +cat >"$DOC_DIR/changelog.Debian" < $CHANGELOG_DATE +EOF +gzip -n9 "$DOC_DIR/changelog.Debian" +chmod 644 "$DOC_DIR/changelog.Debian.gz" +find "$PACKAGE_ROOT" -type d -exec chmod 755 {} + + +while IFS= read -r -d '' path; do + touch -h -d "@$SOURCE_DATE_EPOCH" "$path" +done < <(find "$PACKAGE_ROOT" -print0) + +cat >"$DEBIAN_HELPER_DIR/control" < +Standards-Version: 4.7.0 + +Package: $PACKAGE_NAME +Architecture: $DEB_ARCH +Description: Lightweight microVM sandbox CLI + Microsandbox spins up lightweight, hardware-isolated microVMs from a local + CLI. This package installs the \`msb\` command and the private \`libkrunfw\` + runtime library used to boot and manage microsandbox environments on Debian + and Ubuntu systems. +EOF + +cat >"$DEBIAN_DIR/shlibs" <&2 + exit 1 +} + +rm -f "$DEBIAN_DIR/shlibs" + +INSTALLED_SIZE="$(du -sk "$PACKAGE_ROOT" | cut -f1)" + +render_template \ + "$TEMPLATE_DIR/control.template" \ + "@PACKAGE@" "$PACKAGE_NAME" \ + "@VERSION@" "$DEB_VERSION" \ + "@ARCH@" "$DEB_ARCH" \ + "@INSTALLED_SIZE@" "$INSTALLED_SIZE" \ + "@DEPENDS@" "$SHLIBS_DEPENDS" >"$DEBIAN_DIR/control" +printf '\n' >>"$DEBIAN_DIR/control" + +mkdir -p "$OUTPUT_DIR" +OUTPUT_PATH="$OUTPUT_DIR/${PACKAGE_NAME}_${DEB_VERSION}_${DEB_ARCH}.deb" +dpkg-deb --root-owner-group --uniform-compression -Zxz --build "$PACKAGE_ROOT" "$OUTPUT_PATH" >/dev/null + +printf '%s\n' "$OUTPUT_PATH" diff --git a/scripts/test-apt-repo.sh b/scripts/test-apt-repo.sh new file mode 100755 index 000000000..42e64838d --- /dev/null +++ b/scripts/test-apt-repo.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/test-apt-repo.sh --repo-v1 --repo-v2 --keyring \ + --bad-keyring [--image ...] + +Validate install, reinstall, upgrade, remove, purge, and signature failures +against a local signed APT repository inside Debian/Ubuntu containers. +EOF +} + +canonical_path() { + realpath "$1" +} + +REPO_V1="" +REPO_V2="" +KEYRING="" +BAD_KEYRING="" +IMAGES=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-v1) + REPO_V1="$2" + shift 2 + ;; + --repo-v2) + REPO_V2="$2" + shift 2 + ;; + --keyring) + KEYRING="$2" + shift 2 + ;; + --bad-keyring) + BAD_KEYRING="$2" + shift 2 + ;; + --image) + IMAGES+=("$2") + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$REPO_V1" && -n "$REPO_V2" && -n "$KEYRING" && -n "$BAD_KEYRING" ]] || { + usage >&2 + exit 1 +} + +if [[ ${#IMAGES[@]} -eq 0 ]]; then + IMAGES=("debian:12" "ubuntu:22.04" "ubuntu:24.04") +fi + +require_cmd docker +require_cmd dpkg-deb +require_cmd realpath + +[[ -d "$REPO_V1" && -d "$REPO_V2" ]] || { + echo "error: repository directories must exist" >&2 + exit 1 +} +[[ -f "$KEYRING" && -f "$BAD_KEYRING" ]] || { + echo "error: keyring files must exist" >&2 + exit 1 +} + +REPO_V1="$(canonical_path "$REPO_V1")" +REPO_V2="$(canonical_path "$REPO_V2")" +KEYRING="$(canonical_path "$KEYRING")" +BAD_KEYRING="$(canonical_path "$BAD_KEYRING")" + +DEB_V1="$(find "$REPO_V1/pool" -type f -name 'microsandbox_*.deb' | sort | head -n1)" +DEB_V2="$(find "$REPO_V2/pool" -type f -name 'microsandbox_*.deb' | sort | head -n1)" +VERSION_V1="$(dpkg-deb -f "$DEB_V1" Version)" +VERSION_V2="$(dpkg-deb -f "$DEB_V2" Version)" + +for image in "${IMAGES[@]}"; do + echo "==> Testing install and upgrade in $image" + docker run --rm \ + -e DEBIAN_FRONTEND=noninteractive \ + -e VERSION_V1="$VERSION_V1" \ + -e VERSION_V2="$VERSION_V2" \ + -v "$REPO_V1":/repo-v1:ro \ + -v "$REPO_V2":/repo-v2:ro \ + -v "$KEYRING":/tmp/microsandbox-archive-keyring.gpg:ro \ + "$image" \ + bash -euxo pipefail -c ' + apt-get update + apt-get install -y ca-certificates + mkdir -p /root/.microsandbox + touch /root/.microsandbox/state-marker + + install -Dm644 /tmp/microsandbox-archive-keyring.gpg \ + /usr/share/keyrings/microsandbox-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] file:///repo-v1 stable main" \ + >/etc/apt/sources.list.d/microsandbox.list + + apt-get update + apt-get install -y microsandbox + test -x /usr/bin/msb + versioned_lib="$(find /usr/lib/microsandbox -maxdepth 1 -type f -name "libkrunfw.so.*" | sort | head -n1)" + test -n "$versioned_lib" + soname_link="$(basename "$versioned_lib" | sed -E "s/^(libkrunfw\\.so\\.[0-9]+)\\..*$/\\1/")" + test -L "/usr/lib/microsandbox/$soname_link" + test -L /usr/lib/microsandbox/libkrunfw.so + test "$(readlink "/usr/lib/microsandbox/$soname_link")" = "$(basename "$versioned_lib")" + test "$(readlink /usr/lib/microsandbox/libkrunfw.so)" = "$soname_link" + msb --version + test "$(dpkg-query -W -f="\${Version}\n" microsandbox)" = "$VERSION_V1" + + apt-get install -y --reinstall microsandbox + test "$(dpkg-query -W -f="\${Version}\n" microsandbox)" = "$VERSION_V1" + + echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] file:///repo-v2 stable main" \ + >/etc/apt/sources.list.d/microsandbox.list + rm -rf /var/lib/apt/lists/* + apt-get update + apt-get install -y --only-upgrade microsandbox + test "$(dpkg-query -W -f="\${Version}\n" microsandbox)" = "$VERSION_V2" + + apt-get remove -y microsandbox + test ! -e /usr/bin/msb + test -e /root/.microsandbox/state-marker + + apt-get install -y microsandbox + apt-get purge -y microsandbox + test ! -e /usr/bin/msb + test -e /root/.microsandbox/state-marker + ' + + echo "==> Testing missing key failure in $image" + docker run --rm \ + -e DEBIAN_FRONTEND=noninteractive \ + -v "$REPO_V1":/repo-v1:ro \ + "$image" \ + bash -euxo pipefail -c ' + echo "deb file:///repo-v1 stable main" >/etc/apt/sources.list.d/microsandbox.list + if apt-get update; then + echo "error: apt update succeeded without repository key" >&2 + exit 1 + fi + ' + + echo "==> Testing wrong key failure in $image" + docker run --rm \ + -e DEBIAN_FRONTEND=noninteractive \ + -v "$REPO_V1":/repo-v1:ro \ + -v "$BAD_KEYRING":/tmp/wrong-keyring.gpg:ro \ + "$image" \ + bash -euxo pipefail -c ' + install -Dm644 /tmp/wrong-keyring.gpg \ + /usr/share/keyrings/microsandbox-archive-keyring.gpg + echo "deb [signed-by=/usr/share/keyrings/microsandbox-archive-keyring.gpg] file:///repo-v1 stable main" \ + >/etc/apt/sources.list.d/microsandbox.list + if apt-get update; then + echo "error: apt update succeeded with the wrong repository key" >&2 + exit 1 + fi + ' +done diff --git a/scripts/tests/test-apt-common.sh b/scripts/tests/test-apt-common.sh new file mode 100644 index 000000000..74816a23d --- /dev/null +++ b/scripts/tests/test-apt-common.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=../lib/apt-common.sh +source "$SCRIPT_DIR/../lib/apt-common.sh" + +assert_eq() { + local actual="$1" + local expected="$2" + local message="$3" + + if [[ "$actual" != "$expected" ]]; then + echo "assertion failed: $message" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 + fi +} + +assert_eq "$(map_deb_arch x86_64)" "amd64" "x86_64 maps to amd64" +assert_eq "$(map_deb_arch aarch64)" "arm64" "aarch64 maps to arm64" +assert_eq "$(normalize_deb_version v1.2.3 4)" "1.2.3-4" "v-prefixed versions gain revision" +assert_eq "$(normalize_deb_version 1.2.3-2 4)" "1.2.3-2" "existing Debian revisions are preserved" + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +template="$tmpdir/control.template" +cat >"$template" <<'EOF' +Package: @PACKAGE@ +Version: @VERSION@ +EOF + +rendered="$(render_template "$template" "@PACKAGE@" "microsandbox" "@VERSION@" "1.2.3-1")" +expected=$'Package: microsandbox\nVersion: 1.2.3-1' +assert_eq "$rendered" "$expected" "template placeholders are rendered" diff --git a/scripts/tests/test-workflows.py b/scripts/tests/test-workflows.py new file mode 100644 index 000000000..a6167069b --- /dev/null +++ b/scripts/tests/test-workflows.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CHECK_WORKFLOW = REPO_ROOT / ".github/workflows/check.yml" +RELEASE_WORKFLOW = REPO_ROOT / ".github/workflows/release.yml" + + +def load_workflow(path: Path) -> dict: + with path.open("r", encoding="utf-8") as handle: + return yaml.safe_load(handle) + + +def workflow_on(document: dict) -> dict: + return document.get("on", document.get(True, {})) + + +def find_checkout_step(document: dict, job_name: str) -> dict | None: + steps = document["jobs"][job_name]["steps"] + return next((step for step in steps if step.get("uses") == "actions/checkout@v4"), None) + + +def main() -> int: + failures: list[str] = [] + + check_text = CHECK_WORKFLOW.read_text(encoding="utf-8") + release_text = RELEASE_WORKFLOW.read_text(encoding="utf-8") + check = load_workflow(CHECK_WORKFLOW) + release = load_workflow(RELEASE_WORKFLOW) + + if workflow_on(release).get("push", {}).get("tags") != ["v*"]: + failures.append("release.yml must publish from push tags ['v*']") + + if "workflow_run" in release_text: + failures.append("release.yml must not depend on workflow_run context") + + if "prepare" in release.get("jobs", {}): + failures.append("release.yml must not gate publication behind a prepare job") + + if "release-metadata" in check_text: + failures.append("check.yml must not carry unreachable release metadata artifacts") + + for document, path, job_name in [ + (check, CHECK_WORKFLOW, "apt-package-test"), + (release, RELEASE_WORKFLOW, "apt-package"), + ]: + checkout = find_checkout_step(document, job_name) + if checkout is None: + failures.append(f"{path.name}:{job_name} must define an actions/checkout@v4 step") + continue + if checkout.get("with", {}).get("submodules") is not True: + failures.append(f"{path.name}:{job_name} checkout must enable submodules") + + if failures: + for failure in failures: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + + print("workflow structure checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-deb.sh b/scripts/validate-deb.sh new file mode 100755 index 000000000..e6d131d33 --- /dev/null +++ b/scripts/validate-deb.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/apt-common.sh +source "$SCRIPT_DIR/lib/apt-common.sh" + +usage() { + cat <<'EOF' +Usage: scripts/validate-deb.sh --deb --arch --version + +Run structural validation and lint checks for a microsandbox Debian package. +EOF +} + +DEB_PATH="" +ARCH="" +VERSION="" +REVISION="1" + +while [[ $# -gt 0 ]]; do + case "$1" in + --deb) + DEB_PATH="$2" + shift 2 + ;; + --arch) + ARCH="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --revision) + REVISION="$2" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +[[ -n "$DEB_PATH" && -n "$ARCH" && -n "$VERSION" ]] || { + usage >&2 + exit 1 +} + +require_cmd dpkg-deb +require_cmd lintian +require_cmd tar +require_cmd readlink + +[[ -f "$DEB_PATH" ]] || { + echo "error: package not found: $DEB_PATH" >&2 + exit 1 +} + +EXPECTED_ARCH="$(map_deb_arch "$ARCH")" +EXPECTED_VERSION="$(normalize_deb_version "$VERSION" "$REVISION")" + +[[ "$(dpkg-deb -f "$DEB_PATH" Package)" == "microsandbox" ]] +[[ "$(dpkg-deb -f "$DEB_PATH" Architecture)" == "$EXPECTED_ARCH" ]] +[[ "$(dpkg-deb -f "$DEB_PATH" Version)" == "$EXPECTED_VERSION" ]] + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +dpkg-deb -x "$DEB_PATH" "$TMP_DIR/root" + +[[ -x "$TMP_DIR/root/usr/bin/msb" ]] +[[ -d "$TMP_DIR/root/usr/lib/microsandbox" ]] + +mapfile -t VERSIONED_LIBS < <(find "$TMP_DIR/root/usr/lib/microsandbox" \ + -maxdepth 1 -type f -name 'libkrunfw.so.*' | sort) +[[ ${#VERSIONED_LIBS[@]} -eq 1 ]] + +VERSIONED_LIB_BASENAME="$(basename "${VERSIONED_LIBS[0]}")" +if [[ "$VERSIONED_LIB_BASENAME" =~ ^libkrunfw\.so\.([0-9]+)\..+$ ]]; then + LIBKRUNFW_SONAME_LINK="libkrunfw.so.${BASH_REMATCH[1]}" +else + echo "error: unsupported libkrunfw filename in package: $VERSIONED_LIB_BASENAME" >&2 + exit 1 +fi + +[[ -L "$TMP_DIR/root/usr/lib/microsandbox/$LIBKRUNFW_SONAME_LINK" ]] +[[ -L "$TMP_DIR/root/usr/lib/microsandbox/libkrunfw.so" ]] +[[ "$(readlink "$TMP_DIR/root/usr/lib/microsandbox/$LIBKRUNFW_SONAME_LINK")" == "$VERSIONED_LIB_BASENAME" ]] +[[ "$(readlink "$TMP_DIR/root/usr/lib/microsandbox/libkrunfw.so")" == "$LIBKRUNFW_SONAME_LINK" ]] + +[[ -f "$TMP_DIR/root/usr/share/doc/microsandbox/copyright" ]] +[[ -f "$TMP_DIR/root/usr/share/doc/microsandbox/changelog.Debian.gz" ]] + +[[ ! -e "$TMP_DIR/root/root/.microsandbox" ]] +[[ ! -e "$TMP_DIR/root/etc/profile.d/microsandbox.sh" ]] +[[ ! -e "$TMP_DIR/root/etc/bash.bashrc" ]] +[[ ! -e "$TMP_DIR/root/etc/skel/.bashrc" ]] +[[ ! -e "$TMP_DIR/root/usr/share/fish/vendor_conf.d/microsandbox.fish" ]] + +CONTROL_LIST="$(dpkg-deb --ctrl-tarfile "$DEB_PATH" | tar -tf -)" +for forbidden in preinst postinst prerm postrm triggers; do + if grep -Eq "(^|/)$forbidden$" <<<"$CONTROL_LIST"; then + echo "error: unexpected maintainer script present: $forbidden" >&2 + exit 1 + fi +done + +dpkg-deb --info "$DEB_PATH" >/dev/null +dpkg-deb --contents "$DEB_PATH" >/dev/null +lintian --fail-on error "$DEB_PATH"