logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
47
crates/dev_container/Cargo.toml
Normal file
47
crates/dev_container/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "dev_container"
|
||||
version = "0.1.0"
|
||||
publish.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-tar.workspace = true
|
||||
async-trait.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json_lenient.workspace = true
|
||||
yaml-rust2.workspace = true
|
||||
shlex.workspace = true
|
||||
http_client.workspace = true
|
||||
http.workspace = true
|
||||
gpui.workspace = true
|
||||
fs.workspace = true
|
||||
futures.workspace = true
|
||||
log.workspace = true
|
||||
menu.workspace = true
|
||||
paths.workspace = true
|
||||
regex.workspace = true
|
||||
picker.workspace = true
|
||||
project.workspace = true
|
||||
settings.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
walkdir.workspace = true
|
||||
worktree.workspace = true
|
||||
workspace.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fs = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
serde_json.workspace = true
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
worktree = { workspace = true, features = ["test-support"] }
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
env_logger.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1
crates/dev_container/LICENSE-GPL
Symbolic link
1
crates/dev_container/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
107
crates/dev_container/src/command_json.rs
Normal file
107
crates/dev_container/src/command_json.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
use std::process::Output;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
use util::command::Command;
|
||||
|
||||
use crate::devcontainer_api::DevContainerError;
|
||||
|
||||
pub(crate) struct DefaultCommandRunner;
|
||||
|
||||
impl DefaultCommandRunner {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl CommandRunner for DefaultCommandRunner {
|
||||
async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error> {
|
||||
command.output().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub(crate) trait CommandRunner: Send + Sync {
|
||||
async fn run_command(&self, command: &mut Command) -> Result<Output, std::io::Error>;
|
||||
}
|
||||
|
||||
pub(crate) async fn evaluate_json_command<T>(
|
||||
mut command: Command,
|
||||
) -> Result<Option<T>, DevContainerError>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let output = command.output().await.map_err(|e| {
|
||||
log::error!("Error running command {:?}: {e}", command);
|
||||
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
||||
})?;
|
||||
|
||||
deserialize_json_output(output).map_err(|e| {
|
||||
log::error!("Error running command {:?}: {e}", command);
|
||||
DevContainerError::CommandFailed(command.get_program().display().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn deserialize_json_output<T>(output: Output) -> Result<Option<T>, String>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
if output.status.success() {
|
||||
let raw = String::from_utf8_lossy(&output.stdout);
|
||||
if raw.is_empty() || raw.trim() == "[]" || raw.trim() == "{}" {
|
||||
return Ok(None);
|
||||
}
|
||||
serde_json_lenient::from_str(&raw)
|
||||
.map_err(|e| format!("Error deserializing from raw json: {e}"))
|
||||
} else {
|
||||
let std_err = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!(
|
||||
"Sent non-successful output; cannot deserialize. StdErr: {std_err}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::process::ExitStatus;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn success_output(stdout: &str) -> Output {
|
||||
Output {
|
||||
status: ExitStatus::default(),
|
||||
stdout: stdout.as_bytes().to_vec(),
|
||||
stderr: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct TestItem {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_newline_delimited_json_rejected() {
|
||||
// Strict single-value contract: NDJSON must be rejected. Commands that
|
||||
// may legitimately return multiple rows (e.g. `docker ps`) parse their
|
||||
// output themselves rather than routing through this helper.
|
||||
let output = success_output("{\"id\":\"first\"}\n{\"id\":\"second\"}\n");
|
||||
let result: Result<Option<TestItem>, String> = deserialize_json_output(output);
|
||||
assert!(result.is_err(), "expected parse error, got {result:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_empty_output() {
|
||||
let output = success_output("");
|
||||
let result: Option<TestItem> = deserialize_json_output(output).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_empty_object() {
|
||||
let output = success_output("{}");
|
||||
let result: Option<TestItem> = deserialize_json_output(output).unwrap();
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
754
crates/dev_container/src/devcontainer_api.rs
Normal file
754
crates/dev_container/src/devcontainer_api.rs
Normal file
@@ -0,0 +1,754 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt::Display,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use futures::TryFutureExt;
|
||||
use gpui::{AsyncWindowContext, Entity};
|
||||
use project::Worktree;
|
||||
use serde::Deserialize;
|
||||
use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text};
|
||||
use util::rel_path::RelPath;
|
||||
use walkdir::WalkDir;
|
||||
use workspace::Workspace;
|
||||
use worktree::Snapshot;
|
||||
|
||||
use crate::{
|
||||
DevContainerContext, DevContainerFeature, DevContainerTemplate,
|
||||
devcontainer_json::DevContainer,
|
||||
devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container},
|
||||
devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry,
|
||||
oci::download_oci_tarball,
|
||||
};
|
||||
|
||||
/// Represents a discovered devcontainer configuration
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DevContainerConfig {
|
||||
/// Display name for the configuration (subfolder name or "default")
|
||||
pub name: String,
|
||||
/// Relative path to the devcontainer.json file from the project root
|
||||
pub config_path: PathBuf,
|
||||
}
|
||||
|
||||
impl DevContainerConfig {
|
||||
pub fn default_config() -> Self {
|
||||
Self {
|
||||
name: "default".to_string(),
|
||||
config_path: PathBuf::from(".devcontainer/devcontainer.json"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_config() -> Self {
|
||||
Self {
|
||||
name: "root".to_string(),
|
||||
config_path: PathBuf::from(".devcontainer.json"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DevContainerUp {
|
||||
pub(crate) container_id: String,
|
||||
pub(crate) remote_user: String,
|
||||
pub(crate) remote_workspace_folder: String,
|
||||
#[serde(default)]
|
||||
pub(crate) extension_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) remote_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DevContainerApply {
|
||||
pub(crate) project_files: Vec<Arc<RelPath>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum DevContainerError {
|
||||
CommandFailed(String),
|
||||
DockerNotAvailable,
|
||||
ContainerNotValid(String),
|
||||
DevContainerTemplateApplyFailed(String),
|
||||
DevContainerScriptsFailed,
|
||||
DevContainerUpFailed(String),
|
||||
DevContainerNotFound,
|
||||
DevContainerParseFailed,
|
||||
DevContainerValidationFailed(String),
|
||||
FilesystemError,
|
||||
ResourceFetchFailed,
|
||||
NotInValidProject,
|
||||
/// Multiple existing containers match this project's identifying labels
|
||||
/// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec
|
||||
/// expects those labels to be unique per project, so Zed can't choose
|
||||
/// which one to connect to. The user must remove the duplicate(s).
|
||||
MultipleMatchingContainers(Vec<String>),
|
||||
}
|
||||
|
||||
impl Display for DevContainerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
DevContainerError::DockerNotAvailable =>
|
||||
"docker CLI not found on $PATH".to_string(),
|
||||
DevContainerError::ContainerNotValid(id) => format!(
|
||||
"docker image {id} did not have expected configuration for a dev container"
|
||||
),
|
||||
DevContainerError::DevContainerScriptsFailed =>
|
||||
"lifecycle scripts could not execute for dev container".to_string(),
|
||||
DevContainerError::DevContainerUpFailed(_) => {
|
||||
"DevContainer creation failed".to_string()
|
||||
}
|
||||
DevContainerError::DevContainerTemplateApplyFailed(_) => {
|
||||
"DevContainer template apply failed".to_string()
|
||||
}
|
||||
DevContainerError::DevContainerNotFound =>
|
||||
"No valid dev container definition found in project".to_string(),
|
||||
DevContainerError::DevContainerParseFailed =>
|
||||
"Failed to parse file .devcontainer/devcontainer.json".to_string(),
|
||||
DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
|
||||
DevContainerError::CommandFailed(program) =>
|
||||
format!("Failure running external program {program}"),
|
||||
DevContainerError::FilesystemError =>
|
||||
"Error downloading resources locally".to_string(),
|
||||
DevContainerError::ResourceFetchFailed =>
|
||||
"Failed to fetch resources from template or feature repository".to_string(),
|
||||
DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(),
|
||||
DevContainerError::MultipleMatchingContainers(ids) => format!(
|
||||
"Multiple containers match this project's dev container labels ({}). \
|
||||
Zed can't decide which to connect to. Stop and remove the stale one(s) with \
|
||||
`docker stop <id>` and `docker rm <id>`, then try again.",
|
||||
ids.join(", ")
|
||||
),
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_default_devcontainer_configuration(
|
||||
cx: &DevContainerContext,
|
||||
environment: HashMap<String, String>,
|
||||
) -> Result<DevContainer, DevContainerError> {
|
||||
let default_config = DevContainerConfig::default_config();
|
||||
|
||||
read_devcontainer_configuration(default_config, cx, environment)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Default configuration not found: {:?}", e);
|
||||
DevContainerError::DevContainerNotFound
|
||||
})
|
||||
}
|
||||
|
||||
/// Finds all available devcontainer configurations in the project.
|
||||
///
|
||||
/// See [`find_configs_in_snapshot`] for the locations that are scanned.
|
||||
pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
|
||||
let project = workspace.project().read(cx);
|
||||
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
|
||||
|
||||
let Some(worktree) = worktree else {
|
||||
log::debug!("find_devcontainer_configs: No worktree found");
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let worktree = worktree.read(cx);
|
||||
find_configs_in_snapshot(worktree)
|
||||
}
|
||||
|
||||
/// Scans a worktree snapshot for devcontainer configurations.
|
||||
///
|
||||
/// Scans for configurations in these locations:
|
||||
/// 1. `.devcontainer/devcontainer.json` (the default location)
|
||||
/// 2. `.devcontainer.json` in the project root
|
||||
/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
|
||||
///
|
||||
/// All found configurations are returned so the user can pick between them.
|
||||
pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
|
||||
let mut configs = Vec::new();
|
||||
|
||||
let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
|
||||
|
||||
if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
|
||||
if devcontainer_entry.is_dir() {
|
||||
log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
|
||||
let devcontainer_json_path =
|
||||
RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
|
||||
for entry in snapshot.child_entries(devcontainer_dir_path) {
|
||||
log::debug!(
|
||||
"find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
|
||||
entry.path.as_unix_str(),
|
||||
entry.is_file(),
|
||||
entry.is_dir()
|
||||
);
|
||||
|
||||
if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
|
||||
log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
|
||||
configs.push(DevContainerConfig::default_config());
|
||||
} else if entry.is_dir() {
|
||||
let subfolder_name = entry
|
||||
.path
|
||||
.file_name()
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let config_json_path =
|
||||
format!("{}/devcontainer.json", entry.path.as_unix_str());
|
||||
if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
|
||||
if snapshot.entry_for_path(rel_config_path).is_some() {
|
||||
log::debug!(
|
||||
"find_configs_in_snapshot: Found config in subfolder: {}",
|
||||
subfolder_name
|
||||
);
|
||||
configs.push(DevContainerConfig {
|
||||
name: subfolder_name,
|
||||
config_path: PathBuf::from(&config_json_path),
|
||||
});
|
||||
} else {
|
||||
log::debug!(
|
||||
"find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
|
||||
subfolder_name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include `.devcontainer.json` so the user can pick it from the UI
|
||||
// even when `.devcontainer/devcontainer.json` also exists.
|
||||
let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
|
||||
if snapshot
|
||||
.entry_for_path(root_config_path)
|
||||
.is_some_and(|entry| entry.is_file())
|
||||
{
|
||||
log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
|
||||
configs.push(DevContainerConfig::root_config());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"find_configs_in_snapshot: Found {} configurations",
|
||||
configs.len()
|
||||
);
|
||||
|
||||
configs.sort_by(|a, b| {
|
||||
let a_is_primary = a.name == "default" || a.name == "root";
|
||||
let b_is_primary = b.name == "default" || b.name == "root";
|
||||
match (a_is_primary, b_is_primary) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => a.name.cmp(&b.name),
|
||||
}
|
||||
});
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
pub async fn start_dev_container_with_config(
|
||||
context: DevContainerContext,
|
||||
config: Option<DevContainerConfig>,
|
||||
environment: HashMap<String, String>,
|
||||
) -> Result<(DevContainerConnection, String), DevContainerError> {
|
||||
check_for_docker(context.use_podman).await?;
|
||||
|
||||
let Some(actual_config) = config.clone() else {
|
||||
return Err(DevContainerError::NotInValidProject);
|
||||
};
|
||||
|
||||
match spawn_dev_container(
|
||||
&context,
|
||||
environment.clone(),
|
||||
actual_config.clone(),
|
||||
context.project_directory.clone().as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(DevContainerUp {
|
||||
container_id,
|
||||
remote_workspace_folder,
|
||||
remote_user,
|
||||
extension_ids,
|
||||
remote_env,
|
||||
..
|
||||
}) => {
|
||||
let project_name =
|
||||
match read_devcontainer_configuration(actual_config, &context, environment).await {
|
||||
Ok(DevContainer {
|
||||
name: Some(name), ..
|
||||
}) => name,
|
||||
_ => get_backup_project_name(&remote_workspace_folder, &container_id),
|
||||
};
|
||||
|
||||
let connection = DevContainerConnection {
|
||||
name: project_name,
|
||||
container_id,
|
||||
use_podman: context.use_podman,
|
||||
remote_user,
|
||||
extension_ids,
|
||||
remote_env: remote_env.into_iter().collect(),
|
||||
};
|
||||
|
||||
Ok((connection, remote_workspace_folder))
|
||||
}
|
||||
Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err),
|
||||
Err(err) => {
|
||||
let message = format!("Failed with nested error: {:?}", err);
|
||||
Err(DevContainerError::DevContainerUpFailed(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
|
||||
let mut command = if use_podman {
|
||||
util::command::new_command("podman")
|
||||
} else {
|
||||
util::command::new_command("docker")
|
||||
};
|
||||
command.arg("--version");
|
||||
|
||||
match command.output().await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
log::error!("Unable to find docker in $PATH: {:?}", e);
|
||||
Err(DevContainerError::DockerNotAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_devcontainer_template(
|
||||
worktree: Entity<Worktree>,
|
||||
template: &DevContainerTemplate,
|
||||
template_options: &HashMap<String, String>,
|
||||
features_selected: &HashSet<DevContainerFeature>,
|
||||
context: &DevContainerContext,
|
||||
cx: &mut AsyncWindowContext,
|
||||
) -> Result<DevContainerApply, DevContainerError> {
|
||||
let token = get_oci_token(
|
||||
ghcr_registry(),
|
||||
devcontainer_templates_repository(),
|
||||
&context.http_client,
|
||||
)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to get OCI auth token: {e}");
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})
|
||||
.await?;
|
||||
let manifest = get_latest_oci_manifest(
|
||||
&token.token,
|
||||
ghcr_registry(),
|
||||
devcontainer_templates_repository(),
|
||||
&context.http_client,
|
||||
Some(&template.id),
|
||||
)
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to fetch template from OCI repository: {e}");
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})
|
||||
.await?;
|
||||
|
||||
let layer = &manifest.layers.get(0).ok_or_else(|| {
|
||||
log::error!("Given manifest has no layers to query for blob. Aborting");
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let extract_dir = std::env::temp_dir()
|
||||
.join(&template.id)
|
||||
.join(format!("extracted-{timestamp}"));
|
||||
|
||||
context.fs.create_dir(&extract_dir).await.map_err(|e| {
|
||||
log::error!("Could not create temporary directory: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?;
|
||||
|
||||
download_oci_tarball(
|
||||
&token.token,
|
||||
ghcr_registry(),
|
||||
devcontainer_templates_repository(),
|
||||
&layer.digest,
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
&extract_dir,
|
||||
&context.http_client,
|
||||
&context.fs,
|
||||
Some(&template.id),
|
||||
)
|
||||
.map_err(|e| {
|
||||
log::error!("Error downloading tarball: {:?}", e);
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})
|
||||
.await?;
|
||||
|
||||
let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/");
|
||||
let mut project_files = Vec::new();
|
||||
for entry in WalkDir::new(downloaded_devcontainer_folder) {
|
||||
let Ok(entry) = entry else {
|
||||
continue;
|
||||
};
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| {
|
||||
log::error!("Can't create relative path: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?;
|
||||
let rel_path = RelPath::unix(relative_path)
|
||||
.map_err(|e| {
|
||||
log::error!("Can't create relative path: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?
|
||||
.into_arc();
|
||||
let content = context.fs.load(entry.path()).await.map_err(|e| {
|
||||
log::error!("Unable to read file: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?;
|
||||
|
||||
let mut content = expand_template_options(content, template_options);
|
||||
if let Some("devcontainer.json") = &rel_path.file_name() {
|
||||
content = insert_features_into_devcontainer_json(&content, features_selected)
|
||||
}
|
||||
worktree
|
||||
.update(cx, |worktree, cx| {
|
||||
worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("Unable to create entry in worktree: {e}");
|
||||
DevContainerError::NotInValidProject
|
||||
})?;
|
||||
project_files.push(rel_path);
|
||||
}
|
||||
|
||||
Ok(DevContainerApply { project_files })
|
||||
}
|
||||
|
||||
fn insert_features_into_devcontainer_json(
|
||||
content: &str,
|
||||
features: &HashSet<DevContainerFeature>,
|
||||
) -> String {
|
||||
if features.is_empty() {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let features_value: serde_json::Value = features
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let key = format!(
|
||||
"{}/{}:{}",
|
||||
f.source_repository.as_deref().unwrap_or(""),
|
||||
f.id,
|
||||
f.major_version()
|
||||
);
|
||||
(key, serde_json::Value::Object(Default::default()))
|
||||
})
|
||||
.collect::<serde_json::Map<String, serde_json::Value>>()
|
||||
.into();
|
||||
|
||||
let tab_size = infer_json_indent_size(content);
|
||||
let (range, replacement) = replace_value_in_json_text(
|
||||
content,
|
||||
&["features"],
|
||||
tab_size,
|
||||
Some(&features_value),
|
||||
None,
|
||||
);
|
||||
|
||||
let mut result = content.to_string();
|
||||
result.replace_range(range, &replacement);
|
||||
result
|
||||
}
|
||||
|
||||
fn expand_template_options(content: String, template_options: &HashMap<String, String>) -> String {
|
||||
let mut replaced_content = content;
|
||||
for (key, val) in template_options {
|
||||
replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val)
|
||||
}
|
||||
replaced_content
|
||||
}
|
||||
|
||||
fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
|
||||
Path::new(remote_workspace_folder)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|string| string.to_string())
|
||||
.unwrap_or_else(|| container_id.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot};
|
||||
use fs::FakeFs;
|
||||
use gpui::TestAppContext;
|
||||
use project::Project;
|
||||
use serde_json::json;
|
||||
use settings::SettingsStore;
|
||||
use util::path;
|
||||
|
||||
fn init_test(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer.json": "{}"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert_eq!(configs[0].name, "root");
|
||||
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer": {
|
||||
"devcontainer.json": "{}"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert_eq!(configs[0], DevContainerConfig::default_config());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer.json": "{}",
|
||||
".devcontainer": {
|
||||
"devcontainer.json": "{}"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
assert_eq!(configs[0], DevContainerConfig::default_config());
|
||||
assert_eq!(configs[1], DevContainerConfig::root_config());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer": {
|
||||
"rust": {
|
||||
"devcontainer.json": "{}"
|
||||
},
|
||||
"python": {
|
||||
"devcontainer.json": "{}"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
|
||||
assert!(names.contains(&"python"));
|
||||
assert!(names.contains(&"rust"));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer": {
|
||||
"devcontainer.json": "{}",
|
||||
"gpu": {
|
||||
"devcontainer.json": "{}"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
assert_eq!(configs[0].name, "default");
|
||||
assert_eq!(configs[1].name, "gpu");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
"src": {
|
||||
"main.rs": "fn main() {}"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert!(configs.is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer.json": "{}",
|
||||
".devcontainer": {
|
||||
"rust": {
|
||||
"devcontainer.json": "{}"
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 2);
|
||||
assert_eq!(configs[0].name, "root");
|
||||
assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
|
||||
assert_eq!(configs[1].name, "rust");
|
||||
assert_eq!(
|
||||
configs[1].config_path,
|
||||
PathBuf::from(".devcontainer/rust/devcontainer.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/project"),
|
||||
json!({
|
||||
".devcontainer.json": "{}",
|
||||
".devcontainer": {}
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
||||
cx.run_until_parked();
|
||||
|
||||
let configs = project.read_with(cx, |project, cx| {
|
||||
let worktree = project
|
||||
.visible_worktrees(cx)
|
||||
.next()
|
||||
.expect("should have a worktree");
|
||||
find_configs_in_snapshot(worktree.read(cx))
|
||||
});
|
||||
|
||||
assert_eq!(configs.len(), 1);
|
||||
assert_eq!(configs[0], DevContainerConfig::root_config());
|
||||
}
|
||||
}
|
||||
1685
crates/dev_container/src/devcontainer_json.rs
Normal file
1685
crates/dev_container/src/devcontainer_json.rs
Normal file
File diff suppressed because it is too large
Load Diff
8093
crates/dev_container/src/devcontainer_manifest.rs
Normal file
8093
crates/dev_container/src/devcontainer_manifest.rs
Normal file
File diff suppressed because it is too large
Load Diff
1147
crates/dev_container/src/docker.rs
Normal file
1147
crates/dev_container/src/docker.rs
Normal file
File diff suppressed because it is too large
Load Diff
254
crates/dev_container/src/features.rs
Normal file
254
crates/dev_container/src/features.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
use fs::Fs;
|
||||
use serde::Deserialize;
|
||||
use serde_json_lenient::Value;
|
||||
|
||||
use crate::{
|
||||
devcontainer_api::DevContainerError,
|
||||
devcontainer_json::{FeatureOptions, MountDefinition},
|
||||
safe_id_upper,
|
||||
};
|
||||
|
||||
/// Parsed components of an OCI feature reference such as
|
||||
/// `ghcr.io/devcontainers/features/aws-cli:1`.
|
||||
///
|
||||
/// Mirrors the CLI's `OCIRef` in `containerCollectionsOCI.ts`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OciFeatureRef {
|
||||
/// Registry hostname, e.g. `ghcr.io`
|
||||
pub registry: String,
|
||||
/// Full repository path within the registry, e.g. `devcontainers/features/aws-cli`
|
||||
pub path: String,
|
||||
/// Version tag, digest, or `latest`
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Minimal representation of a `devcontainer-feature.json` file, used to
|
||||
/// extract option default values after the feature tarball is downloaded.
|
||||
///
|
||||
/// See: https://containers.dev/implementors/features/#devcontainer-featurejson-properties
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DevContainerFeatureJson {
|
||||
#[serde(rename = "id")]
|
||||
pub(crate) _id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) options: HashMap<String, FeatureOptionDefinition>,
|
||||
pub(crate) mounts: Option<Vec<MountDefinition>>,
|
||||
pub(crate) privileged: Option<bool>,
|
||||
pub(crate) entrypoint: Option<String>,
|
||||
pub(crate) container_env: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// A single option definition inside `devcontainer-feature.json`.
|
||||
/// We only need the `default` field to populate env variables.
|
||||
#[derive(Debug, Deserialize, Eq, PartialEq)]
|
||||
pub(crate) struct FeatureOptionDefinition {
|
||||
pub(crate) default: Option<Value>,
|
||||
}
|
||||
|
||||
impl FeatureOptionDefinition {
|
||||
fn serialize_default(&self) -> Option<String> {
|
||||
self.default.as_ref().map(|some_value| match some_value {
|
||||
Value::Bool(b) => b.to_string(),
|
||||
Value::String(s) => s.to_string(),
|
||||
Value::Number(n) => n.to_string(),
|
||||
other => other.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Default)]
|
||||
pub(crate) struct FeatureManifest {
|
||||
consecutive_id: String,
|
||||
file_path: PathBuf,
|
||||
feature_json: DevContainerFeatureJson,
|
||||
}
|
||||
|
||||
impl FeatureManifest {
|
||||
pub(crate) fn new(
|
||||
consecutive_id: String,
|
||||
file_path: PathBuf,
|
||||
feature_json: DevContainerFeatureJson,
|
||||
) -> Self {
|
||||
Self {
|
||||
consecutive_id,
|
||||
file_path,
|
||||
feature_json,
|
||||
}
|
||||
}
|
||||
pub(crate) fn container_env(&self) -> HashMap<String, String> {
|
||||
self.feature_json.container_env.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn generate_dockerfile_feature_layer(
|
||||
&self,
|
||||
use_buildkit: bool,
|
||||
dest: &str,
|
||||
) -> String {
|
||||
let id = &self.consecutive_id;
|
||||
if use_buildkit {
|
||||
format!(
|
||||
r#"
|
||||
RUN --mount=type=bind,from=dev_containers_feature_content_source,source=./{id},target=/tmp/build-features-src/{id} \
|
||||
cp -ar /tmp/build-features-src/{id} {dest} \
|
||||
&& chmod -R 0755 {dest}/{id} \
|
||||
&& cd {dest}/{id} \
|
||||
&& chmod +x ./devcontainer-features-install.sh \
|
||||
&& ./devcontainer-features-install.sh \
|
||||
&& rm -rf {dest}/{id}
|
||||
"#,
|
||||
)
|
||||
} else {
|
||||
let source = format!("/tmp/build-features/{id}");
|
||||
let full_dest = format!("{dest}/{id}");
|
||||
format!(
|
||||
r#"
|
||||
COPY --chown=root:root --from=dev_containers_feature_content_source {source} {full_dest}
|
||||
RUN chmod -R 0755 {full_dest} \
|
||||
&& cd {full_dest} \
|
||||
&& chmod +x ./devcontainer-features-install.sh \
|
||||
&& ./devcontainer-features-install.sh
|
||||
"#
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate_dockerfile_env(&self) -> String {
|
||||
let mut layer = "".to_string();
|
||||
let env = self.container_env();
|
||||
let mut env: Vec<(&String, &String)> = env.iter().collect();
|
||||
env.sort();
|
||||
|
||||
for (key, value) in env {
|
||||
layer = format!("{layer}ENV {key}={value}\n")
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
/// Merges user options from devcontainer.json with default options defined in this feature manifest
|
||||
pub(crate) fn generate_merged_env(&self, options: &FeatureOptions) -> HashMap<String, String> {
|
||||
let mut merged: HashMap<String, String> = self
|
||||
.feature_json
|
||||
.options
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.serialize_default()
|
||||
.map(|v_some| (safe_id_upper(k), v_some))
|
||||
})
|
||||
.collect();
|
||||
|
||||
match options {
|
||||
FeatureOptions::Bool(_) => {}
|
||||
FeatureOptions::String(version) => {
|
||||
merged.insert("VERSION".to_string(), version.clone());
|
||||
}
|
||||
FeatureOptions::Options(map) => {
|
||||
for (key, value) in map {
|
||||
merged.insert(safe_id_upper(key), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
pub(crate) async fn write_feature_env(
|
||||
&self,
|
||||
fs: &Arc<dyn Fs>,
|
||||
options: &FeatureOptions,
|
||||
) -> Result<String, DevContainerError> {
|
||||
let merged_env = self.generate_merged_env(options);
|
||||
|
||||
let mut env_vars: Vec<(&String, &String)> = merged_env.iter().collect();
|
||||
env_vars.sort();
|
||||
|
||||
let env_file_content = env_vars
|
||||
.iter()
|
||||
.fold("".to_string(), |acc, (k, v)| format!("{acc}{}={}\n", k, v));
|
||||
|
||||
fs.write(
|
||||
&self.file_path.join("devcontainer-features.env"),
|
||||
env_file_content.as_bytes(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("error writing devcontainer feature environment: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?;
|
||||
|
||||
Ok(env_file_content)
|
||||
}
|
||||
|
||||
pub(crate) fn mounts(&self) -> Vec<MountDefinition> {
|
||||
if let Some(mounts) = &self.feature_json.mounts {
|
||||
mounts.clone()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn privileged(&self) -> bool {
|
||||
self.feature_json.privileged.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn entrypoint(&self) -> Option<String> {
|
||||
self.feature_json.entrypoint.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn file_path(&self) -> PathBuf {
|
||||
self.file_path.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an OCI feature reference string into its components.
|
||||
///
|
||||
/// Handles formats like:
|
||||
/// - `ghcr.io/devcontainers/features/aws-cli:1`
|
||||
/// - `ghcr.io/user/repo/go` (implicitly `:latest`)
|
||||
/// - `ghcr.io/devcontainers/features/rust@sha256:abc123`
|
||||
///
|
||||
/// Returns `None` for local paths (`./…`) and direct tarball URIs (`https://…`).
|
||||
pub(crate) fn parse_oci_feature_ref(input: &str) -> Option<OciFeatureRef> {
|
||||
if input.starts_with('.')
|
||||
|| input.starts_with('/')
|
||||
|| input.starts_with("https://")
|
||||
|| input.starts_with("http://")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let input_lower = input.to_lowercase();
|
||||
|
||||
let (resource, version) = if let Some(at_idx) = input_lower.rfind('@') {
|
||||
// Digest-based: ghcr.io/foo/bar@sha256:abc
|
||||
(
|
||||
input_lower[..at_idx].to_string(),
|
||||
input_lower[at_idx + 1..].to_string(),
|
||||
)
|
||||
} else {
|
||||
let last_slash = input_lower.rfind('/');
|
||||
let last_colon = input_lower.rfind(':');
|
||||
match (last_slash, last_colon) {
|
||||
(Some(slash), Some(colon)) if colon > slash => (
|
||||
input_lower[..colon].to_string(),
|
||||
input_lower[colon + 1..].to_string(),
|
||||
),
|
||||
_ => (input_lower, "latest".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
let parts: Vec<&str> = resource.split('/').collect();
|
||||
if parts.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let registry = parts[0].to_string();
|
||||
let path = parts[1..].join("/");
|
||||
|
||||
Some(OciFeatureRef {
|
||||
registry,
|
||||
path,
|
||||
version,
|
||||
})
|
||||
}
|
||||
1759
crates/dev_container/src/lib.rs
Normal file
1759
crates/dev_container/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
470
crates/dev_container/src/oci.rs
Normal file
470
crates/dev_container/src/oci.rs
Normal file
@@ -0,0 +1,470 @@
|
||||
use std::{path::PathBuf, pin::Pin, sync::Arc};
|
||||
|
||||
use fs::Fs;
|
||||
use futures::{AsyncRead, AsyncReadExt, io::BufReader};
|
||||
use http::Request;
|
||||
use http_client::{AsyncBody, HttpClient};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::devcontainer_api::DevContainerError;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct TokenResponse {
|
||||
pub(crate) token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct DockerManifestsResponse {
|
||||
pub(crate) layers: Vec<ManifestLayer>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ManifestLayer {
|
||||
pub(crate) digest: String,
|
||||
}
|
||||
|
||||
/// Gets a bearer token for pulling from a container registry repository.
|
||||
///
|
||||
/// This uses the registry's `/token` endpoint directly, which works for
|
||||
/// `ghcr.io` and other registries that follow the same convention. For
|
||||
/// registries that require a full `WWW-Authenticate` negotiation flow this
|
||||
/// would need to be extended.
|
||||
pub(crate) async fn get_oci_token(
|
||||
registry: &str,
|
||||
repository_path: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<TokenResponse, String> {
|
||||
let url = format!(
|
||||
"https://{registry}/token?service={registry}&scope=repository:{repository_path}:pull",
|
||||
);
|
||||
log::debug!("Fetching OCI token from: {}", url);
|
||||
get_deserialized_response("", &url, client)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::error!("OCI token request failed for {}: {e}", url);
|
||||
e
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn get_latest_oci_manifest(
|
||||
token: &str,
|
||||
registry: &str,
|
||||
repository_path: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
id: Option<&str>,
|
||||
) -> Result<DockerManifestsResponse, String> {
|
||||
get_oci_manifest(registry, repository_path, token, client, "latest", id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_oci_manifest(
|
||||
registry: &str,
|
||||
repository_path: &str,
|
||||
token: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
version: &str,
|
||||
id: Option<&str>,
|
||||
) -> Result<DockerManifestsResponse, String> {
|
||||
let url = match id {
|
||||
Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/manifests/{version}"),
|
||||
None => format!("https://{registry}/v2/{repository_path}/manifests/{version}"),
|
||||
};
|
||||
|
||||
get_deserialized_response(token, &url, client).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_deserializable_oci_blob<T>(
|
||||
token: &str,
|
||||
registry: &str,
|
||||
repository_path: &str,
|
||||
blob_digest: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<T, String>
|
||||
where
|
||||
T: for<'a> Deserialize<'a>,
|
||||
{
|
||||
let url = format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}");
|
||||
get_deserialized_response(token, &url, client).await
|
||||
}
|
||||
|
||||
pub(crate) async fn download_oci_tarball(
|
||||
token: &str,
|
||||
registry: &str,
|
||||
repository_path: &str,
|
||||
blob_digest: &str,
|
||||
accept_header: &str,
|
||||
dest_dir: &PathBuf,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
fs: &Arc<dyn Fs>,
|
||||
id: Option<&str>,
|
||||
) -> Result<(), DevContainerError> {
|
||||
let url = match id {
|
||||
Some(id) => format!("https://{registry}/v2/{repository_path}/{id}/blobs/{blob_digest}"),
|
||||
None => format!("https://{registry}/v2/{repository_path}/blobs/{blob_digest}"),
|
||||
};
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", accept_header)
|
||||
.body(AsyncBody::default())
|
||||
.map_err(|e| {
|
||||
log::error!("Failed to create blob request: {e}");
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})?;
|
||||
|
||||
let mut response = client.send(request).await.map_err(|e| {
|
||||
log::error!("Failed to download feature blob: {e}");
|
||||
DevContainerError::ResourceFetchFailed
|
||||
})?;
|
||||
let status = response.status();
|
||||
|
||||
let body = BufReader::new(response.body_mut());
|
||||
|
||||
if !status.is_success() {
|
||||
let body_text = String::from_utf8_lossy(body.buffer());
|
||||
log::error!(
|
||||
"Feature blob download returned HTTP {}: {}",
|
||||
status.as_u16(),
|
||||
body_text,
|
||||
);
|
||||
return Err(DevContainerError::ResourceFetchFailed);
|
||||
}
|
||||
|
||||
futures::pin_mut!(body);
|
||||
let body: Pin<&mut (dyn AsyncRead + Send)> = body;
|
||||
let archive = async_tar::Archive::new(body);
|
||||
fs.extract_tar_file(dest_dir, archive).await.map_err(|e| {
|
||||
log::error!("Failed to extract feature tarball: {e}");
|
||||
DevContainerError::FilesystemError
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn get_deserialized_response<T>(
|
||||
token: &str,
|
||||
url: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<T, String>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let request = match Request::get(url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||
.body(AsyncBody::default())
|
||||
{
|
||||
Ok(request) => request,
|
||||
Err(e) => return Err(format!("Failed to create request: {}", e)),
|
||||
};
|
||||
let response = match client.send(request).await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to send request to {}: {}", url, e));
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let mut output = String::new();
|
||||
|
||||
if let Err(e) = response.into_body().read_to_string(&mut output).await {
|
||||
return Err(format!("Failed to read response body from {}: {}", url, e));
|
||||
};
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"OCI request to {} returned HTTP {}: {}",
|
||||
url,
|
||||
status.as_u16(),
|
||||
&output[..output.len().min(500)],
|
||||
));
|
||||
}
|
||||
|
||||
match serde_json_lenient::from_str(&output) {
|
||||
Ok(response) => Ok(response),
|
||||
Err(e) => Err(format!(
|
||||
"Failed to deserialize response from {}: {} (body: {})",
|
||||
url,
|
||||
e,
|
||||
&output[..output.len().min(500)],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use fs::{FakeFs, Fs};
|
||||
use gpui::TestAppContext;
|
||||
use http_client::{FakeHttpClient, anyhow};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::oci::{
|
||||
TokenResponse, download_oci_tarball, get_deserializable_oci_blob,
|
||||
get_deserialized_response, get_latest_oci_manifest, get_oci_token,
|
||||
};
|
||||
|
||||
async fn build_test_tarball() -> Vec<u8> {
|
||||
let devcontainer_json = concat!(
|
||||
"// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n",
|
||||
"// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n",
|
||||
"{\n",
|
||||
"\t\"name\": \"Alpine\",\n",
|
||||
"\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n",
|
||||
"\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n",
|
||||
"}\n",
|
||||
);
|
||||
|
||||
let dependabot_yml = concat!(
|
||||
"version: 2\n",
|
||||
"updates:\n",
|
||||
" - package-ecosystem: \"devcontainers\"\n",
|
||||
" directory: \"/\"\n",
|
||||
" schedule:\n",
|
||||
" interval: weekly\n",
|
||||
);
|
||||
|
||||
let buffer = futures::io::Cursor::new(Vec::new());
|
||||
let mut builder = async_tar::Builder::new(buffer);
|
||||
|
||||
let files: &[(&str, &[u8], u32)] = &[
|
||||
(
|
||||
".devcontainer/devcontainer.json",
|
||||
devcontainer_json.as_bytes(),
|
||||
0o644,
|
||||
),
|
||||
(".github/dependabot.yml", dependabot_yml.as_bytes(), 0o644),
|
||||
("NOTES.md", b"Some notes", 0o644),
|
||||
("README.md", b"# Alpine\n", 0o644),
|
||||
];
|
||||
|
||||
for (path, data, mode) in files {
|
||||
let mut header = async_tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(*mode);
|
||||
header.set_entry_type(async_tar::EntryType::Regular);
|
||||
header.set_cksum();
|
||||
builder.append_data(&mut header, path, *data).await.unwrap();
|
||||
}
|
||||
|
||||
let buffer = builder.into_inner().await.unwrap();
|
||||
buffer.into_inner()
|
||||
}
|
||||
fn test_oci_registry() -> &'static str {
|
||||
"ghcr.io"
|
||||
}
|
||||
fn test_oci_repository() -> &'static str {
|
||||
"repository"
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_get_deserialized_response(_cx: &mut TestAppContext) {
|
||||
let client = FakeHttpClient::create(|_request| async move {
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body("{ \"token\": \"thisisatoken\" }".into())
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
let response =
|
||||
get_deserialized_response::<TokenResponse>("", "https://ghcr.io/token", &client).await;
|
||||
assert!(response.is_ok());
|
||||
assert_eq!(response.unwrap().token, "thisisatoken".to_string())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_get_oci_token() {
|
||||
let client = FakeHttpClient::create(|request| async move {
|
||||
let host = request.uri().host();
|
||||
if host.is_none() || host.unwrap() != test_oci_registry() {
|
||||
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if path != "/token" {
|
||||
return Err(anyhow!("Unexpected path: {}", path));
|
||||
}
|
||||
let query = request.uri().query();
|
||||
if query.is_none()
|
||||
|| query.unwrap()
|
||||
!= format!(
|
||||
"service=ghcr.io&scope=repository:{}:pull",
|
||||
test_oci_repository()
|
||||
)
|
||||
{
|
||||
return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default()));
|
||||
}
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body("{ \"token\": \"thisisatoken\" }".into())
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
let response = get_oci_token(test_oci_registry(), test_oci_repository(), &client).await;
|
||||
|
||||
assert!(response.is_ok());
|
||||
assert_eq!(response.unwrap().token, "thisisatoken".to_string());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_get_latest_manifests() {
|
||||
let client = FakeHttpClient::create(|request| async move {
|
||||
let host = request.uri().host();
|
||||
if host.is_none() || host.unwrap() != test_oci_registry() {
|
||||
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if path != format!("/v2/{}/manifests/latest", test_oci_repository()) {
|
||||
return Err(anyhow!("Unexpected path: {}", path));
|
||||
}
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body("{
|
||||
\"schemaVersion\": 2,
|
||||
\"mediaType\": \"application/vnd.oci.image.manifest.v1+json\",
|
||||
\"config\": {
|
||||
\"mediaType\": \"application/vnd.devcontainers\",
|
||||
\"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",
|
||||
\"size\": 2
|
||||
},
|
||||
\"layers\": [
|
||||
{
|
||||
\"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\",
|
||||
\"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\",
|
||||
\"size\": 65235,
|
||||
\"annotations\": {
|
||||
\"org.opencontainers.image.title\": \"devcontainer-collection.json\"
|
||||
}
|
||||
}
|
||||
],
|
||||
\"annotations\": {
|
||||
\"com.github.package.type\": \"devcontainer_collection\"
|
||||
}
|
||||
}".into())
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
let response = get_latest_oci_manifest(
|
||||
"",
|
||||
test_oci_registry(),
|
||||
test_oci_repository(),
|
||||
&client,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(response.is_ok());
|
||||
let response = response.unwrap();
|
||||
|
||||
assert_eq!(response.layers.len(), 1);
|
||||
assert_eq!(
|
||||
response.layers[0].digest,
|
||||
"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_get_oci_blob() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DeserializableTestStruct {
|
||||
foo: String,
|
||||
}
|
||||
|
||||
let client = FakeHttpClient::create(|request| async move {
|
||||
let host = request.uri().host();
|
||||
if host.is_none() || host.unwrap() != test_oci_registry() {
|
||||
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) {
|
||||
return Err(anyhow!("Unexpected path: {}", path));
|
||||
}
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body(
|
||||
r#"
|
||||
{
|
||||
"foo": "bar"
|
||||
}
|
||||
"#
|
||||
.into(),
|
||||
)
|
||||
.unwrap())
|
||||
});
|
||||
|
||||
let response: Result<DeserializableTestStruct, String> = get_deserializable_oci_blob(
|
||||
"",
|
||||
test_oci_registry(),
|
||||
test_oci_repository(),
|
||||
"blobdigest",
|
||||
&client,
|
||||
)
|
||||
.await;
|
||||
assert!(response.is_ok());
|
||||
let response = response.unwrap();
|
||||
|
||||
assert_eq!(response.foo, "bar".to_string());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_download_oci_tarball(cx: &mut TestAppContext) {
|
||||
cx.executor().allow_parking();
|
||||
let fs: Arc<dyn Fs> = FakeFs::new(cx.executor());
|
||||
|
||||
let destination_dir = PathBuf::from("/tmp/extracted");
|
||||
fs.create_dir(&destination_dir).await.unwrap();
|
||||
|
||||
let tarball_bytes = build_test_tarball().await;
|
||||
let tarball = std::sync::Arc::new(tarball_bytes);
|
||||
|
||||
let client = FakeHttpClient::create(move |request| {
|
||||
let tarball = tarball.clone();
|
||||
async move {
|
||||
let host = request.uri().host();
|
||||
if host.is_none() || host.unwrap() != test_oci_registry() {
|
||||
return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
|
||||
}
|
||||
let path = request.uri().path();
|
||||
if path != format!("/v2/{}/blobs/blobdigest", test_oci_repository()) {
|
||||
return Err(anyhow!("Unexpected path: {}", path));
|
||||
}
|
||||
Ok(http_client::Response::builder()
|
||||
.status(200)
|
||||
.body(tarball.to_vec().into())
|
||||
.unwrap())
|
||||
}
|
||||
});
|
||||
|
||||
let response = download_oci_tarball(
|
||||
"",
|
||||
test_oci_registry(),
|
||||
test_oci_repository(),
|
||||
"blobdigest",
|
||||
"header",
|
||||
&destination_dir,
|
||||
&client,
|
||||
&fs,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(response.is_ok());
|
||||
|
||||
let expected_devcontainer_json = concat!(
|
||||
"// For format details, see https://aka.ms/devcontainer.json. For config options, see the\n",
|
||||
"// README at: https://github.com/devcontainers/templates/tree/main/src/alpine\n",
|
||||
"{\n",
|
||||
"\t\"name\": \"Alpine\",\n",
|
||||
"\t// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile\n",
|
||||
"\t\"image\": \"mcr.microsoft.com/devcontainers/base:alpine-${templateOption:imageVariant}\"\n",
|
||||
"}\n",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fs.load(&destination_dir.join(".devcontainer/devcontainer.json"))
|
||||
.await
|
||||
.unwrap(),
|
||||
expected_devcontainer_json
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user