Skip to content

Commit 478dfcc

Browse files
fix(hummingbird): go back to stateless download
Previous work attempted to make the hummingbird provider a little bit faster by more caching in the input directory, but this has possible failure modes and raises the disk space requirements. Instead, just always recreate the input directory by downloading the archive and then following changes.csv and deletions.txt for information about what needs to change after the archive. Additionally, unlink the archive extraction dir before re-extracting to prevent disk space exhaustion, and tag vunnel as "large" to give it a bit more space. Signed-off-by: Will Murphy <willmurphyscode@users.noreply.github.com>
1 parent 8765932 commit 478dfcc

3 files changed

Lines changed: 107 additions & 321 deletions

File tree

src/vunnel/providers/hummingbird/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def name(cls) -> str:
5757

5858
@classmethod
5959
def tags(cls) -> list[str]:
60-
return ["vulnerability", "os"]
60+
return ["vulnerability", "os", "large"]
6161

6262
def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]:
6363
with timer(self.name(), self.logger):

src/vunnel/providers/hummingbird/csaf_client.py

Lines changed: 31 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
from datetime import UTC, datetime
1010
from typing import TYPE_CHECKING
1111

12-
import requests
13-
1412
from vunnel.utils import http_wrapper as http
1513
from vunnel.utils.archive import extract
1614

@@ -21,11 +19,13 @@
2119

2220
VEX_FEED_LATEST_URL = "https://security.access.redhat.com/data/csaf/v2/vex-feed/archive_latest.txt"
2321

24-
TIMESTAMP_FILE = "archive_timestamp.txt"
25-
CHANGES_TIMESTAMP_FILE = "changes_timestamp.txt"
26-
2722
ARCHIVE_SUFFIXES = (".tar", ".tar.zst", ".tar.gz", ".tar.xz", ".tar.bz2")
2823

24+
# state files written by a previous incremental version of this client; they are
25+
# meaningless now that every run downloads the archive, but would otherwise
26+
# persist in the workspace (and any cached copies of it) forever
27+
LEGACY_STATE_FILES = ("archive_timestamp.txt", "changes_timestamp.txt")
28+
2929

3030
class CSAFVEXClient:
3131
def __init__(
@@ -47,7 +47,6 @@ def __init__(
4747
if not skip_download:
4848
self._sync()
4949
else:
50-
self._load_timestamp()
5150
self.logger.info("skipping downloads in hummingbird CSAF VEX client")
5251

5352
@staticmethod
@@ -79,47 +78,12 @@ def _archive_url(self) -> str:
7978

8079
# ── local paths ───────────────────────────────────────────────────
8180

82-
def _timestamp_path(self) -> str:
83-
return os.path.join(self.workspace.input_path, TIMESTAMP_FILE)
84-
85-
def _changes_timestamp_path(self) -> str:
86-
return os.path.join(self.workspace.input_path, CHANGES_TIMESTAMP_FILE)
87-
8881
def _local_changes_path(self) -> str:
8982
return os.path.join(self.workspace.input_path, "changes.csv")
9083

9184
def _local_deletions_path(self) -> str:
9285
return os.path.join(self.workspace.input_path, "deletions.csv")
9386

94-
# ── timestamp persistence ─────────────────────────────────────────
95-
96-
def _load_timestamp(self) -> None:
97-
ts_path = self._timestamp_path()
98-
if os.path.exists(ts_path):
99-
with open(ts_path) as fh:
100-
self.archive_mod_time = datetime.fromisoformat(fh.read().strip())
101-
self.logger.debug(f"loaded archive timestamp: {self.archive_mod_time}")
102-
103-
def _save_timestamp(self, mod_time: datetime) -> None:
104-
self.archive_mod_time = mod_time
105-
with open(self._timestamp_path(), "w") as fh:
106-
fh.write(mod_time.isoformat())
107-
108-
def _load_changes_timestamp(self) -> datetime | None:
109-
ts_path = self._changes_timestamp_path()
110-
if os.path.exists(ts_path):
111-
with open(ts_path) as fh:
112-
return datetime.fromisoformat(fh.read().strip())
113-
return None
114-
115-
def _save_changes_timestamp(self, mod_time: datetime) -> None:
116-
with open(self._changes_timestamp_path(), "w") as fh:
117-
fh.write(mod_time.isoformat())
118-
119-
def _clear_changes_timestamp(self) -> None:
120-
with contextlib.suppress(FileNotFoundError):
121-
os.remove(self._changes_timestamp_path())
122-
12387
# ── download helpers ──────────────────────────────────────────────
12488

12589
def _download_stream(self, url: str, path: str) -> datetime | None:
@@ -133,41 +97,18 @@ def _download_stream(self, url: str, path: str) -> datetime | None:
13397
return email.utils.parsedate_to_datetime(lm)
13498
return None
13599

136-
def _head_last_modified(self, url: str) -> datetime | None:
137-
"""HEAD the URL and return Last-Modified as a tz-aware datetime, or None."""
138-
resp = requests.head(url, timeout=30)
139-
resp.raise_for_status()
140-
lm = resp.headers.get("Last-Modified")
141-
if lm:
142-
return email.utils.parsedate_to_datetime(lm)
143-
return None
144-
145100
# ── core sync logic ───────────────────────────────────────────────
146101

147102
def _sync(self) -> None:
148103
os.makedirs(self.advisories_path, exist_ok=True)
149104
self._remove_stray_files()
150105

151-
archive_url = self._archive_url()
152-
self._load_timestamp()
106+
# stateless flow: every run downloads and extracts the archive, then
107+
# re-applies everything that changed since the archive was baked. this
108+
# trades some redundant downloading for having no persisted state that
109+
# can disagree with what is actually on disk.
110+
self._download_archive(self._archive_url())
153111

154-
# decide whether we need to (re-)download the archive
155-
need_download = False
156-
if self.archive_mod_time is None:
157-
self.logger.info("no local timestamp found - downloading archive")
158-
need_download = True
159-
else:
160-
remote_mod = self._head_last_modified(archive_url)
161-
if remote_mod and remote_mod > self.archive_mod_time:
162-
self.logger.info(f"remote archive is newer ({remote_mod}) than local ({self.archive_mod_time}) - re-downloading")
163-
need_download = True
164-
else:
165-
self.logger.info("archive is up to date")
166-
167-
if need_download:
168-
self._download_archive(archive_url)
169-
170-
# always apply incremental updates
171112
self._download_stream(self._changes_url(), self._local_changes_path())
172113
self._download_stream(self._deletions_url(), self._local_deletions_path())
173114
self._process_changes_and_deletions()
@@ -186,6 +127,10 @@ def _remove_stray_files(self) -> None:
186127
with contextlib.suppress(OSError):
187128
os.remove(os.path.join(self.workspace.input_path, name))
188129

130+
for name in LEGACY_STATE_FILES:
131+
with contextlib.suppress(FileNotFoundError):
132+
os.remove(os.path.join(self.workspace.input_path, name))
133+
189134
def _advisories_tmp_path(self) -> str:
190135
return self.advisories_path + ".tmp"
191136

@@ -197,19 +142,18 @@ def _download_archive(self, archive_url: str) -> None:
197142
try:
198143
remote_mod = self._download_stream(archive_url, archive_path)
199144

200-
# extract to a temp dir and swap so a failed extraction can't
201-
# destroy the existing advisories tree
145+
# the downloaded archive supersedes the existing advisories tree, so
146+
# remove the tree before extracting to keep peak disk usage at one
147+
# tree plus the archive (instead of two trees plus the archive)
148+
if os.path.isdir(self.advisories_path):
149+
shutil.rmtree(self.advisories_path)
150+
202151
self.logger.info("extracting archive")
203152
tmp_dir = self._advisories_tmp_path()
204153
extract(archive_path, tmp_dir)
205-
206-
if os.path.isdir(self.advisories_path):
207-
shutil.rmtree(self.advisories_path)
208154
os.rename(tmp_dir, self.advisories_path)
209155

210-
self._save_timestamp(remote_mod or datetime.now(tz=UTC))
211-
# the new tree supersedes any previously applied changes
212-
self._clear_changes_timestamp()
156+
self.archive_mod_time = remote_mod or datetime.now(tz=UTC)
213157
finally:
214158
# always clean up the archive file to save disk space
215159
with contextlib.suppress(OSError):
@@ -218,7 +162,7 @@ def _download_archive(self, archive_url: str) -> None:
218162
def _process_changes_and_deletions(self) -> None:
219163
self._apply_deletions()
220164

221-
seen_files, years, newest_change = self._collect_pending_changes()
165+
seen_files, years = self._collect_pending_changes()
222166

223167
if not seen_files:
224168
self.logger.info("no changed files newer than archive")
@@ -229,11 +173,7 @@ def _process_changes_and_deletions(self) -> None:
229173
for year in years:
230174
os.makedirs(os.path.join(self.advisories_path, year), exist_ok=True)
231175

232-
any_failed = self._download_changed_files(seen_files)
233-
234-
# only advance the watermark when everything landed, so failed files are retried next run
235-
if not any_failed and newest_change:
236-
self._save_changes_timestamp(newest_change)
176+
self._download_changed_files(seen_files)
237177

238178
def _apply_deletions(self) -> None:
239179
with open(self._local_deletions_path(), newline="") as fh:
@@ -243,37 +183,24 @@ def _apply_deletions(self) -> None:
243183
with contextlib.suppress(FileNotFoundError):
244184
os.remove(os.path.join(self.advisories_path, deleted_fragment))
245185

246-
def _collect_pending_changes(self) -> tuple[set[str], set[str], datetime | None]:
247-
"""Read changes.csv (newest first) and return (files, years, newest change date) not yet applied."""
248-
# skip changes already applied by a previous run (the watermark), falling
249-
# back to the archive timestamp when no changes have been applied yet
250-
watermark = self.archive_mod_time
251-
changes_applied = self._load_changes_timestamp()
252-
if changes_applied and (watermark is None or changes_applied > watermark):
253-
watermark = changes_applied
254-
186+
def _collect_pending_changes(self) -> tuple[set[str], set[str]]:
187+
"""Read changes.csv (newest first) and return (files, years) for entries newer than the archive."""
255188
seen_files: set[str] = set()
256189
years: set[str] = set()
257-
newest_change: datetime | None = None
258190
with open(self._local_changes_path(), newline="") as fh:
259191
reader = csv.reader(fh)
260192
for row in reader:
261193
changed_file = row[0]
262-
date_str = row[1]
263-
change_date = datetime.fromisoformat(date_str)
264-
if watermark and change_date < watermark:
194+
change_date = datetime.fromisoformat(row[1])
195+
if self.archive_mod_time and change_date < self.archive_mod_time:
265196
break
266-
if newest_change is None or change_date > newest_change:
267-
newest_change = change_date
268197
seen_files.add(changed_file)
269-
year = changed_file.split("/")[0]
270-
years.add(year)
198+
years.add(changed_file.split("/")[0])
271199

272-
return seen_files, years, newest_change
200+
return seen_files, years
273201

274-
def _download_changed_files(self, seen_files: set[str]) -> bool:
275-
"""Download the given advisory fragments in parallel, returning True if any failed."""
276-
any_failed = False
202+
def _download_changed_files(self, seen_files: set[str]) -> None:
203+
"""Download the given advisory fragments in parallel; failures are logged and retried next run."""
277204
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as executor:
278205
futures = {
279206
executor.submit(
@@ -286,6 +213,4 @@ def _download_changed_files(self, seen_files: set[str]) -> bool:
286213
concurrent.futures.wait(futures.keys())
287214
for future, changed_file in futures.items():
288215
if future.exception() is not None:
289-
any_failed = True
290216
self.logger.warning(f"failed to download {changed_file}: {future.exception()}")
291-
return any_failed

0 commit comments

Comments
 (0)