Skip to content

Commit 161b1bc

Browse files
englertorJenkins
authored andcommitted
cmk-dev-deploy: replace site files via rename instead of in-place copy
The config deployer wrote onto existing destination files with shutil.copy2(), which opens them for writing. Pristine version trees ship read-only files -- e.g. the 0555 console-script wrappers that package_wheel generates since e6b62de -- and the version clone only guarantees writable *directories* (see _ensure_writable_dirs), so a fresh clone deterministically failed with fail config_deploy -- [Errno 13] Permission denied: '/omd/sites/v300/version/bin/cmk-general-version-infos' A re-run only appeared to heal it because the concurrently running wheel_deploy step had meanwhile replaced the file with a writable one. Honour the documented invariant: copy to a sibling temp file and os.replace() it over the destination, which only needs the writable parent directory and is atomic on top. Change-Id: I0f965fd714c85d4eacf1c6ca31ffc54685e58dbf
1 parent 84e7af5 commit 161b1bc

2 files changed

Lines changed: 109 additions & 22 deletions

File tree

packages/cmk-dev-deploy/cmk/dev_deploy/deployers/config_deployer.py

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,24 @@ def _resolve_src(entry: ConfigFileEntry, repo_root: Path) -> Path:
8181
return repo_root / entry.src
8282

8383

84+
def _replace_file(src: Path, dst: Path, mode: int | None) -> None:
85+
"""Install ``src`` at ``dst`` without opening the destination for writing.
86+
87+
Version trees ship read-only files (e.g. 0555 console-script wrappers,
88+
0444 bills of materials) and the clone only guarantees writable
89+
*directories* (see ``version_clone._ensure_writable_dirs``). Copying
90+
onto the destination would fail with EACCES, so copy to a sibling temp
91+
file and rename it over the destination, which only needs the writable
92+
parent.
93+
"""
94+
tmp = dst.with_name(dst.name + ".cmk-dev-deploy-tmp")
95+
tmp.unlink(missing_ok=True) # stale temp from an interrupted deploy
96+
shutil.copy2(src, tmp)
97+
if mode:
98+
os.chmod(tmp, mode)
99+
os.replace(tmp, dst)
100+
101+
84102
def _copy_dir(source: Path, dest: Path, spec: ConfigDeploySpec, repo_root: Path) -> None:
85103
"""Copy a config/data directory to the site using the Bazel-derived file list.
86104
@@ -109,13 +127,7 @@ def _copy_dir(source: Path, dest: Path, spec: ConfigDeploySpec, repo_root: Path)
109127

110128
dst = dest / rel
111129
dst.parent.mkdir(parents=True, exist_ok=True)
112-
shutil.copy2(src_path, dst)
113-
114-
# Apply file mode
115-
mode = _resolve_mode(entry.mode, spec.mode, spec.file_chmod)
116-
if mode:
117-
os.chmod(dst, mode)
118-
130+
_replace_file(src_path, dst, _resolve_mode(entry.mode, spec.mode, spec.file_chmod))
119131
expected_files.add(rel)
120132

121133
# Also copy files matching include patterns (dev convenience)
@@ -125,10 +137,7 @@ def _copy_dir(source: Path, dest: Path, spec: ConfigDeploySpec, repo_root: Path)
125137
rel = str(match.relative_to(source))
126138
dst = dest / rel
127139
dst.parent.mkdir(parents=True, exist_ok=True)
128-
shutil.copy2(match, dst)
129-
mode = _resolve_mode("", spec.mode, spec.file_chmod)
130-
if mode:
131-
os.chmod(dst, mode)
140+
_replace_file(match, dst, _resolve_mode("", spec.mode, spec.file_chmod))
132141
expected_files.add(rel)
133142

134143
# Delete extra files at dest not present in source.
@@ -170,19 +179,17 @@ def _install_files(source: Path, dest: Path, spec: ConfigDeploySpec, repo_root:
170179
continue
171180
dest.mkdir(parents=True, exist_ok=True)
172181
dest_file = dest / os.path.basename(entry.dest)
173-
shutil.copy2(src_path, dest_file)
174-
mode = _resolve_mode(entry.mode, spec.mode, spec.file_chmod)
175-
if mode:
176-
os.chmod(dest_file, mode)
182+
_replace_file(
183+
src_path, dest_file, _resolve_mode(entry.mode, spec.mode, spec.file_chmod)
184+
)
177185
count += 1
178186
else:
179187
for src_entry in sorted(source.iterdir()):
180188
if src_entry.is_dir():
181189
continue
182190
assert spec.mode is not None
183191
dest.mkdir(parents=True, exist_ok=True)
184-
shutil.copy2(src_entry, dest / src_entry.name)
185-
os.chmod(dest / src_entry.name, spec.mode)
192+
_replace_file(src_entry, dest / src_entry.name, spec.mode)
186193
count += 1
187194

188195
return count
@@ -257,14 +264,12 @@ def _compile_and_deploy_locale(source: Path, dest: Path, spec: ConfigDeploySpec)
257264
# Install alias file if present
258265
alias_file = lang_dir / "alias"
259266
if alias_file.exists():
260-
shutil.copy2(alias_file, dest / name / "alias")
261-
os.chmod(dest / name / "alias", 0o644)
267+
_replace_file(alias_file, dest / name / "alias", 0o644)
262268

263269
# Install compiled multisite.mo if present
264270
mo_file = lang_dir / "LC_MESSAGES" / "multisite.mo"
265271
if mo_file.exists():
266-
shutil.copy2(mo_file, lc_dest / "multisite.mo")
267-
os.chmod(lc_dest / "multisite.mo", 0o644)
272+
_replace_file(mo_file, lc_dest / "multisite.mo", 0o644)
268273
files_installed += 1
269274

270275
return files_installed, po_compiled

packages/cmk-dev-deploy/tests/test_config_deployer.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
import pytest
1313

1414
from cmk.dev_deploy.deployers import config_deployer
15-
from cmk.dev_deploy.deployers.config_deployer import _copy_dir, _install_files, deploy_config
15+
from cmk.dev_deploy.deployers.config_deployer import (
16+
_compile_and_deploy_locale,
17+
_copy_dir,
18+
_install_files,
19+
deploy_config,
20+
)
1621
from cmk.dev_deploy.types import (
1722
ConfigDeploySpec,
1823
ConfigFileEntry,
@@ -126,6 +131,83 @@ def test_delete_extra_keeps_renamed_file_and_removes_stray(self, tmp_path: Path)
126131
assert not (site_bin / "check_mk.py").exists()
127132

128133

134+
class TestReadOnlyDestination:
135+
"""Pristine version trees ship read-only files (e.g. 0555 wrappers).
136+
137+
The clone only guarantees writable parent directories, so deployers
138+
must replace files via rename instead of opening them for writing.
139+
"""
140+
141+
def test_copy_dir_replaces_read_only_file(self, tmp_path: Path) -> None:
142+
repo = tmp_path / "repo"
143+
(repo / "bin").mkdir(parents=True)
144+
(repo / "bin" / "check_mk.py").write_text("new")
145+
site_bin = tmp_path / "site" / "bin"
146+
site_bin.mkdir(parents=True)
147+
(site_bin / "check_mk").write_text("pristine wrapper")
148+
os.chmod(site_bin / "check_mk", 0o555)
149+
150+
spec = _spec(
151+
source_prefix="bin/",
152+
site_dest="bin/",
153+
files=(ConfigFileEntry(src="bin/check_mk.py", dest="bin/check_mk", mode="0755"),),
154+
)
155+
_copy_dir(repo / "bin", site_bin, spec, repo)
156+
157+
assert (site_bin / "check_mk").read_text() == "new"
158+
assert os.stat(site_bin / "check_mk").st_mode & 0o777 == 0o755
159+
160+
def test_install_files_replaces_read_only_file(self, tmp_path: Path) -> None:
161+
repo = tmp_path / "repo"
162+
(repo / "active_checks").mkdir(parents=True)
163+
(repo / "active_checks" / "check_foo.py").write_text("new")
164+
dest = tmp_path / "site" / "lib" / "nagios" / "plugins"
165+
dest.mkdir(parents=True)
166+
(dest / "check_foo").write_text("pristine")
167+
os.chmod(dest / "check_foo", 0o555)
168+
169+
spec = _spec(
170+
source_prefix="active_checks/",
171+
site_dest="lib/nagios/plugins/",
172+
method=DeployMethod.INSTALL_FILES,
173+
files=(
174+
ConfigFileEntry(
175+
src="active_checks/check_foo.py",
176+
dest="lib/nagios/plugins/check_foo",
177+
mode="0755",
178+
),
179+
),
180+
)
181+
assert _install_files(repo / "active_checks", dest, spec, repo) == 1
182+
183+
assert (dest / "check_foo").read_text() == "new"
184+
assert os.stat(dest / "check_foo").st_mode & 0o777 == 0o755
185+
186+
def test_locale_deploy_replaces_read_only_files(self, tmp_path: Path) -> None:
187+
source = tmp_path / "locale"
188+
(source / "de" / "LC_MESSAGES").mkdir(parents=True)
189+
(source / "de" / "alias").write_text("Deutsch")
190+
(source / "de" / "LC_MESSAGES" / "multisite.mo").write_bytes(b"new mo")
191+
dest = tmp_path / "site" / "locale"
192+
(dest / "de" / "LC_MESSAGES").mkdir(parents=True)
193+
(dest / "de" / "alias").write_text("old")
194+
os.chmod(dest / "de" / "alias", 0o444)
195+
(dest / "de" / "LC_MESSAGES" / "multisite.mo").write_bytes(b"old mo")
196+
os.chmod(dest / "de" / "LC_MESSAGES" / "multisite.mo", 0o444)
197+
198+
spec = _spec(
199+
source_prefix="locale/",
200+
site_dest="share/check_mk/locale/",
201+
method=DeployMethod.LOCALE_COMPILE,
202+
files=(),
203+
)
204+
installed, _compiled = _compile_and_deploy_locale(source, dest, spec)
205+
206+
assert installed == 1
207+
assert (dest / "de" / "alias").read_text() == "Deutsch"
208+
assert (dest / "de" / "LC_MESSAGES" / "multisite.mo").read_bytes() == b"new mo"
209+
210+
129211
def _make_site(tmp_path: Path) -> tuple[SiteInfo, Path]:
130212
"""Simulate an OMD site home next to a writable version clone.
131213

0 commit comments

Comments
 (0)