Skip to content

Commit ca2bf46

Browse files
committed
Add support for group-writable roots to managed directories
This is needed for directory used by NCO to point to the latest E3SM-Unified pixi environment.
1 parent 4a1ce55 commit ca2bf46

6 files changed

Lines changed: 380 additions & 14 deletions

File tree

mache/deploy/run.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1058,6 +1058,10 @@ def _apply_deploy_permissions(
10581058
managed_paths.extend(
10591059
_normalize_permission_path(path) for path in spack_paths
10601060
)
1061+
managed_paths.extend(
1062+
_normalize_permission_path(path)
1063+
for path in shared_artifacts.managed_recursive_dirs
1064+
)
10611065
managed_paths.extend(
10621066
_normalize_permission_path(path)
10631067
for path in shared_artifacts.managed_files
@@ -1072,17 +1076,35 @@ def _apply_deploy_permissions(
10721076

10731077
managed_paths = list(dict.fromkeys(managed_paths))
10741078

1075-
if not managed_paths:
1076-
return
1079+
if managed_paths:
1080+
update_permissions(
1081+
managed_paths,
1082+
group,
1083+
show_progress=True,
1084+
group_writable=False,
1085+
other_readable=world_readable,
1086+
recursive=True,
1087+
)
10771088

1078-
update_permissions(
1079-
managed_paths,
1080-
group,
1081-
show_progress=True,
1082-
group_writable=False,
1083-
other_readable=world_readable,
1084-
recursive=True,
1085-
)
1089+
root_group_writable_dirs = [
1090+
path
1091+
for path in (
1092+
_normalize_permission_path(path)
1093+
for path in shared_artifacts.root_group_writable_dirs
1094+
)
1095+
if path is not None
1096+
]
1097+
root_group_writable_dirs = list(dict.fromkeys(root_group_writable_dirs))
1098+
1099+
if root_group_writable_dirs:
1100+
update_permissions(
1101+
root_group_writable_dirs,
1102+
group,
1103+
show_progress=True,
1104+
group_writable=True,
1105+
other_readable=world_readable,
1106+
recursive=False,
1107+
)
10861108

10871109

10881110
def _normalize_permission_path(path: str | None) -> str | None:

mache/deploy/shared.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ class SharedDeployArtifacts:
1212
base_path: str | None = None
1313
managed_dirs: list[str] = field(default_factory=list)
1414
managed_files: list[str] = field(default_factory=list)
15+
managed_recursive_dirs: list[str] = field(default_factory=list)
16+
root_group_writable_dirs: list[str] = field(default_factory=list)
1517

1618

1719
def create_shared_deploy_artifacts(
@@ -29,11 +31,15 @@ def create_shared_deploy_artifacts(
2931
repo_root=repo_root,
3032
field_name='shared.base_path',
3133
)
32-
managed_dirs = _normalize_path_entries(
34+
(
35+
managed_recursive_dirs,
36+
root_group_writable_dirs,
37+
) = _normalize_managed_directory_entries(
3338
shared_cfg.get('managed_directories'),
3439
repo_root=repo_root,
3540
field_name='shared.managed_directories',
3641
)
42+
managed_dirs: list[str] = []
3743
managed_files = _normalize_path_entries(
3844
shared_cfg.get('managed_files'),
3945
repo_root=repo_root,
@@ -81,10 +87,16 @@ def create_shared_deploy_artifacts(
8187
dest_link.symlink_to(target_path)
8288
managed_dirs.append(str(dest_link.parent))
8389

90+
managed_dirs = _dedupe_existing_paths(managed_dirs)
91+
8492
return SharedDeployArtifacts(
8593
base_path=base_path,
86-
managed_dirs=_dedupe_existing_paths(managed_dirs),
94+
managed_dirs=managed_dirs,
8795
managed_files=_dedupe_existing_paths(managed_files),
96+
managed_recursive_dirs=_dedupe_existing_paths(managed_recursive_dirs),
97+
root_group_writable_dirs=_dedupe_existing_paths(
98+
root_group_writable_dirs
99+
),
88100
)
89101

90102

@@ -133,6 +145,54 @@ def _normalize_path_entries(
133145
return entries
134146

135147

148+
def _normalize_managed_directory_entries(
149+
value: Any,
150+
*,
151+
repo_root: str,
152+
field_name: str,
153+
) -> tuple[list[str], list[str]]:
154+
if value is None:
155+
return [], []
156+
if not isinstance(value, list):
157+
raise ValueError(f'{field_name} must be a list if provided')
158+
159+
managed_entries: list[str] = []
160+
root_group_writable_entries: list[str] = []
161+
for index, item in enumerate(value):
162+
item_field_name = f'{field_name}[{index}]'
163+
path_value: Any
164+
root_group_writable: bool
165+
if isinstance(item, str):
166+
path_value = item
167+
root_group_writable = False
168+
elif isinstance(item, dict):
169+
path_value = item.get('path')
170+
raw_root_group_writable = _coerce_optional_bool(
171+
item.get('root_group_writable'),
172+
field_name=f'{item_field_name}.root_group_writable',
173+
)
174+
root_group_writable = (
175+
False
176+
if raw_root_group_writable is None
177+
else raw_root_group_writable
178+
)
179+
else:
180+
raise ValueError(
181+
f'{item_field_name} must be a string or mapping with a path'
182+
)
183+
184+
path = _resolve_path(
185+
value=path_value,
186+
repo_root=repo_root,
187+
field_name=f'{item_field_name}.path',
188+
)
189+
managed_entries.append(path)
190+
if root_group_writable:
191+
root_group_writable_entries.append(path)
192+
193+
return managed_entries, root_group_writable_entries
194+
195+
136196
def _normalize_load_script_copy_entries(
137197
value: Any,
138198
*,
@@ -215,6 +275,26 @@ def _normalize_load_script_symlink_entries(
215275
return list(deduped.values())
216276

217277

278+
def _coerce_optional_bool(
279+
value: Any,
280+
*,
281+
field_name: str,
282+
) -> bool | None:
283+
if value is None:
284+
return None
285+
if isinstance(value, bool):
286+
return value
287+
if isinstance(value, str):
288+
candidate = value.strip().lower()
289+
if candidate in ('true', 'yes', 'on', '1'):
290+
return True
291+
if candidate in ('false', 'no', 'off', '0'):
292+
return False
293+
if candidate in ('', 'none', 'null', 'dynamic'):
294+
return None
295+
raise ValueError(f'{field_name} must be a boolean if provided')
296+
297+
218298
def _resolve_path(
219299
*,
220300
value: Any,

mache/deploy/templates/config.yaml.j2.j2

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,13 @@ shared:
194194
# post-deploy permission update step. This is helpful when a downstream
195195
# pre_publish hook creates additional shared artifacts that mache itself
196196
# does not create.
197+
# `managed_directories` entries may be either:
198+
# - "/absolute/or/relative/path/to/directory"
199+
# - {path: "/path/to/directory", root_group_writable: true}
200+
#
201+
# All managed directories are updated recursively without group-write
202+
# permission. Use `root_group_writable: true` to apply group write only to
203+
# that directory path itself after other permission updates.
197204
managed_directories: []
198205
managed_files: []
199206

mache/deploy/templates/hooks.py.j2

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ def pre_pixi(ctx: DeployContext) -> dict[str, Any] | None:
9494
# - runtime["shared"]["base_path"]: shared base directory to update
9595
# permissions on recursively before path-specific updates
9696
# - runtime["shared"]["managed_directories"]: extra shared directories to
97-
# include in the permission update step
97+
# include in the permission update step. Entries may be strings or
98+
# mappings like:
99+
# {"path": "/shared/latest", "root_group_writable": True}
98100
# - runtime["shared"]["managed_files"]: extra shared files to include in
99101
# the permission update step
100102
#
@@ -114,6 +116,12 @@ def pre_pixi(ctx: DeployContext) -> dict[str, Any] | None:
114116
# updates.setdefault("shared", {})["load_script_copies"] = [
115117
# "/shared/load_my_software.sh",
116118
# ]
119+
# updates.setdefault("shared", {})["managed_directories"] = [
120+
# {
121+
# "path": "/shared/latest",
122+
# "root_group_writable": True,
123+
# },
124+
# ]
117125

118126
return updates
119127

tests/test_deploy_run.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,6 +1195,124 @@ def _fake_update_permissions(*args, **kwargs):
11951195
assert third_kwargs['recursive'] is True
11961196

11971197

1198+
def test_apply_deploy_permissions_applies_recursive_dir_and_writable_root(
1199+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1200+
):
1201+
prefix = tmp_path / 'prefix'
1202+
prefix.mkdir()
1203+
1204+
managed_dir = tmp_path / 'shared' / 'latest'
1205+
managed_dir.mkdir(parents=True)
1206+
1207+
calls = []
1208+
1209+
def _fake_update_permissions(*args, **kwargs):
1210+
calls.append((args, kwargs))
1211+
1212+
monkeypatch.setattr(
1213+
deploy_run, 'update_permissions', _fake_update_permissions
1214+
)
1215+
1216+
logger = deploy_run.logging.getLogger(
1217+
'test-apply-deploy-permissions-recursive-writable-root'
1218+
)
1219+
logger.handlers = [deploy_run.logging.NullHandler()]
1220+
logger.propagate = False
1221+
1222+
deploy_run._apply_deploy_permissions(
1223+
prefix=str(prefix),
1224+
extra_prefixes=None,
1225+
load_script_paths=[],
1226+
spack_paths=[],
1227+
shared_artifacts=SharedDeployArtifacts(
1228+
managed_recursive_dirs=[str(managed_dir)],
1229+
root_group_writable_dirs=[str(managed_dir)],
1230+
),
1231+
group='e3sm',
1232+
world_readable=True,
1233+
logger=logger,
1234+
)
1235+
1236+
assert len(calls) == 3
1237+
1238+
first_args, first_kwargs = calls[0]
1239+
assert first_args == (str(prefix), 'e3sm')
1240+
assert first_kwargs['group_writable'] is False
1241+
assert first_kwargs['recursive'] is False
1242+
1243+
second_args, second_kwargs = calls[1]
1244+
assert second_args == ([str(managed_dir)], 'e3sm')
1245+
assert second_kwargs['group_writable'] is False
1246+
assert second_kwargs['recursive'] is True
1247+
1248+
third_args, third_kwargs = calls[2]
1249+
assert third_args == ([str(managed_dir)], 'e3sm')
1250+
assert third_kwargs['group_writable'] is True
1251+
assert third_kwargs['other_readable'] is True
1252+
assert third_kwargs['recursive'] is False
1253+
1254+
1255+
def test_apply_deploy_permissions_applies_writable_root_under_shared_base(
1256+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1257+
):
1258+
prefix = tmp_path / 'prefix'
1259+
prefix.mkdir()
1260+
1261+
shared_base = tmp_path / 'shared'
1262+
shared_base.mkdir()
1263+
writable_dir = shared_base / 'latest'
1264+
writable_dir.mkdir()
1265+
1266+
calls = []
1267+
1268+
def _fake_update_permissions(*args, **kwargs):
1269+
calls.append((args, kwargs))
1270+
1271+
monkeypatch.setattr(
1272+
deploy_run, 'update_permissions', _fake_update_permissions
1273+
)
1274+
1275+
logger = deploy_run.logging.getLogger(
1276+
'test-apply-deploy-permissions-group-writable'
1277+
)
1278+
logger.handlers = [deploy_run.logging.NullHandler()]
1279+
logger.propagate = False
1280+
1281+
deploy_run._apply_deploy_permissions(
1282+
prefix=str(prefix),
1283+
extra_prefixes=None,
1284+
load_script_paths=[],
1285+
spack_paths=[],
1286+
shared_artifacts=SharedDeployArtifacts(
1287+
base_path=str(shared_base),
1288+
managed_recursive_dirs=[str(writable_dir)],
1289+
root_group_writable_dirs=[str(writable_dir)],
1290+
managed_files=[],
1291+
),
1292+
group='e3sm',
1293+
world_readable=True,
1294+
logger=logger,
1295+
)
1296+
1297+
assert len(calls) == 3
1298+
1299+
first_args, first_kwargs = calls[0]
1300+
assert first_args == (str(shared_base), 'e3sm')
1301+
assert first_kwargs['group_writable'] is False
1302+
assert first_kwargs['recursive'] is True
1303+
1304+
second_args, second_kwargs = calls[1]
1305+
assert second_args == (str(prefix), 'e3sm')
1306+
assert second_kwargs['group_writable'] is False
1307+
assert second_kwargs['recursive'] is False
1308+
1309+
third_args, third_kwargs = calls[2]
1310+
assert third_args == ([str(writable_dir)], 'e3sm')
1311+
assert third_kwargs['group_writable'] is True
1312+
assert third_kwargs['other_readable'] is True
1313+
assert third_kwargs['recursive'] is False
1314+
1315+
11981316
def test_apply_deploy_permissions_updates_shared_base_first(
11991317
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
12001318
):

0 commit comments

Comments
 (0)