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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 1 addition & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"]`|
Expand Down
86 changes: 80 additions & 6 deletions src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ pub struct TomlEnvironment {
#[serde(default)]
dockerfile: String,

#[serde(default)]
build_context: String,

#[serde(default)]
entry_options: Vec<String>,

Expand Down Expand Up @@ -137,6 +140,9 @@ pub struct TomlPreset {
#[serde(default)]
dockerfile: String,

#[serde(default)]
build_context: String,

#[serde(default)]
entry_options: Vec<String>,

Expand Down Expand Up @@ -171,6 +177,7 @@ pub struct Environment {
pub original_name: String,
pub image: String,
pub dockerfile: Option<PathBuf>,
pub build_context: Option<PathBuf>,
pub entry_cmd: String,
pub entry_options: Vec<String>,
pub exec_cmds: Vec<String>,
Expand Down Expand Up @@ -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}"),
};

Expand All @@ -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}"),
};

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -466,20 +486,22 @@ 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 {
name: name.to_string(),
original_name: name.to_string(),
image,
dockerfile,
build_context,
entry_cmd: env.entry_cmd,
entry_options: env.entry_options,
exec_cmds: env.exec_cmds,
Expand Down Expand Up @@ -539,6 +561,58 @@ impl Configuration {
Ok(resolved)
}

fn validate_build_context(
&self,
build_context_dir: &str,
env_name: &str,
) -> Result<Option<PathBuf>> {
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<String> {
let create_error = |path: &Path| -> miette::Report {
ConfigError::FailedToInteractWithDockerfile(path.display().to_string()).into()
Expand Down
24 changes: 15 additions & 9 deletions src/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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<()> {
Expand All @@ -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(())
}
Expand All @@ -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(())
}
Expand All @@ -283,20 +284,24 @@ impl DockerHandler {

pub async fn is_anyone_connected(&self) -> Result<bool> {
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<Output> {
fn run_docker_command_with_output(
&self,
args: Vec<&str>,
working_dir: &Path,
) -> Result<Output> {
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()))?;

Expand All @@ -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(|_| ())
}
}
29 changes: 29 additions & 0 deletions tests/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
Loading