From 54d9b314d8a8c4e6872d5111fce7bf2530f988db Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 19 Nov 2025 07:54:45 -0800 Subject: [PATCH 1/8] Write frozen files from metadata --- constructor/preconda.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/constructor/preconda.py b/constructor/preconda.py index 12201ee77..56bbf2b80 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -147,6 +147,7 @@ def write_files(info: dict, workspace: str): - `conda-meta/initial-state.explicit.txt`: Lockfile to provision the base environment. - `conda-meta/history`: Prepared history file with the right requested specs in input file. + - `conda-meta/frozen`: Frozen marker file used to protect conda environment state. - `pkgs/urls` and `pkgs/urls.txt`: Direct URLs of packages used, with and without MD5 hashes. - `pkgs/cache/*.json`: Trimmed repodata to mock offline channels in use. - `pkgs/channels.txt`: Channels in use. @@ -199,6 +200,10 @@ def write_files(info: dict, workspace: str): # (list of specs/dists to install) write_initial_state_explicit_txt(info, join(workspace, "conda-meta"), final_urls_md5s) + # base environment frozen marker files + if info.get("freeze_base"): + write_frozen(info["freeze_base"], join(workspace, "conda-meta")) + for fn in files: os.chmod(join(workspace, fn), 0o664) @@ -218,6 +223,9 @@ def write_files(info: dict, workspace: str): write_channels_txt(info, env_pkgs, env_config) # shortcuts write_shortcuts_txt(info, env_pkgs, env_config) + # frozen marker file + if env_config.get("freeze_env"): + write_frozen(env_config["freeze_env"], env_conda_meta) def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): @@ -244,6 +252,13 @@ def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): fh.write("\n".join(builder)) +def write_frozen(freeze_info, dst_dir): + if freeze_info and "conda" in freeze_info: + frozen_path = join(dst_dir, "frozen") + with open(frozen_path, "w") as ff: + json.dump(freeze_info["conda"], ff, indent=2, sort_keys=True) + + def write_repodata_record(info, dst_dir): all_dists = info["_dists"].copy() for env_data in info.get("_extra_envs_info", {}).values(): From 452ed7a3d32565e0fed192469657d8416b6951db Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 19 Nov 2025 10:12:14 -0800 Subject: [PATCH 2/8] Add frozen files to postconda tarball --- constructor/shar.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/constructor/shar.py b/constructor/shar.py index 745f95c61..15e32ce08 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -180,6 +180,18 @@ def create(info, verbose=False): f"envs/{env_name}/conda-meta/history", ) + if info.get("freeze_base"): + pre_t.addfile(tarinfo=tarfile.TarInfo("conda-meta/frozen")) + post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") + + for env_name, env_config in info.get("_extra_envs_info", {}).items(): + if env_config.get("freeze_env"): + pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/frozen")) + post_t.add( + join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), + f"envs/{env_name}/conda-meta/frozen", + ) + extra_files = copy_extra_files(info.get("extra_files", []), tmp_dir) for path in extra_files: post_t.add(path, relpath(path, tmp_dir)) From 3f77366d0a0b027358cd1e6d1020618c16decc5d Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 19 Nov 2025 00:29:36 -0800 Subject: [PATCH 3/8] Separate frozen logic into own function --- constructor/main.py | 61 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/constructor/main.py b/constructor/main.py index 73d98e033..9b53d240b 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -77,6 +77,67 @@ def get_output_filename(info): ext, ) +# Validate frozen environments +def validate_frozen_envs(info, exe_type, exe_version): + """Validate frozen environments. + + Checks: + - No conflicts between freeze_base/freeze_env and extra_files for same environment + - conda-standalone 25.5.x is not used (has known issues) + - Warns if conda-standalone < 25.5.0 (frozen files will be ignored) + + Stores frozen environment info in `_frozen_markers` dict. + """ + def get_frozen_env_from_path(dest: str) -> str | None: + """Extract environment name from frozen marker destination path.""" + parts = Path(dest).parts + if parts == ("conda-meta", "frozen"): + return "base" + if len(parts) == 4 and parts[0] == "envs" and parts[-2:] == ("conda-meta", "frozen"): + return parts[1] + return None + + # Collect environments using freeze_base/freeze_env + frozen_envs = {} + if info.get("freeze_base"): + frozen_envs["base"] = { + "method": "freeze_base", + "config": info["freeze_base"], + } + for env_name, env_config in info.get("extra_envs", {}).items(): + if env_config.get("freeze_env"): + frozen_envs[env_name] = { + "method": "freeze_env", + "config": env_config["freeze_env"], + } + + # Check for conflicts with extra_files + for file in info.get("extra_files", []): + if isinstance(file, dict): + for dest in file.values(): + env = get_frozen_env_from_path(dest) + if env and env in frozen_envs: + raise RuntimeError( + f"Environment '{env}' has frozen markers from both " + f"'{'freeze_base' if env == 'base' else 'freeze_env'}' and 'extra_files'. " + "Please use only one method to provide frozen markers for each environment.") + + info["_frozen_markers"] = frozen_envs + + # Conda-standalone version validation + if frozen_envs and exe_type == StandaloneExe.CONDA: + # Block conda-standalone 25.5.x (has known issues with frozen environments) + if check_version(exe_version, min_version="25.5.0", max_version="25.7.0"): + sys.exit( + "Error: conda-standalone 25.5.x has known issues with frozen environments. " + "Please use conda-standalone 25.7.0 or newer." + ) + # Warn for older versions (will ignore frozen files) + elif not check_version(exe_version, min_version="25.5.0"): + logger.warning( + "conda-standalone older than 25.5.0 does not support frozen environments. " + "Frozen marker files will be ignored at install time." + ) def _conda_exe_supports_logging(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: """Test if the standalone binary supports the the --log-file argument. From c038bb78238805e758c98033aa7d14137e98c65b Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Wed, 19 Nov 2025 09:20:11 -0800 Subject: [PATCH 4/8] Use sets instead --- constructor/main.py | 61 --------------------------------------------- 1 file changed, 61 deletions(-) diff --git a/constructor/main.py b/constructor/main.py index 9b53d240b..73d98e033 100644 --- a/constructor/main.py +++ b/constructor/main.py @@ -77,67 +77,6 @@ def get_output_filename(info): ext, ) -# Validate frozen environments -def validate_frozen_envs(info, exe_type, exe_version): - """Validate frozen environments. - - Checks: - - No conflicts between freeze_base/freeze_env and extra_files for same environment - - conda-standalone 25.5.x is not used (has known issues) - - Warns if conda-standalone < 25.5.0 (frozen files will be ignored) - - Stores frozen environment info in `_frozen_markers` dict. - """ - def get_frozen_env_from_path(dest: str) -> str | None: - """Extract environment name from frozen marker destination path.""" - parts = Path(dest).parts - if parts == ("conda-meta", "frozen"): - return "base" - if len(parts) == 4 and parts[0] == "envs" and parts[-2:] == ("conda-meta", "frozen"): - return parts[1] - return None - - # Collect environments using freeze_base/freeze_env - frozen_envs = {} - if info.get("freeze_base"): - frozen_envs["base"] = { - "method": "freeze_base", - "config": info["freeze_base"], - } - for env_name, env_config in info.get("extra_envs", {}).items(): - if env_config.get("freeze_env"): - frozen_envs[env_name] = { - "method": "freeze_env", - "config": env_config["freeze_env"], - } - - # Check for conflicts with extra_files - for file in info.get("extra_files", []): - if isinstance(file, dict): - for dest in file.values(): - env = get_frozen_env_from_path(dest) - if env and env in frozen_envs: - raise RuntimeError( - f"Environment '{env}' has frozen markers from both " - f"'{'freeze_base' if env == 'base' else 'freeze_env'}' and 'extra_files'. " - "Please use only one method to provide frozen markers for each environment.") - - info["_frozen_markers"] = frozen_envs - - # Conda-standalone version validation - if frozen_envs and exe_type == StandaloneExe.CONDA: - # Block conda-standalone 25.5.x (has known issues with frozen environments) - if check_version(exe_version, min_version="25.5.0", max_version="25.7.0"): - sys.exit( - "Error: conda-standalone 25.5.x has known issues with frozen environments. " - "Please use conda-standalone 25.7.0 or newer." - ) - # Warn for older versions (will ignore frozen files) - elif not check_version(exe_version, min_version="25.5.0"): - logger.warning( - "conda-standalone older than 25.5.0 does not support frozen environments. " - "Frozen marker files will be ignored at install time." - ) def _conda_exe_supports_logging(conda_exe: str, conda_exe_type: StandaloneExe | None) -> bool: """Test if the standalone binary supports the the --log-file argument. From 5da5fbac4987bf350db696ed79c8ba98694cff3f Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 5 Dec 2025 08:54:51 -0800 Subject: [PATCH 5/8] Correctly dump entire dict into json file --- constructor/preconda.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/constructor/preconda.py b/constructor/preconda.py index 56bbf2b80..6b4c8f239 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -256,7 +256,7 @@ def write_frozen(freeze_info, dst_dir): if freeze_info and "conda" in freeze_info: frozen_path = join(dst_dir, "frozen") with open(frozen_path, "w") as ff: - json.dump(freeze_info["conda"], ff, indent=2, sort_keys=True) + json.dump(freeze_info, ff, indent=2, sort_keys=True) def write_repodata_record(info, dst_dir): From e553521ffa859ab5b537f0ebfaf2fa1688e49748 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 12 Dec 2025 08:07:10 -0800 Subject: [PATCH 6/8] Address review comments --- constructor/preconda.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/constructor/preconda.py b/constructor/preconda.py index 6b4c8f239..f5145dbd4 100644 --- a/constructor/preconda.py +++ b/constructor/preconda.py @@ -201,8 +201,7 @@ def write_files(info: dict, workspace: str): write_initial_state_explicit_txt(info, join(workspace, "conda-meta"), final_urls_md5s) # base environment frozen marker files - if info.get("freeze_base"): - write_frozen(info["freeze_base"], join(workspace, "conda-meta")) + write_frozen(info.get("freeze_base"), join(workspace, "conda-meta")) for fn in files: os.chmod(join(workspace, fn), 0o664) @@ -224,8 +223,7 @@ def write_files(info: dict, workspace: str): # shortcuts write_shortcuts_txt(info, env_pkgs, env_config) # frozen marker file - if env_config.get("freeze_env"): - write_frozen(env_config["freeze_env"], env_conda_meta) + write_frozen(env_config.get("freeze_env"), env_conda_meta) def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): @@ -253,10 +251,11 @@ def write_conda_meta(info, dst_dir, final_urls_md5s, user_requested_specs=None): def write_frozen(freeze_info, dst_dir): - if freeze_info and "conda" in freeze_info: - frozen_path = join(dst_dir, "frozen") - with open(frozen_path, "w") as ff: - json.dump(freeze_info, ff, indent=2, sort_keys=True) + if not freeze_info or "conda" not in freeze_info: + return + frozen_path = join(dst_dir, "frozen") + with open(frozen_path, "w") as ff: + json.dump(freeze_info["conda"], ff) def write_repodata_record(info, dst_dir): From 25a50894ef0125e095b87da0af64ee3733dd18a5 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 12 Dec 2025 08:18:45 -0800 Subject: [PATCH 7/8] Only write to post-install tarball --- constructor/shar.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/constructor/shar.py b/constructor/shar.py index 15e32ce08..dfc807fa8 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -173,20 +173,17 @@ def create(info, verbose=False): pre_t.addfile(tarinfo=tarfile.TarInfo("conda-meta/history")) post_t.add(join(tmp_dir, "conda-meta", "history"), "conda-meta/history") + if info.get("freeze_base"): + post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") + for env_name in info.get("_extra_envs_info", {}): pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/history")) post_t.add( join(tmp_dir, "envs", env_name, "conda-meta", "history"), f"envs/{env_name}/conda-meta/history", ) - - if info.get("freeze_base"): - pre_t.addfile(tarinfo=tarfile.TarInfo("conda-meta/frozen")) - post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") - - for env_name, env_config in info.get("_extra_envs_info", {}).items(): + env_config = info["_extra_envs_info"][env_name] if env_config.get("freeze_env"): - pre_t.addfile(tarinfo=tarfile.TarInfo(f"envs/{env_name}/conda-meta/frozen")) post_t.add( join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), f"envs/{env_name}/conda-meta/frozen", From c5a8f891c87fdff9e9bc971dd0a4c4bd5b25a465 Mon Sep 17 00:00:00 2001 From: Jaida Rice Date: Fri, 12 Dec 2025 12:26:50 -0800 Subject: [PATCH 8/8] Check if files exist instead of reusing config logic --- constructor/shar.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/constructor/shar.py b/constructor/shar.py index dfc807fa8..a0b631267 100644 --- a/constructor/shar.py +++ b/constructor/shar.py @@ -173,7 +173,7 @@ def create(info, verbose=False): pre_t.addfile(tarinfo=tarfile.TarInfo("conda-meta/history")) post_t.add(join(tmp_dir, "conda-meta", "history"), "conda-meta/history") - if info.get("freeze_base"): + if os.path.exists(join(tmp_dir, "conda-meta", "frozen")): post_t.add(join(tmp_dir, "conda-meta", "frozen"), "conda-meta/frozen") for env_name in info.get("_extra_envs_info", {}): @@ -182,8 +182,7 @@ def create(info, verbose=False): join(tmp_dir, "envs", env_name, "conda-meta", "history"), f"envs/{env_name}/conda-meta/history", ) - env_config = info["_extra_envs_info"][env_name] - if env_config.get("freeze_env"): + if os.path.exists(join(tmp_dir, "envs", env_name, "conda-meta", "frozen")): post_t.add( join(tmp_dir, "envs", env_name, "conda-meta", "frozen"), f"envs/{env_name}/conda-meta/frozen",