diff --git a/README.md b/README.md index 9d5cb5f..be80818 100644 --- a/README.md +++ b/README.md @@ -7,22 +7,6 @@ berth is a Blazingly Fast™ CLI-focused alternative to the VSCode Dev Container `berth` is a work in progress and promises no backwards compatibility at this state. Currently supports Linux and has been tested on WSL2 with Docker Desktop. -## Table of Content - -- [Installation](#installation) -- [Usage](#usage) -- [Configuration](#configuration) - - [Presets](#presets) - - [Merging](#merging) - - [Mounts Environment Variable Expansion Side Effects](#mounts-environment-variable-expansion-side-effects) -- [Motivations](#motivations) -- [Possible Future Features](#possible-future-features) -- [Information for Nerds](#information-for-nerds) - - [Container Naming](#container-naming) - - [Image Naming](#image-naming) - - [Application Dependencies](#application-dependencies) - - [Development Dependencies](#development-dependencies) - ## Installation Requires: @@ -79,6 +63,7 @@ Each environment is defined in a `environment` sub-table, with the name used to |:-:|:-:|:-:|:-:| | `image` | String | The container image to use. Passed to `docker create`. This or the `dockerfile` field must be present. | `image = "alpine:edge"` | | `dockerfile` | String | The path to a dockerfile, this will be build and passed to `docker create`. This or the `image` field must be present. | `dockerfile = "$HOME/dockerfile"` | +| `build_context` | String | The path of a build context directory used when building a provided `dockerfile` | `build_context = "/my/build/context"` | `entry_cmd` | String| The command that will be run in the container when the environment is started. Passed to `docker exec`. This is a required field. | `entry_cmd = ["/bin/bash"]` | | `entry_options` | String Array | Options passed to `docker exec` for the `entry_cmd` | `entry_options = ["-it"]`| | `cp_cmds`| String Array | A list of commands to copy files to or from the container. Use `CONTAINER`as a placeholder for the container name. Passed directly to `docker cp` | `cp_cmds = [" -L /home/my_script.sh CONTAINER:/home/init_script.sh"]`| diff --git a/src/configuration.rs b/src/configuration.rs index aefb3b4..ba81ea2 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -105,6 +105,9 @@ pub struct TomlEnvironment { #[serde(default)] dockerfile: String, + #[serde(default)] + build_context: String, + #[serde(default)] entry_options: Vec, @@ -137,6 +140,9 @@ pub struct TomlPreset { #[serde(default)] dockerfile: String, + #[serde(default)] + build_context: String, + #[serde(default)] entry_options: Vec, @@ -171,6 +177,7 @@ pub struct Environment { pub original_name: String, pub image: String, pub dockerfile: Option, + pub build_context: Option, pub entry_cmd: String, pub entry_options: Vec, pub exec_cmds: Vec, @@ -286,6 +293,7 @@ impl Configuration { "entry_cmd" => !env.entry_cmd.is_empty(), "image" => !env.provided_image.is_empty(), "dockerfile" => !env.dockerfile.is_empty(), + "build_context" => !env.build_context.is_empty(), _ => unreachable!("Unknown field {field}"), }; @@ -306,6 +314,7 @@ impl Configuration { "entry_cmd" => !config.presets[preset_name].entry_cmd.is_empty(), "image" => !config.presets[preset_name].provided_image.is_empty(), "dockerfile" => !config.presets[preset_name].dockerfile.is_empty(), + "build_context" => !config.presets[preset_name].build_context.is_empty(), _ => unreachable!("Unknown field {field}"), }; @@ -350,10 +359,11 @@ impl Configuration { Ok(()) }; + let unique_fields = ["entry_cmd", "image", "dockerfile", "build_context"]; for (env_name, env) in &config.environments { - check_unique("entry_cmd", env, env_name)?; - check_unique("image", env, env_name)?; - check_unique("dockerfile", env, env_name)?; + for field in unique_fields { + check_unique(field, env, env_name)?; + } } Ok(config) } @@ -431,6 +441,16 @@ impl Configuration { _ => (), } + + if !env.build_context.is_empty() && env.dockerfile.is_empty() { + return Err(labeled_error!( + self, + EnvironmentValidation, + get_span(name)?, + "'build_context' can only be used with a 'dockerfile'" + ) + .into()); + } } Ok(envs) @@ -466,13 +486,14 @@ impl Configuration { .for_each(|s| *s = envmnt::expand(s, Some(options))) }); - let (image, dockerfile) = match env.provided_image.as_str() { + let (image, dockerfile, build_context) = match env.provided_image.as_str() { "" => { let dockerfile_path = self.validate_dockerfile(&env.dockerfile, &name)?; + let build_context = self.validate_build_context(&env.build_context, &name)?; let image_name = Self::generate_image_name(&name, &dockerfile_path)?; - (image_name, Some(dockerfile_path)) + (image_name, Some(dockerfile_path), build_context) } - _ => (env.provided_image, None), + _ => (env.provided_image, None, None), }; let mut env = Environment { @@ -480,6 +501,7 @@ impl Configuration { original_name: name.to_string(), image, dockerfile, + build_context, entry_cmd: env.entry_cmd, entry_options: env.entry_options, exec_cmds: env.exec_cmds, @@ -539,6 +561,58 @@ impl Configuration { Ok(resolved) } + fn validate_build_context( + &self, + build_context_dir: &str, + env_name: &str, + ) -> Result> { + if build_context_dir.is_empty() { + return Ok(None); + } + + let mut options = ExpandOptions::new(); + options.expansion_type = Some(ExpansionType::Unix); + + let dockerfile = envmnt::expand(build_context_dir, Some(options)); + + let path = Path::new(&dockerfile); + + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + self.app + .config_path + .parent() + .ok_or_else(|| { + ConfigError::FailedToInteractWithDockerfile(path.display().to_string()) + })? + .join(path) + }; + + if !resolved.exists() || !resolved.is_dir() { + let span = self + .doc + .as_ref() + .unexpected()? + .get("environment") + .and_then(|env| env.as_table()) + .and_then(|envs| envs.get(env_name)) + .and_then(|env| env.get("build_context")) + .and_then(|item| item.span()) + .unexpected()?; + + return Err(labeled_error!( + self, + InvalidDockerfilePath, + span, + "Could not find build context / Build context must be a directory" + ) + .into()); + } + + Ok(Some(resolved)) + } + fn generate_image_name(name: &str, path: &Path) -> Result { let create_error = |path: &Path| -> miette::Report { ConfigError::FailedToInteractWithDockerfile(path.display().to_string()).into() diff --git a/src/docker.rs b/src/docker.rs index abf06c9..dfb5340 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -103,7 +103,8 @@ impl DockerHandler { .to_string_lossy() .to_string(); let args = vec!["build", "-t", &self.env.image, "-f", &dockerfile_path, "."]; - self.run_docker_command(args)?; + let build_context = self.env.build_context.as_ref().unwrap_or(&self.config_dir); + self.run_docker_command(args, build_context)?; spinner.finish_and_clear(); @@ -237,7 +238,7 @@ impl DockerHandler { args.push(&self.env.image); args.extend_from_slice(&["tail", "-f", "/dev/null"]); - self.run_docker_command(args) + self.run_docker_command(args, &self.config_dir) } fn exec_setup_commands(&self) -> Result<()> { @@ -252,7 +253,7 @@ impl DockerHandler { let split_cmd = shell_words::split(cmd).unwrap(); args.extend(split_cmd.iter().map(|s| s.as_str())); - self.run_docker_command(args)?; + self.run_docker_command(args, &self.config_dir)?; } Ok(()) } @@ -266,7 +267,7 @@ impl DockerHandler { let split_cmd = shell_words::split(&fixed_string).unwrap(); args.extend(split_cmd.iter().map(|s| s.as_str())); - self.run_docker_command(args)?; + self.run_docker_command(args, &self.config_dir)?; } Ok(()) } @@ -283,20 +284,24 @@ impl DockerHandler { pub async fn is_anyone_connected(&self) -> Result { let args = vec!["exec", &self.env.name, "ls", "/dev/pts"]; - let output = self.run_docker_command_with_output(args)?; + let output = self.run_docker_command_with_output(args, &self.config_dir)?; let ps_count = String::from_utf8(output.stdout).unwrap().lines().count(); let no_connections_ps_count = 2; Ok(ps_count > no_connections_ps_count) } - fn run_docker_command_with_output(&self, args: Vec<&str>) -> Result { + fn run_docker_command_with_output( + &self, + args: Vec<&str>, + working_dir: &Path, + ) -> Result { let command = format!("{} {}", CONTAINER_ENGINE, shell_words::join(&args)); info!("{command}"); let output = Command::new(CONTAINER_ENGINE) .args(&args) - .current_dir(&self.config_dir) + .current_dir(working_dir) .output() .map_err(|_| DockerError::CommandFailed(command.clone()))?; @@ -312,7 +317,8 @@ impl DockerHandler { } } - fn run_docker_command(&self, args: Vec<&str>) -> Result<()> { - self.run_docker_command_with_output(args).map(|_| ()) + fn run_docker_command(&self, args: Vec<&str>, working_dir: &Path) -> Result<()> { + self.run_docker_command_with_output(args, working_dir) + .map(|_| ()) } } diff --git a/tests/configuration.rs b/tests/configuration.rs index 742e1c9..c83d42d 100644 --- a/tests/configuration.rs +++ b/tests/configuration.rs @@ -456,6 +456,35 @@ fn both_dockerfile_or_image() { ); } +#[test] +fn build_context_and_no_dockerfile() { + let config = ConfigTest::new(indoc! {r#" + [environment.Env] + image = "foo" + entry_cmd = "hello" + build_context = "world" + "#}); + let err = config.get_env("Env").unwrap_err().render(); + assert_eq!( + err, + formatdoc!( + r#" + configuration::environment::validation + + × Malformed Environment + ╭─[{}:1:1] + 1 │ ╭─▶ [environment.Env] + 2 │ │ image = "foo" + 3 │ │ entry_cmd = "hello" + 4 │ ├─▶ build_context = "world" + · ╰──── 'build_context' can only be used with a 'dockerfile' + ╰──── + "#, + config.file_path() + ) + ); +} + #[test] fn preset_not_found() { let config = ConfigTest::new(indoc! {r#" diff --git a/tests/docker.rs b/tests/docker.rs index 67b9eae..ea840ff 100644 --- a/tests/docker.rs +++ b/tests/docker.rs @@ -2,7 +2,11 @@ use bollard::{container::ListContainersOptions, Docker}; use color_eyre::Result; use indoc::{formatdoc, indoc}; use serial_test::serial; -use std::{collections::HashMap, fs::File, io::Write}; +use std::{ + collections::HashMap, + fs::{create_dir, File}, + io::Write, +}; use tempfile::{NamedTempFile, TempDir}; use test_utils::{TestHarness, TestOutput, APK_ADD_ARGS, DEFAULT_TIMEOUT}; @@ -299,6 +303,91 @@ fn dockerfile() -> Result<()> { Ok(()) } +#[test] +#[serial] +fn dockerfile_default_build_context() -> Result<()> { + let dir = TempDir::new().unwrap(); + let dockerfile = File::create(dir.path().join("dockerfile")).unwrap(); + File::create(dir.path().join("test_file")).unwrap(); + let content = indoc! { + r#" + FROM alpine:edge + COPY test_file test_file + "#}; + write!(&dockerfile, "{}", content).unwrap(); + + TestHarness::new() + .config_with_path( + &formatdoc!( + r#" + dockerfile = "{}" + entry_cmd = "/bin/ash" + create_options = ["-it"] + entry_options = ["-it"] + "#, + dir.path().join("dockerfile").to_str().unwrap(), + ), + &dir.path().join("config.toml"), + )? + .args(vec![ + "--config-path", + dir.path().join("config.toml").to_str().unwrap(), + "[name]", + ])? + .run(DEFAULT_TIMEOUT)? + .send_line("ls")? + .expect_string("test_file")? + .send_line("exit")? + .expect_terminate()? + .success()?; + + dir.close()?; + Ok(()) +} + +#[test] +#[serial] +fn dockerfile_provided_build_context() -> Result<()> { + let dir = TempDir::new().unwrap(); + create_dir(dir.path().join("dockerfile_dir")).unwrap(); + create_dir(dir.path().join("context_dir")).unwrap(); + + let dockerfile = File::create(dir.path().join("dockerfile_dir/dockerfile")).unwrap(); + File::create(dir.path().join("context_dir/test_file")).unwrap(); + let content = indoc! { + r#" + FROM alpine:edge + COPY test_file test_file + "#}; + write!(&dockerfile, "{}", content).unwrap(); + + TestHarness::new() + .config(&formatdoc!( + r#" + dockerfile = "{}" + build_context = "{}" + entry_cmd = "/bin/ash" + create_options = ["-it"] + entry_options = ["-it"] + "#, + dir.path() + .join("dockerfile_dir/dockerfile") + .to_str() + .unwrap(), + dir.path().join("context_dir").to_str().unwrap(), + ))? + .args(vec!["--config-path", "[config_path]", "[name]"])? + .run(DEFAULT_TIMEOUT)? + .send_line("ls")? + .expect_string("test_file")? + .send_line("exit")? + .expect_terminate()? + .success()?; + + dir.close()?; + Ok(()) +} + #[test] fn badly_formed_dockerfile() -> Result<()> { let dockerfile = NamedTempFile::new().unwrap();