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..3f98924 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,64 @@ 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] [--local-path ] [--git ] [--subfolder ] [--branch ] [--build] [--tag ] [--push] +``` + +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/` +- **[`--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: + +- **[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` +- **[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: + +```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 +``` + +```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` @@ -741,6 +802,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/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/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 new file mode 100644 index 0000000..1392d0c --- /dev/null +++ b/crates/cli/src/deploy/mod.rs @@ -0,0 +1,262 @@ +mod bundle; +mod docker; +mod template; + +use crate::common::{self, PathConfigArgs}; +use clap::{Args, ValueEnum}; +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"; + +#[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, + /// 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, + /// 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)] +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)?; + + render_deploy_template( + &output_dir, + &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()); + + 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(()) + } + + 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) + } + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::{DeployArgs, DeployTemplateKind}; + use crate::common::PathConfigArgs; + use module_parser::test_utils::TempDirExt; + use std::fs; + use std::path::PathBuf; + + struct CurrentDirGuard(PathBuf); + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } + } + + #[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/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{{ 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; + // 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"); + 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, + local_path: Some(workspace_root.join("templates").display().to_string()), + git: None, + subfolder: "Deploy".to_owned(), + branch: None, + build: false, + tag: None, + push: false, + }; + + 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/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(()) +} 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(), 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",