Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions smitebot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ workspace = true

[dependencies]
clap = { version = "4.6", features = ["derive"] }
log.workspace = true
serde.workspace = true
serde_json.workspace = true
simple_logger.workspace = true
thiserror.workspace = true

[dev-dependencies]
Expand Down
26 changes: 26 additions & 0 deletions smitebot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,32 @@ smitebot doctor --aflpp-path ~/AFLplusplus --json

## Commands

### smitebot build

`smitebot build` builds Smite workload Docker images for manual rebuilds and debugging.

```bash
smitebot build --target lnd --scenario encrypted_bytes
smitebot build --target cln --scenario noise --coverage
smitebot build --target ldk --scenario init --image local/ldk-init:debug --no-cache
```
Comment thread
Ashish-Kumar-Dash marked this conversation as resolved.

Flags:

- `--target`: Workload implementation to build an image for (`lnd`, `cln`, `ldk`, or `eclair`).
- `--scenario`: Scenario that the image should run.
- `--coverage`: Build a coverage-instrumented image.
- `--image`: Use a custom image tag instead of the default tag used by Smite.
- `--smite-dir`: Path to the Smite repository root. Defaults to the current directory.
- `--no-cache`: Perform a clean rebuild without using cached Docker layers.

By default, image tags follow the existing Smite convention:

```text
smite-<target>-<scenario>
smite-<target>-<scenario>-coverage
```

### smitebot doctor

`smitebot doctor` validates host prerequisites before running Smite campaigns.
Expand Down
2 changes: 2 additions & 0 deletions smitebot/src/commands.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod build;
pub mod doctor;

pub use build::{BuildArgs, BuildCommand};
pub use doctor::{DoctorArgs, DoctorCommand};
266 changes: 266 additions & 0 deletions smitebot/src/commands/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//! Docker image builds for Smite workloads.
//! The command keeps Docker's output visible so rebuild failures are easy to debug.

use std::fmt;
use std::path::PathBuf;
use std::process::{Command, ExitStatus};

use clap::{Args, ValueEnum};

/// Command handler for `smitebot build`.
pub struct BuildCommand;

/// CLI arguments for `smitebot build`.
#[derive(Debug, Args)]
pub struct BuildArgs {
/// Target implementation to build.
#[arg(long)]
target: WorkloadTarget,
/// Scenario binary selected by the workload Dockerfile.
#[arg(long)]
scenario: String,
/// Build the coverage-instrumented Docker image.
#[arg(long)]
coverage: bool,
/// Override the Docker image tag.
#[arg(long)]
image: Option<String>,
/// Path to smite repository root.
#[arg(long, default_value = ".")]
smite_dir: PathBuf,
/// Pass --no-cache to docker build.
#[arg(long)]
no_cache: bool,
}

/// Smite workload targets with Dockerfiles under `workloads/`.
#[derive(Clone, Copy, Debug, ValueEnum)]
enum WorkloadTarget {
/// Lightning Network Daemon workload.
Lnd,
/// Core Lightning workload.
Cln,
/// LDK Node workload.
Ldk,
/// Eclair workload.
Eclair,
}

impl fmt::Display for WorkloadTarget {
/// Formats the lowercase target name used in paths and Docker image tags.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lnd => write!(f, "lnd"),
Self::Cln => write!(f, "cln"),
Self::Ldk => write!(f, "ldk"),
Self::Eclair => write!(f, "eclair"),
}
}
}

/// Fully resolved Docker build inputs.
#[derive(Debug, PartialEq, Eq)]
struct BuildInputs {
/// Docker image tag to produce.
image: String,
/// Workload Dockerfile selected from `--target` and `--coverage`.
dockerfile: PathBuf,
/// Smite repository root used as the Docker build context.
smite_dir: PathBuf,
/// Scenario passed to Docker as `--build-arg SCENARIO=...`.
scenario: String,
/// Whether Docker should rebuild without using its layer cache.
no_cache: bool,
Comment thread
Ashish-Kumar-Dash marked this conversation as resolved.
}

impl BuildInputs {
/// Resolves Docker build inputs from parsed CLI arguments.
fn from_args(args: &BuildArgs) -> Self {
let dockerfile_name = if args.coverage {
"Dockerfile.coverage"
} else {
"Dockerfile"
};

Self {
image: args.image.clone().unwrap_or_else(|| {
default_workload_image_tag(args.target, &args.scenario, args.coverage)
}),
dockerfile: args
.smite_dir
.join("workloads")
.join(args.target.to_string())
.join(dockerfile_name),
smite_dir: args.smite_dir.clone(),
scenario: args.scenario.clone(),
no_cache: args.no_cache,
}
}
}

impl BuildCommand {
/// Builds the requested Smite Docker image and returns whether Docker succeeded.
pub fn execute(args: &BuildArgs) -> bool {
let inputs = BuildInputs::from_args(args);
if !inputs.dockerfile.exists() {
log::error!("Dockerfile not found: {}", inputs.dockerfile.display());
return false;
}

log::info!(
"building {} with {}",
inputs.image,
inputs.dockerfile.display()
);

let status = match run_docker_build(&inputs) {
Ok(status) => status,
Err(e) => {
log::error!("failed to run docker build: {e}");
return false;
}
};

if !status.success() {
log::error!("docker build failed with {status}");
return false;
}

log::info!("built {}", inputs.image);
true
}
}

/// Returns the default image tag used by Smite's manual Docker build flow.
fn default_workload_image_tag(target: WorkloadTarget, scenario: &str, coverage: bool) -> String {
let suffix = if coverage { "-coverage" } else { "" };
format!("smite-{target}-{scenario}{suffix}")
}

/// Runs `docker build`, streaming stdout/stderr directly to the terminal.
fn run_docker_build(inputs: &BuildInputs) -> std::io::Result<ExitStatus> {
let mut command = Command::new("docker");
command.arg("build");
if inputs.no_cache {
command.arg("--no-cache");
}
command
.arg("-t")
.arg(&inputs.image)
.arg("--build-arg")
.arg(format!("SCENARIO={}", inputs.scenario))
.arg("-f")
.arg(&inputs.dockerfile)
.arg(&inputs.smite_dir);

command.status()
}

#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;

fn sample_build_args(target: WorkloadTarget, scenario: &str) -> BuildArgs {
BuildArgs {
target,
scenario: scenario.to_string(),
coverage: false,
image: None,
smite_dir: PathBuf::from("/repo/smite"),
no_cache: false,
}
}

#[test]
fn default_workload_image_tag_matches_smite_convention() {
assert_eq!(
default_workload_image_tag(WorkloadTarget::Lnd, "encrypted_bytes", false),
"smite-lnd-encrypted_bytes"
);
assert_eq!(
default_workload_image_tag(WorkloadTarget::Cln, "noise", true),
"smite-cln-noise-coverage"
);
}

#[test]
fn build_inputs_use_normal_dockerfile_by_default() {
let args = sample_build_args(WorkloadTarget::Ldk, "init");
let inputs = BuildInputs::from_args(&args);

assert_eq!(inputs.image, "smite-ldk-init");
assert_eq!(
inputs.dockerfile,
Path::new("/repo/smite/workloads/ldk/Dockerfile")
);
assert_eq!(inputs.smite_dir, Path::new("/repo/smite"));
assert_eq!(inputs.scenario, "init");
assert!(!inputs.no_cache);
}

#[test]
fn build_inputs_select_expected_dockerfile_for_each_target() {
let cases = [
(WorkloadTarget::Lnd, "/repo/smite/workloads/lnd/Dockerfile"),
(WorkloadTarget::Cln, "/repo/smite/workloads/cln/Dockerfile"),
(WorkloadTarget::Ldk, "/repo/smite/workloads/ldk/Dockerfile"),
(
WorkloadTarget::Eclair,
"/repo/smite/workloads/eclair/Dockerfile",
),
];

for (target, expected_dockerfile) in cases {
let args = sample_build_args(target, "noise");
let inputs = BuildInputs::from_args(&args);

assert_eq!(inputs.dockerfile, Path::new(expected_dockerfile));
}
}

#[test]
fn build_inputs_support_coverage_and_custom_image() {
let mut args = sample_build_args(WorkloadTarget::Eclair, "encrypted_bytes");
args.coverage = true;
args.image = Some("local/eclair-eb:debug".to_string());
args.no_cache = true;

let inputs = BuildInputs::from_args(&args);

assert_eq!(inputs.image, "local/eclair-eb:debug");
assert_eq!(
inputs.dockerfile,
Path::new("/repo/smite/workloads/eclair/Dockerfile.coverage")
);
assert!(inputs.no_cache);
}

#[test]
fn build_inputs_use_default_coverage_image_when_not_overridden() {
let mut args = sample_build_args(WorkloadTarget::Cln, "noise");
args.coverage = true;

let inputs = BuildInputs::from_args(&args);

assert_eq!(inputs.image, "smite-cln-noise-coverage");
assert_eq!(
inputs.dockerfile,
Path::new("/repo/smite/workloads/cln/Dockerfile.coverage")
);
}

#[test]
fn build_inputs_preserve_custom_smite_dir() {
let mut args = sample_build_args(WorkloadTarget::Lnd, "encrypted_bytes");
args.smite_dir = PathBuf::from("/tmp/local-smite");

let inputs = BuildInputs::from_args(&args);

assert_eq!(inputs.smite_dir, Path::new("/tmp/local-smite"));
assert_eq!(
inputs.dockerfile,
Path::new("/tmp/local-smite/workloads/lnd/Dockerfile")
);
}
}
7 changes: 6 additions & 1 deletion smitebot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::process::ExitCode;

use clap::{Parser, Subcommand};

use commands::{DoctorArgs, DoctorCommand};
use commands::{BuildArgs, BuildCommand, DoctorArgs, DoctorCommand};

#[derive(Debug, Parser)]
#[command(name = "smitebot", version, about = "Smite campaign manager")]
Expand All @@ -18,13 +18,18 @@ struct Cli {

#[derive(Debug, Subcommand)]
enum Commands {
/// Build Smite workload Docker images.
Build(BuildArgs),
/// Validate host prerequisites for running Smite campaigns.
Doctor(DoctorArgs),
}

fn main() -> ExitCode {
simple_logger::init_with_env().expect("Failed to initialize logger");

let cli = Cli::parse();
let success = match cli.command {
Commands::Build(args) => BuildCommand::execute(&args),
Commands::Doctor(args) => DoctorCommand::execute(&args),
};

Expand Down