Skip to content

Commit 646d464

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

6 files changed

Lines changed: 303 additions & 13 deletions

File tree

mache/deploy/run.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1072,17 +1072,35 @@ def _apply_deploy_permissions(
10721072

10731073
managed_paths = list(dict.fromkeys(managed_paths))
10741074

1075-
if not managed_paths:
1076-
return
1075+
if managed_paths:
1076+
update_permissions(
1077+
managed_paths,
1078+
group,
1079+
show_progress=True,
1080+
group_writable=False,
1081+
other_readable=world_readable,
1082+
recursive=True,
1083+
)
10771084

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-
)
1085+
group_writable_dirs = [
1086+
path
1087+
for path in (
1088+
_normalize_permission_path(path)
1089+
for path in shared_artifacts.group_writable_dirs
1090+
)
1091+
if path is not None
1092+
]
1093+
group_writable_dirs = list(dict.fromkeys(group_writable_dirs))
1094+
1095+
if group_writable_dirs:
1096+
update_permissions(
1097+
group_writable_dirs,
1098+
group,
1099+
show_progress=True,
1100+
group_writable=True,
1101+
other_readable=world_readable,
1102+
recursive=False,
1103+
)
10861104

10871105

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

mache/deploy/shared.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ 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+
group_writable_dirs: list[str] = field(default_factory=list)
1516

1617

1718
def create_shared_deploy_artifacts(
@@ -29,7 +30,7 @@ def create_shared_deploy_artifacts(
2930
repo_root=repo_root,
3031
field_name='shared.base_path',
3132
)
32-
managed_dirs = _normalize_path_entries(
33+
managed_dirs, group_writable_dirs = _normalize_managed_directory_entries(
3334
shared_cfg.get('managed_directories'),
3435
repo_root=repo_root,
3536
field_name='shared.managed_directories',
@@ -81,9 +82,16 @@ def create_shared_deploy_artifacts(
8182
dest_link.symlink_to(target_path)
8283
managed_dirs.append(str(dest_link.parent))
8384

85+
managed_dirs = _dedupe_existing_paths(managed_dirs)
86+
group_writable_dir_set = set(_dedupe_existing_paths(group_writable_dirs))
87+
group_writable_dirs = [
88+
path for path in managed_dirs if path in group_writable_dir_set
89+
]
90+
8491
return SharedDeployArtifacts(
8592
base_path=base_path,
86-
managed_dirs=_dedupe_existing_paths(managed_dirs),
93+
managed_dirs=managed_dirs,
94+
group_writable_dirs=group_writable_dirs,
8795
managed_files=_dedupe_existing_paths(managed_files),
8896
)
8997

@@ -133,6 +141,52 @@ def _normalize_path_entries(
133141
return entries
134142

135143

144+
def _normalize_managed_directory_entries(
145+
value: Any,
146+
*,
147+
repo_root: str,
148+
field_name: str,
149+
) -> tuple[list[str], list[str]]:
150+
if value is None:
151+
return [], []
152+
if not isinstance(value, list):
153+
raise ValueError(f'{field_name} must be a list if provided')
154+
155+
entries: list[str] = []
156+
group_writable_entries: list[str] = []
157+
for index, item in enumerate(value):
158+
item_field_name = f'{field_name}[{index}]'
159+
path_value: Any
160+
group_writable: bool
161+
if isinstance(item, str):
162+
path_value = item
163+
group_writable = False
164+
elif isinstance(item, dict):
165+
path_value = item.get('path')
166+
raw_group_writable = _coerce_optional_bool(
167+
item.get('group_writable'),
168+
field_name=f'{item_field_name}.group_writable',
169+
)
170+
group_writable = (
171+
False if raw_group_writable is None else raw_group_writable
172+
)
173+
else:
174+
raise ValueError(
175+
f'{item_field_name} must be a string or mapping with a path'
176+
)
177+
178+
path = _resolve_path(
179+
value=path_value,
180+
repo_root=repo_root,
181+
field_name=f'{item_field_name}.path',
182+
)
183+
entries.append(path)
184+
if group_writable:
185+
group_writable_entries.append(path)
186+
187+
return entries, group_writable_entries
188+
189+
136190
def _normalize_load_script_copy_entries(
137191
value: Any,
138192
*,
@@ -215,6 +269,26 @@ def _normalize_load_script_symlink_entries(
215269
return list(deduped.values())
216270

217271

272+
def _coerce_optional_bool(
273+
value: Any,
274+
*,
275+
field_name: str,
276+
) -> bool | None:
277+
if value is None:
278+
return None
279+
if isinstance(value, bool):
280+
return value
281+
if isinstance(value, str):
282+
candidate = value.strip().lower()
283+
if candidate in ('true', 'yes', 'on', '1'):
284+
return True
285+
if candidate in ('false', 'no', 'off', '0'):
286+
return False
287+
if candidate in ('', 'none', 'null', 'dynamic'):
288+
return None
289+
raise ValueError(f'{field_name} must be a boolean if provided')
290+
291+
218292
def _resolve_path(
219293
*,
220294
value: Any,

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ 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", group_writable: true}
200+
#
201+
# `group_writable: true` is applied only to that directory path itself,
202+
# not recursively to its contents.
197203
managed_directories: []
198204
managed_files: []
199205

mache/deploy/templates/hooks.py.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ 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 {"path": "/shared/latest", "group_writable": True}.
9899
# - runtime["shared"]["managed_files"]: extra shared files to include in
99100
# the permission update step
100101
#
@@ -114,6 +115,9 @@ def pre_pixi(ctx: DeployContext) -> dict[str, Any] | None:
114115
# updates.setdefault("shared", {})["load_script_copies"] = [
115116
# "/shared/load_my_software.sh",
116117
# ]
118+
# updates.setdefault("shared", {})["managed_directories"] = [
119+
# {"path": "/shared/latest", "group_writable": True},
120+
# ]
117121

118122
return updates
119123

tests/test_deploy_run.py

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

11971197

1198+
def test_apply_deploy_permissions_applies_group_writable_overlay(
1199+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1200+
):
1201+
prefix = tmp_path / 'prefix'
1202+
prefix.mkdir()
1203+
1204+
shared_base = tmp_path / 'shared'
1205+
shared_base.mkdir()
1206+
writable_dir = shared_base / 'latest'
1207+
writable_dir.mkdir()
1208+
1209+
calls = []
1210+
1211+
def _fake_update_permissions(*args, **kwargs):
1212+
calls.append((args, kwargs))
1213+
1214+
monkeypatch.setattr(
1215+
deploy_run, 'update_permissions', _fake_update_permissions
1216+
)
1217+
1218+
logger = deploy_run.logging.getLogger(
1219+
'test-apply-deploy-permissions-group-writable'
1220+
)
1221+
logger.handlers = [deploy_run.logging.NullHandler()]
1222+
logger.propagate = False
1223+
1224+
deploy_run._apply_deploy_permissions(
1225+
prefix=str(prefix),
1226+
extra_prefixes=None,
1227+
load_script_paths=[],
1228+
spack_paths=[],
1229+
shared_artifacts=SharedDeployArtifacts(
1230+
base_path=str(shared_base),
1231+
managed_dirs=[str(writable_dir)],
1232+
group_writable_dirs=[str(writable_dir)],
1233+
managed_files=[],
1234+
),
1235+
group='e3sm',
1236+
world_readable=True,
1237+
logger=logger,
1238+
)
1239+
1240+
assert len(calls) == 3
1241+
1242+
first_args, first_kwargs = calls[0]
1243+
assert first_args == (str(shared_base), 'e3sm')
1244+
assert first_kwargs['group_writable'] is False
1245+
assert first_kwargs['recursive'] is True
1246+
1247+
second_args, second_kwargs = calls[1]
1248+
assert second_args == (str(prefix), 'e3sm')
1249+
assert second_kwargs['group_writable'] is False
1250+
assert second_kwargs['recursive'] is False
1251+
1252+
third_args, third_kwargs = calls[2]
1253+
assert third_args == ([str(writable_dir)], 'e3sm')
1254+
assert third_kwargs['group_writable'] is True
1255+
assert third_kwargs['other_readable'] is True
1256+
assert third_kwargs['recursive'] is False
1257+
1258+
11981259
def test_apply_deploy_permissions_updates_shared_base_first(
11991260
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
12001261
):

tests/test_deploy_shared.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,133 @@ def test_create_shared_deploy_artifacts_resolves_runtime_base_path(
9090
)
9191

9292

93+
def test_create_shared_deploy_artifacts_marks_group_writable_dirs(
94+
tmp_path: Path,
95+
):
96+
repo_root = tmp_path / 'repo'
97+
repo_root.mkdir()
98+
99+
readonly_dir = repo_root / 'shared' / 'readonly'
100+
writable_dir = repo_root / 'shared' / 'writable'
101+
duplicate_dir = repo_root / 'shared' / 'duplicate'
102+
runtime_dir = repo_root / 'shared' / 'runtime'
103+
for path in [readonly_dir, writable_dir, duplicate_dir, runtime_dir]:
104+
path.mkdir(parents=True)
105+
106+
artifacts = create_shared_deploy_artifacts(
107+
config={
108+
'shared': {
109+
'managed_directories': [
110+
'shared/readonly',
111+
{
112+
'path': 'shared/writable',
113+
'group_writable': 'true',
114+
},
115+
{
116+
'path': 'shared/duplicate',
117+
'group_writable': False,
118+
},
119+
{
120+
'path': 'shared/duplicate',
121+
'group_writable': True,
122+
},
123+
],
124+
}
125+
},
126+
runtime={},
127+
repo_root=str(repo_root),
128+
load_script_paths=[],
129+
logger=_logger(),
130+
)
131+
132+
assert artifacts == SharedDeployArtifacts(
133+
managed_dirs=[
134+
str(readonly_dir),
135+
str(writable_dir),
136+
str(duplicate_dir),
137+
],
138+
group_writable_dirs=[
139+
str(writable_dir),
140+
str(duplicate_dir),
141+
],
142+
)
143+
144+
runtime_artifacts = create_shared_deploy_artifacts(
145+
config={
146+
'shared': {
147+
'managed_directories': ['shared/readonly'],
148+
}
149+
},
150+
runtime={
151+
'shared': {
152+
'managed_directories': [
153+
{
154+
'path': 'shared/runtime',
155+
'group_writable': True,
156+
}
157+
],
158+
}
159+
},
160+
repo_root=str(repo_root),
161+
load_script_paths=[],
162+
logger=_logger(),
163+
)
164+
165+
assert runtime_artifacts == SharedDeployArtifacts(
166+
managed_dirs=[str(runtime_dir)],
167+
group_writable_dirs=[str(runtime_dir)],
168+
)
169+
170+
171+
def test_create_shared_deploy_artifacts_validates_managed_directory_entries(
172+
tmp_path: Path,
173+
):
174+
with pytest.raises(
175+
ValueError,
176+
match='shared.managed_directories\\[0\\] must be a string',
177+
):
178+
create_shared_deploy_artifacts(
179+
config={'shared': {'managed_directories': [42]}},
180+
runtime={},
181+
repo_root=str(tmp_path),
182+
load_script_paths=[],
183+
logger=_logger(),
184+
)
185+
186+
with pytest.raises(
187+
ValueError,
188+
match='shared.managed_directories\\[0\\].path must not be null',
189+
):
190+
create_shared_deploy_artifacts(
191+
config={'shared': {'managed_directories': [{}]}},
192+
runtime={},
193+
repo_root=str(tmp_path),
194+
load_script_paths=[],
195+
logger=_logger(),
196+
)
197+
198+
with pytest.raises(
199+
ValueError,
200+
match='shared.managed_directories\\[0\\].group_writable',
201+
):
202+
create_shared_deploy_artifacts(
203+
config={
204+
'shared': {
205+
'managed_directories': [
206+
{
207+
'path': 'shared/writable',
208+
'group_writable': 'maybe',
209+
}
210+
]
211+
}
212+
},
213+
runtime={},
214+
repo_root=str(tmp_path),
215+
load_script_paths=[],
216+
logger=_logger(),
217+
)
218+
219+
93220
def test_create_shared_deploy_artifacts_requires_single_load_script(
94221
tmp_path: Path,
95222
):

0 commit comments

Comments
 (0)