foundation_deployment_platform 0.1.1

Foundation deployment platform — VM/container orchestration, Docker runtime, guest infrastructure
Documentation
//! `testbed init` — generates project scaffolding.
//!
//! Creates `$PWD/.testbed/` with:
//! - `testbed.toml` — minimal VM definition
//! - `.gitignore` — ignores ephemeral state, keeps config + scripts
//! - `<vm>/logs/`, `<vm>/artifacts/`, `<vm>/state/` — per-VM directories
//! - `scripts/<vm>/startup/` and `shutdown/` — built-in defaults

use std::fs;
use std::path::Path;

use crate::config::{GuestOs, Result, TestbedError};

/// Run `testbed init`: scaffold `$PWD/.testbed/` + `testbed.toml` in project root.
pub fn init(project_root: &Path, vm_names: &[String]) -> Result<()> {
    let testbed_dir = project_root.join(".testbed");
    let toml_path = project_root.join("testbed.toml");

    if toml_path.exists() {
        return Err(TestbedError::Qcow2Error {
            message: format!(
                "{} already exists — delete it or edit existing config",
                toml_path.display()
            ),
        });
    }

    // Create .testbed directory structure with per-VM subdirectories
    fs::create_dir_all(&testbed_dir).map_err(|e| TestbedError::Qcow2Error {
        message: format!("creating {}: {e}", testbed_dir.display()),
    })?;

    // Create per-VM directories for logs, artifacts, and scripts
    for vm_name in vm_names {
        let vm_dir = testbed_dir.join(vm_name);
        for subdir in &["logs", "artifacts", "state"] {
            fs::create_dir_all(vm_dir.join(subdir)).map_err(|e| TestbedError::Qcow2Error {
                message: format!("creating {vm_name}/{subdir}/: {e}"),
            })?;
        }
        generate_scripts(&testbed_dir, vm_name)?;
    }

    // Also create top-level shared directories
    fs::create_dir_all(testbed_dir.join("artifacts")).map_err(|e| TestbedError::Qcow2Error {
        message: format!("creating artifacts/: {e}"),
    })?;

    // Generate testbed.toml in project root ($PWD)
    let toml_content = generate_testbed_toml(vm_names);
    fs::write(&toml_path, &toml_content).map_err(|e| TestbedError::Qcow2Error {
        message: format!("writing testbed.toml: {e}"),
    })?;

    // Generate .gitignore
    let gitignore = generate_gitignore();
    fs::write(testbed_dir.join(".gitignore"), &gitignore).map_err(|e| TestbedError::Qcow2Error {
        message: format!("writing .gitignore: {e}"),
    })?;

    // Generate scripts for each VM
    for vm_name in vm_names {
        generate_scripts(&testbed_dir, vm_name)?;
    }

    Ok(())
}

/// Determine the guest mount path for a VM based on its name.
fn guest_mount_path(vm_name: &str) -> &'static str {
    let lower = vm_name.to_lowercase();
    if lower.contains("windows") {
        "C:/Users/vagrant/project"
    } else if lower.contains("macos") {
        "/Users/vagrant/project"
    } else {
        "/mnt/project"
    }
}

/// Generate minimal testbed.toml content.
pub fn generate_testbed_toml(vm_names: &[String]) -> String {
    let mut out = String::from(
        "# testbed.toml — project-level VM configuration\n\
         # Generated by `ewe_platform testbed init`\n\n",
    );

    for name in vm_names {
        let guest_path = guest_mount_path(name);
        out.push_str(&format!(
            "[[vms]]\n\
             name = \"{name}\"\n\
             profile = \"{name}\"\n\
             mount = {{ host_path = \".\", guest_path = \"{guest_path}\", readonly = false }}\n\n",
        ));
    }

    // Include macOS VM if not already listed
    let has_macos = vm_names.iter().any(|n| n.contains("macos"));
    if !has_macos {
        out.push_str(
            "# macOS VM — uses pre-baked image from store or native IPSW fallback\n\
             [[vms]]\n\
             name = \"macos-build\"\n\
             profile = \"macos-build\"\n\
             mount = {{ host_path = \".\", guest_path = \"/Users/vagrant/project\", readonly = false }}\n\n",
        );
    }

    out.push_str(
        "# Artifact mirroring\n\
         [artifacts]\n\
         sync = true                           # Mirror build outputs\n\n",
    );

    out.push_str(
        "# Image stores — ordered sources for VM disk images.\n\
         # First store with a matching image wins.\n\n\
         # macOS pre-baked tarball (BaseSystem.qcow2 + opencore.qcow2 + macOS-data.qcow2)\n\
         [[image_stores]]\n\
         name = \"macos-build\"\n\
         type = \"http\"\n\
         destination = \"https://pub-abc123.r2.dev/testbed-images\"\n\
         key_prefix = \"macos-build/\"\n\n",
    );

    out.push_str(
        "# Vagrant Cloud — default fallback for Windows/Linux images\n\
         [[image_stores]]\n\
         name = \"vagrant_cloud\"\n\
         type = \"vagrant\"\n\
         registry = \"libvirt\"\n\n",
    );

    let local_store = dirs::home_dir()
        .map(|h| h.join(".testbed"))
        .unwrap_or_default()
        .to_string_lossy()
        .into_owned();
    out.push_str(&format!(
        "# Local image store — user home directory\n\
         [[image_stores]]\n\
         name = \"local\"\n\
         type = \"local\"\n\
         destination = \"{local_store}\"\n\
         key_prefix = \"\"\n\n",
    ));


    out
}

/// Generate .gitignore for $PWD/.testbed/.
pub fn generate_gitignore() -> String {
    String::from(
        "# $PWD/.testbed/.gitignore — auto-generated by testbed init\n\
         # Commit testbed.toml and scripts/; ignore ephemeral data.\n\n\
         # VM state — host-specific PIDs, ports, disk paths\n\
         /*/state/\n\n\
         # Build/run logs — can be large, regenerated on next build\n\
         /*/logs/\n\n\
         # Build artifacts — live in host's target/ directory, symlinked here\n\
         /*/artifacts/\n\
         /artifacts/\n\n\
         # Mount points — symlink to project root\n\
         /mounts/\n\n\
         # Keep: testbed.toml (project config) ✓\n\
         # Keep: scripts/ (user custom startup/shutdown scripts) ✓\n",
    )
}

/// Generate built-in scripts for a VM.
fn generate_scripts(testbed_dir: &Path, vm_name: &str) -> Result<()> {
    let scripts_dir = testbed_dir.join("scripts").join(vm_name);

    // Determine OS for the VM name to pick the right built-in script
    let is_windows = vm_name.to_lowercase().contains("windows");

    if is_windows {
        let startup = scripts_dir.join("startup");
        fs::create_dir_all(&startup).map_err(|e| TestbedError::Qcow2Error {
            message: format!("creating {startup:?}: {e}"),
        })?;
        fs::write(
            startup.join("00-defender.ps1"),
            BUILTIN_DEFENDER_EXCLUSIONS,
        )
        .map_err(|e| TestbedError::Qcow2Error {
            message: format!("writing 00-defender.ps1: {e}"),
        })?;
    } else {
        let startup = scripts_dir.join("startup");
        fs::create_dir_all(&startup).map_err(|e| TestbedError::Qcow2Error {
            message: format!("creating {startup:?}: {e}"),
        })?;
        fs::write(
            startup.join("00-mount.sh"),
            BUILTIN_MOUNT_CHECK,
        )
        .map_err(|e| TestbedError::Qcow2Error {
            message: format!("writing 00-mount.sh: {e}"),
        })?;
    }

    // Always create shutdown dir (empty, user can add scripts)
    fs::create_dir_all(scripts_dir.join("shutdown")).map_err(|e| TestbedError::Qcow2Error {
        message: format!("creating shutdown/: {e}"),
    })?;

    Ok(())
}

/// Built-in Windows Defender exclusions script.
pub const BUILTIN_DEFENDER_EXCLUSIONS: &str = r#"# Windows Defender exclusions for development directories
# Edit or delete if you don't need these
Add-MpPreference -ExclusionPath "C:\Users\vagrant\project" -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath "C:\Users\vagrant\.cargo" -ErrorAction SilentlyContinue
Add-MpPreference -ExclusionPath "C:\Users\vagrant\.rustup" -ErrorAction SilentlyContinue
"#;

/// Built-in Linux mount verification script.
pub const BUILTIN_MOUNT_CHECK: &str = r#"# Verify project mount is accessible
if mount | grep -q "/mnt/project"; then
  echo "✓ /mnt/project mounted"
else
  echo "⚠ /mnt/project not mounted, attempting mount..."
  sudo mount -t 9p -o trans=virtio,version=9p2000.L project /mnt/project 2>/dev/null || true
fi
"#;

/// Execute startup scripts for a VM in numbered order.
///
/// Scripts in `$PWD/.testbed/scripts/<vm>/startup/` are sorted by filename
/// and executed sequentially. If a script fails, execution stops.
pub fn run_startup_scripts(vm_name: &str, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
    let scripts_dir = Path::new(".testbed").join("scripts").join(vm_name).join("startup");
    run_scripts_dir(&scripts_dir, os, ssh_port, user)
}

/// Execute shutdown scripts for a VM in numbered order.
pub fn run_shutdown_scripts(vm_name: &str, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
    let scripts_dir = Path::new(".testbed").join("scripts").join(vm_name).join("shutdown");
    run_scripts_dir(&scripts_dir, os, ssh_port, user)
}

/// Run all scripts in a directory, sorted by filename.
fn run_scripts_dir(dir: &Path, os: GuestOs, ssh_port: u16, user: &str) -> Result<()> {
    if !dir.exists() {
        return Ok(());
    }

    let mut entries: Vec<_> = fs::read_dir(dir)
        .map_err(|e| TestbedError::SshFailed {
            port: ssh_port,
            source: anyhow::anyhow!("reading {dir:?}: {e}"),
        })?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().is_some())
        .collect();
    entries.sort_by_key(|e| e.file_name());

    for entry in &entries {
        let path = entry.path();
        let content = fs::read_to_string(&path).map_err(|e| TestbedError::SshFailed {
            port: ssh_port,
            source: anyhow::anyhow!("reading {:?}: {e}", path.display()),
        })?;

        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
        let (output, code) = match (os, ext) {
            (GuestOs::Windows, "ps1") => {
                let mut session = crate::ssh::connect_from_port(ssh_port, user, os)?;
                crate::ssh::exec_ps_windows(&mut session, &content)?
            }
            _ => {
                let mut session = crate::ssh::connect_from_port(ssh_port, user, os)?;
                let wrapped = format!("nu -c {}", shell_quote(&content));
                crate::ssh::exec_with_exit(&mut session, &wrapped)?
            }
        };

        if code != 0 {
            return Err(TestbedError::SshFailed {
                port: ssh_port,
                source: anyhow::anyhow!(
                    "script {:?} failed with exit code {code}\n{output}",
                    path.display()
                ),
            });
        }
    }

    Ok(())
}

fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', r"'\''"))
}