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
33 changes: 33 additions & 0 deletions cmd/devcontainer/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ mod tests {
fn substitutes_local_env_and_workspace_tokens() {
let mut env = HashMap::new();
env.insert("USER".to_string(), "johan".to_string());
env.insert("baz".to_string(), "somevalue".to_string());
let context = ConfigContext {
workspace_folder: PathBuf::from("/workspace/demo"),
env,
Expand All @@ -61,6 +62,7 @@ mod tests {
let value = json!({
"containerEnv": {
"USER_NAME": "${localEnv:USER}",
"ENV_ALIAS": "bar${env:baz}bar",
"WORKSPACE": "${localWorkspaceFolder}",
"CONTAINER_WORKSPACE": "${containerWorkspaceFolder}",
"CONTAINER_BASENAME": "${containerWorkspaceFolderBasename}"
Expand All @@ -70,6 +72,7 @@ mod tests {
let substituted = substitute_local_context(&value, &context);

assert_eq!(substituted["containerEnv"]["USER_NAME"], "johan");
assert_eq!(substituted["containerEnv"]["ENV_ALIAS"], "barsomevaluebar");
assert_eq!(substituted["containerEnv"]["WORKSPACE"], "/workspace/demo");
assert_eq!(
substituted["containerEnv"]["CONTAINER_WORKSPACE"],
Expand Down Expand Up @@ -180,4 +183,34 @@ mod tests {
.chars()
.all(|character| matches!(character, '0'..='9' | 'a'..='v')));
}

#[test]
fn devcontainer_id_changes_when_labels_change() {
let value = json!({
"test": "${devcontainerId}"
});
let first = substitute_local_context(
&value,
&ConfigContext {
workspace_folder: PathBuf::from("/workspace/demo"),
env: HashMap::new(),
container_workspace_folder: None,
id_labels: HashMap::from([("a".to_string(), "b".to_string())]),
},
);
let second = substitute_local_context(
&value,
&ConfigContext {
workspace_folder: PathBuf::from("/workspace/demo"),
env: HashMap::new(),
container_workspace_folder: None,
id_labels: HashMap::from([
("a".to_string(), "b".to_string()),
("c".to_string(), "d".to_string()),
]),
},
);

assert_ne!(first["test"], second["test"]);
}
}
3 changes: 2 additions & 1 deletion cmd/devcontainer/src/runtime/compose/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ pub(crate) fn load_compose_spec(resolved: &ResolvedConfig) -> Result<Option<Comp
.to_string();
let project_name = project::compose_project_name(&files)?;
let definition = service::inspect_service_definition(&files, &service)?;
let has_build = definition.has_build || definition.build.is_some();

Ok(Some(ComposeSpec {
files,
service,
image: definition.image,
has_build: definition.has_build,
has_build,
user: definition.user,
project_name,
}))
Expand Down
59 changes: 59 additions & 0 deletions cmd/devcontainer/src/runtime/compose/service.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Compose service inspection and build metadata helpers.

use std::collections::HashMap;
use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
Expand All @@ -14,11 +15,20 @@ use crate::runtime::paths::resolve_relative;
pub(super) struct ServiceDefinition {
pub(super) image: Option<String>,
pub(super) has_build: bool,
pub(super) build: Option<ServiceBuildInfo>,
pub(super) user: Option<String>,
pub(super) entrypoint: Option<Vec<String>>,
pub(super) command: Option<Vec<String>>,
}

#[derive(Debug, Eq, PartialEq)]
pub(super) struct ServiceBuildInfo {
pub(super) context: String,
pub(super) dockerfile_path: String,
pub(super) target: Option<String>,
pub(super) args: Option<HashMap<String, String>>,
}

pub(super) fn compose_files(
configuration: &Value,
config_root: &Path,
Expand Down Expand Up @@ -93,10 +103,16 @@ pub(super) fn inspect_service_definition(
) -> Result<ServiceDefinition, String> {
let mut image = None;
let mut has_build = false;
let mut build = None;
let mut user = None;
let mut entrypoint = None;
let mut command = None;
let mut found_service = false;
let default_build_context = compose_files
.first()
.and_then(|path| path.parent())
.map(|path| path.display().to_string())
.unwrap_or_else(|| ".".to_string());

for compose_file in compose_files {
let raw = std::fs::read_to_string(compose_file).map_err(|error| error.to_string())?;
Expand All @@ -116,6 +132,9 @@ pub(super) fn inspect_service_definition(
if service_definition.contains_key(YamlValue::String("build".to_string())) {
has_build = true;
}
if let Some(value) = service_field(service_definition, "build") {
build = parse_service_build(value, &default_build_context);
}
if let Some(value) = service_field(service_definition, "image").and_then(YamlValue::as_str)
{
image = Some(value.to_string());
Expand Down Expand Up @@ -144,6 +163,7 @@ pub(super) fn inspect_service_definition(
Ok(ServiceDefinition {
image,
has_build,
build,
user,
entrypoint,
command,
Expand All @@ -154,6 +174,45 @@ fn service_field<'a>(mapping: &'a Mapping, key: &str) -> Option<&'a YamlValue> {
mapping.get(YamlValue::String(key.to_string()))
}

fn parse_service_build(value: &YamlValue, default_context: &str) -> Option<ServiceBuildInfo> {
match value {
YamlValue::String(context) => Some(ServiceBuildInfo {
context: context.to_string(),
dockerfile_path: "Dockerfile".to_string(),
target: None,
args: None,
}),
YamlValue::Mapping(mapping) => Some(ServiceBuildInfo {
context: service_field(mapping, "context")
.and_then(YamlValue::as_str)
.map(str::to_string)
.unwrap_or_else(|| default_context.to_string()),
dockerfile_path: service_field(mapping, "dockerfile")
.and_then(YamlValue::as_str)
.map(str::to_string)
.unwrap_or_else(|| "Dockerfile".to_string()),
target: service_field(mapping, "target")
.and_then(YamlValue::as_str)
.map(str::to_string),
args: service_field(mapping, "args").and_then(parse_build_args),
}),
_ => None,
}
}

fn parse_build_args(value: &YamlValue) -> Option<HashMap<String, String>> {
let mapping = value.as_mapping()?;
let args = mapping
.iter()
.filter_map(|(key, value)| {
let key = yaml_scalar_to_string(key)?;
let value = yaml_scalar_to_string(value)?;
Some((key, value))
})
.collect::<HashMap<_, _>>();
(!args.is_empty()).then_some(args)
}

pub(super) fn read_version_prefix(compose_files: &[PathBuf]) -> Result<String, String> {
let Some(first_compose_file) = compose_files.first() else {
return Ok(String::new());
Expand Down
89 changes: 89 additions & 0 deletions cmd/devcontainer/src/runtime/compose/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,95 @@ fn inspects_service_image_and_build_presence() {
let _ = fs::remove_dir_all(root);
}

#[test]
fn inspects_service_build_info_for_upstream_compose_shapes() {
let root = unique_temp_dir("devcontainer-compose-test");
let compose_dir = root.join("somepath");
let compose_file = compose_dir.join("docker-compose.yml");
fs::create_dir_all(&compose_dir).expect("compose root");
fs::write(
&compose_file,
r#"
services:
fully_specified:
image: my-image
build:
context: context-path
dockerfile: my-dockerfile
target: a-target
args:
arg1: value1
image_only:
image: my-image
string_build:
image: my-image
build: ./a-path
default_dockerfile:
build:
context: ./a-path
default_context:
build:
dockerfile: my-dockerfile
"#,
)
.expect("compose file");

let fully_specified =
inspect_service_definition(std::slice::from_ref(&compose_file), "fully_specified")
.expect("fully specified service");
let fully_specified_build = fully_specified.build.as_ref().expect("build info");
assert_eq!(fully_specified.image.as_deref(), Some("my-image"));
assert_eq!(fully_specified_build.context, "context-path");
assert_eq!(fully_specified_build.dockerfile_path, "my-dockerfile");
assert_eq!(fully_specified_build.target.as_deref(), Some("a-target"));
assert_eq!(
fully_specified_build
.args
.as_ref()
.and_then(|args| args.get("arg1"))
.map(String::as_str),
Some("value1")
);

let image_only = inspect_service_definition(std::slice::from_ref(&compose_file), "image_only")
.expect("image-only service");
assert_eq!(image_only.image.as_deref(), Some("my-image"));
assert!(image_only.build.is_none());

let string_build =
inspect_service_definition(std::slice::from_ref(&compose_file), "string_build")
.expect("string build service");
let string_build_info = string_build.build.as_ref().expect("string build info");
assert_eq!(string_build.image.as_deref(), Some("my-image"));
assert_eq!(string_build_info.context, "./a-path");
assert_eq!(string_build_info.dockerfile_path, "Dockerfile");
assert_eq!(string_build_info.target, None);
assert_eq!(string_build_info.args, None);

let default_dockerfile =
inspect_service_definition(std::slice::from_ref(&compose_file), "default_dockerfile")
.expect("default dockerfile service");
let default_dockerfile_build = default_dockerfile.build.as_ref().expect("build info");
assert_eq!(default_dockerfile_build.context, "./a-path");
assert_eq!(default_dockerfile_build.dockerfile_path, "Dockerfile");
assert_eq!(default_dockerfile_build.target, None);
assert_eq!(default_dockerfile_build.args, None);

let default_context =
inspect_service_definition(std::slice::from_ref(&compose_file), "default_context")
.expect("default context service");
let default_context_build = default_context.build.as_ref().expect("build info");
assert_eq!(
default_context_build.context,
compose_dir.display().to_string()
);
assert_eq!(default_context_build.dockerfile_path, "my-dockerfile");
assert_eq!(default_context_build.target, None);
assert_eq!(default_context_build.args, None);

let _ = fs::remove_dir_all(root);
}

#[test]
fn compose_project_name_defaults_to_workspace_devcontainer() {
let root = unique_temp_dir("devcontainer-compose-test");
Expand Down
Loading
Loading