chore(baker/prebake): Add comprehensive documentation to prebake module
- Document prebake.rs, bootstrap.rs, and hook.rs with module-level and function-level docs - Rename parse_prebake_config to parse for conciseness - Change bootstrap() return type from ExecutionResult to () - Add dry-run support to bootstrap stage - Improve test robustness with Docker availability check and unique container names
This commit is contained in:
@@ -1,3 +1,25 @@
|
||||
//! Prebake workflow orchestration module.
|
||||
//!
|
||||
//! This module provides the main prebake workflow execution logic, orchestrating
|
||||
//! the various stages of build environment preparation.
|
||||
//!
|
||||
//! # Stages
|
||||
//!
|
||||
//! The prebake workflow executes the following stages in order:
|
||||
//!
|
||||
//! 1. **Bootstrap**: Sets up the build environment (user, workspace, privileges)
|
||||
//! 2. **EarlyHook**: Executes user-defined commands before dependency installation
|
||||
//! 3. **DepsSystem**: Installs system-level package dependencies
|
||||
//! 4. **DepsUser**: Installs user-level dependencies
|
||||
//! 5. **LateHook**: Executes user-defined commands after dependency installation
|
||||
//! 6. **Ready**: Signals completion of the prebake workflow
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! let result = prebake("/path/to/prebake.yml", &cli, None).await;
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod env;
|
||||
pub mod security;
|
||||
@@ -12,14 +34,53 @@ use crate::{
|
||||
pub use config::PrebakeConfig;
|
||||
pub use security::prebake_drop_privilege;
|
||||
|
||||
pub fn parse_prebake_config(yaml_content: &str) -> Result<PrebakeConfig, serde_yaml::Error> {
|
||||
/// Parses a YAML configuration string into a `PrebakeConfig`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `yaml_content` - A string containing YAML configuration
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(PrebakeConfig)` on successful parsing, or `Err(serde_yaml::Error)`
|
||||
/// if the YAML is invalid.
|
||||
|
||||
pub fn parse(yaml_content: &str) -> Result<PrebakeConfig, serde_yaml::Error> {
|
||||
serde_yaml::from_str(yaml_content)
|
||||
}
|
||||
|
||||
pub fn serialize_prebake_config(config: &PrebakeConfig) -> Result<String, serde_yaml::Error> {
|
||||
serde_yaml::to_string(config)
|
||||
}
|
||||
// pub fn serialize_prebake_config(config: &PrebakeConfig) -> Result<String, serde_yaml::Error> {
|
||||
// serde_yaml::to_string(config)
|
||||
// }
|
||||
|
||||
/// Executes the complete prebake workflow.
|
||||
///
|
||||
/// This function orchestrates the entire prebake process, including:
|
||||
/// - Configuration loading and validation
|
||||
/// - Privilege dropping based on security settings
|
||||
/// - Sequential execution of all prebake stages
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config_path` - Path to the prebake.yml configuration file
|
||||
/// * `cli` - CLI arguments containing pipeline, build_id, username, and dry_run flag
|
||||
/// * `stage` - Optional specific stage to start from (defaults to Bootstrap)
|
||||
///
|
||||
/// # Stage Execution
|
||||
///
|
||||
/// The function executes stages sequentially based on the `stage` parameter:
|
||||
/// - If `stage` is `None`, starts from Bootstrap
|
||||
/// - If `stage` is `Some`, starts from the specified stage
|
||||
///
|
||||
/// Each stage may drop privileges before execution according to the security configuration.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(())` on successful completion of all stages, or `anyhow::Result<()>` if:
|
||||
/// - Configuration file cannot be read
|
||||
/// - YAML parsing fails
|
||||
/// - Configuration validation fails
|
||||
/// - Any stage execution fails
|
||||
pub async fn prebake(
|
||||
config_path: &str,
|
||||
cli: &Cli,
|
||||
@@ -27,7 +88,7 @@ pub async fn prebake(
|
||||
) -> anyhow::Result<()> {
|
||||
// Initialize
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
let config = parse_prebake_config(config_content.as_str()).map_err(|e| {
|
||||
let config = parse(config_content.as_str()).map_err(|e| {
|
||||
log::error!("Failed to parse prebake.yml: {}", e);
|
||||
PrebakeError::YamlParseError(e)
|
||||
})?;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
//! Bootstrap stage implementation for the prebake workflow.
|
||||
//!
|
||||
//! This module handles the initial setup of the build environment, including:
|
||||
//! - User creation for the build environment
|
||||
//! - Workspace directory setup
|
||||
//! - Privilege executor configuration (sudo/doas)
|
||||
//!
|
||||
//! The bootstrap script is generated dynamically based on the prebake configuration
|
||||
//! and the target operating system, supporting multiple package managers.
|
||||
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::{Engine, EventSender, ExecutionResult, pm};
|
||||
use crate::engine::{Engine, EventSender, pm};
|
||||
use crate::error::BootstrapError;
|
||||
use crate::prebake::config::Bootstrap;
|
||||
use crate::prebake::config::PrebakeConfig;
|
||||
@@ -8,12 +18,35 @@ use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use which::which;
|
||||
|
||||
/// Executes the bootstrap stage of the prebake workflow.
|
||||
///
|
||||
/// This function generates a bootstrap script based on the provided configuration,
|
||||
/// writes it to `/bootstrap.sh`, and then executes it within the build environment.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - The prebake configuration containing bootstrap, dependencies, and security settings
|
||||
/// * `osinfo` - Operating system information for platform-specific configuration
|
||||
/// * `ctx` - Execution context containing pipeline parameters and dry-run flag
|
||||
/// * `event_tx` - Event sender for reporting stage progress (currently unused)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(())` on successful bootstrap execution, or a `BootstrapError` if:
|
||||
/// - The bootstrap script generation fails
|
||||
/// - The generated script does not exist at `/bootstrap.sh`
|
||||
/// - Script execution fails
|
||||
///
|
||||
/// # Dry Run Mode
|
||||
///
|
||||
/// When `ctx.dry_run` is `true`, the function generates the bootstrap script
|
||||
/// and logs it without executing it, allowing inspection of the generated script.
|
||||
pub async fn bootstrap(
|
||||
config: &PrebakeConfig,
|
||||
osinfo: &os_info::Info,
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: &EventSender,
|
||||
) -> Result<ExecutionResult, BootstrapError> {
|
||||
) -> Result<(), BootstrapError> {
|
||||
let engine = Engine::new();
|
||||
let bootstrap_path = PathBuf::from("/bootstrap.sh");
|
||||
|
||||
@@ -21,9 +54,15 @@ pub async fn bootstrap(
|
||||
|
||||
if bootstrap_config.custom.is_none() {
|
||||
let script = generate_bootstrap(&bootstrap_config, &config, &osinfo)?;
|
||||
if ctx.dry_run {
|
||||
log::info!("[DRY_RUN] Generated bootstrap script:\n{}", script);
|
||||
return Ok(());
|
||||
}
|
||||
log::debug!("Generated bootstrap script:\n{}", script);
|
||||
let mut file = File::create(&bootstrap_path)?;
|
||||
file.write_all(script.as_bytes())?;
|
||||
} else {
|
||||
// Requires PIP(Pipeline Implicit Parameters) to be implemented
|
||||
unimplemented!("Not supporting custom bootstrap.sh at this time");
|
||||
}
|
||||
|
||||
@@ -37,9 +76,39 @@ pub async fn bootstrap(
|
||||
engine
|
||||
.execute_script(&bootstrap_path, ctx, &event_tx)
|
||||
.await
|
||||
.map_err(|e| BootstrapError::ExecutionError(e))
|
||||
.map_err(|e| BootstrapError::ExecutionError(e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Generates a bootstrap script based on configuration and target OS.
|
||||
///
|
||||
/// The generated script performs the following setup tasks:
|
||||
///
|
||||
/// 1. **User Creation**: Creates a dedicated user for the build environment
|
||||
/// using `useradd`, `adduser`, or `busybox useradd` depending on availability
|
||||
///
|
||||
/// 2. **Workspace Setup**: Creates the workspace directory and optionally
|
||||
/// symlinks it to `/workspace` for convenient access
|
||||
///
|
||||
/// 3. **Privilege Executor**: Configures `sudo` or `doas` to allow the build user
|
||||
/// to execute package manager commands without a password
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `bootstrap_config` - Bootstrap-specific configuration (user, workspace, sudoers)
|
||||
/// * `config` - Full prebake configuration for dependency and package manager detection
|
||||
/// * `osinfo` - Operating system information for package manager detection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns the generated bootstrap script as a `String`, or a `BootstrapError` if:
|
||||
/// - No suitable user creation binary is found
|
||||
/// - Package manager configuration is invalid
|
||||
///
|
||||
/// # Script Format
|
||||
///
|
||||
/// The generated script uses `#!/bin/sh` and includes `set -e` for error handling.
|
||||
/// It outputs "Bootstrap complete!" upon successful completion.
|
||||
fn generate_bootstrap(
|
||||
bootstrap_config: &Bootstrap,
|
||||
config: &PrebakeConfig,
|
||||
@@ -54,6 +123,7 @@ fn generate_bootstrap(
|
||||
// Create user
|
||||
let user = &bootstrap_config.user;
|
||||
if user == "root" {
|
||||
// Requires PIP(Pipeline Implicit Parameters) to be implemented
|
||||
unimplemented!("Permission check is not implemented, failing...");
|
||||
} else {
|
||||
script.push_str("# User creation\n");
|
||||
@@ -97,15 +167,17 @@ fn generate_bootstrap(
|
||||
let allowed_commands = format!("/usr/bin/{} *", pkg_manager.binary());
|
||||
let content = format!("{} ALL=(ALL:ALL) NOPASSWD {}", user, allowed_commands);
|
||||
script.push_str(&format!(
|
||||
"echo '{}' | tee /etc/sudoers.d/workshop\n",
|
||||
"echo '{}' | tee /etc/sudoers.d/workshop > /dev/null\n",
|
||||
content
|
||||
));
|
||||
script.push_str("chmod 440 /etc/sudoers.d/workshop\n");
|
||||
}
|
||||
} else if which("doas").is_ok() {
|
||||
if bootstrap_config.doas.is_some() {
|
||||
// Requires PIP(Pipeline Implicit Parameters) to be implemented
|
||||
unimplemented!("Permission check is not implemented, failing...")
|
||||
}
|
||||
// We do not know how to write doas.conf lol
|
||||
unimplemented!("doas is not implemented");
|
||||
}
|
||||
|
||||
@@ -113,6 +185,28 @@ fn generate_bootstrap(
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Detects and selects an appropriate package manager based on configuration and OS.
|
||||
///
|
||||
/// This function first attempts to detect the OS's native package manager, then
|
||||
/// checks if it's configured in the prebake.yml. If the detected package manager
|
||||
/// is not configured, it falls back to the first configured package manager.
|
||||
///
|
||||
/// # Detection Priority
|
||||
///
|
||||
/// 1. **Detected Package Manager**: Uses `pm::detect()` to find the OS-native package manager
|
||||
/// (e.g., `apt` on Debian/Ubuntu, `pacman` on Arch Linux)
|
||||
/// 2. **Configured Package Manager**: If detected manager is not in config, uses the
|
||||
/// first configured package manager from the dependencies section
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Prebake configuration containing dependencies.system configuration
|
||||
/// * `osinfo` - Operating system information for package manager detection
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Some(Box<dyn PackageManager>)` if a suitable package manager is found,
|
||||
/// or `None` if no package manager is configured or detectable.
|
||||
fn get_package_manager(config: &PrebakeConfig, osinfo: &os_info::Info) -> Option<Box<dyn pm::PackageManager>> {
|
||||
config
|
||||
.dependencies
|
||||
@@ -141,7 +235,7 @@ fn get_package_manager(config: &PrebakeConfig, osinfo: &os_info::Info) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
log::error!("No valid package manager found in configuration");
|
||||
log::warn!("No valid package manager found in configuration");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,59 @@
|
||||
//! Hook execution stage implementation for the prebake workflow.
|
||||
//!
|
||||
//! This module handles the execution of user-defined hook commands at various stages
|
||||
//! of the prebake workflow. Hooks are custom commands that can be executed before
|
||||
//! or after dependency installation.
|
||||
//!
|
||||
//! # Hook Types
|
||||
//!
|
||||
//! - **Early Hooks**: Executed before dependency installation
|
||||
//! - **Late Hooks**: Executed after dependency installation
|
||||
//!
|
||||
//! Each hook command supports custom working directories, environment variables,
|
||||
//! and timeout configurations.
|
||||
|
||||
use crate::engine::types::DeltaExecutionContext;
|
||||
use crate::ExecutionContext;
|
||||
use crate::engine::{Engine, EventSender, ExecutionError};
|
||||
use crate::types::command::CustomCommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Executes a sequence of hook commands.
|
||||
///
|
||||
/// This function processes a list of hook commands, executing each one in sequence
|
||||
/// with its own working directory, environment variables, and timeout settings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `hook` - Optional vector of hook commands to execute. If `None` or empty,
|
||||
/// the function returns immediately with `Ok(())`.
|
||||
/// * `ctx` - Base execution context containing pipeline parameters and settings
|
||||
/// * `event_tx` - Event sender for reporting hook execution progress
|
||||
///
|
||||
/// # Execution Behavior
|
||||
///
|
||||
/// 1. Hooks are executed sequentially in the order they appear in the configuration
|
||||
/// 2. Each hook can specify:
|
||||
/// - A custom working directory (inherited from parent if not specified)
|
||||
/// - Custom environment variables (merged with parent environment)
|
||||
/// - A custom timeout (defaults to the hook's configured value)
|
||||
/// 3. If any hook fails, execution stops and an error is returned
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns `Ok(())` if all hooks execute successfully, or `Err(ExecutionError)`
|
||||
/// if any hook command fails. The error includes the hook index and failure details.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```yaml
|
||||
/// hooks:
|
||||
/// early:
|
||||
/// - command: "echo 'Starting dependency installation'"
|
||||
/// name: "announce-start"
|
||||
/// working_dir: "/workspace"
|
||||
/// timeout: 30
|
||||
/// ```
|
||||
pub async fn hook(
|
||||
hook: Option<Vec<CustomCommand>>,
|
||||
ctx: &ExecutionContext,
|
||||
|
||||
@@ -22,6 +22,17 @@ import docker
|
||||
import pytest
|
||||
|
||||
|
||||
def docker_available():
|
||||
"""Check if Docker daemon is available."""
|
||||
try:
|
||||
client = docker.from_env()
|
||||
client.ping()
|
||||
client.close()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class DockerContainer:
|
||||
"""Context manager for Docker container lifecycle."""
|
||||
|
||||
@@ -117,20 +128,19 @@ class DockerContainer:
|
||||
self.container.put_archive(os.path.dirname(container_path), tar_stream)
|
||||
|
||||
|
||||
class TestPrebakeIntegration:
|
||||
"""Integration tests for workshop-baker prebake workflow."""
|
||||
class TestBootstrapUnit:
|
||||
"""Unit tests for bootstrap stage - tests bootstrap.sh generation."""
|
||||
|
||||
IMAGE = "archlinux:latest"
|
||||
CONTAINER_NAME = "workshop-baker-integration-test"
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
BINARY_NAME = "workshop-executor"
|
||||
EXAMPLES_DIR = PROJECT_ROOT / "examples"
|
||||
PREBAKE_YML = EXAMPLES_DIR / "prebake.yml"
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def container_with_binary(self):
|
||||
"""Provide a running archlinux container with pre-built binary copied."""
|
||||
# Determine binary path (prefer release)
|
||||
if not docker_available():
|
||||
pytest.skip("Docker daemon is not available")
|
||||
|
||||
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
|
||||
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
|
||||
|
||||
@@ -143,7 +153,6 @@ class TestPrebakeIntegration:
|
||||
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
|
||||
)
|
||||
|
||||
# Check if image exists locally
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.images.get(self.IMAGE)
|
||||
@@ -162,29 +171,25 @@ class TestPrebakeIntegration:
|
||||
"volumes": {
|
||||
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
|
||||
},
|
||||
"working_dir": "/workshop-baker",
|
||||
}
|
||||
|
||||
container = DockerContainer(
|
||||
self.IMAGE,
|
||||
self.CONTAINER_NAME,
|
||||
f"workshop-baker-unit-{int(time.time())}",
|
||||
**container_kwargs,
|
||||
)
|
||||
|
||||
with container:
|
||||
# Copy pre-built binary to container
|
||||
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
|
||||
|
||||
# Make binary executable
|
||||
exit_code, stdout, stderr = container.exec_run(
|
||||
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
|
||||
)
|
||||
if exit_code != 0:
|
||||
pytest.fail(f"Failed to make binary executable: {stderr}")
|
||||
|
||||
# Create a minimal bootstrap.sh (required by prebake.yml)
|
||||
bootstrap_sh = container.host_dir / "bootstrap.sh"
|
||||
bootstrap_sh.write_text("#!/bin/bash\nexit 0\n")
|
||||
bootstrap_sh.write_text("#!/bin/bash\necho 'Bootstrap executed'\nexit 0\n")
|
||||
container.copy_file(bootstrap_sh, "/bootstrap.sh")
|
||||
|
||||
exit_code, stdout, stderr = container.exec_run("chmod +x /bootstrap.sh")
|
||||
@@ -193,88 +198,118 @@ class TestPrebakeIntegration:
|
||||
|
||||
yield container
|
||||
|
||||
def test_binary_help(self, container_with_binary: DockerContainer):
|
||||
"""Verify binary runs and shows help."""
|
||||
cmd = f"/usr/local/bin/{self.BINARY_NAME} --help"
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
|
||||
assert exit_code == 0, f"Binary help failed: {stderr}"
|
||||
|
||||
def test_prebake_subcommand_exists(self, container_with_binary: DockerContainer):
|
||||
"""Verify prebake subcommand is recognized."""
|
||||
cmd = f"/usr/local/bin/{self.BINARY_NAME} --help"
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
combined = stdout + stderr
|
||||
assert "prebake" in combined.lower() or exit_code == 0
|
||||
|
||||
def test_bootstrap_script_runs(self, container_with_binary: DockerContainer):
|
||||
"""Test that bootstrap.sh can be executed."""
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
"/bootstrap.sh", workdir="/workshop-baker"
|
||||
)
|
||||
assert exit_code == 0, f"Bootstrap script failed: {stderr}"
|
||||
assert "Bootstrap executed" in stdout
|
||||
|
||||
|
||||
class TestPrebakeStages:
|
||||
"""Integration tests for each prebake stage."""
|
||||
|
||||
IMAGE = "archlinux:latest"
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
BINARY_NAME = "workshop-executor"
|
||||
EXAMPLES_DIR = PROJECT_ROOT / "examples"
|
||||
PREBAKE_YML = EXAMPLES_DIR / "prebake.yml"
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def container(self):
|
||||
"""Provide a running archlinux container (without binary copy)."""
|
||||
def container_with_binary(self):
|
||||
"""Provide a running archlinux container with pre-built binary copied."""
|
||||
if not docker_available():
|
||||
pytest.skip("Docker daemon is not available")
|
||||
|
||||
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
|
||||
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
|
||||
|
||||
if release_binary.exists():
|
||||
binary_path = release_binary
|
||||
elif debug_binary.exists():
|
||||
binary_path = debug_binary
|
||||
else:
|
||||
pytest.skip(
|
||||
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
|
||||
)
|
||||
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.images.get(self.IMAGE)
|
||||
except docker.errors.ImageNotFound:
|
||||
try:
|
||||
print(f"Pulling image {self.IMAGE}...")
|
||||
client.images.pull(self.IMAGE)
|
||||
except docker.errors.DockerException as e:
|
||||
pytest.skip(f"Could not pull image {self.IMAGE}: {e}")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Could not access Docker: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
container_kwargs = {
|
||||
"volumes": {
|
||||
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
|
||||
},
|
||||
"working_dir": "/workshop-baker",
|
||||
}
|
||||
|
||||
container = DockerContainer(
|
||||
self.IMAGE,
|
||||
None,
|
||||
f"workshop-baker-stages-{int(time.time())}",
|
||||
**container_kwargs,
|
||||
)
|
||||
|
||||
with container:
|
||||
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
|
||||
|
||||
exit_code, stdout, stderr = container.exec_run(
|
||||
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
|
||||
)
|
||||
if exit_code != 0:
|
||||
pytest.fail(f"Failed to make binary executable: {stderr}")
|
||||
|
||||
bootstrap_sh = container.host_dir / "bootstrap.sh"
|
||||
bootstrap_sh.write_text(
|
||||
"#!/bin/bash\n"
|
||||
"set -e\n"
|
||||
"echo 'Bootstrap stage: Creating user'\n"
|
||||
"if command -v useradd &> /dev/null; then\n"
|
||||
" useradd -m -s /bin/nologin vulcan 2>/dev/null || true\n"
|
||||
"elif command -v adduser &> /dev/null; then\n"
|
||||
" adduser -D vulcan 2>/dev/null || true\n"
|
||||
"fi\n"
|
||||
"echo 'Bootstrap stage: Setting up workspace'\n"
|
||||
"mkdir -p /workspace\n"
|
||||
"ln -sf /workspace /workspace_link 2>/dev/null || true\n"
|
||||
"echo 'Bootstrap complete!'\n"
|
||||
"exit 0\n"
|
||||
)
|
||||
container.copy_file(bootstrap_sh, "/bootstrap.sh")
|
||||
|
||||
exit_code, stdout, stderr = container.exec_run("chmod +x /bootstrap.sh")
|
||||
if exit_code != 0:
|
||||
pytest.fail(f"Failed to set bootstrap.sh executable: {stderr}")
|
||||
|
||||
yield container
|
||||
|
||||
@staticmethod
|
||||
def _get_setup_script() -> str:
|
||||
"""Return shell script to setup container environment (for non-binary tests)."""
|
||||
return """
|
||||
set -e
|
||||
|
||||
# Update package database
|
||||
pacman -Sy --noconfirm
|
||||
|
||||
# Install base development tools
|
||||
pacman -S --noconfirm \
|
||||
base-devel \
|
||||
git \
|
||||
curl \
|
||||
sudo \
|
||||
python
|
||||
|
||||
echo "=== Container Setup Complete ==="
|
||||
"""
|
||||
|
||||
def test_container_connectivity(self, container: DockerContainer):
|
||||
"""Test that container can reach external networks."""
|
||||
exit_code, stdout, stderr = container.exec_run("ping -c 1 8.8.8.8")
|
||||
assert exit_code == 0, f"Container network connectivity failed: {stderr}"
|
||||
|
||||
def test_binary_exists(self):
|
||||
"""Verify pre-built binary exists."""
|
||||
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
|
||||
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
|
||||
|
||||
assert release_binary.exists() or debug_binary.exists(), (
|
||||
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
|
||||
)
|
||||
|
||||
def test_binary_runs_in_container(self, container_with_binary: DockerContainer):
|
||||
"""Test that the pre-built binary can execute in the container."""
|
||||
cmd = f"/usr/local/bin/{self.BINARY_NAME} --help"
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
|
||||
|
||||
print(f"Binary help output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
# Binary should at least run and show help or version
|
||||
assert exit_code == 0, f"Binary failed to execute: {stderr}"
|
||||
assert (
|
||||
"usage" in stdout.lower()
|
||||
or "help" in stdout.lower()
|
||||
or "version" in stdout.lower()
|
||||
), f"Binary help output unexpected: {stdout}"
|
||||
|
||||
def test_prebake_help_command(self, container_with_binary: DockerContainer):
|
||||
"""Test that the CLI help works."""
|
||||
cmd = f"/usr/local/bin/{self.BINARY_NAME} --help"
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
|
||||
|
||||
assert exit_code == 0, f"Help command failed: {stderr}"
|
||||
assert (
|
||||
"Honey Biscuit Workshop" in stdout
|
||||
or "Usage" in stdout
|
||||
or "help" in stdout.lower()
|
||||
)
|
||||
|
||||
def test_prebake_config_parsing(self, container_with_binary: DockerContainer):
|
||||
"""Test that prebake.yml can be parsed correctly."""
|
||||
"""Test that prebake.yml can be parsed without errors."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -287,15 +322,15 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Config parsing output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
print(f"Config parsing:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
# dry-run should succeed even if actual execution would fail
|
||||
assert exit_code == 0 or "ERROR" not in stderr.upper(), (
|
||||
combined = stdout + stderr
|
||||
assert "error" not in combined.lower() or exit_code == 0, (
|
||||
f"Config parsing failed: {stderr}"
|
||||
)
|
||||
|
||||
def test_prebake_bootstrap_stage(self, container_with_binary: DockerContainer):
|
||||
"""Test bootstrap stage execution."""
|
||||
def test_bootstrap_stage_executes(self, container_with_binary: DockerContainer):
|
||||
"""Test bootstrap stage runs successfully."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -307,13 +342,14 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Bootstrap test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
print(f"Bootstrap stage:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
combined_output = stdout + stderr
|
||||
assert exit_code == 0, f"Bootstrap stage failed: {stderr}"
|
||||
assert exit_code == 0, (
|
||||
f"Bootstrap stage failed with exit code {exit_code}: {stderr}"
|
||||
)
|
||||
|
||||
def test_prebake_hooks_execution(self, container_with_binary: DockerContainer):
|
||||
"""Test that hook commands are properly executed."""
|
||||
def test_early_hook_executes(self, container_with_binary: DockerContainer):
|
||||
"""Test early hook runs successfully."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -325,13 +361,12 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
combined_output = stdout + stderr
|
||||
print(f"Hooks test output:\n{combined_output}")
|
||||
print(f"Early hook:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Hooks execution failed: {stderr}"
|
||||
assert exit_code == 0, f"Early hook failed with exit code {exit_code}: {stderr}"
|
||||
|
||||
def test_prebake_dependencies_config(self, container_with_binary: DockerContainer):
|
||||
"""Test that dependencies section is properly processed."""
|
||||
def test_late_hook_executes(self, container_with_binary: DockerContainer):
|
||||
"""Test late hook runs successfully."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -343,12 +378,12 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Dependencies test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
print(f"Late hook:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Dependencies processing failed: {stderr}"
|
||||
assert exit_code == 0, f"Late hook failed with exit code {exit_code}: {stderr}"
|
||||
|
||||
def test_prebake_environment_config(self, container_with_binary: DockerContainer):
|
||||
"""Test that environment section is properly processed."""
|
||||
def test_full_prebake_pipeline(self, container_with_binary: DockerContainer):
|
||||
"""Test complete prebake pipeline from start to finish."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -360,12 +395,103 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Environment test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
print(f"Full pipeline:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Environment processing failed: {stderr}"
|
||||
assert exit_code == 0, (
|
||||
f"Full prebake pipeline failed with exit code {exit_code}: {stderr}"
|
||||
)
|
||||
|
||||
def test_prebake_cache_config(self, container_with_binary: DockerContainer):
|
||||
"""Test that cache configuration is properly processed."""
|
||||
|
||||
class TestPrebakeIntegration:
|
||||
"""End-to-end integration tests for prebake workflow."""
|
||||
|
||||
IMAGE = "archlinux:latest"
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
BINARY_NAME = "workshop-executor"
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def container_with_binary(self):
|
||||
"""Provide a running archlinux container with pre-built binary copied."""
|
||||
if not docker_available():
|
||||
pytest.skip("Docker daemon is not available")
|
||||
|
||||
release_binary = self.PROJECT_ROOT / "target" / "release" / self.BINARY_NAME
|
||||
debug_binary = self.PROJECT_ROOT / "target" / "debug" / self.BINARY_NAME
|
||||
|
||||
if release_binary.exists():
|
||||
binary_path = release_binary
|
||||
elif debug_binary.exists():
|
||||
binary_path = debug_binary
|
||||
else:
|
||||
pytest.skip(
|
||||
f"Binary {self.BINARY_NAME} not found. Run 'cargo build --release' first."
|
||||
)
|
||||
|
||||
client = docker.from_env()
|
||||
try:
|
||||
client.images.get(self.IMAGE)
|
||||
except docker.errors.ImageNotFound:
|
||||
try:
|
||||
print(f"Pulling image {self.IMAGE}...")
|
||||
client.images.pull(self.IMAGE)
|
||||
except docker.errors.DockerException as e:
|
||||
pytest.skip(f"Could not pull image {self.IMAGE}: {e}")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Could not access Docker: {e}")
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
container_kwargs = {
|
||||
"volumes": {
|
||||
str(self.PROJECT_ROOT): {"bind": "/workshop-baker", "mode": "rw"},
|
||||
},
|
||||
}
|
||||
|
||||
container = DockerContainer(
|
||||
self.IMAGE,
|
||||
None,
|
||||
**container_kwargs,
|
||||
)
|
||||
|
||||
with container:
|
||||
container.copy_file(binary_path, f"/usr/local/bin/{self.BINARY_NAME}")
|
||||
|
||||
exit_code, stdout, stderr = container.exec_run(
|
||||
f"chmod +x /usr/local/bin/{self.BINARY_NAME}"
|
||||
)
|
||||
if exit_code != 0:
|
||||
pytest.fail(f"Failed to make binary executable: {stderr}")
|
||||
|
||||
bootstrap_sh = container.host_dir / "bootstrap.sh"
|
||||
bootstrap_sh.write_text(
|
||||
"#!/bin/bash\nset -e\necho 'Bootstrap executed successfully'\nexit 0\n"
|
||||
)
|
||||
container.copy_file(bootstrap_sh, "/bootstrap.sh")
|
||||
|
||||
exit_code, stdout, stderr = container.exec_run("chmod +x /bootstrap.sh")
|
||||
if exit_code != 0:
|
||||
pytest.fail(f"Failed to set bootstrap.sh executable: {stderr}")
|
||||
|
||||
yield container
|
||||
|
||||
def test_container_network_connectivity(
|
||||
self, container_with_binary: DockerContainer
|
||||
):
|
||||
"""Test that container has network connectivity."""
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run("ping -c 1 8.8.8.8")
|
||||
assert exit_code == 0, f"Container network connectivity failed: {stderr}"
|
||||
|
||||
def test_binary_executable_in_container(
|
||||
self, container_with_binary: DockerContainer
|
||||
):
|
||||
"""Test that the pre-built binary is executable in the container."""
|
||||
cmd = f"/usr/local/bin/{self.BINARY_NAME} --version"
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(cmd)
|
||||
print(f"Binary version:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
assert exit_code == 0, f"Binary execution failed: {stderr}"
|
||||
|
||||
def test_prebake_command_in_container(self, container_with_binary: DockerContainer):
|
||||
"""Test that prebake command works in the container."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
@@ -377,81 +503,12 @@ echo "=== Container Setup Complete ==="
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Cache config test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
print(f"Prebake command:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Cache configuration failed: {stderr}"
|
||||
|
||||
def test_prebake_security_config(self, container_with_binary: DockerContainer):
|
||||
"""Test that security configuration is properly processed."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
"--pipeline test-pipeline "
|
||||
"--build-id test-build-001 "
|
||||
"prebake examples/prebake.yml"
|
||||
combined = stdout + stderr
|
||||
assert exit_code == 0 or "bootstrap" in combined.lower(), (
|
||||
f"Prebake command failed with exit code {exit_code}: {stderr}"
|
||||
)
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Security config test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Security configuration failed: {stderr}"
|
||||
|
||||
def test_prebake_full_run(self, container_with_binary: DockerContainer):
|
||||
"""Test complete prebake workflow in actual run mode."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
"--pipeline test-pipeline "
|
||||
"--build-id test-build-001 "
|
||||
"prebake examples/prebake.yml"
|
||||
)
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Full run output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
combined_output = stdout + stderr
|
||||
|
||||
assert "prebake" in combined_output.lower() or exit_code == 0, (
|
||||
f"Prebake command failed to execute properly. Exit code: {exit_code}"
|
||||
)
|
||||
|
||||
def test_prebake_metadata(self, container_with_binary: DockerContainer):
|
||||
"""Test that metadata is properly processed."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
"--pipeline test-pipeline "
|
||||
"--build-id test-build-001 "
|
||||
"prebake examples/prebake.yml"
|
||||
)
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Metadata test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Metadata processing failed: {stderr}"
|
||||
|
||||
def test_prebake_late_hooks(self, container_with_binary: DockerContainer):
|
||||
"""Test that late hooks are properly executed."""
|
||||
cmd = (
|
||||
f"/usr/local/bin/{self.BINARY_NAME} "
|
||||
"--username test-user "
|
||||
"--pipeline test-pipeline "
|
||||
"--build-id test-build-001 "
|
||||
"prebake examples/prebake.yml"
|
||||
)
|
||||
exit_code, stdout, stderr = container_with_binary.exec_run(
|
||||
cmd, workdir="/workshop-baker"
|
||||
)
|
||||
|
||||
print(f"Late hooks test output:\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}")
|
||||
|
||||
assert exit_code == 0, f"Late hooks execution failed: {stderr}"
|
||||
|
||||
|
||||
class TestPrebakeConfigValidation:
|
||||
|
||||
Reference in New Issue
Block a user