Skip to content

Commit c2b6dd5

Browse files
committed
csm env unpack command
1 parent 448c734 commit c2b6dd5

5 files changed

Lines changed: 196 additions & 17 deletions

File tree

Cargo.lock

Lines changed: 43 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ version = "0.1.0"
44
edition = "2024"
55

66
[lints.clippy]
7-
all = "deny"
7+
#all = "deny"
8+
complexity = "deny"
89

910
[features]
1011
default = ["__test_bash"] # Don't assume devs have other shells installed
@@ -19,6 +20,7 @@ clap = { version = "4.5.50", features = ["derive", "wrap_help"] }
1920
clap_complete = "4.5.60"
2021
dirs = "6.0.0"
2122
env_logger = "0.11.8"
23+
flate2 = "1.1.5"
2224
log = "0.4.28"
2325
reqwest = { version = "0.12.24", default-features = false, features = ["http2", "charset", "system-proxy", "blocking", "rustls-tls-native-roots"] }
2426
serde = { version = "1.0.228", features = ["derive"] }

src/env.rs

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ use crate::shell::SupportedShell;
44

55
use log::{debug, error, info, warn};
66
use serde::Deserialize;
7-
use std::io::{Error, ErrorKind};
7+
use std::fs;
8+
use std::io;
89
use std::path::{Component, Path, PathBuf};
910
use std::process::ExitCode;
1011

@@ -21,7 +22,7 @@ pub enum Subcommand {
2122
/// Create an archive from an environment. Requires `conda-pack` to be in the environment.
2223
Pack(PackArgs),
2324
/// Unpack an archive to create an environment from it.
24-
Unpack(CommonEnvArgs),
25+
Unpack(UnpackArgs),
2526
/// List existing environments
2627
List,
2728
/// Display information about the micromamba setup
@@ -73,13 +74,23 @@ pub struct PackArgs {
7374
/// Common environment arguments
7475
#[command(flatten)]
7576
common: CommonEnvArgs,
76-
/// Output path/filename of the packed environment. If not specified, the
77-
/// same method is used to determine the environment name as for the
78-
/// "--name" parameter, and the default name is <env_name>.tar.gz
77+
/// Output path/filename of the packed environment, ending in .tar.gz. If
78+
/// not specified, the same method is used to determine the environment name
79+
/// as for the "--name" parameter, and the default name is <env_name>.tar.gz
7980
#[arg(long, short, value_name = "OUTPUT")]
8081
output: Option<String>,
8182
}
8283

84+
#[derive(Debug, clap::Args)]
85+
pub struct UnpackArgs {
86+
/// Common environment arguments
87+
#[command(flatten)]
88+
common: CommonEnvArgs,
89+
/// Path to a packed environment archive (ending in .tar.gz)
90+
#[arg(value_name = "ARCHIVE")]
91+
archive_path: PathBuf,
92+
}
93+
8394
/// Contains the fields we need from a parsed environment file.
8495
#[derive(Deserialize)]
8596
struct RobotmkEnv {
@@ -102,9 +113,10 @@ struct PostBuildCommand {
102113

103114
impl RobotmkSetup {
104115
/// Attempt to parse a setup file.
105-
fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, std::io::Error> {
116+
fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, io::Error> {
106117
let contents = std::fs::read_to_string(path)?;
107-
serde_yaml_ng::from_str(&contents).map_err(|e| Error::new(ErrorKind::InvalidData, e))
118+
serde_yaml_ng::from_str(&contents)
119+
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
108120
}
109121

110122
fn run_post_create(self, config: &Config, env_name: &str) -> Result<(), ExitCode> {
@@ -129,9 +141,9 @@ impl RobotmkSetup {
129141
}
130142

131143
/// Attempt to parse an environment file.
132-
fn parse_env_yaml<P: AsRef<Path>>(path: P) -> Result<RobotmkEnv, std::io::Error> {
144+
fn parse_env_yaml<P: AsRef<Path>>(path: P) -> Result<RobotmkEnv, io::Error> {
133145
let contents = std::fs::read_to_string(path)?;
134-
serde_yaml_ng::from_str(&contents).map_err(|e| Error::new(ErrorKind::InvalidData, e))
146+
serde_yaml_ng::from_str(&contents).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
135147
}
136148

137149
pub fn determine_env_name(explicit_name: Option<String>, env_yaml_path: &Path) -> Option<String> {
@@ -233,7 +245,7 @@ pub fn run(config: Config, subcommand: Subcommand) -> Result<(), ExitCode> {
233245
Some(path) => (path, true),
234246
};
235247
let setup = match RobotmkSetup::from_path(&filename) {
236-
Err(e) if e.kind() == ErrorKind::NotFound => {
248+
Err(e) if e.kind() == io::ErrorKind::NotFound => {
237249
if required {
238250
// Handled above, but theoretical race here, so handle it
239251
error!("The specified setup file was not found");
@@ -365,10 +377,73 @@ pub fn run(config: Config, subcommand: Subcommand) -> Result<(), ExitCode> {
365377
)
366378
.into()
367379
}
368-
_ => {
369-
println!("{:?}", config);
370-
println!("{:?}", subcommand);
371-
Ok(())
380+
Subcommand::Unpack(args) => {
381+
fn archive_name_to_env_name(archive_path: &Path) -> Option<&str> {
382+
let filename = archive_path.file_name()?.to_str()?;
383+
filename
384+
.strip_suffix(".tar.gz")
385+
.or_else(|| filename.strip_suffix(".tgz"))
386+
}
387+
let env_name = match args.common.name {
388+
Some(name) => name,
389+
None => match archive_name_to_env_name(&args.archive_path) {
390+
Some(name) => {
391+
info!(
392+
"Using '{}' as environment name, based on archive filename",
393+
name
394+
);
395+
String::from(name)
396+
}
397+
None => {
398+
error!(
399+
"Could not determine environment name from archive filename. Please specify an environment name with --name."
400+
);
401+
return Err(ExitCode::FAILURE);
402+
}
403+
},
404+
};
405+
406+
// TODO: We really need a more generic error type to avoid this kind of mapping everywhere
407+
let target_env_path = micromamba::create_env_dir(&config, &env_name).map_err(|e| {
408+
error!("{}", e);
409+
ExitCode::FAILURE
410+
})?;
411+
412+
// Send the archive to flate2 to decompress and untar
413+
info!(
414+
"Unpacking archive '{}' to create environment '{}'",
415+
args.archive_path.display(),
416+
env_name
417+
);
418+
debug!("Opening '{}' for read", args.archive_path.display());
419+
let archive_file = fs::File::open(&args.archive_path).map_err(|e| {
420+
error!("{}", e);
421+
ExitCode::FAILURE
422+
})?;
423+
let decompressor = flate2::read::GzDecoder::new(archive_file);
424+
let mut archive = tar::Archive::new(decompressor);
425+
archive.unpack(&target_env_path).map_err(|e| {
426+
error!(
427+
"Could not unpack archive to '{}': {}",
428+
target_env_path.display(),
429+
e
430+
);
431+
ExitCode::FAILURE
432+
})?;
433+
info!(
434+
"Successfully unpacked environment to '{}'",
435+
target_env_path.display()
436+
);
437+
438+
info!("Running 'conda-unpack' in the new environment to fix paths...");
439+
let result = micromamba(
440+
&config,
441+
vec!["run", "--name", &env_name, "conda-unpack"],
442+
config.verbose,
443+
);
444+
let rc = result.exit_code();
445+
dump_micromamba_captured_output_on_error(&result, rc);
446+
result.into()
372447
}
373448
}
374449
}

src/micromamba.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,3 +353,27 @@ pub fn bin_path_for_env(config: &Config, name: &str) -> Option<PathBuf> {
353353
let os_specific_bin = if cfg!(windows) { "Scripts" } else { "bin" };
354354
path_for_env(config, name).map(|p| p.join(os_specific_bin))
355355
}
356+
357+
/// Create a directory for a new environment, if it does not already exist.
358+
pub fn create_env_dir(config: &Config, name: &str) -> Result<PathBuf, std::io::Error> {
359+
let env_path = match path_for_env(config, name) {
360+
Some(path) => path,
361+
None => {
362+
return Err(io::Error::new(
363+
io::ErrorKind::NotFound,
364+
format!("Could not determine path for environment '{}'", name),
365+
));
366+
}
367+
};
368+
if env_path.exists() {
369+
return Err(io::Error::new(
370+
io::ErrorKind::AlreadyExists,
371+
format!(
372+
"The target environment directory '{:?}' already exists",
373+
env_path
374+
),
375+
));
376+
}
377+
std::fs::create_dir_all(&env_path)?;
378+
Ok(env_path)
379+
}

tests/env.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,43 @@ fn test_csm_env_pack_unpack() -> Result<(), Error> {
284284
.success()
285285
.stdout(predicate::str::contains("100% Completed"));
286286

287-
// TODO: unpack
287+
csm.command()
288+
.args(["env", "unpack", "-n", "unpacked", "packed.tar.gz"])
289+
.assert()
290+
.success()
291+
.stderr(predicate::str::contains(
292+
"Successfully unpacked environment",
293+
));
294+
295+
let (shell, cmd) = if cfg!(windows) {
296+
("powershell", "echo $env:CONDA_PROMPT_MODIFIER")
297+
} else {
298+
("sh", "echo $CONDA_PROMPT_MODIFIER")
299+
};
300+
301+
csm.command()
302+
.args(["env", "run", "-n", "unpacked", "--", shell, "-c", cmd])
303+
.assert()
304+
.success()
305+
.stdout(predicate::str::contains("(unpacked)"));
306+
307+
Ok(())
308+
}
309+
310+
/// Test `csm env unpack` environment name detection
311+
#[test]
312+
fn test_csm_env_unpack_detect_name() -> Result<(), Error> {
313+
let csm = common::Csm::new()?;
314+
315+
for path in ["/path/to/my_env.tar.gz", "my_env.tgz"] {
316+
csm.command()
317+
.args(["-n", "env", "unpack", path])
318+
.assert()
319+
.failure() // with -n, it won't be able to resolve paths, so it'll fail
320+
.stderr(predicate::str::contains(
321+
"Using 'my_env' as environment name",
322+
));
323+
}
288324

289325
Ok(())
290326
}

0 commit comments

Comments
 (0)