From 04499a97fcbed28ef4a728457693e2fb1aed6c1b Mon Sep 17 00:00:00 2001 From: devjow Date: Thu, 2 Apr 2026 15:50:50 +0100 Subject: [PATCH 1/5] feat: docker template command resolves #17 Signed-off-by: devjow --- Cargo.lock | 1 + README.md | 11 + SKILLS.md | 58 ++- crates/cli/Cargo.toml | 1 + crates/cli/src/common.rs | 49 ++- crates/cli/src/deploy/mod.rs | 746 +++++++++++++++++++++++++++++++++++ crates/cli/src/lib.rs | 3 + 7 files changed, 858 insertions(+), 11 deletions(-) create mode 100644 crates/cli/src/deploy/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3f4f912..81c370d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,6 +402,7 @@ dependencies = [ "cargo-generate", "clap", "flate2", + "liquid", "module-parser", "notify", "reqwest", diff --git a/README.md b/README.md index 9f25ea8..bd68453 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,17 @@ Example manual run of the generated project: CF_CLI_CONFIG=/tmp/cf-demo/config/quickstart.yml cargo run --manifest-path /tmp/cf-demo/.cyberfabric/quickstart/Cargo.toml ``` +### Deployment bundle generation + +- `deploy --template docker` generates a Docker build bundle under `.cyberfabric/deploy//` using deploy assets + from `cf-template-rust` +- the generated bundle includes a Dockerfile, `config.yml`, the generated `.cyberfabric/` + server project, and the workspace members needed by local path dependencies +- existing bundle directories under `.cyberfabric/deploy/` are replaced automatically, while existing custom + `--output-dir` paths require `--force` +- copied workspace paths skip common local-only entries such as `.git`, `.vscode`, `target`, `.env*`, and swap files; + symlinked entries are rejected + ### Source inspection - `docs` resolves Rust source for crates, modules, and items from the workspace, local cache, or `crates.io` diff --git a/SKILLS.md b/SKILLS.md index 1e5d306..8efb219 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -52,6 +52,7 @@ cyberfabric │ ├── add │ ├── edit │ └── rm +├── deploy ├── docs ├── lint ├── test @@ -69,7 +70,10 @@ cyberfabric there is no default. For `build` and `run`, the CLI forwards this path to the generated server through the `CF_CLI_CONFIG` environment variable. - **[`--name `]** For `build` and `run`, overrides the generated server project and binary name that would - otherwise default to the config filename stem. + otherwise default to the config filename stem. `deploy` uses the same name resolution for the generated executable and + output bundle. +- **[`--template `]** For `deploy`, selects which deployment asset set to render. Only `docker` is currently + implemented. - **[`-v, --verbose`]** Usually enables more logging or richer output. - **[name validation]** Config-managed names for modules, DB servers, and generated server names only allow letters, numbers, `-`, and `_`. @@ -82,6 +86,7 @@ From the current implementation, the CLI is mainly for: - **[config management]** Enable modules and patch YAML config sections - **[server generation]** Generate a runnable Cargo project under `.cyberfabric//` - **[build/run]** Build or run that generated server +- **[deploy bundle generation]** Generate a Docker build bundle under `.cyberfabric/deploy//` - **[source inspection]** Resolve Rust source for crates/items through workspace metadata or crates.io - **[tool bootstrap]** Install or upgrade `rustup`, `cargofmt`, and `clippy` @@ -652,8 +657,55 @@ cyberfabric build -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --releas cyberfabric build -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --otel --clean ``` +### `deploy` + +Generate a Docker deployment bundle under `.cyberfabric/deploy//`. + +Synopsis: + +```bash +cyberfabric deploy --template docker -c [-p ] [--name ] [--output-dir ] [--force] [--image-name ] [--image-tag ] [--local-path ] [--git ] [--subfolder ] [--branch ] +``` + +Arguments: + +- **[`--template docker`]** Render the Docker deployment bundle template +- **[`-c, --config `]** Required config file path +- **[`-p, --path `]** Workspace root, defaults to `.` +- **[`--name `]** Override the generated server project and executable name; defaults to the config filename stem +- **[`--output-dir `]** Override the deploy bundle output directory; defaults to `.cyberfabric/deploy//` +- **[`--force`]** Allow replacing an existing custom output directory outside `.cyberfabric/deploy/` +- **[`--image-name `]** Optional image name used in the generated helper command +- **[`--image-tag `]** Optional image tag used in the generated helper command; defaults to `latest` +- **[`--local-path `]** Use a local deploy template repository instead of Git +- **[`--git `]** Deploy template repo URL, defaults to `https://github.com/cyberfabric/cf-template-rust` +- **[`--subfolder `]** Template repo subfolder root, defaults to `Deploy` +- **[`--branch `]** Template repo branch, defaults to `main` + +Behavior: + +- **[reuses name resolution]** Uses the config filename stem by default, so `config/quickstart.yml` generates under `.cyberfabric/deploy/quickstart/`; `--name` overrides that default +- **[recreates generated server structure]** Writes a generated server project under `.cyberfabric/deploy//.cyberfabric//` configured to load `/srv/config.yml` at runtime +- **[copies workspace inputs]** Copies the workspace manifest, optional `Cargo.lock`, local workspace members, and dependency paths needed for Docker compilation +- **[filters common junk entries]** Skips common local-only files and folders such as `.git`, `.vscode`, `target`, `.env*`, swap files, and Finder metadata when copying workspace paths into the bundle +- **[rejects symlinked bundle inputs]** Fails fast if a copied workspace path contains a symlinked entry +- **[copies config as `config.yml`]** Places the selected config file in the deploy bundle root as `config.yml` +- **[renders from deploy templates]** Loads `Deploy/docker` templates from `cf-template-rust` and renders the `Dockerfile` +- **[protects custom output directories]** Replaces existing bundle directories under `.cyberfabric/deploy/` automatically, but requires `--force` before deleting an existing custom `--output-dir` +- **[generate-only]** Does not run `docker build`, push to a registry, or deploy to a cluster + +Examples: + +```bash +cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml +``` + +```bash +cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --name demo-server +``` + ```bash -cyberfabric build -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --name demo-server +cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --local-path ~/dev/cf-template-rust ``` ### `lint` @@ -741,6 +793,8 @@ cyberfabric docs --verbose tokio::sync cyberfabric init cyberfabric mod add [-p ] +cyberfabric deploy --template docker [-p ] -c + cyberfabric config mod list [-p ] -c cyberfabric config mod add [-p ] -c cyberfabric config mod rm [-p ] -c diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 29880f2..4fe010c 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -37,6 +37,7 @@ tar = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } semver = { workspace = true } +liquid = "0.26.11" [lints] workspace = true diff --git a/crates/cli/src/common.rs b/crates/cli/src/common.rs index e8e52c9..819d6c4 100644 --- a/crates/cli/src/common.rs +++ b/crates/cli/src/common.rs @@ -87,6 +87,17 @@ impl Display for Registry { } } +pub fn resolve_workspace_config_and_name( + path_config: &PathConfigArgs, + override_name: Option<&str>, +) -> anyhow::Result<(PathBuf, PathBuf, String)> { + let path = workspace_root()?; + let config_path = path_config.resolve_config()?; + let project_name = resolve_generated_project_name(&config_path, override_name)?; + + Ok((path, config_path, project_name)) +} + impl BuildRunArgs { pub fn resolve_config_and_name(&self) -> anyhow::Result<(PathBuf, String)> { let config_path = self.path_config.resolve_config()?; @@ -241,6 +252,15 @@ fn create_required_deps() -> anyhow::Result { pub fn generate_server_structure( project_name: &str, current_dependencies: &CargoTomlDependencies, +) -> anyhow::Result<()> { + let output_root = workspace_root()?; + generate_server_structure_at(&output_root, project_name, current_dependencies) +} + +pub fn generate_server_structure_at( + output_root: &Path, + project_name: &str, + current_dependencies: &CargoTomlDependencies, ) -> anyhow::Result<()> { let mut dependencies = current_dependencies.clone(); dependencies.extend(create_required_deps()?); @@ -257,9 +277,14 @@ pub fn generate_server_structure( toml::to_string(&cargo_toml).context("something went wrong when transforming to toml")?; let main_rs = prepare_cargo_server_main(current_dependencies); - create_file_structure(project_name, "Cargo.toml", &cargo_toml_str)?; - create_file_structure(project_name, ".cargo/config.toml", CARGO_CONFIG_TOML)?; - create_file_structure(project_name, "src/main.rs", &main_rs)?; + create_file_structure_at(output_root, project_name, "Cargo.toml", &cargo_toml_str)?; + create_file_structure_at( + output_root, + project_name, + ".cargo/config.toml", + CARGO_CONFIG_TOML, + )?; + create_file_structure_at(output_root, project_name, "src/main.rs", &main_rs)?; Ok(()) } @@ -268,13 +293,17 @@ pub fn generated_project_dir(project_name: &str) -> anyhow::Result { Ok(workspace_root()?.join(BASE_PATH).join(project_name)) } -fn create_file_structure( +fn create_file_structure_at( + output_root: &Path, project_name: &str, relative_path: &str, contents: &str, ) -> anyhow::Result<()> { use std::io::Write; - let path = generated_project_dir(project_name)?.join(relative_path); + let path = output_root + .join(BASE_PATH) + .join(project_name) + .join(relative_path); fs::create_dir_all( path.parent().context( "this should be unreachable, the parent for the file structure always exists", @@ -337,7 +366,9 @@ fn prepare_cargo_server_main(dependencies: &CargoTomlDependencies) -> String { #[cfg(test)] mod tests { use super::{merge_module_metadata, prepare_cargo_server_main, resolve_generated_project_name}; - use module_parser::{Capability, CargoTomlDependencies, ConfigModuleMetadata}; + use module_parser::{ + Capability, CargoTomlDependencies, CargoTomlDependency, ConfigModuleMetadata, + }; use std::path::Path; #[test] @@ -390,9 +421,9 @@ mod tests { #[test] fn generated_server_main_reads_config_from_env_and_includes_dependencies() { let dependencies = CargoTomlDependencies::from([ - ("module_a".to_owned(), Default::default()), - ("module_b".to_owned(), Default::default()), - ("api-db-handler".to_owned(), Default::default()), + ("module_a".to_owned(), CargoTomlDependency::default()), + ("module_b".to_owned(), CargoTomlDependency::default()), + ("api-db-handler".to_owned(), CargoTomlDependency::default()), ]); let main_rs = prepare_cargo_server_main(&dependencies); diff --git a/crates/cli/src/deploy/mod.rs b/crates/cli/src/deploy/mod.rs new file mode 100644 index 0000000..3e413b3 --- /dev/null +++ b/crates/cli/src/deploy/mod.rs @@ -0,0 +1,746 @@ +use crate::common::{self, PathConfigArgs}; +use anyhow::{Context, bail}; +use clap::{Args, ValueEnum}; +use liquid::ParserBuilder; +use module_parser::CargoTomlDependencies; +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const OUTPUT_SUBDIR: &str = "deploy"; +const FILTERED_ENTRY_NAMES: &[&str] = + &[".DS_Store", ".git", ".github", ".idea", ".vscode", "target"]; +const TEMPLATE_FILE_PAIRS: [(&str, &str); 1] = [("Dockerfile.liquid", "Dockerfile")]; + +#[derive(Args)] +pub struct DeployArgs { + /// Deployment template kind to render + #[arg(long, value_enum)] + template: DeployTemplateKind, + #[command(flatten)] + path_config: PathConfigArgs, + /// Override the generated server and binary name + #[arg(long)] + name: Option, + /// Output directory for the generated deploy bundle + #[arg(long)] + output_dir: Option, + /// Allow replacing an existing custom output directory + #[arg(long)] + force: bool, + /// Optional local image name used in helper output + #[arg(long)] + image_name: Option, + /// Optional local image tag used in helper output + #[arg(long, default_value = "latest")] + image_tag: String, + /// Path to a local deploy template repository + #[arg(long)] + local_path: Option, + /// URL to the template git repo + #[arg( + long, + default_value = "https://github.com/cyberfabric/cf-template-rust" + )] + git: Option, + /// Subfolder relative to the template repo root + #[arg(long, default_value = "Deploy")] + subfolder: String, + /// Branch of the template git repo + #[arg(long, default_value = "main")] + branch: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum DeployTemplateKind { + #[value(name = "docker")] + Docker, +} + +impl DeployTemplateKind { + const fn as_str(self) -> &'static str { + match self { + Self::Docker => "docker", + } + } +} + +impl DeployArgs { + pub fn run(&self) -> anyhow::Result<()> { + let (workspace_root, config_path, project_name) = + common::resolve_workspace_config_and_name(&self.path_config, self.name.as_deref())?; + let config = common::get_config(&config_path)?; + let dependencies = config.create_dependencies()?; + let output_dir = self.resolve_output_dir(&workspace_root, &project_name); + prepare_output_dir(&output_dir, &workspace_root, self.force)?; + + copy_file( + &workspace_root.join("Cargo.toml"), + &output_dir.join("Cargo.toml"), + )?; + let has_cargo_lock = copy_optional_file( + &workspace_root.join("Cargo.lock"), + &output_dir.join("Cargo.lock"), + )?; + copy_file(&config_path, &output_dir.join("config.yml"))?; + + let local_paths = collect_required_local_paths(&workspace_root, &dependencies)?; + for relative_path in &local_paths { + copy_relative_workspace_path(&workspace_root, &output_dir, relative_path)?; + } + + let rewritten_dependencies = + rewrite_dependency_paths_for_bundle(&workspace_root, &dependencies)?; + common::generate_server_structure_at(&output_dir, &project_name, &rewritten_dependencies)?; + + let template_checkout = TemplateCheckout::prepare( + self.local_path.as_deref(), + self.git.as_deref(), + self.branch.as_deref(), + )?; + let template_dir = template_checkout.template_dir(&self.subfolder, self.template)?; + render_templates( + &template_dir, + &output_dir, + &build_template_context( + &project_name, + &local_paths, + has_cargo_lock, + &self.image_ref(&project_name), + ), + )?; + + println!("Deploy bundle generated at {}", output_dir.display()); + Ok(()) + } + + fn image_ref(&self, project_name: &str) -> String { + let image_name = self + .image_name + .clone() + .unwrap_or_else(|| project_name.to_owned()); + format!("{}:{}", image_name, self.image_tag) + } + + fn resolve_output_dir(&self, workspace_root: &Path, project_name: &str) -> PathBuf { + self.output_dir.as_ref().map_or_else( + || { + workspace_root + .join(common::BASE_PATH) + .join(OUTPUT_SUBDIR) + .join(project_name) + }, + |path| { + if path.is_absolute() { + path.clone() + } else { + workspace_root.join(path) + } + }, + ) + } +} + +struct TemplateCheckout { + root: PathBuf, + cleanup_root: Option, +} + +impl TemplateCheckout { + fn prepare( + local_path: Option<&str>, + git: Option<&str>, + branch: Option<&str>, + ) -> anyhow::Result { + if let Some(local_path) = local_path { + let root = PathBuf::from(local_path) + .canonicalize() + .with_context(|| format!("can't canonicalize template path {local_path}"))?; + return Ok(Self { + root, + cleanup_root: None, + }); + } + + let git = git.context("template git URL is missing")?; + let branch = branch.unwrap_or("main"); + let checkout_dir = unique_checkout_dir(); + let output = Command::new("git") + .args(["clone", "--depth", "1", "--branch", branch, git]) + .arg(&checkout_dir) + .output() + .context("failed to invoke git clone for deploy templates")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let details = if stderr.is_empty() { stdout } else { stderr }; + bail!("failed to clone deploy template repo: {details}"); + } + + Ok(Self { + root: checkout_dir.clone(), + cleanup_root: Some(checkout_dir), + }) + } + + fn template_dir( + &self, + subfolder: &str, + template: DeployTemplateKind, + ) -> anyhow::Result { + let dir = self.root.join(subfolder).join(template.as_str()); + if !dir.is_dir() { + bail!("deploy template directory not found at {}", dir.display()); + } + Ok(dir) + } +} + +impl Drop for TemplateCheckout { + fn drop(&mut self) { + if let Some(path) = &self.cleanup_root { + let _ = fs::remove_dir_all(path); + } + } +} + +fn prepare_output_dir(output_dir: &Path, workspace_root: &Path, force: bool) -> anyhow::Result<()> { + if output_dir.exists() + && fs::symlink_metadata(output_dir) + .with_context(|| format!("can't inspect {}", output_dir.display()))? + .file_type() + .is_symlink() + { + bail!( + "output directory '{}' cannot be a symlink", + output_dir.display() + ); + } + + let workspace_root = workspace_root + .canonicalize() + .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; + let output_dir = canonicalize_path_for_safety(output_dir)?; + let base_path_root = workspace_root.join(common::BASE_PATH); + let deploy_root = base_path_root.join(OUTPUT_SUBDIR); + + for reserved_path in [&workspace_root, &base_path_root, &deploy_root] { + if output_dir == *reserved_path { + bail!( + "output directory cannot be the reserved path {}", + reserved_path.display() + ); + } + } + + if output_dir.exists() { + if !output_dir.is_dir() { + bail!( + "output directory '{}' exists but is not a directory", + output_dir.display() + ); + } + if !output_dir.starts_with(&deploy_root) && !force { + bail!( + "refusing to replace existing custom output directory {}; pass --force to overwrite it", + output_dir.display() + ); + } + fs::remove_dir_all(&output_dir) + .with_context(|| format!("can't remove {}", output_dir.display()))?; + } + fs::create_dir_all(&output_dir) + .with_context(|| format!("can't create {}", output_dir.display())) +} + +fn unique_checkout_dir() -> PathBuf { + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + std::env::temp_dir().join(format!("cf-cli-deploy-{suffix}")) +} + +fn copy_optional_file(source: &Path, destination: &Path) -> anyhow::Result { + if !source.is_file() { + return Ok(false); + } + copy_file(source, destination)?; + Ok(true) +} + +fn copy_file(source: &Path, destination: &Path) -> anyhow::Result<()> { + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).with_context(|| format!("can't create {}", parent.display()))?; + } + fs::copy(source, destination).with_context(|| { + format!( + "can't copy {} to {}", + source.display(), + destination.display() + ) + })?; + Ok(()) +} + +fn copy_relative_workspace_path( + workspace_root: &Path, + output_dir: &Path, + relative_path: &Path, +) -> anyhow::Result<()> { + let source = workspace_root.join(relative_path); + let destination = output_dir.join(relative_path); + copy_path_recursively(&source, &destination) +} + +fn copy_path_recursively(source: &Path, destination: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("can't inspect {}", source.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "symlinked paths are not supported in deploy bundles: {}", + source.display() + ); + } + + if metadata.is_dir() { + fs::create_dir_all(destination) + .with_context(|| format!("can't create {}", destination.display()))?; + for entry in + fs::read_dir(source).with_context(|| format!("can't read {}", source.display()))? + { + let entry = entry.with_context(|| format!("can't read {}", source.display()))?; + if should_skip_bundle_entry(&entry.file_name()) { + continue; + } + let child_source = entry.path(); + let child_destination = destination.join(entry.file_name()); + copy_path_recursively(&child_source, &child_destination)?; + } + return Ok(()); + } + + copy_file(source, destination) +} + +fn should_skip_bundle_entry(name: &OsStr) -> bool { + let name = name.to_string_lossy(); + FILTERED_ENTRY_NAMES.contains(&name.as_ref()) + || name.starts_with(".env") + || name.ends_with(".swp") + || name.ends_with('~') +} + +fn collect_required_local_paths( + workspace_root: &Path, + dependencies: &CargoTomlDependencies, +) -> anyhow::Result> { + let mut paths = read_workspace_members(workspace_root)?; + for dependency in dependencies.values() { + if let Some(path) = &dependency.path { + paths.insert(resolve_workspace_relative_path( + workspace_root, + Path::new(path), + )?); + } + } + Ok(paths) +} + +fn read_workspace_members(workspace_root: &Path) -> anyhow::Result> { + let cargo_toml_path = workspace_root.join("Cargo.toml"); + let raw = fs::read_to_string(&cargo_toml_path) + .with_context(|| format!("can't read {}", cargo_toml_path.display()))?; + let doc = raw + .parse::() + .with_context(|| format!("can't parse {}", cargo_toml_path.display()))?; + + let members = doc["workspace"]["members"] + .as_array() + .map(|array| { + array + .iter() + .filter_map(toml_edit::Value::as_str) + .map(PathBuf::from) + .collect::>() + }) + .unwrap_or_default(); + + let mut resolved = BTreeSet::new(); + for member in members { + let relative = resolve_workspace_relative_path(workspace_root, &member)?; + if !relative.as_os_str().is_empty() { + resolved.insert(relative); + } + } + Ok(resolved) +} + +fn resolve_workspace_relative_path( + workspace_root: &Path, + raw_path: &Path, +) -> anyhow::Result { + let absolute_path = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + workspace_root.join(raw_path) + }; + let absolute_path = absolute_path + .canonicalize() + .with_context(|| format!("can't canonicalize {}", absolute_path.display()))?; + let workspace_root = workspace_root + .canonicalize() + .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; + + if !absolute_path.starts_with(&workspace_root) { + bail!( + "local dependency path '{}' is outside the workspace root {}", + absolute_path.display(), + workspace_root.display() + ); + } + + absolute_path + .strip_prefix(workspace_root) + .map(Path::to_path_buf) + .context("can't derive workspace-relative path") +} + +fn rewrite_dependency_paths_for_bundle( + workspace_root: &Path, + dependencies: &CargoTomlDependencies, +) -> anyhow::Result { + let mut rewritten = dependencies.clone(); + for dependency in rewritten.values_mut() { + if let Some(path) = &dependency.path { + let relative = resolve_workspace_relative_path(workspace_root, Path::new(path))?; + dependency.path = Some(bundle_relative_dependency_path(&relative)); + } + } + Ok(rewritten) +} + +fn bundle_relative_dependency_path(relative_path: &Path) -> String { + PathBuf::from("..") + .join("..") + .join(relative_path) + .display() + .to_string() + .replace('\\', "/") +} + +fn build_template_context( + project_name: &str, + local_paths: &BTreeSet, + has_cargo_lock: bool, + image_ref: &str, +) -> liquid::Object { + let local_paths = local_paths + .iter() + .map(|path| path.display().to_string().replace('\\', "/")) + .collect::>(); + + liquid::object!({ + "project_name": project_name, + "executable_name": project_name, + "generated_project_dir": format!(".cyberfabric/{project_name}"), + "has_cargo_lock": has_cargo_lock, + "local_paths": local_paths, + "image_ref": image_ref, + "config": { + "opentelemetry": { + "tracing": { + "enabled": true, + }, + }, + }, + }) +} + +fn render_templates( + template_dir: &Path, + output_dir: &Path, + context: &liquid::Object, +) -> anyhow::Result<()> { + let parser = ParserBuilder::with_stdlib().build()?; + for (template_name, output_name) in TEMPLATE_FILE_PAIRS { + let template_path = template_dir.join(template_name); + let template_source = fs::read_to_string(&template_path) + .with_context(|| format!("can't read {}", template_path.display()))?; + let template = parser + .parse(&template_source) + .with_context(|| format!("can't parse {}", template_path.display()))?; + let rendered = template + .render(context) + .with_context(|| format!("can't render {}", template_path.display()))?; + fs::write(output_dir.join(output_name), rendered) + .with_context(|| format!("can't write {}", output_dir.join(output_name).display()))?; + } + Ok(()) +} + +fn canonicalize_path_for_safety(path: &Path) -> anyhow::Result { + if path.exists() { + return path + .canonicalize() + .with_context(|| format!("can't canonicalize {}", path.display())); + } + + let parent = path + .parent() + .context("output directory must have a parent directory")?; + let file_name = path + .file_name() + .context("output directory must have a final path component")?; + + Ok(canonicalize_path_for_safety(parent)?.join(file_name)) +} + +#[cfg(test)] +mod tests { + use super::{ + DeployArgs, DeployTemplateKind, collect_required_local_paths, copy_relative_workspace_path, + prepare_output_dir, rewrite_dependency_paths_for_bundle, + }; + use crate::common::PathConfigArgs; + use module_parser::test_utils::TempDirExt; + use module_parser::{CargoTomlDependencies, CargoTomlDependency}; + use std::fs; + use std::path::Path; + + #[test] + fn collects_workspace_members_and_dependency_paths() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"modules/foo\", \"modules/foo/sdk\"]\n", + ) + .expect("workspace cargo toml"); + fs::create_dir_all(workspace_root.join("modules/foo/sdk/src")).expect("workspace dirs"); + fs::create_dir_all(workspace_root.join("extras/bar/src")).expect("extra dirs"); + + let mut dependencies = CargoTomlDependencies::new(); + dependencies.insert( + "bar".to_owned(), + CargoTomlDependency { + path: Some(workspace_root.join("extras/bar").display().to_string()), + ..CargoTomlDependency::default() + }, + ); + + let paths = collect_required_local_paths(workspace_root, &dependencies).expect("paths"); + assert!(paths.contains(Path::new("modules/foo"))); + assert!(paths.contains(Path::new("modules/foo/sdk"))); + assert!(paths.contains(Path::new("extras/bar"))); + } + + #[test] + fn rewrites_absolute_dependency_paths_for_bundle() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"modules/foo\"]\n", + ) + .expect("workspace cargo toml"); + fs::create_dir_all(workspace_root.join("modules/foo/src")).expect("module dir"); + + let mut dependencies = CargoTomlDependencies::new(); + dependencies.insert( + "foo".to_owned(), + CargoTomlDependency { + path: Some(workspace_root.join("modules/foo").display().to_string()), + ..CargoTomlDependency::default() + }, + ); + + let rewritten = + rewrite_dependency_paths_for_bundle(workspace_root, &dependencies).expect("rewrite"); + assert_eq!(rewritten["foo"].path.as_deref(), Some("../../modules/foo")); + } + + #[test] + fn prepare_output_dir_replaces_default_bundle_dir_without_force() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let output_dir = workspace_root.join(".cyberfabric/deploy/demo"); + + fs::create_dir_all(&output_dir).expect("default deploy dir"); + fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); + + prepare_output_dir(&output_dir, workspace_root, false).expect("default deploy dir"); + + assert!(output_dir.is_dir()); + assert!(!output_dir.join("stale.txt").exists()); + } + + #[test] + fn prepare_output_dir_requires_force_for_existing_custom_dir() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(&output_dir).expect("custom output dir"); + fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); + + let err = prepare_output_dir(&output_dir, workspace_root, false) + .expect_err("custom output dir should require force"); + assert!(err.to_string().contains("--force")); + + prepare_output_dir(&output_dir, workspace_root, true).expect("forced cleanup"); + assert!(output_dir.is_dir()); + assert!(!output_dir.join("stale.txt").exists()); + } + + #[test] + fn copy_relative_workspace_path_filters_known_junk_entries() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let source_dir = workspace_root.join("modules/demo-module"); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(source_dir.join("src")).expect("source dir"); + fs::create_dir_all(source_dir.join("target/debug")).expect("target dir"); + fs::create_dir_all(source_dir.join(".git")).expect("git dir"); + fs::write(source_dir.join("src/lib.rs"), "pub fn demo() {}\n").expect("lib source"); + fs::write(source_dir.join(".env"), "SECRET=value\n").expect("env file"); + fs::write(source_dir.join(".DS_Store"), "junk").expect("finder junk"); + fs::write(source_dir.join("target/debug/demo"), "junk").expect("target artifact"); + + copy_relative_workspace_path( + workspace_root, + &output_dir, + Path::new("modules/demo-module"), + ) + .expect("copy filtered bundle path"); + + assert!(output_dir.join("modules/demo-module/src/lib.rs").is_file()); + assert!(!output_dir.join("modules/demo-module/.env").exists()); + assert!(!output_dir.join("modules/demo-module/.DS_Store").exists()); + assert!(!output_dir.join("modules/demo-module/.git").exists()); + assert!(!output_dir.join("modules/demo-module/target").exists()); + } + + #[cfg(unix)] + #[test] + fn copy_relative_workspace_path_rejects_symlinks() { + use std::os::unix::fs::symlink; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let source_dir = workspace_root.join("modules/demo-module"); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(&source_dir).expect("source dir"); + fs::write(workspace_root.join("outside.txt"), "hello\n").expect("outside file"); + symlink( + workspace_root.join("outside.txt"), + source_dir.join("linked.txt"), + ) + .expect("symlink"); + + let err = copy_relative_workspace_path( + workspace_root, + &output_dir, + Path::new("modules/demo-module"), + ) + .expect_err("symlinked bundle entries should be rejected"); + assert!( + err.to_string() + .contains("symlinked paths are not supported") + ); + } + + #[test] + fn deploy_generates_docker_bundle_from_local_templates() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + temp_dir.write( + "Cargo.toml", + r#"[workspace] +members = ["modules/demo-module"] +resolver = "2" +"#, + ); + temp_dir.write("Cargo.lock", "version = 4\n"); + temp_dir.write( + "config/quickstart.yml", + "modules:\n demo:\n metadata: {}\n", + ); + temp_dir.write( + "modules/demo-module/Cargo.toml", + r#"[package] +name = "demo-module" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/lib.rs" +"#, + ); + temp_dir.write("modules/demo-module/src/lib.rs", "pub mod module;\n"); + temp_dir.write( + "modules/demo-module/src/module.rs", + "#[module(name = \"demo\")]\npub struct DemoModule;\n", + ); + temp_dir.write( + "templates/Deploy/docker/Dockerfile.liquid", + "COPY {{ generated_project_dir }}/Cargo.toml {{ generated_project_dir }}/Cargo.toml\n{% for path in local_paths %}COPY {{ path }} {{ path }}\n{% endfor %}COPY config.yml /srv/config.yml\nCOPY --from=builder /workspace/target/release/{{ executable_name }} /srv/{{ executable_name }}\n", + ); + + // chdir so workspace_root() resolves to the temp directory + std::env::set_current_dir(workspace_root).expect("chdir into temp workspace"); + + let output_dir = workspace_root.join("bundle"); + let args = DeployArgs { + template: DeployTemplateKind::Docker, + path_config: PathConfigArgs { + path: Some(workspace_root.to_path_buf()), + config: workspace_root.join("config/quickstart.yml"), + }, + name: Some("demo".to_owned()), + output_dir: Some(output_dir.clone()), + force: false, + image_name: None, + image_tag: "latest".to_owned(), + local_path: Some(workspace_root.join("templates").display().to_string()), + git: None, + subfolder: "Deploy".to_owned(), + branch: None, + }; + + args.run().expect("deploy run"); + + let generated_cargo_toml = output_dir.join(".cyberfabric/demo/Cargo.toml"); + let generated_main = output_dir.join(".cyberfabric/demo/src/main.rs"); + let dockerfile = output_dir.join("Dockerfile"); + + assert!(generated_cargo_toml.is_file()); + assert!(generated_main.is_file()); + assert!(dockerfile.is_file()); + assert!(output_dir.join("config.yml").is_file()); + assert!(output_dir.join("Cargo.toml").is_file()); + assert!(output_dir.join("Cargo.lock").is_file()); + assert!(output_dir.join("modules/demo-module/Cargo.toml").is_file()); + + let generated_cargo_toml = + fs::read_to_string(&generated_cargo_toml).expect("generated cargo toml"); + assert!(generated_cargo_toml.contains("../../modules/demo-module")); + + let generated_main = fs::read_to_string(&generated_main).expect("generated main"); + assert!(generated_main.contains("CF_CLI_CONFIG")); + assert!(generated_main.contains("run_server(config)")); + + let dockerfile = fs::read_to_string(&dockerfile).expect("dockerfile"); + assert!( + dockerfile.contains("COPY .cyberfabric/demo/Cargo.toml .cyberfabric/demo/Cargo.toml") + ); + assert!(dockerfile.contains("COPY modules/demo-module modules/demo-module")); + assert!(dockerfile.contains("COPY config.yml /srv/config.yml")); + assert!(dockerfile.contains("/srv/demo")); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index d5c329d..b259829 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,6 +1,7 @@ mod build; mod common; mod config; +mod deploy; mod docs; mod init; mod lint; @@ -23,6 +24,7 @@ pub enum Commands { Init(init::InitArgs), Mod(r#mod::ModArgs), Config(Box), + Deploy(deploy::DeployArgs), Docs(docs::DocsArgs), Lint(lint::LintArgs), Test(test::TestArgs), @@ -37,6 +39,7 @@ impl Cli { Commands::Init(init) => init.run(), Commands::Mod(r#mod) => r#mod.run(), Commands::Config(config) => config.run(), + Commands::Deploy(deploy) => deploy.run(), Commands::Docs(docs) => docs.run(), Commands::Lint(lint) => lint.run(), Commands::Test(test) => test.run(), From b24d924241c6f8965f3411aaf1434eb455281b95 Mon Sep 17 00:00:00 2001 From: devjow Date: Wed, 8 Apr 2026 15:05:15 +0100 Subject: [PATCH 2/5] refactor: collapse generate_server_structure into single function and address PR review comment --- crates/cli/src/deploy/mod.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/cli/src/deploy/mod.rs b/crates/cli/src/deploy/mod.rs index 3e413b3..85e18f5 100644 --- a/crates/cli/src/deploy/mod.rs +++ b/crates/cli/src/deploy/mod.rs @@ -11,6 +11,9 @@ use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; const OUTPUT_SUBDIR: &str = "deploy"; +/// Exact directory/file names excluded from deploy bundle copies. +/// Additional pattern-based exclusions (`.env*`, `*.swp`, `*~`) are +/// handled by [`should_skip_bundle_entry`]. const FILTERED_ENTRY_NAMES: &[&str] = &[".DS_Store", ".git", ".github", ".idea", ".vscode", "target"]; const TEMPLATE_FILE_PAIRS: [(&str, &str); 1] = [("Dockerfile.liquid", "Dockerfile")]; @@ -450,6 +453,10 @@ fn build_template_context( "has_cargo_lock": has_cargo_lock, "local_paths": local_paths, "image_ref": image_ref, + // Intentionally hardcoded: the Dockerfile template uses these paths to + // conditionally gate OpenTelemetry setup in the generated server main. + // The actual runtime value comes from the config.yml copied into the + // bundle, not from this template context. "config": { "opentelemetry": { "tracing": { From b0c0a105c29838ed5a0bdc841cb33663101c5b70 Mon Sep 17 00:00:00 2001 From: devjow Date: Wed, 8 Apr 2026 20:17:15 +0100 Subject: [PATCH 3/5] fix: symlink and set current dir Signed-off-by: devjow --- crates/cli/src/deploy/mod.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/cli/src/deploy/mod.rs b/crates/cli/src/deploy/mod.rs index 85e18f5..a8578c5 100644 --- a/crates/cli/src/deploy/mod.rs +++ b/crates/cli/src/deploy/mod.rs @@ -276,6 +276,7 @@ fn copy_optional_file(source: &Path, destination: &Path) -> anyhow::Result } fn copy_file(source: &Path, destination: &Path) -> anyhow::Result<()> { + reject_symlink(source)?; if let Some(parent) = destination.parent() { fs::create_dir_all(parent).with_context(|| format!("can't create {}", parent.display()))?; } @@ -289,6 +290,18 @@ fn copy_file(source: &Path, destination: &Path) -> anyhow::Result<()> { Ok(()) } +fn reject_symlink(path: &Path) -> anyhow::Result<()> { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("can't inspect {}", path.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "symlinked paths are not supported in deploy bundles: {}", + path.display() + ); + } + Ok(()) +} + fn copy_relative_workspace_path( workspace_root: &Path, output_dir: &Path, @@ -300,14 +313,9 @@ fn copy_relative_workspace_path( } fn copy_path_recursively(source: &Path, destination: &Path) -> anyhow::Result<()> { + reject_symlink(source)?; let metadata = fs::symlink_metadata(source) .with_context(|| format!("can't inspect {}", source.display()))?; - if metadata.file_type().is_symlink() { - bail!( - "symlinked paths are not supported in deploy bundles: {}", - source.display() - ); - } if metadata.is_dir() { fs::create_dir_all(destination) @@ -516,7 +524,15 @@ mod tests { use module_parser::test_utils::TempDirExt; use module_parser::{CargoTomlDependencies, CargoTomlDependency}; use std::fs; - use std::path::Path; + use std::path::{Path, PathBuf}; + + struct CurrentDirGuard(PathBuf); + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } #[test] fn collects_workspace_members_and_dependency_paths() { @@ -699,7 +715,10 @@ path = "src/lib.rs" "COPY {{ generated_project_dir }}/Cargo.toml {{ generated_project_dir }}/Cargo.toml\n{% for path in local_paths %}COPY {{ path }} {{ path }}\n{% endfor %}COPY config.yml /srv/config.yml\nCOPY --from=builder /workspace/target/release/{{ executable_name }} /srv/{{ executable_name }}\n", ); - // chdir so workspace_root() resolves to the temp directory + // chdir so workspace_root() resolves to the temp directory; + // the guard restores the original CWD on drop to avoid leaking + // process-global state to other parallel tests. + let _cwd_guard = CurrentDirGuard(std::env::current_dir().expect("current dir")); std::env::set_current_dir(workspace_root).expect("chdir into temp workspace"); let output_dir = workspace_root.join("bundle"); From c67648277cd032b644dcec7fb715928558f009ec Mon Sep 17 00:00:00 2001 From: devjow Date: Mon, 13 Apr 2026 10:57:29 +0100 Subject: [PATCH 4/5] refactor: migrate deploy to cargo_generate and fix clippy warnings Signed-off-by: devjow --- Cargo.lock | 1 - crates/cli/Cargo.toml | 1 - crates/cli/src/deploy/mod.rs | 238 ++++++++++----------------- crates/module-parser/src/metadata.rs | 12 +- crates/module-parser/src/source.rs | 20 +-- 5 files changed, 104 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 81c370d..3f4f912 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,7 +402,6 @@ dependencies = [ "cargo-generate", "clap", "flate2", - "liquid", "module-parser", "notify", "reqwest", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 4fe010c..29880f2 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -37,7 +37,6 @@ tar = { workspace = true } toml = { workspace = true } toml_edit = { workspace = true } semver = { workspace = true } -liquid = "0.26.11" [lints] workspace = true diff --git a/crates/cli/src/deploy/mod.rs b/crates/cli/src/deploy/mod.rs index a8578c5..485bccc 100644 --- a/crates/cli/src/deploy/mod.rs +++ b/crates/cli/src/deploy/mod.rs @@ -1,14 +1,12 @@ use crate::common::{self, PathConfigArgs}; use anyhow::{Context, bail}; +use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate}; use clap::{Args, ValueEnum}; -use liquid::ParserBuilder; use module_parser::CargoTomlDependencies; use std::collections::BTreeSet; use std::ffi::OsStr; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::{SystemTime, UNIX_EPOCH}; const OUTPUT_SUBDIR: &str = "deploy"; /// Exact directory/file names excluded from deploy bundle copies. @@ -16,7 +14,6 @@ const OUTPUT_SUBDIR: &str = "deploy"; /// handled by [`should_skip_bundle_entry`]. const FILTERED_ENTRY_NAMES: &[&str] = &[".DS_Store", ".git", ".github", ".idea", ".vscode", "target"]; -const TEMPLATE_FILE_PAIRS: [(&str, &str); 1] = [("Dockerfile.liquid", "Dockerfile")]; #[derive(Args)] pub struct DeployArgs { @@ -34,12 +31,6 @@ pub struct DeployArgs { /// Allow replacing an existing custom output directory #[arg(long)] force: bool, - /// Optional local image name used in helper output - #[arg(long)] - image_name: Option, - /// Optional local image tag used in helper output - #[arg(long, default_value = "latest")] - image_tag: String, /// Path to a local deploy template repository #[arg(long)] local_path: Option, @@ -99,35 +90,24 @@ impl DeployArgs { rewrite_dependency_paths_for_bundle(&workspace_root, &dependencies)?; common::generate_server_structure_at(&output_dir, &project_name, &rewritten_dependencies)?; - let template_checkout = TemplateCheckout::prepare( - self.local_path.as_deref(), - self.git.as_deref(), - self.branch.as_deref(), - )?; - let template_dir = template_checkout.template_dir(&self.subfolder, self.template)?; - render_templates( - &template_dir, + render_deploy_template( &output_dir, - &build_template_context( - &project_name, - &local_paths, - has_cargo_lock, - &self.image_ref(&project_name), - ), + &project_name, + &local_paths, + has_cargo_lock, + &TemplateSource { + local_path: self.local_path.as_deref(), + git: self.git.as_deref(), + subfolder: &self.subfolder, + kind: self.template, + branch: self.branch.as_deref(), + }, )?; println!("Deploy bundle generated at {}", output_dir.display()); Ok(()) } - fn image_ref(&self, project_name: &str) -> String { - let image_name = self - .image_name - .clone() - .unwrap_or_else(|| project_name.to_owned()); - format!("{}:{}", image_name, self.image_tag) - } - fn resolve_output_dir(&self, workspace_root: &Path, project_name: &str) -> PathBuf { self.output_dir.as_ref().map_or_else( || { @@ -147,69 +127,6 @@ impl DeployArgs { } } -struct TemplateCheckout { - root: PathBuf, - cleanup_root: Option, -} - -impl TemplateCheckout { - fn prepare( - local_path: Option<&str>, - git: Option<&str>, - branch: Option<&str>, - ) -> anyhow::Result { - if let Some(local_path) = local_path { - let root = PathBuf::from(local_path) - .canonicalize() - .with_context(|| format!("can't canonicalize template path {local_path}"))?; - return Ok(Self { - root, - cleanup_root: None, - }); - } - - let git = git.context("template git URL is missing")?; - let branch = branch.unwrap_or("main"); - let checkout_dir = unique_checkout_dir(); - let output = Command::new("git") - .args(["clone", "--depth", "1", "--branch", branch, git]) - .arg(&checkout_dir) - .output() - .context("failed to invoke git clone for deploy templates")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let details = if stderr.is_empty() { stdout } else { stderr }; - bail!("failed to clone deploy template repo: {details}"); - } - - Ok(Self { - root: checkout_dir.clone(), - cleanup_root: Some(checkout_dir), - }) - } - - fn template_dir( - &self, - subfolder: &str, - template: DeployTemplateKind, - ) -> anyhow::Result { - let dir = self.root.join(subfolder).join(template.as_str()); - if !dir.is_dir() { - bail!("deploy template directory not found at {}", dir.display()); - } - Ok(dir) - } -} - -impl Drop for TemplateCheckout { - fn drop(&mut self) { - if let Some(path) = &self.cleanup_root { - let _ = fs::remove_dir_all(path); - } - } -} - fn prepare_output_dir(output_dir: &Path, workspace_root: &Path, force: bool) -> anyhow::Result<()> { if output_dir.exists() && fs::symlink_metadata(output_dir) @@ -259,14 +176,6 @@ fn prepare_output_dir(output_dir: &Path, workspace_root: &Path, force: bool) -> .with_context(|| format!("can't create {}", output_dir.display())) } -fn unique_checkout_dir() -> PathBuf { - let suffix = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - std::env::temp_dir().join(format!("cf-cli-deploy-{suffix}")) -} - fn copy_optional_file(source: &Path, destination: &Path) -> anyhow::Result { if !source.is_file() { return Ok(false); @@ -443,57 +352,84 @@ fn bundle_relative_dependency_path(relative_path: &Path) -> String { .replace('\\', "/") } -fn build_template_context( +struct TemplateSource<'a> { + local_path: Option<&'a str>, + git: Option<&'a str>, + subfolder: &'a str, + kind: DeployTemplateKind, + branch: Option<&'a str>, +} + +fn render_deploy_template( + output_dir: &Path, project_name: &str, local_paths: &BTreeSet, has_cargo_lock: bool, - image_ref: &str, -) -> liquid::Object { - let local_paths = local_paths + source: &TemplateSource<'_>, +) -> anyhow::Result<()> { + let generated_project_dir = format!(".cyberfabric/{project_name}"); + + let copy_cargo_lock = if has_cargo_lock { + "COPY Cargo.lock Cargo.lock\n".to_owned() + } else { + String::new() + }; + + let copy_local_paths = local_paths .iter() - .map(|path| path.display().to_string().replace('\\', "/")) - .collect::>(); - - liquid::object!({ - "project_name": project_name, - "executable_name": project_name, - "generated_project_dir": format!(".cyberfabric/{project_name}"), - "has_cargo_lock": has_cargo_lock, - "local_paths": local_paths, - "image_ref": image_ref, - // Intentionally hardcoded: the Dockerfile template uses these paths to - // conditionally gate OpenTelemetry setup in the generated server main. - // The actual runtime value comes from the config.yml copied into the - // bundle, not from this template context. - "config": { - "opentelemetry": { - "tracing": { - "enabled": true, - }, - }, + .map(|p| { + let p = p.display().to_string().replace('\\', "/"); + format!("COPY {p} {p}") + }) + .collect::>() + .join("\n"); + + let values_path = output_dir.join(".cargo-generate-values.toml"); + fs::write( + &values_path, + format!( + "[values]\ngenerated_project_dir = {gen}\nexecutable_name = {exe}\ncopy_cargo_lock = {lock}\ncopy_local_paths = {paths}\n", + gen = toml::Value::from(&*generated_project_dir), + exe = toml::Value::from(project_name), + lock = toml::Value::from(&*copy_cargo_lock), + paths = toml::Value::from(&*copy_local_paths), + ), + ) + .context("can't write template values file")?; + + let auto_path = format!("{}/{}", source.subfolder, source.kind.as_str()); + + let (git, branch) = if source.local_path.is_some() { + (None, None) + } else { + ( + source.git.map(ToOwned::to_owned), + source.branch.map(ToOwned::to_owned), + ) + }; + + generate(GenerateArgs { + template_path: TemplatePath { + auto_path: Some(auto_path), + git, + path: source.local_path.map(ToOwned::to_owned), + branch, + ..TemplatePath::default() }, + destination: Some(output_dir.to_path_buf()), + name: Some(project_name.to_owned()), + force: true, + silent: true, + vcs: Some(Vcs::None), + init: true, + overwrite: true, + no_workspace: true, + template_values_file: Some(values_path.display().to_string()), + ..GenerateArgs::default() }) -} + .context("can't render deploy template")?; -fn render_templates( - template_dir: &Path, - output_dir: &Path, - context: &liquid::Object, -) -> anyhow::Result<()> { - let parser = ParserBuilder::with_stdlib().build()?; - for (template_name, output_name) in TEMPLATE_FILE_PAIRS { - let template_path = template_dir.join(template_name); - let template_source = fs::read_to_string(&template_path) - .with_context(|| format!("can't read {}", template_path.display()))?; - let template = parser - .parse(&template_source) - .with_context(|| format!("can't parse {}", template_path.display()))?; - let rendered = template - .render(context) - .with_context(|| format!("can't render {}", template_path.display()))?; - fs::write(output_dir.join(output_name), rendered) - .with_context(|| format!("can't write {}", output_dir.join(output_name).display()))?; - } + let _ = fs::remove_file(&values_path); Ok(()) } @@ -710,9 +646,13 @@ path = "src/lib.rs" "modules/demo-module/src/module.rs", "#[module(name = \"demo\")]\npub struct DemoModule;\n", ); + temp_dir.write( + "templates/Deploy/docker/cargo-generate.toml", + "[template]\nexclude = [\"**/.DS_Store\"]\n", + ); temp_dir.write( "templates/Deploy/docker/Dockerfile.liquid", - "COPY {{ generated_project_dir }}/Cargo.toml {{ generated_project_dir }}/Cargo.toml\n{% for path in local_paths %}COPY {{ path }} {{ path }}\n{% endfor %}COPY config.yml /srv/config.yml\nCOPY --from=builder /workspace/target/release/{{ executable_name }} /srv/{{ executable_name }}\n", + "COPY {{ generated_project_dir }}/Cargo.toml {{ generated_project_dir }}/Cargo.toml\n{{ copy_local_paths }}\nCOPY config.yml /srv/config.yml\nCOPY --from=builder /workspace/target/release/{{ executable_name }} /srv/{{ executable_name }}\n", ); // chdir so workspace_root() resolves to the temp directory; @@ -731,8 +671,6 @@ path = "src/lib.rs" name: Some("demo".to_owned()), output_dir: Some(output_dir.clone()), force: false, - image_name: None, - image_tag: "latest".to_owned(), local_path: Some(workspace_root.join("templates").display().to_string()), git: None, subfolder: "Deploy".to_owned(), diff --git a/crates/module-parser/src/metadata.rs b/crates/module-parser/src/metadata.rs index 309c9a5..d21e4cc 100644 --- a/crates/module-parser/src/metadata.rs +++ b/crates/module-parser/src/metadata.rs @@ -397,14 +397,14 @@ mod tests { ); temp_dir.write( "src/lib.rs", - r#" + r" use proc_macro::TokenStream; #[proc_macro_attribute] pub fn module(_attr: TokenStream, item: TokenStream) -> TokenStream { item } - "#, + ", ); let resolved = resolve_source_from_metadata(temp_dir.path(), "cf-demo-macros::module") @@ -549,9 +549,9 @@ mod tests { ); temp_dir.write( "src/lib.rs", - r#" + r" pub fn app() {} - "#, + ", ); temp_dir.write( "cf-helper/Cargo.toml", @@ -570,9 +570,9 @@ mod tests { ); temp_dir.write( "cf-helper/src/lib.rs", - r#" + r" pub fn helper() {} - "#, + ", ); let dependency_aliases = diff --git a/crates/module-parser/src/source.rs b/crates/module-parser/src/source.rs index 1a3bd6c..14f04ee 100644 --- a/crates/module-parser/src/source.rs +++ b/crates/module-parser/src/source.rs @@ -506,13 +506,13 @@ mod tests { let temp_dir = TempDir::new().expect("failed to create temp dir"); temp_dir.write( "src/lib.rs", - r#" + r" pub mod utils; - "#, + ", ); temp_dir.write( "src/utils.rs", - r#" + r" pub fn always() -> bool { true } #[cfg(not(test))] @@ -520,7 +520,7 @@ mod tests { #[cfg(test)] fn test_only() -> bool { false } - "#, + ", ); let resolved = resolve_rust_path(&temp_dir.path().join("src/lib.rs"), &["utils"]) @@ -543,18 +543,18 @@ pub fn prod_only() -> bool { let temp_dir = TempDir::new().expect("failed to create temp dir"); temp_dir.write( "src/lib.rs", - r#" + r" pub mod handler; - "#, + ", ); temp_dir.write( "src/handler.rs", - r#" + r" pub async fn handle() {} #[tokio::test] async fn test_handle() {} - "#, + ", ); let resolved = resolve_rust_path(&temp_dir.path().join("src/lib.rs"), &["handler"]) @@ -568,9 +568,9 @@ pub fn prod_only() -> bool { let temp_dir = TempDir::new().expect("failed to create temp dir"); temp_dir.write( "src/lib.rs", - r#" + r" pub mod mixed; - "#, + ", ); temp_dir.write( "src/mixed.rs", From d42d7acca2845c7a207ed5a8b2c631e48071dbb7 Mon Sep 17 00:00:00 2001 From: devjow Date: Tue, 14 Apr 2026 17:31:16 +0100 Subject: [PATCH 5/5] feat(deploy): wrap Docker commands in deploy CLI and decompose mod.rs into submodules --- SKILLS.md | 17 +- crates/cli/src/deploy/bundle.rs | 414 ++++++++++++++++++++++++ crates/cli/src/deploy/docker.rs | 29 ++ crates/cli/src/deploy/mod.rs | 520 +++--------------------------- crates/cli/src/deploy/template.rs | 88 +++++ 5 files changed, 580 insertions(+), 488 deletions(-) create mode 100644 crates/cli/src/deploy/bundle.rs create mode 100644 crates/cli/src/deploy/docker.rs create mode 100644 crates/cli/src/deploy/template.rs diff --git a/SKILLS.md b/SKILLS.md index 8efb219..3f98924 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -664,7 +664,7 @@ Generate a Docker deployment bundle under `.cyberfabric/deploy//`. Synopsis: ```bash -cyberfabric deploy --template docker -c [-p ] [--name ] [--output-dir ] [--force] [--image-name ] [--image-tag ] [--local-path ] [--git ] [--subfolder ] [--branch ] +cyberfabric deploy --template docker -c [-p ] [--name ] [--output-dir ] [--force] [--local-path ] [--git ] [--subfolder ] [--branch ] [--build] [--tag ] [--push] ``` Arguments: @@ -675,12 +675,13 @@ Arguments: - **[`--name `]** Override the generated server project and executable name; defaults to the config filename stem - **[`--output-dir `]** Override the deploy bundle output directory; defaults to `.cyberfabric/deploy//` - **[`--force`]** Allow replacing an existing custom output directory outside `.cyberfabric/deploy/` -- **[`--image-name `]** Optional image name used in the generated helper command -- **[`--image-tag `]** Optional image tag used in the generated helper command; defaults to `latest` - **[`--local-path `]** Use a local deploy template repository instead of Git - **[`--git `]** Deploy template repo URL, defaults to `https://github.com/cyberfabric/cf-template-rust` - **[`--subfolder `]** Template repo subfolder root, defaults to `Deploy` - **[`--branch `]** Template repo branch, defaults to `main` +- **[`--build`]** Build the Docker image from the generated bundle after generation +- **[`--tag `]** Tag the built image with the given reference (implies `--build`); defaults to `:latest` when only `--build` is used +- **[`--push`]** Push the tagged image to a registry (requires `--tag`) Behavior: @@ -692,7 +693,7 @@ Behavior: - **[copies config as `config.yml`]** Places the selected config file in the deploy bundle root as `config.yml` - **[renders from deploy templates]** Loads `Deploy/docker` templates from `cf-template-rust` and renders the `Dockerfile` - **[protects custom output directories]** Replaces existing bundle directories under `.cyberfabric/deploy/` automatically, but requires `--force` before deleting an existing custom `--output-dir` -- **[generate-only]** Does not run `docker build`, push to a registry, or deploy to a cluster +- **[optional Docker lifecycle]** When `--build` or `--tag` is given, runs `docker build` after generating the bundle; `--push` additionally pushes the tagged image to a registry Examples: @@ -708,6 +709,14 @@ cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quic cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --local-path ~/dev/cf-template-rust ``` +```bash +cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --build +``` + +```bash +cyberfabric deploy --template docker -p /tmp/cf-demo -c /tmp/cf-demo/config/quickstart.yml --tag myapp:v1.0 --push +``` + ### `lint` Declared in the CLI but **currently unimplemented**. diff --git a/crates/cli/src/deploy/bundle.rs b/crates/cli/src/deploy/bundle.rs new file mode 100644 index 0000000..7c1f1f2 --- /dev/null +++ b/crates/cli/src/deploy/bundle.rs @@ -0,0 +1,414 @@ +use crate::common; +use anyhow::{Context, bail}; +use module_parser::CargoTomlDependencies; +use std::collections::BTreeSet; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Exact directory/file names excluded from deploy bundle copies. +/// Additional pattern-based exclusions (`.env*`, `*.swp`, `*~`) are +/// handled by [`should_skip_bundle_entry`]. +const FILTERED_ENTRY_NAMES: &[&str] = + &[".DS_Store", ".git", ".github", ".idea", ".vscode", "target"]; + +pub(super) fn prepare_output_dir( + output_dir: &Path, + workspace_root: &Path, + force: bool, +) -> anyhow::Result<()> { + if output_dir.exists() + && fs::symlink_metadata(output_dir) + .with_context(|| format!("can't inspect {}", output_dir.display()))? + .file_type() + .is_symlink() + { + bail!( + "output directory '{}' cannot be a symlink", + output_dir.display() + ); + } + + let workspace_root = workspace_root + .canonicalize() + .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; + let output_dir = canonicalize_path_for_safety(output_dir)?; + let base_path_root = workspace_root.join(common::BASE_PATH); + let deploy_root = base_path_root.join(super::OUTPUT_SUBDIR); + + for reserved_path in [&workspace_root, &base_path_root, &deploy_root] { + if output_dir == *reserved_path { + bail!( + "output directory cannot be the reserved path {}", + reserved_path.display() + ); + } + } + + if output_dir.exists() { + if !output_dir.is_dir() { + bail!( + "output directory '{}' exists but is not a directory", + output_dir.display() + ); + } + if !output_dir.starts_with(&deploy_root) && !force { + bail!( + "refusing to replace existing custom output directory {}; pass --force to overwrite it", + output_dir.display() + ); + } + fs::remove_dir_all(&output_dir) + .with_context(|| format!("can't remove {}", output_dir.display()))?; + } + fs::create_dir_all(&output_dir) + .with_context(|| format!("can't create {}", output_dir.display())) +} + +pub(super) fn copy_optional_file(source: &Path, destination: &Path) -> anyhow::Result { + if !source.is_file() { + return Ok(false); + } + copy_file(source, destination)?; + Ok(true) +} + +pub(super) fn copy_file(source: &Path, destination: &Path) -> anyhow::Result<()> { + reject_symlink(source)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent).with_context(|| format!("can't create {}", parent.display()))?; + } + fs::copy(source, destination).with_context(|| { + format!( + "can't copy {} to {}", + source.display(), + destination.display() + ) + })?; + Ok(()) +} + +fn reject_symlink(path: &Path) -> anyhow::Result<()> { + let metadata = + fs::symlink_metadata(path).with_context(|| format!("can't inspect {}", path.display()))?; + if metadata.file_type().is_symlink() { + bail!( + "symlinked paths are not supported in deploy bundles: {}", + path.display() + ); + } + Ok(()) +} + +pub(super) fn copy_relative_workspace_path( + workspace_root: &Path, + output_dir: &Path, + relative_path: &Path, +) -> anyhow::Result<()> { + let source = workspace_root.join(relative_path); + let destination = output_dir.join(relative_path); + copy_path_recursively(&source, &destination) +} + +fn copy_path_recursively(source: &Path, destination: &Path) -> anyhow::Result<()> { + reject_symlink(source)?; + let metadata = fs::symlink_metadata(source) + .with_context(|| format!("can't inspect {}", source.display()))?; + + if metadata.is_dir() { + fs::create_dir_all(destination) + .with_context(|| format!("can't create {}", destination.display()))?; + for entry in + fs::read_dir(source).with_context(|| format!("can't read {}", source.display()))? + { + let entry = entry.with_context(|| format!("can't read {}", source.display()))?; + if should_skip_bundle_entry(&entry.file_name()) { + continue; + } + let child_source = entry.path(); + let child_destination = destination.join(entry.file_name()); + copy_path_recursively(&child_source, &child_destination)?; + } + return Ok(()); + } + + copy_file(source, destination) +} + +fn should_skip_bundle_entry(name: &OsStr) -> bool { + let name = name.to_string_lossy(); + FILTERED_ENTRY_NAMES.contains(&name.as_ref()) + || name.starts_with(".env") + || name.ends_with(".swp") + || name.ends_with('~') +} + +pub(super) fn collect_required_local_paths( + workspace_root: &Path, + dependencies: &CargoTomlDependencies, +) -> anyhow::Result> { + let mut paths = read_workspace_members(workspace_root)?; + for dependency in dependencies.values() { + if let Some(path) = &dependency.path { + paths.insert(resolve_workspace_relative_path( + workspace_root, + Path::new(path), + )?); + } + } + Ok(paths) +} + +fn read_workspace_members(workspace_root: &Path) -> anyhow::Result> { + let cargo_toml_path = workspace_root.join("Cargo.toml"); + let raw = fs::read_to_string(&cargo_toml_path) + .with_context(|| format!("can't read {}", cargo_toml_path.display()))?; + let doc = raw + .parse::() + .with_context(|| format!("can't parse {}", cargo_toml_path.display()))?; + + let members = doc["workspace"]["members"] + .as_array() + .map(|array| { + array + .iter() + .filter_map(toml_edit::Value::as_str) + .map(PathBuf::from) + .collect::>() + }) + .unwrap_or_default(); + + let mut resolved = BTreeSet::new(); + for member in members { + let relative = resolve_workspace_relative_path(workspace_root, &member)?; + if !relative.as_os_str().is_empty() { + resolved.insert(relative); + } + } + Ok(resolved) +} + +fn resolve_workspace_relative_path( + workspace_root: &Path, + raw_path: &Path, +) -> anyhow::Result { + let absolute_path = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + workspace_root.join(raw_path) + }; + let absolute_path = absolute_path + .canonicalize() + .with_context(|| format!("can't canonicalize {}", absolute_path.display()))?; + let workspace_root = workspace_root + .canonicalize() + .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; + + if !absolute_path.starts_with(&workspace_root) { + bail!( + "local dependency path '{}' is outside the workspace root {}", + absolute_path.display(), + workspace_root.display() + ); + } + + absolute_path + .strip_prefix(workspace_root) + .map(Path::to_path_buf) + .context("can't derive workspace-relative path") +} + +pub(super) fn rewrite_dependency_paths_for_bundle( + workspace_root: &Path, + dependencies: &CargoTomlDependencies, +) -> anyhow::Result { + let mut rewritten = dependencies.clone(); + for dependency in rewritten.values_mut() { + if let Some(path) = &dependency.path { + let relative = resolve_workspace_relative_path(workspace_root, Path::new(path))?; + dependency.path = Some(bundle_relative_dependency_path(&relative)); + } + } + Ok(rewritten) +} + +fn bundle_relative_dependency_path(relative_path: &Path) -> String { + PathBuf::from("..") + .join("..") + .join(relative_path) + .display() + .to_string() + .replace('\\', "/") +} + +fn canonicalize_path_for_safety(path: &Path) -> anyhow::Result { + if path.exists() { + return path + .canonicalize() + .with_context(|| format!("can't canonicalize {}", path.display())); + } + + let parent = path + .parent() + .context("output directory must have a parent directory")?; + let file_name = path + .file_name() + .context("output directory must have a final path component")?; + + Ok(canonicalize_path_for_safety(parent)?.join(file_name)) +} + +#[cfg(test)] +mod tests { + use super::{ + collect_required_local_paths, copy_relative_workspace_path, prepare_output_dir, + rewrite_dependency_paths_for_bundle, + }; + use module_parser::{CargoTomlDependencies, CargoTomlDependency}; + use std::fs; + use std::path::Path; + + #[test] + fn collects_workspace_members_and_dependency_paths() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"modules/foo\", \"modules/foo/sdk\"]\n", + ) + .expect("workspace cargo toml"); + fs::create_dir_all(workspace_root.join("modules/foo/sdk/src")).expect("workspace dirs"); + fs::create_dir_all(workspace_root.join("extras/bar/src")).expect("extra dirs"); + + let mut dependencies = CargoTomlDependencies::new(); + dependencies.insert( + "bar".to_owned(), + CargoTomlDependency { + path: Some(workspace_root.join("extras/bar").display().to_string()), + ..CargoTomlDependency::default() + }, + ); + + let paths = collect_required_local_paths(workspace_root, &dependencies).expect("paths"); + assert!(paths.contains(Path::new("modules/foo"))); + assert!(paths.contains(Path::new("modules/foo/sdk"))); + assert!(paths.contains(Path::new("extras/bar"))); + } + + #[test] + fn rewrites_absolute_dependency_paths_for_bundle() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + fs::write( + workspace_root.join("Cargo.toml"), + "[workspace]\nmembers = [\"modules/foo\"]\n", + ) + .expect("workspace cargo toml"); + fs::create_dir_all(workspace_root.join("modules/foo/src")).expect("module dir"); + + let mut dependencies = CargoTomlDependencies::new(); + dependencies.insert( + "foo".to_owned(), + CargoTomlDependency { + path: Some(workspace_root.join("modules/foo").display().to_string()), + ..CargoTomlDependency::default() + }, + ); + + let rewritten = + rewrite_dependency_paths_for_bundle(workspace_root, &dependencies).expect("rewrite"); + assert_eq!(rewritten["foo"].path.as_deref(), Some("../../modules/foo")); + } + + #[test] + fn prepare_output_dir_replaces_default_bundle_dir_without_force() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let output_dir = workspace_root.join(".cyberfabric/deploy/demo"); + + fs::create_dir_all(&output_dir).expect("default deploy dir"); + fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); + + prepare_output_dir(&output_dir, workspace_root, false).expect("default deploy dir"); + + assert!(output_dir.is_dir()); + assert!(!output_dir.join("stale.txt").exists()); + } + + #[test] + fn prepare_output_dir_requires_force_for_existing_custom_dir() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(&output_dir).expect("custom output dir"); + fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); + + let err = prepare_output_dir(&output_dir, workspace_root, false) + .expect_err("custom output dir should require force"); + assert!(err.to_string().contains("--force")); + + prepare_output_dir(&output_dir, workspace_root, true).expect("forced cleanup"); + assert!(output_dir.is_dir()); + assert!(!output_dir.join("stale.txt").exists()); + } + + #[test] + fn copy_relative_workspace_path_filters_known_junk_entries() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let source_dir = workspace_root.join("modules/demo-module"); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(source_dir.join("src")).expect("source dir"); + fs::create_dir_all(source_dir.join("target/debug")).expect("target dir"); + fs::create_dir_all(source_dir.join(".git")).expect("git dir"); + fs::write(source_dir.join("src/lib.rs"), "pub fn demo() {}\n").expect("lib source"); + fs::write(source_dir.join(".env"), "SECRET=value\n").expect("env file"); + fs::write(source_dir.join(".DS_Store"), "junk").expect("finder junk"); + fs::write(source_dir.join("target/debug/demo"), "junk").expect("target artifact"); + + copy_relative_workspace_path( + workspace_root, + &output_dir, + Path::new("modules/demo-module"), + ) + .expect("copy filtered bundle path"); + + assert!(output_dir.join("modules/demo-module/src/lib.rs").is_file()); + assert!(!output_dir.join("modules/demo-module/.env").exists()); + assert!(!output_dir.join("modules/demo-module/.DS_Store").exists()); + assert!(!output_dir.join("modules/demo-module/.git").exists()); + assert!(!output_dir.join("modules/demo-module/target").exists()); + } + + #[cfg(unix)] + #[test] + fn copy_relative_workspace_path_rejects_symlinks() { + use std::os::unix::fs::symlink; + + let temp_dir = tempfile::tempdir().expect("temp dir"); + let workspace_root = temp_dir.path(); + let source_dir = workspace_root.join("modules/demo-module"); + let output_dir = workspace_root.join("bundle"); + + fs::create_dir_all(&source_dir).expect("source dir"); + fs::write(workspace_root.join("outside.txt"), "hello\n").expect("outside file"); + symlink( + workspace_root.join("outside.txt"), + source_dir.join("linked.txt"), + ) + .expect("symlink"); + + let err = copy_relative_workspace_path( + workspace_root, + &output_dir, + Path::new("modules/demo-module"), + ) + .expect_err("symlinked bundle entries should be rejected"); + assert!( + err.to_string() + .contains("symlinked paths are not supported") + ); + } +} diff --git a/crates/cli/src/deploy/docker.rs b/crates/cli/src/deploy/docker.rs new file mode 100644 index 0000000..c997898 --- /dev/null +++ b/crates/cli/src/deploy/docker.rs @@ -0,0 +1,29 @@ +use anyhow::{Context, bail}; +use std::path::Path; +use std::process::Command; + +fn run_docker(args: &[&str]) -> anyhow::Result<()> { + let status = Command::new("docker") + .args(args) + .status() + .context("failed to run docker — is it installed and on PATH?")?; + if !status.success() { + bail!( + "docker {} exited with {}", + args.first().unwrap_or(&""), + status + ); + } + Ok(()) +} + +pub(super) fn docker_build(bundle_dir: &Path, image_ref: &str) -> anyhow::Result<()> { + println!("Building Docker image {image_ref}…"); + let bundle = bundle_dir.display().to_string(); + run_docker(&["build", "-t", image_ref, &bundle]) +} + +pub(super) fn docker_push(image_ref: &str) -> anyhow::Result<()> { + println!("Pushing Docker image {image_ref}…"); + run_docker(&["push", image_ref]) +} diff --git a/crates/cli/src/deploy/mod.rs b/crates/cli/src/deploy/mod.rs index 485bccc..1392d0c 100644 --- a/crates/cli/src/deploy/mod.rs +++ b/crates/cli/src/deploy/mod.rs @@ -1,19 +1,19 @@ +mod bundle; +mod docker; +mod template; + use crate::common::{self, PathConfigArgs}; -use anyhow::{Context, bail}; -use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate}; use clap::{Args, ValueEnum}; -use module_parser::CargoTomlDependencies; -use std::collections::BTreeSet; -use std::ffi::OsStr; -use std::fs; use std::path::{Path, PathBuf}; +use bundle::{ + collect_required_local_paths, copy_file, copy_optional_file, copy_relative_workspace_path, + prepare_output_dir, rewrite_dependency_paths_for_bundle, +}; +use docker::{docker_build, docker_push}; +use template::{TemplateSource, render_deploy_template}; + const OUTPUT_SUBDIR: &str = "deploy"; -/// Exact directory/file names excluded from deploy bundle copies. -/// Additional pattern-based exclusions (`.env*`, `*.swp`, `*~`) are -/// handled by [`should_skip_bundle_entry`]. -const FILTERED_ENTRY_NAMES: &[&str] = - &[".DS_Store", ".git", ".github", ".idea", ".vscode", "target"]; #[derive(Args)] pub struct DeployArgs { @@ -46,6 +46,15 @@ pub struct DeployArgs { /// Branch of the template git repo #[arg(long, default_value = "main")] branch: Option, + /// Build the Docker image from the generated bundle + #[arg(long)] + build: bool, + /// Tag the built image with a given reference (implies --build) + #[arg(long)] + tag: Option, + /// Push the tagged image to a registry (requires --tag) + #[arg(long, requires = "tag")] + push: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] @@ -105,6 +114,17 @@ impl DeployArgs { )?; println!("Deploy bundle generated at {}", output_dir.display()); + + let should_build = self.build || self.tag.is_some(); + if should_build { + let default_ref = format!("{project_name}:latest"); + let image_ref = self.tag.as_deref().unwrap_or(&default_ref); + docker_build(&output_dir, image_ref)?; + if self.push { + docker_push(image_ref)?; + } + } + Ok(()) } @@ -127,340 +147,13 @@ impl DeployArgs { } } -fn prepare_output_dir(output_dir: &Path, workspace_root: &Path, force: bool) -> anyhow::Result<()> { - if output_dir.exists() - && fs::symlink_metadata(output_dir) - .with_context(|| format!("can't inspect {}", output_dir.display()))? - .file_type() - .is_symlink() - { - bail!( - "output directory '{}' cannot be a symlink", - output_dir.display() - ); - } - - let workspace_root = workspace_root - .canonicalize() - .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; - let output_dir = canonicalize_path_for_safety(output_dir)?; - let base_path_root = workspace_root.join(common::BASE_PATH); - let deploy_root = base_path_root.join(OUTPUT_SUBDIR); - - for reserved_path in [&workspace_root, &base_path_root, &deploy_root] { - if output_dir == *reserved_path { - bail!( - "output directory cannot be the reserved path {}", - reserved_path.display() - ); - } - } - - if output_dir.exists() { - if !output_dir.is_dir() { - bail!( - "output directory '{}' exists but is not a directory", - output_dir.display() - ); - } - if !output_dir.starts_with(&deploy_root) && !force { - bail!( - "refusing to replace existing custom output directory {}; pass --force to overwrite it", - output_dir.display() - ); - } - fs::remove_dir_all(&output_dir) - .with_context(|| format!("can't remove {}", output_dir.display()))?; - } - fs::create_dir_all(&output_dir) - .with_context(|| format!("can't create {}", output_dir.display())) -} - -fn copy_optional_file(source: &Path, destination: &Path) -> anyhow::Result { - if !source.is_file() { - return Ok(false); - } - copy_file(source, destination)?; - Ok(true) -} - -fn copy_file(source: &Path, destination: &Path) -> anyhow::Result<()> { - reject_symlink(source)?; - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent).with_context(|| format!("can't create {}", parent.display()))?; - } - fs::copy(source, destination).with_context(|| { - format!( - "can't copy {} to {}", - source.display(), - destination.display() - ) - })?; - Ok(()) -} - -fn reject_symlink(path: &Path) -> anyhow::Result<()> { - let metadata = - fs::symlink_metadata(path).with_context(|| format!("can't inspect {}", path.display()))?; - if metadata.file_type().is_symlink() { - bail!( - "symlinked paths are not supported in deploy bundles: {}", - path.display() - ); - } - Ok(()) -} - -fn copy_relative_workspace_path( - workspace_root: &Path, - output_dir: &Path, - relative_path: &Path, -) -> anyhow::Result<()> { - let source = workspace_root.join(relative_path); - let destination = output_dir.join(relative_path); - copy_path_recursively(&source, &destination) -} - -fn copy_path_recursively(source: &Path, destination: &Path) -> anyhow::Result<()> { - reject_symlink(source)?; - let metadata = fs::symlink_metadata(source) - .with_context(|| format!("can't inspect {}", source.display()))?; - - if metadata.is_dir() { - fs::create_dir_all(destination) - .with_context(|| format!("can't create {}", destination.display()))?; - for entry in - fs::read_dir(source).with_context(|| format!("can't read {}", source.display()))? - { - let entry = entry.with_context(|| format!("can't read {}", source.display()))?; - if should_skip_bundle_entry(&entry.file_name()) { - continue; - } - let child_source = entry.path(); - let child_destination = destination.join(entry.file_name()); - copy_path_recursively(&child_source, &child_destination)?; - } - return Ok(()); - } - - copy_file(source, destination) -} - -fn should_skip_bundle_entry(name: &OsStr) -> bool { - let name = name.to_string_lossy(); - FILTERED_ENTRY_NAMES.contains(&name.as_ref()) - || name.starts_with(".env") - || name.ends_with(".swp") - || name.ends_with('~') -} - -fn collect_required_local_paths( - workspace_root: &Path, - dependencies: &CargoTomlDependencies, -) -> anyhow::Result> { - let mut paths = read_workspace_members(workspace_root)?; - for dependency in dependencies.values() { - if let Some(path) = &dependency.path { - paths.insert(resolve_workspace_relative_path( - workspace_root, - Path::new(path), - )?); - } - } - Ok(paths) -} - -fn read_workspace_members(workspace_root: &Path) -> anyhow::Result> { - let cargo_toml_path = workspace_root.join("Cargo.toml"); - let raw = fs::read_to_string(&cargo_toml_path) - .with_context(|| format!("can't read {}", cargo_toml_path.display()))?; - let doc = raw - .parse::() - .with_context(|| format!("can't parse {}", cargo_toml_path.display()))?; - - let members = doc["workspace"]["members"] - .as_array() - .map(|array| { - array - .iter() - .filter_map(toml_edit::Value::as_str) - .map(PathBuf::from) - .collect::>() - }) - .unwrap_or_default(); - - let mut resolved = BTreeSet::new(); - for member in members { - let relative = resolve_workspace_relative_path(workspace_root, &member)?; - if !relative.as_os_str().is_empty() { - resolved.insert(relative); - } - } - Ok(resolved) -} - -fn resolve_workspace_relative_path( - workspace_root: &Path, - raw_path: &Path, -) -> anyhow::Result { - let absolute_path = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - workspace_root.join(raw_path) - }; - let absolute_path = absolute_path - .canonicalize() - .with_context(|| format!("can't canonicalize {}", absolute_path.display()))?; - let workspace_root = workspace_root - .canonicalize() - .with_context(|| format!("can't canonicalize {}", workspace_root.display()))?; - - if !absolute_path.starts_with(&workspace_root) { - bail!( - "local dependency path '{}' is outside the workspace root {}", - absolute_path.display(), - workspace_root.display() - ); - } - - absolute_path - .strip_prefix(workspace_root) - .map(Path::to_path_buf) - .context("can't derive workspace-relative path") -} - -fn rewrite_dependency_paths_for_bundle( - workspace_root: &Path, - dependencies: &CargoTomlDependencies, -) -> anyhow::Result { - let mut rewritten = dependencies.clone(); - for dependency in rewritten.values_mut() { - if let Some(path) = &dependency.path { - let relative = resolve_workspace_relative_path(workspace_root, Path::new(path))?; - dependency.path = Some(bundle_relative_dependency_path(&relative)); - } - } - Ok(rewritten) -} - -fn bundle_relative_dependency_path(relative_path: &Path) -> String { - PathBuf::from("..") - .join("..") - .join(relative_path) - .display() - .to_string() - .replace('\\', "/") -} - -struct TemplateSource<'a> { - local_path: Option<&'a str>, - git: Option<&'a str>, - subfolder: &'a str, - kind: DeployTemplateKind, - branch: Option<&'a str>, -} - -fn render_deploy_template( - output_dir: &Path, - project_name: &str, - local_paths: &BTreeSet, - has_cargo_lock: bool, - source: &TemplateSource<'_>, -) -> anyhow::Result<()> { - let generated_project_dir = format!(".cyberfabric/{project_name}"); - - let copy_cargo_lock = if has_cargo_lock { - "COPY Cargo.lock Cargo.lock\n".to_owned() - } else { - String::new() - }; - - let copy_local_paths = local_paths - .iter() - .map(|p| { - let p = p.display().to_string().replace('\\', "/"); - format!("COPY {p} {p}") - }) - .collect::>() - .join("\n"); - - let values_path = output_dir.join(".cargo-generate-values.toml"); - fs::write( - &values_path, - format!( - "[values]\ngenerated_project_dir = {gen}\nexecutable_name = {exe}\ncopy_cargo_lock = {lock}\ncopy_local_paths = {paths}\n", - gen = toml::Value::from(&*generated_project_dir), - exe = toml::Value::from(project_name), - lock = toml::Value::from(&*copy_cargo_lock), - paths = toml::Value::from(&*copy_local_paths), - ), - ) - .context("can't write template values file")?; - - let auto_path = format!("{}/{}", source.subfolder, source.kind.as_str()); - - let (git, branch) = if source.local_path.is_some() { - (None, None) - } else { - ( - source.git.map(ToOwned::to_owned), - source.branch.map(ToOwned::to_owned), - ) - }; - - generate(GenerateArgs { - template_path: TemplatePath { - auto_path: Some(auto_path), - git, - path: source.local_path.map(ToOwned::to_owned), - branch, - ..TemplatePath::default() - }, - destination: Some(output_dir.to_path_buf()), - name: Some(project_name.to_owned()), - force: true, - silent: true, - vcs: Some(Vcs::None), - init: true, - overwrite: true, - no_workspace: true, - template_values_file: Some(values_path.display().to_string()), - ..GenerateArgs::default() - }) - .context("can't render deploy template")?; - - let _ = fs::remove_file(&values_path); - Ok(()) -} - -fn canonicalize_path_for_safety(path: &Path) -> anyhow::Result { - if path.exists() { - return path - .canonicalize() - .with_context(|| format!("can't canonicalize {}", path.display())); - } - - let parent = path - .parent() - .context("output directory must have a parent directory")?; - let file_name = path - .file_name() - .context("output directory must have a final path component")?; - - Ok(canonicalize_path_for_safety(parent)?.join(file_name)) -} - #[cfg(test)] mod tests { - use super::{ - DeployArgs, DeployTemplateKind, collect_required_local_paths, copy_relative_workspace_path, - prepare_output_dir, rewrite_dependency_paths_for_bundle, - }; + use super::{DeployArgs, DeployTemplateKind}; use crate::common::PathConfigArgs; use module_parser::test_utils::TempDirExt; - use module_parser::{CargoTomlDependencies, CargoTomlDependency}; use std::fs; - use std::path::{Path, PathBuf}; + use std::path::PathBuf; struct CurrentDirGuard(PathBuf); @@ -470,150 +163,6 @@ mod tests { } } - #[test] - fn collects_workspace_members_and_dependency_paths() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"modules/foo\", \"modules/foo/sdk\"]\n", - ) - .expect("workspace cargo toml"); - fs::create_dir_all(workspace_root.join("modules/foo/sdk/src")).expect("workspace dirs"); - fs::create_dir_all(workspace_root.join("extras/bar/src")).expect("extra dirs"); - - let mut dependencies = CargoTomlDependencies::new(); - dependencies.insert( - "bar".to_owned(), - CargoTomlDependency { - path: Some(workspace_root.join("extras/bar").display().to_string()), - ..CargoTomlDependency::default() - }, - ); - - let paths = collect_required_local_paths(workspace_root, &dependencies).expect("paths"); - assert!(paths.contains(Path::new("modules/foo"))); - assert!(paths.contains(Path::new("modules/foo/sdk"))); - assert!(paths.contains(Path::new("extras/bar"))); - } - - #[test] - fn rewrites_absolute_dependency_paths_for_bundle() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - fs::write( - workspace_root.join("Cargo.toml"), - "[workspace]\nmembers = [\"modules/foo\"]\n", - ) - .expect("workspace cargo toml"); - fs::create_dir_all(workspace_root.join("modules/foo/src")).expect("module dir"); - - let mut dependencies = CargoTomlDependencies::new(); - dependencies.insert( - "foo".to_owned(), - CargoTomlDependency { - path: Some(workspace_root.join("modules/foo").display().to_string()), - ..CargoTomlDependency::default() - }, - ); - - let rewritten = - rewrite_dependency_paths_for_bundle(workspace_root, &dependencies).expect("rewrite"); - assert_eq!(rewritten["foo"].path.as_deref(), Some("../../modules/foo")); - } - - #[test] - fn prepare_output_dir_replaces_default_bundle_dir_without_force() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - let output_dir = workspace_root.join(".cyberfabric/deploy/demo"); - - fs::create_dir_all(&output_dir).expect("default deploy dir"); - fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); - - prepare_output_dir(&output_dir, workspace_root, false).expect("default deploy dir"); - - assert!(output_dir.is_dir()); - assert!(!output_dir.join("stale.txt").exists()); - } - - #[test] - fn prepare_output_dir_requires_force_for_existing_custom_dir() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - let output_dir = workspace_root.join("bundle"); - - fs::create_dir_all(&output_dir).expect("custom output dir"); - fs::write(output_dir.join("stale.txt"), "stale").expect("stale file"); - - let err = prepare_output_dir(&output_dir, workspace_root, false) - .expect_err("custom output dir should require force"); - assert!(err.to_string().contains("--force")); - - prepare_output_dir(&output_dir, workspace_root, true).expect("forced cleanup"); - assert!(output_dir.is_dir()); - assert!(!output_dir.join("stale.txt").exists()); - } - - #[test] - fn copy_relative_workspace_path_filters_known_junk_entries() { - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - let source_dir = workspace_root.join("modules/demo-module"); - let output_dir = workspace_root.join("bundle"); - - fs::create_dir_all(source_dir.join("src")).expect("source dir"); - fs::create_dir_all(source_dir.join("target/debug")).expect("target dir"); - fs::create_dir_all(source_dir.join(".git")).expect("git dir"); - fs::write(source_dir.join("src/lib.rs"), "pub fn demo() {}\n").expect("lib source"); - fs::write(source_dir.join(".env"), "SECRET=value\n").expect("env file"); - fs::write(source_dir.join(".DS_Store"), "junk").expect("finder junk"); - fs::write(source_dir.join("target/debug/demo"), "junk").expect("target artifact"); - - copy_relative_workspace_path( - workspace_root, - &output_dir, - Path::new("modules/demo-module"), - ) - .expect("copy filtered bundle path"); - - assert!(output_dir.join("modules/demo-module/src/lib.rs").is_file()); - assert!(!output_dir.join("modules/demo-module/.env").exists()); - assert!(!output_dir.join("modules/demo-module/.DS_Store").exists()); - assert!(!output_dir.join("modules/demo-module/.git").exists()); - assert!(!output_dir.join("modules/demo-module/target").exists()); - } - - #[cfg(unix)] - #[test] - fn copy_relative_workspace_path_rejects_symlinks() { - use std::os::unix::fs::symlink; - - let temp_dir = tempfile::tempdir().expect("temp dir"); - let workspace_root = temp_dir.path(); - let source_dir = workspace_root.join("modules/demo-module"); - let output_dir = workspace_root.join("bundle"); - - fs::create_dir_all(&source_dir).expect("source dir"); - fs::write(workspace_root.join("outside.txt"), "hello\n").expect("outside file"); - symlink( - workspace_root.join("outside.txt"), - source_dir.join("linked.txt"), - ) - .expect("symlink"); - - let err = copy_relative_workspace_path( - workspace_root, - &output_dir, - Path::new("modules/demo-module"), - ) - .expect_err("symlinked bundle entries should be rejected"); - assert!( - err.to_string() - .contains("symlinked paths are not supported") - ); - } - #[test] fn deploy_generates_docker_bundle_from_local_templates() { let temp_dir = tempfile::tempdir().expect("temp dir"); @@ -675,6 +224,9 @@ path = "src/lib.rs" git: None, subfolder: "Deploy".to_owned(), branch: None, + build: false, + tag: None, + push: false, }; args.run().expect("deploy run"); diff --git a/crates/cli/src/deploy/template.rs b/crates/cli/src/deploy/template.rs new file mode 100644 index 0000000..7d59a7f --- /dev/null +++ b/crates/cli/src/deploy/template.rs @@ -0,0 +1,88 @@ +use anyhow::Context; +use cargo_generate::{GenerateArgs, TemplatePath, Vcs, generate}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use super::DeployTemplateKind; + +pub(super) struct TemplateSource<'a> { + pub local_path: Option<&'a str>, + pub git: Option<&'a str>, + pub subfolder: &'a str, + pub kind: DeployTemplateKind, + pub branch: Option<&'a str>, +} + +pub(super) fn render_deploy_template( + output_dir: &Path, + project_name: &str, + local_paths: &BTreeSet, + has_cargo_lock: bool, + source: &TemplateSource<'_>, +) -> anyhow::Result<()> { + let generated_project_dir = format!(".cyberfabric/{project_name}"); + + let copy_cargo_lock = if has_cargo_lock { + "COPY Cargo.lock Cargo.lock\n".to_owned() + } else { + String::new() + }; + + let copy_local_paths = local_paths + .iter() + .map(|p| { + let p = p.display().to_string().replace('\\', "/"); + format!("COPY {p} {p}") + }) + .collect::>() + .join("\n"); + + let values_path = output_dir.join(".cargo-generate-values.toml"); + fs::write( + &values_path, + format!( + "[values]\ngenerated_project_dir = {gen}\nexecutable_name = {exe}\ncopy_cargo_lock = {lock}\ncopy_local_paths = {paths}\n", + gen = toml::Value::from(&*generated_project_dir), + exe = toml::Value::from(project_name), + lock = toml::Value::from(&*copy_cargo_lock), + paths = toml::Value::from(&*copy_local_paths), + ), + ) + .context("can't write template values file")?; + + let auto_path = format!("{}/{}", source.subfolder, source.kind.as_str()); + + let (git, branch) = if source.local_path.is_some() { + (None, None) + } else { + ( + source.git.map(ToOwned::to_owned), + source.branch.map(ToOwned::to_owned), + ) + }; + + generate(GenerateArgs { + template_path: TemplatePath { + auto_path: Some(auto_path), + git, + path: source.local_path.map(ToOwned::to_owned), + branch, + ..TemplatePath::default() + }, + destination: Some(output_dir.to_path_buf()), + name: Some(project_name.to_owned()), + force: true, + silent: true, + vcs: Some(Vcs::None), + init: true, + overwrite: true, + no_workspace: true, + template_values_file: Some(values_path.display().to_string()), + ..GenerateArgs::default() + }) + .context("can't render deploy template")?; + + let _ = fs::remove_file(&values_path); + Ok(()) +}