Skip to content

Commit 7b717ce

Browse files
authored
Merge branch 'main' into create-pull-request/maintenance-v1
2 parents b97f408 + 5b3ab9b commit 7b717ce

4 files changed

Lines changed: 941 additions & 29 deletions

File tree

src/cfnlint/schema/_lock.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Cross-platform file locking for schema cache updates.
3+
4+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
SPDX-License-Identifier: MIT-0
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import logging
11+
import sys
12+
import time
13+
from contextlib import contextmanager
14+
from pathlib import Path
15+
from typing import IO, Iterator
16+
17+
LOGGER = logging.getLogger(__name__)
18+
19+
20+
@contextmanager
21+
def file_lock(lock_path: Path, timeout: float = 300.0) -> Iterator[IO[str]]:
22+
"""Acquire an exclusive file lock, blocking until available or timeout.
23+
24+
Creates the lock file and parent directories if they don't exist.
25+
The lock is released when the context manager exits.
26+
27+
Args:
28+
lock_path: Path to the lock file
29+
timeout: Maximum seconds to wait for the lock (default 5 minutes)
30+
31+
Yields:
32+
The open lock file handle
33+
34+
Raises:
35+
TimeoutError: If the lock cannot be acquired within timeout
36+
OSError: If lock file cannot be created or locked
37+
"""
38+
lock_path.parent.mkdir(parents=True, exist_ok=True)
39+
40+
# Open in write mode to create if missing
41+
lock_file = open(lock_path, "w", encoding="utf-8")
42+
try:
43+
_acquire_lock(lock_file, timeout)
44+
yield lock_file
45+
finally:
46+
_release_lock(lock_file)
47+
lock_file.close()
48+
49+
50+
def _acquire_lock(lock_file: IO[str], timeout: float) -> None:
51+
"""Platform-specific lock acquisition with retry loop."""
52+
if sys.platform == "win32": # pragma: no cover
53+
_acquire_lock_windows(lock_file, timeout)
54+
else:
55+
_acquire_lock_unix(lock_file, timeout)
56+
57+
58+
def _acquire_lock_unix(lock_file: IO[str], timeout: float) -> None:
59+
"""Acquire lock on Unix using fcntl.flock()."""
60+
import fcntl
61+
62+
start = time.monotonic()
63+
while True:
64+
try:
65+
fcntl.flock( # type: ignore[attr-defined]
66+
lock_file.fileno(),
67+
fcntl.LOCK_EX | fcntl.LOCK_NB, # type: ignore[attr-defined]
68+
)
69+
LOGGER.debug("Acquired schema cache lock")
70+
return
71+
except (BlockingIOError, OSError):
72+
if time.monotonic() - start >= timeout:
73+
raise TimeoutError(
74+
f"Could not acquire schema cache lock within {timeout}s. "
75+
"Another cfn-lint process may be updating the cache."
76+
)
77+
time.sleep(0.1)
78+
79+
80+
def _acquire_lock_windows(
81+
lock_file: IO[str], timeout: float
82+
) -> None: # pragma: no cover
83+
"""Acquire lock on Windows using msvcrt.locking()."""
84+
import msvcrt
85+
86+
start = time.monotonic()
87+
while True:
88+
try:
89+
# Lock byte 0 with exclusive lock (non-blocking)
90+
msvcrt.locking( # type: ignore[attr-defined]
91+
lock_file.fileno(),
92+
msvcrt.LK_NBLCK, # type: ignore[attr-defined]
93+
1,
94+
)
95+
LOGGER.debug("Acquired schema cache lock")
96+
return
97+
except OSError:
98+
if time.monotonic() - start >= timeout:
99+
raise TimeoutError(
100+
f"Could not acquire schema cache lock within {timeout}s. "
101+
"Another cfn-lint process may be updating the cache."
102+
)
103+
time.sleep(0.1)
104+
105+
106+
def _release_lock(lock_file: IO[str]) -> None:
107+
"""Platform-specific lock release."""
108+
if sys.platform == "win32": # pragma: no cover
109+
import msvcrt
110+
111+
try:
112+
msvcrt.locking( # type: ignore[attr-defined]
113+
lock_file.fileno(),
114+
msvcrt.LK_UNLCK, # type: ignore[attr-defined]
115+
1,
116+
)
117+
except OSError:
118+
pass # Already unlocked or file closed
119+
else:
120+
import fcntl
121+
122+
try:
123+
fcntl.flock( # type: ignore[attr-defined]
124+
lock_file.fileno(),
125+
fcntl.LOCK_UN, # type: ignore[attr-defined]
126+
)
127+
except OSError: # pragma: no cover
128+
pass # Already unlocked or file closed
129+
LOGGER.debug("Released schema cache lock")

src/cfnlint/schema/manager.py

Lines changed: 129 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
import logging
1010
import os
1111
import re
12+
import shutil
1213
import sys
14+
import tempfile
1315
import zipfile
1416
from functools import lru_cache
1517
from pathlib import Path
@@ -24,6 +26,7 @@
2426
)
2527
from cfnlint.schema._exceptions import ResourceNotFoundError
2628
from cfnlint.schema._getatts import AttributeDict
29+
from cfnlint.schema._lock import file_lock
2730
from cfnlint.schema._schema import Schema
2831

2932
if TYPE_CHECKING:
@@ -298,52 +301,113 @@ def get_resource_types(self, region: str) -> list[str]:
298301
def update(self, force: bool) -> int:
299302
"""Update schemas from the enhanced schemas repository.
300303
301-
Writes to the user cache directory. After update, switches
302-
to reading from the cache so the fresh schemas are used.
304+
Uses file locking to prevent concurrent processes from corrupting the
305+
cache. Extracts to a temporary directory and atomically replaces the
306+
active cache directories.
303307
304308
Args:
305309
force (bool): force the schemas to be downloaded
306310
Returns:
307311
int: exit code (0=success, 2=failure)
308312
"""
309-
if not (url_has_newer_version(_ENHANCED_SCHEMAS_URL) or force):
310-
LOGGER.info("Schemas are up to date")
311-
return 0
313+
# url_has_newer_version() performs a network HEAD request. URLError is
314+
# an OSError subclass, so wrap it here — otherwise a network failure
315+
# would surface later as a misleading lock-acquisition error.
316+
# `force` is evaluated first so a --force update bypasses the network
317+
# check entirely (matches the short-circuit order in _update_locked).
318+
try:
319+
if not (force or url_has_newer_version(_ENHANCED_SCHEMAS_URL)):
320+
LOGGER.info("Schemas are up to date")
321+
return 0
322+
except OSError as e:
323+
LOGGER.error("Failed to check schema version: %s", e)
324+
return 2
325+
326+
_cache = Path(get_cache_dir())
327+
lock_path = _cache / ".update.lock"
328+
329+
try:
330+
with file_lock(lock_path):
331+
return self._update_locked(_cache, force)
332+
except TimeoutError as e:
333+
LOGGER.error("Timed out waiting for schema cache lock: %s", e)
334+
return 2
335+
except OSError as e:
336+
# Raised by file_lock while creating/locking the lock file
337+
LOGGER.error("Failed to acquire schema cache lock: %s", e)
338+
return 2
339+
except Exception as e: # pragma: no cover
340+
LOGGER.error("Schema update failed: %s", e)
341+
return 2
342+
343+
def _update_locked(self, cache_dir: Path, force: bool) -> int:
344+
"""Perform the actual update while holding the lock.
345+
346+
Extracts schemas to a temporary directory, then atomically replaces
347+
the live providers/ and resources/ directories.
348+
349+
Args:
350+
cache_dir: The cache directory root
351+
force: Whether the update was forced
352+
Returns:
353+
int: exit code (0=success, 2=failure)
354+
"""
355+
# Re-check version under lock in case another process just updated.
356+
# url_has_newer_version() makes a network request; URLError subclasses
357+
# OSError, so handle it here rather than letting it propagate to the
358+
# caller's lock-acquisition handler. This keeps the invariant that
359+
# _update_locked never raises — it always returns an exit code.
360+
try:
361+
if not force and not url_has_newer_version(_ENHANCED_SCHEMAS_URL):
362+
LOGGER.info("Schemas were updated by another process")
363+
return 0
364+
except OSError as e:
365+
LOGGER.error("Failed to check schema version: %s", e)
366+
return 2
312367

313368
try:
314369
filehandle = get_url_retrieve(_ENHANCED_SCHEMAS_URL, caching=True)
315370
except Exception as e:
316371
LOGGER.error("Failed to download enhanced schemas: %s", e)
317372
return 2
318373

319-
_cache = Path(get_cache_dir())
320-
providers_dir = _cache / "providers"
321-
resources_dir = _cache / "resources"
322-
323-
with zipfile.ZipFile(filehandle, "r") as zip_ref:
324-
providers_dir.mkdir(parents=True, exist_ok=True)
325-
resources_dir.mkdir(parents=True, exist_ok=True)
326-
327-
for f in providers_dir.glob("*.json"):
328-
f.unlink()
329-
for f in resources_dir.glob("*.json"):
330-
f.unlink()
374+
providers_dir = cache_dir / "providers"
375+
resources_dir = cache_dir / "resources"
331376

332-
for name in zip_ref.namelist():
333-
if not name.endswith(".json"):
334-
continue
335-
if name.startswith("providers/"):
336-
dest = providers_dir / Path(name).name
337-
with zip_ref.open(name) as src, open(dest, "wb") as dst:
338-
dst.write(src.read())
339-
elif name.startswith("resources/"):
340-
dest = resources_dir / Path(name).name
341-
with zip_ref.open(name) as src, open(dest, "wb") as dst:
342-
dst.write(src.read())
377+
# Extract to a temporary directory, then atomically swap
378+
try:
379+
with tempfile.TemporaryDirectory(dir=cache_dir) as tmpdir:
380+
tmp_path = Path(tmpdir)
381+
tmp_providers = tmp_path / "providers"
382+
tmp_resources = tmp_path / "resources"
383+
tmp_providers.mkdir()
384+
tmp_resources.mkdir()
385+
386+
with zipfile.ZipFile(filehandle, "r") as zip_ref:
387+
for name in zip_ref.namelist():
388+
if not name.endswith(".json"):
389+
continue
390+
if name.startswith("providers/"):
391+
dest = tmp_providers / Path(name).name
392+
with zip_ref.open(name) as src, open(dest, "wb") as dst:
393+
dst.write(src.read())
394+
elif name.startswith("resources/"):
395+
dest = tmp_resources / Path(name).name
396+
with zip_ref.open(name) as src, open(dest, "wb") as dst:
397+
dst.write(src.read())
398+
399+
# Atomic replacement: remove old, rename new. On POSIX, rename()
400+
# is atomic when src and dst share a filesystem, which is
401+
# guaranteed here by extracting under the same cache_dir.
402+
self._atomic_replace_dir(tmp_providers, providers_dir)
403+
self._atomic_replace_dir(tmp_resources, resources_dir)
404+
except (OSError, zipfile.BadZipFile) as e:
405+
LOGGER.error("Failed to extract and install schema cache: %s", e)
406+
return 2
343407

344408
try:
345409
version_content = get_url_content(_VERSION_URL)
346-
with open(_cache / "version.json", "w", encoding="utf-8") as vf:
410+
with open(cache_dir / "version.json", "w", encoding="utf-8") as vf:
347411
vf.write(version_content)
348412
except Exception:
349413
LOGGER.debug("Could not download version.json")
@@ -354,6 +418,42 @@ def update(self, force: bool) -> int:
354418
self.reset()
355419
return 0
356420

421+
@staticmethod
422+
def _atomic_replace_dir(src: Path, dst: Path) -> None:
423+
"""Atomically replace dst directory with src.
424+
425+
Renames any existing dst to a backup, renames src to dst,
426+
then removes the backup. If rename fails (cross-device),
427+
falls back to shutil.move.
428+
429+
Args:
430+
src: Source directory (will be moved)
431+
dst: Destination directory (will be replaced)
432+
"""
433+
backup = dst.with_suffix(".bak")
434+
435+
# Remove any stale backup from a previous failed update
436+
if backup.exists():
437+
shutil.rmtree(backup, ignore_errors=True)
438+
439+
# Move existing dst out of the way
440+
if dst.exists():
441+
try:
442+
dst.rename(backup)
443+
except OSError:
444+
# Cross-device or other issue; use shutil
445+
shutil.move(str(dst), str(backup))
446+
447+
# Move new dir into place
448+
try:
449+
src.rename(dst)
450+
except OSError:
451+
shutil.move(str(src), str(dst))
452+
453+
# Clean up backup
454+
if backup.exists():
455+
shutil.rmtree(backup, ignore_errors=True)
456+
357457
def patch(self, patch: SchemaPatch, region: str) -> None:
358458
"""Patch the schemas as needed
359459

0 commit comments

Comments
 (0)