From 34619b6507d96573e9ffd939650ff30ce5d23e62 Mon Sep 17 00:00:00 2001 From: bcumming Date: Thu, 21 Aug 2025 13:15:35 +0200 Subject: [PATCH 01/13] squash --- stackinator/recipe.py | 72 ++++++------------- stackinator/templates/Makefile.environments | 8 +-- stackinator/templates/environments.spack.yaml | 8 ++- 3 files changed, 32 insertions(+), 56 deletions(-) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index a55bad5..c362d75 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -269,8 +269,7 @@ def environment_view_meta(self): # generate the view meta data that is presented in the squashfs image meta data view_meta = {} for _, env in self.environments.items(): - view = env["view"] - if view is not None: + for view in env["views"]: view_meta[view["name"]] = { "root": view["config"]["root"], "activate": view["config"]["root"] + "/activate.sh", @@ -368,58 +367,33 @@ def generate_environment_specs(self, raw): env_name_map = {} for name, config in environments.items(): env_name_map[name] = [] - for view, vc in config["views"].items(): - if view in env_names: - raise Exception(f"An environment view with the name '{view}' already exists.") + views = [] + for view_name, vc in config["views"].items(): + if view_name in env_names: + raise Exception(f"An environment view with the name '{name}' already exists.") + env_names.add(view_name) + view_config = copy.deepcopy(vc) # set some default values: - # vc["link"] = "roots" - # vc["uenv"]["add_compilers"] = True - # vc["uenv"]["prefix_paths"] = {} - if vc is None: - vc = {} - vc.setdefault("link", "roots") - vc.setdefault("uenv", {}) - vc["uenv"].setdefault("add_compilers", True) - vc["uenv"].setdefault("prefix_paths", {}) + # ["link"] = "roots" + # ["uenv"]["add_compilers"] = True + # ["uenv"]["prefix_paths"] = {} + if view_config is None: + view_config = {} + view_config.setdefault("link", "roots") + view_config.setdefault("uenv", {}) + view_config["uenv"].setdefault("add_compilers", True) + view_config["uenv"].setdefault("prefix_paths", {}) prefix_string = ",".join( - [f"{name}={':'.join(paths)}" for name, paths in vc["uenv"]["prefix_paths"].items()] + [f"{pname}={':'.join(paths)}" for pname, paths in view_config["uenv"]["prefix_paths"].items()] ) - vc["uenv"]["prefix_string"] = prefix_string - # save a copy of the view configuration - env_name_map[name].append((view, vc)) - - # Iterate over each environment: - # - creating copies of the env so that there is one copy per view. - # - configure each view - for name, views in env_name_map.items(): - numviews = len(env_name_map[name]) - - # The configuration of the environment without views - base = copy.deepcopy(environments[name]) - - environments[name]["view"] = None - for i in range(numviews): - # pick a name for the environment - cname = name if i == 0 else name + f"-{i + 1}__" - if i > 0: - environments[cname] = copy.deepcopy(base) - - view_name, view_config = views[i] - # note: the root path is stored as a string, not as a pathlib.PosixPath - # to avoid serialisation issues when generating the spack.yaml file for - # each environment. - if view_config is None: - view_config = {"root": str(self.mount / "env" / view_name)} - else: - view_config["root"] = str(self.mount / "env" / view_name) - - # The "uenv" field is not spack configuration, it is additional information - # used by stackinator additionally set compiler paths and LD_LIBRARY_PATH - # Remove it from the view_config that will be passed directly to spack, and pass - # it separately for configuring the envvars.py helper during the uenv build. + view_config["uenv"]["prefix_string"] = prefix_string + view_config["root"] = str(self.mount / "env" / view_name) + extra = view_config.pop("uenv") + env_name_map[name].append((view_name, view_config)) + views.append({"name": view_name, "config": view_config, "extra": extra}) - environments[cname]["view"] = {"name": view_name, "config": view_config, "extra": extra} + config["views"] = views self.environments = environments diff --git a/stackinator/templates/Makefile.environments b/stackinator/templates/Makefile.environments index 5024131..5a53023 100644 --- a/stackinator/templates/Makefile.environments +++ b/stackinator/templates/Makefile.environments @@ -32,10 +32,10 @@ all:{% for env in environments %} {{ env }}/generated/build_cache{% endfor %} # Create environment view where requested {% for env, config in environments.items() %} {{ env }}/generated/view_config: {{ env }}/generated/env -{% if config.view %} - $(SPACK) env activate --with-view default --sh ./{{ env }} > $(STORE)/env/{{ config.view.name }}/activate.sh - $(BUILD_ROOT)/envvars.py view {% if config.view.extra.add_compilers %}--compilers=./{{ env }}/packages.yaml {% endif %} --prefix_paths="{{ config.view.extra.prefix_string }}" $(STORE)/env/{{ config.view.name }} $(BUILD_ROOT) -{% endif %} +{% for view in config.views %} + $(SPACK) env activate --with-view {{ view.name }} --sh ./{{ env }} > $(STORE)/env/{{ view.name }}/activate.sh + $(BUILD_ROOT)/envvars.py view {% if view.extra.add_compilers %}--compilers=./{{ env }}/packages.yaml {% endif %} --prefix_paths="{{ view.extra.prefix_string }}" $(STORE)/env/{{ view.name }} $(BUILD_ROOT) +{% endfor %} touch $@ {% endfor %} diff --git a/stackinator/templates/environments.spack.yaml b/stackinator/templates/environments.spack.yaml index deff36f..a79ac8b 100644 --- a/stackinator/templates/environments.spack.yaml +++ b/stackinator/templates/environments.spack.yaml @@ -29,10 +29,12 @@ spack: require: '{{ config.mpi }}' {% endif %} {% endif %} -{% if config.view %} +{% if config.views %} view: - default: - {{ config.view.config|py2yaml(6) }} +{% for view in config.views %} + {{ view.name }}: + {{ view.config|py2yaml(6) }} +{% endfor %} {% else %} view: false {% endif %} From 606a48a5e1daef2909545e9819487d50f2ce94de Mon Sep 17 00:00:00 2001 From: bcumming Date: Fri, 22 Aug 2025 08:54:55 +0200 Subject: [PATCH 02/13] v1: list of named pairs --- stackinator/recipe.py | 15 +++++++++++++++ stackinator/schema/environments.json | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index c362d75..f90be3c 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -365,6 +365,7 @@ def generate_environment_specs(self, raw): # the name default to generated the activation script. env_names = set() env_name_map = {} + print() for name, config in environments.items(): env_name_map[name] = [] views = [] @@ -377,12 +378,15 @@ def generate_environment_specs(self, raw): # ["link"] = "roots" # ["uenv"]["add_compilers"] = True # ["uenv"]["prefix_paths"] = {} + # ["uenv"]["env_vars"] = [] if view_config is None: view_config = {} view_config.setdefault("link", "roots") view_config.setdefault("uenv", {}) view_config["uenv"].setdefault("add_compilers", True) view_config["uenv"].setdefault("prefix_paths", {}) + view_config["uenv"].setdefault("env_vars", []) + print(view_config["uenv"]["env_vars"]) prefix_string = ",".join( [f"{pname}={':'.join(paths)}" for pname, paths in view_config["uenv"]["prefix_paths"].items()] ) @@ -395,6 +399,7 @@ def generate_environment_specs(self, raw): config["views"] = views + print() self.environments = environments # creates the self.compilers field that describes the full specifications @@ -505,6 +510,16 @@ def environment_files(self): files["config"] = {} for env, config in self.environments.items(): spack_yaml_template = jenv.get_template("environments.spack.yaml") + # generate the spack.yaml file files["config"][env] = spack_yaml_template.render(config=config, name=env, store=self.mount) + # generate the view inputs (if any) + for view in config["views"]: + env_vars = view["extra"]["env_vars"] + lines = [ + f"unset {name}" if value is None else f'export {name}="{value}"' + for d in env_vars + for (name, value) in d.items() + ] + print(lines) return files diff --git a/stackinator/schema/environments.json b/stackinator/schema/environments.json index 8a9fd6a..0e70a9d 100644 --- a/stackinator/schema/environments.json +++ b/stackinator/schema/environments.json @@ -114,6 +114,22 @@ "items": {"type": "string"} } } + }, + "env_vars": { + "type": "array", + "items": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "oneOf": [ + {"type": "null"}, + {"type": "string"} + ] + } + }, + "additionalProperties": false + }, + "default": [] } } } From 24f9c285fcdb908d8c1f831559dc3f1441e4d2d8 Mon Sep 17 00:00:00 2001 From: bcumming Date: Fri, 22 Aug 2025 12:54:12 +0200 Subject: [PATCH 03/13] use the same env var formatting used by envvars.py backend --- stackinator/builder.py | 3 ++ stackinator/etc/envvars.py | 1 + stackinator/recipe.py | 52 +++++++++++++++++++++------- stackinator/schema/environments.json | 45 +++++++++++++++++------- 4 files changed, 76 insertions(+), 25 deletions(-) diff --git a/stackinator/builder.py b/stackinator/builder.py index f7fffb3..53de2ba 100644 --- a/stackinator/builder.py +++ b/stackinator/builder.py @@ -139,6 +139,9 @@ def environment_meta(self, recipe): "root": /user-environment/env/default, "activate": /user-environment/env/default/activate.sh, "description": "simple devolpment env: compilers, MPI, python, cmake." + "env_vars": { + ... + } }, "tools": { "root": /user-environment/env/tools, diff --git a/stackinator/etc/envvars.py b/stackinator/etc/envvars.py index 8447c69..a43d479 100755 --- a/stackinator/etc/envvars.py +++ b/stackinator/etc/envvars.py @@ -443,6 +443,7 @@ def view_impl(args): # force all prefix path style variables (list vars) to use PREPEND the first operation. envvars.make_dirty() + # remove all prefix path variable values that point to a location inside the build path. envvars.remove_root(args.build_path) if args.compilers is not None: diff --git a/stackinator/recipe.py b/stackinator/recipe.py index f90be3c..bf0238e 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -6,6 +6,7 @@ import yaml from . import cache, root_logger, schema, spack_util +from .etc import envvars class Recipe: @@ -270,10 +271,43 @@ def environment_view_meta(self): view_meta = {} for _, env in self.environments.items(): for view in env["views"]: + # TODO: generate the env_vars field, that is userd by envvars.py to generate results + print(f"view {view['name']}: {view['extra']['env_vars']}") + ev_inputs = view["extra"]["env_vars"] + env = envvars.EnvVarSet() + + ## assert that unset and set do not overlap + for name in ev_inputs["unset"]: + if envvars.is_list_var(name): + env.set_list(name, [], envvars.EnvVarOp.SET) + else: + env.set_scalar(name, None) + for v in ev_inputs["set"]: + ((name, value),) = v.items() + if envvars.is_list_var(name): + raise RuntimeError( + f"{name} in the {view['name']} view a prefix path variable. Use unset, prepend_path and append_path to set it." + ) + else: + env.set_scalar(name, value) + for v in ev_inputs["prepend_path"]: + ((name, value),) = v.items() + if not envvars.is_list_var(name): + raise RuntimeError(f"{name} in the {view['name']} view is not a known prefix path variable") + + env.set_list(name, [value], envvars.EnvVarOp.APPEND) + for v in ev_inputs["append_path"]: + ((name, value),) = v.items() + if not envvars.is_list_var(name): + raise RuntimeError(f"{name} in the {view['name']} view is not a known prefix path variable") + + env.set_list(name, [value], envvars.EnvVarOp.PREPEND) + view_meta[view["name"]] = { "root": view["config"]["root"], "activate": view["config"]["root"] + "/activate.sh", "description": "", # leave the description empty for now + "recipe_variables": env.as_dict() } return view_meta @@ -378,15 +412,18 @@ def generate_environment_specs(self, raw): # ["link"] = "roots" # ["uenv"]["add_compilers"] = True # ["uenv"]["prefix_paths"] = {} - # ["uenv"]["env_vars"] = [] + # ["uenv"]["env_vars"] = {"set": [], "unset": [], "prepend_path": [], "append_path": []} if view_config is None: view_config = {} view_config.setdefault("link", "roots") view_config.setdefault("uenv", {}) view_config["uenv"].setdefault("add_compilers", True) view_config["uenv"].setdefault("prefix_paths", {}) - view_config["uenv"].setdefault("env_vars", []) - print(view_config["uenv"]["env_vars"]) + view_config["uenv"].setdefault("env_vars", {}) + view_config["uenv"]["env_vars"].setdefault("set", []) + view_config["uenv"]["env_vars"].setdefault("unset", []) + view_config["uenv"]["env_vars"].setdefault("prepend_path", []) + view_config["uenv"]["env_vars"].setdefault("append_path", []) prefix_string = ",".join( [f"{pname}={':'.join(paths)}" for pname, paths in view_config["uenv"]["prefix_paths"].items()] ) @@ -512,14 +549,5 @@ def environment_files(self): spack_yaml_template = jenv.get_template("environments.spack.yaml") # generate the spack.yaml file files["config"][env] = spack_yaml_template.render(config=config, name=env, store=self.mount) - # generate the view inputs (if any) - for view in config["views"]: - env_vars = view["extra"]["env_vars"] - lines = [ - f"unset {name}" if value is None else f'export {name}="{value}"' - for d in env_vars - for (name, value) in d.items() - ] - print(lines) return files diff --git a/stackinator/schema/environments.json b/stackinator/schema/environments.json index 0e70a9d..dbf823a 100644 --- a/stackinator/schema/environments.json +++ b/stackinator/schema/environments.json @@ -3,6 +3,19 @@ "title": "Schema for Spack Stack environments.yaml recipe file", "type": "object", "additionalProperties": false, + "defs": { + "gcc_version_spec": { + "type": "string", + "pattern": "^gcc@\\d{1,2}(\\.\\d{1}(\\.\\d{1})?)?$" + }, + "env_var_def": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { "type": "string" } + }, + "additionalProperties": false + } + }, "patternProperties": { "\\w[\\w-]*": { "type": "object", @@ -116,20 +129,26 @@ } }, "env_vars": { - "type": "array", - "items": { - "type": "object", - "patternProperties": { - "^[A-Za-z_][A-Za-z0-9_]*$": { - "oneOf": [ - {"type": "null"}, - {"type": "string"} - ] - } + "type": "object", + "additionalProperties": false, + "properties": { + "set": { + "type": "array", + "items": {"$ref": "#/defs/env_var_def"} + }, + "unset": { + "type": "array", + "items": {"type": "string"} }, - "additionalProperties": false - }, - "default": [] + "prepend_path": { + "type": "array", + "items": {"$ref": "#/defs/env_var_def"} + }, + "append_path": { + "type": "array", + "items": {"$ref": "#/defs/env_var_def"} + } + } } } } From 314555b025d2ce295037fb26c01ac1db53540f77 Mon Sep 17 00:00:00 2001 From: bcumming Date: Fri, 22 Aug 2025 17:44:26 +0200 Subject: [PATCH 04/13] fix bugs in envvars.sh; remove printf --- stackinator/etc/envvars.py | 17 +++++++++++++++-- stackinator/recipe.py | 12 +++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/stackinator/etc/envvars.py b/stackinator/etc/envvars.py index a43d479..c124f0f 100755 --- a/stackinator/etc/envvars.py +++ b/stackinator/etc/envvars.py @@ -528,11 +528,24 @@ def meta_impl(args): print(f"error - meta data file '{json_path}' does not exist.") exit(1) + # load the environment variable state that based on the activate.sh + # script generated by spack with open(json_path, "r") as fid: - envvar_dict = json.load(fid) + spack_vars = json.load(fid) + + # load the environment variable state changes specified in the + # view:uenv:env_vars field in the recipe environments.yaml file + recipe_vars = data["recipe_variables"] + + # update the view environment variables by appending variables from the recipe + for name, value in recipe_vars["scalar"].items(): + spack_vars["scalars"][name] = value + for name, updates in recipe_vars["list"].items(): + spack_vars["list"].setdefault(name, []) + spack_vars["list"]["name"] += updates # update the global meta data to include the environment variable state - meta["views"][name]["env"] = envvar_dict + meta["views"][name]["env"] = spack_vars meta["views"][name]["type"] = "spack-view" # process spack and modules diff --git a/stackinator/recipe.py b/stackinator/recipe.py index bf0238e..c86174a 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -271,12 +271,16 @@ def environment_view_meta(self): view_meta = {} for _, env in self.environments.items(): for view in env["views"]: - # TODO: generate the env_vars field, that is userd by envvars.py to generate results - print(f"view {view['name']}: {view['extra']['env_vars']}") ev_inputs = view["extra"]["env_vars"] env = envvars.EnvVarSet() - ## assert that unset and set do not overlap + # TODO: one day this code will be revisited because we need to append_path + # or prepend_path to a variable that isn't in envvars.is_list_var + # On that day, extend the environments.yaml views:uenv:env_vars field + # to also accept a list of env var names to add to the blessed list of prefix paths + + # process the unset options before set options - we have to assume that if both are set + # the intention was to ultimately set the variable for name in ev_inputs["unset"]: if envvars.is_list_var(name): env.set_list(name, [], envvars.EnvVarOp.SET) @@ -399,7 +403,6 @@ def generate_environment_specs(self, raw): # the name default to generated the activation script. env_names = set() env_name_map = {} - print() for name, config in environments.items(): env_name_map[name] = [] views = [] @@ -436,7 +439,6 @@ def generate_environment_specs(self, raw): config["views"] = views - print() self.environments = environments # creates the self.compilers field that describes the full specifications From 471857dac98d4cc730eedb6b8e06320a5c2a815b Mon Sep 17 00:00:00 2001 From: bcumming Date: Fri, 22 Aug 2025 17:52:46 +0200 Subject: [PATCH 05/13] feed linter --- stackinator/recipe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index c86174a..1f7d5ac 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -290,7 +290,7 @@ def environment_view_meta(self): ((name, value),) = v.items() if envvars.is_list_var(name): raise RuntimeError( - f"{name} in the {view['name']} view a prefix path variable. Use unset, prepend_path and append_path to set it." + f"{name} in the {view['name']} view a prefix path variable." ) else: env.set_scalar(name, value) @@ -311,7 +311,7 @@ def environment_view_meta(self): "root": view["config"]["root"], "activate": view["config"]["root"] + "/activate.sh", "description": "", # leave the description empty for now - "recipe_variables": env.as_dict() + "recipe_variables": env.as_dict(), } return view_meta From c56ed3deb7c5e01c0c1e9c76b6def27e94ed85f1 Mon Sep 17 00:00:00 2001 From: bcumming Date: Fri, 22 Aug 2025 17:58:18 +0200 Subject: [PATCH 06/13] fix envvars.py errors --- stackinator/etc/envvars.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stackinator/etc/envvars.py b/stackinator/etc/envvars.py index c124f0f..ea15038 100755 --- a/stackinator/etc/envvars.py +++ b/stackinator/etc/envvars.py @@ -538,11 +538,11 @@ def meta_impl(args): recipe_vars = data["recipe_variables"] # update the view environment variables by appending variables from the recipe - for name, value in recipe_vars["scalar"].items(): - spack_vars["scalars"][name] = value - for name, updates in recipe_vars["list"].items(): - spack_vars["list"].setdefault(name, []) - spack_vars["list"]["name"] += updates + for var_name, value in recipe_vars["scalar"].items(): + spack_vars["values"]["scalar"][var_name] = value + for var_name, updates in recipe_vars["list"].items(): + spack_vars["values"]["list"].setdefault(name, []) + spack_vars["values"]["list"][var_name] += updates # update the global meta data to include the environment variable state meta["views"][name]["env"] = spack_vars From 6eade82f8815bc41b6096d01306dff02b97f1540 Mon Sep 17 00:00:00 2001 From: bcumming Date: Mon, 25 Aug 2025 15:44:46 +0200 Subject: [PATCH 07/13] come on linter, split the string yourself --- stackinator/recipe.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index 10cac00..fbeb6b8 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -289,9 +289,7 @@ def environment_view_meta(self): for v in ev_inputs["set"]: ((name, value),) = v.items() if envvars.is_list_var(name): - raise RuntimeError( - f"{name} in the {view['name']} view a prefix path variable." - ) + raise RuntimeError(f"{name} in the {view['name']} view a prefix path variable.") else: env.set_scalar(name, value) for v in ev_inputs["prepend_path"]: From 7b536b24d1e418cd4b9b2e846131d18c939094b9 Mon Sep 17 00:00:00 2001 From: Ben Cumming Date: Mon, 25 Aug 2025 15:46:42 +0200 Subject: [PATCH 08/13] Update stackinator/schema/environments.json Co-authored-by: Alberto Invernizzi <9337627+albestro@users.noreply.github.com> --- stackinator/schema/environments.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/stackinator/schema/environments.json b/stackinator/schema/environments.json index dbf823a..331b1ee 100644 --- a/stackinator/schema/environments.json +++ b/stackinator/schema/environments.json @@ -138,7 +138,10 @@ }, "unset": { "type": "array", - "items": {"type": "string"} + "items": { + "type": "string", + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" + } }, "prepend_path": { "type": "array", From 3917eb9ba9757a062ad64fd59148eba7ff961469 Mon Sep 17 00:00:00 2001 From: bcumming Date: Mon, 25 Aug 2025 16:17:14 +0200 Subject: [PATCH 09/13] restrict env var definitions to one variable and one variable only --- stackinator/schema/environments.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stackinator/schema/environments.json b/stackinator/schema/environments.json index 331b1ee..a51a2b7 100644 --- a/stackinator/schema/environments.json +++ b/stackinator/schema/environments.json @@ -13,6 +13,8 @@ "patternProperties": { "^[A-Za-z_][A-Za-z0-9_]*$": { "type": "string" } }, + "minProperties": 1, + "maxProperties": 1, "additionalProperties": false } }, From 91c4a7057e5626f26c495b13054094d232ab0b0f Mon Sep 17 00:00:00 2001 From: bcumming Date: Mon, 25 Aug 2025 17:31:37 +0200 Subject: [PATCH 10/13] simplify environment variable setting rules --- stackinator/etc/envvars.py | 1 - stackinator/recipe.py | 18 ++++++++---------- stackinator/schema/environments.json | 23 +++++++++++++++-------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/stackinator/etc/envvars.py b/stackinator/etc/envvars.py index ea15038..396ddaa 100755 --- a/stackinator/etc/envvars.py +++ b/stackinator/etc/envvars.py @@ -89,7 +89,6 @@ def __init__(self, name: str): def name(self): return self._name - class ListEnvVar(EnvVar): def __init__(self, name: str, value: List[str], op: EnvVarOp): super().__init__(name) diff --git a/stackinator/recipe.py b/stackinator/recipe.py index fbeb6b8..4b974a3 100644 --- a/stackinator/recipe.py +++ b/stackinator/recipe.py @@ -279,19 +279,17 @@ def environment_view_meta(self): # On that day, extend the environments.yaml views:uenv:env_vars field # to also accept a list of env var names to add to the blessed list of prefix paths - # process the unset options before set options - we have to assume that if both are set - # the intention was to ultimately set the variable - for name in ev_inputs["unset"]: - if envvars.is_list_var(name): - env.set_list(name, [], envvars.EnvVarOp.SET) - else: - env.set_scalar(name, None) for v in ev_inputs["set"]: ((name, value),) = v.items() - if envvars.is_list_var(name): - raise RuntimeError(f"{name} in the {view['name']} view a prefix path variable.") + # insist that the only 'set' operation on prefix variables is to unset/reset them + # this requires that users use append and prepend to build up the variables + if envvars.is_list_var(name) and value is not None: + raise RuntimeError(f"{name} in the {view['name']} view is a prefix variable.") else: - env.set_scalar(name, value) + if envvars.is_list_var(name): + env.set_list(name, [], envvars.EnvVarOp.SET) + else: + env.set_scalar(name, value) for v in ev_inputs["prepend_path"]: ((name, value),) = v.items() if not envvars.is_list_var(name): diff --git a/stackinator/schema/environments.json b/stackinator/schema/environments.json index a51a2b7..74f5e76 100644 --- a/stackinator/schema/environments.json +++ b/stackinator/schema/environments.json @@ -16,6 +16,20 @@ "minProperties": 1, "maxProperties": 1, "additionalProperties": false + }, + "env_var_def_nullable": { + "type": "object", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "oneOf": [ + {"type": "string"}, + {"type": "null"} + ] + } + }, + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": false } }, "patternProperties": { @@ -136,14 +150,7 @@ "properties": { "set": { "type": "array", - "items": {"$ref": "#/defs/env_var_def"} - }, - "unset": { - "type": "array", - "items": { - "type": "string", - "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" - } + "items": {"$ref": "#/defs/env_var_def_nullable"} }, "prepend_path": { "type": "array", From 6fa602caead970d6e82ad4486ba4176369cd4825 Mon Sep 17 00:00:00 2001 From: bcumming Date: Mon, 25 Aug 2025 17:32:37 +0200 Subject: [PATCH 11/13] feed the linter --- stackinator/etc/envvars.py | 1 + 1 file changed, 1 insertion(+) diff --git a/stackinator/etc/envvars.py b/stackinator/etc/envvars.py index 396ddaa..ea15038 100755 --- a/stackinator/etc/envvars.py +++ b/stackinator/etc/envvars.py @@ -89,6 +89,7 @@ def __init__(self, name: str): def name(self): return self._name + class ListEnvVar(EnvVar): def __init__(self, name: str, value: List[str], op: EnvVarOp): super().__init__(name) From 2d9d97d01222e014187c2aed9e7914e506dd130b Mon Sep 17 00:00:00 2001 From: bcumming Date: Mon, 25 Aug 2025 18:20:16 +0200 Subject: [PATCH 12/13] add env var docs --- docs/recipes.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/recipes.md b/docs/recipes.md index 5d6e69d..01c8868 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -359,14 +359,73 @@ cuda-env: add_compilers: true prefix_paths: LD_LIBRARY_PATH: [lib, lib64] + env_vars: + set: + - WOMBAT: null + - NOCOLOR: "1" + - JULIAUP_INSTALLDIR: "${@SCRATCH@}/.julia/gh200/juliaup" + - PKG_CONFIG_PATH: null + prepend_path: + - PATH: "${@HOME@}/.local/x86_4/bin/" + append_path: + - PKG_CONFIG_PATH: /usr/lib/pkgconfig + - PKG_CONFIG_PATH: /opt/cray/libfabric/1.15.2.0/lib64/pkgconfig ``` * `add_compilers` (default `true`): by default Spack will not add compilers to the `PATH` variable. Stackinator automatically adds the `gcc` and/or `nvhpc` to path. This option can be used to explicitly disable or enable this feature. * `prefix_paths` (default empty): this option can be used to customise prefix style environment variables (`PATH`, `LD_LIBRARY_PATH`, `PKG_CONFIG_PATH`, `PYTHONPATH`, etc). * the key is the environment variable, and the value is a list of paths to search for in the environment view. All paths that match an entry in the list will be prepended to the prefix path environment variable. * the main use for this feature is to opt-in to setting `LD_LIBRARY_PATH`. By default Spack does not add `lib` and `lib64` to `LD_LIBRARY_PATH` because that can break system installed applications that depend on `LD_LIBRARY_PATH` or finding their dependencies in standard locations like `/usr/lib`. +* `env_vars`: see below for a more detailed explanation of how to fine tune environment variables in the view. -See the [interfaces documentation](interfaces.md#environment-views) for more information about how the environment views are provided to users of a stack. +!!! info + See the [interfaces documentation](interfaces.md#environment-views) for more information about how the environment views are provided. + +#### Setting environment variables with `env_vars` + +The `views::uenv:env_vars` field can be used to further fine-tune the environment variables that are set when the view is started. +There are three environment variable "operation" that can be specified - `set`, `prepend_path`, and `append_path` - as demonstrated in the example above. + +The `set` field is a list of environment variables key-value pairs that specify the variable name and the value to set it to. + +* Setting a field to `null` will unset the variable if it was set by the parent environment. +* `set` is a list, and values will be applied in the order that they are provided. + It is possible to provide two values for a variable, and the last value will be the one used. +* It is not possible to set an initial value that is not `null` for a prefix path variable. + Set such variables to `null` (unset it), then provide `append_path` and `prefix_path` operations below to set the individual paths. + +!!! note "using `${@VAR@}` to use environment variables" + Sometimes you want to compase an environment variable **that has been set in the runtime environment** in your environment variable definition. + For example, every user has a different `HOME` or `SCRATCH` value, and you might want to configure your view to store / read configuration from this path. + The special syntax `${@VAR@}` will defer expanding the environment variable `VAR` until the view is loaded by uenv. + The example above shows how to set the Juliaup install directory to be in the user's local scratch, i.e. a personalised private location for each user. + +The `prepend_path` field takes a list of key-value pairs that define paths to prepend to a prefix path variable. + +* Each entry is a single path +* To prepend more than one path to a variable, pass multiple values (see `PKG_CONFIG_PATH` in `append_path` above) +* The order in the list matters: paths will be prepended in the order that they appear in the list. + +The `prepend_path` field is the same as `prepend_path`, except it appends instead of prepending. + +!!! question "What are prefix path variables?" + Prefix path variables are variables are environment variables that are `:`-separated list of paths, like `PATH`, `LD_LIBRARY_PATH`, `PYTHONPATH` etc that provide a list of paths that typically searched in order. + + Currently the set of supported prefix path variables is hard coded in stackinator. + If you need to set a prefix path that isn't on this list, contact the stackinator devs and we can implement support for user-defined prefix paths. + + ??? info "the hard-coded prefix paths" + + * `ACLOCAL_PATH` + * `CMAKE_PREFIX_PATH` + * `CPATH` + * `LD_LIBRARY_PATH` + * `LIBRARY_PATH` + * `MANPATH` + * `MODULEPATH` + * `PATH` + * `PKG_CONFIG_PATH` + * `PYTHONPATH` ## Modules From a5c90b10798c7071b58fa6fe8b1ed4ed71ea991c Mon Sep 17 00:00:00 2001 From: Ben Cumming Date: Tue, 26 Aug 2025 08:47:32 +0200 Subject: [PATCH 13/13] Apply suggestions from code review Co-authored-by: Alberto Invernizzi <9337627+albestro@users.noreply.github.com> --- docs/recipes.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index 01c8868..7561a32 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -395,7 +395,7 @@ The `set` field is a list of environment variables key-value pairs that specify Set such variables to `null` (unset it), then provide `append_path` and `prefix_path` operations below to set the individual paths. !!! note "using `${@VAR@}` to use environment variables" - Sometimes you want to compase an environment variable **that has been set in the runtime environment** in your environment variable definition. + Sometimes you want to compose an environment variable **that has been set in the runtime environment** in your environment variable definition. For example, every user has a different `HOME` or `SCRATCH` value, and you might want to configure your view to store / read configuration from this path. The special syntax `${@VAR@}` will defer expanding the environment variable `VAR` until the view is loaded by uenv. The example above shows how to set the Juliaup install directory to be in the user's local scratch, i.e. a personalised private location for each user. @@ -406,10 +406,10 @@ The `prepend_path` field takes a list of key-value pairs that define paths to pr * To prepend more than one path to a variable, pass multiple values (see `PKG_CONFIG_PATH` in `append_path` above) * The order in the list matters: paths will be prepended in the order that they appear in the list. -The `prepend_path` field is the same as `prepend_path`, except it appends instead of prepending. +The `append_path` field is the same as `prepend_path`, except it appends instead of prepending. !!! question "What are prefix path variables?" - Prefix path variables are variables are environment variables that are `:`-separated list of paths, like `PATH`, `LD_LIBRARY_PATH`, `PYTHONPATH` etc that provide a list of paths that typically searched in order. + Prefix path variables are environment variables that are `:`-separated list of paths, like `PATH`, `LD_LIBRARY_PATH`, `PYTHONPATH` etc that provide a list of paths that are typically searched in order. Currently the set of supported prefix path variables is hard coded in stackinator. If you need to set a prefix path that isn't on this list, contact the stackinator devs and we can implement support for user-defined prefix paths.