99from datetime import UTC , datetime
1010from typing import TYPE_CHECKING
1111
12- import requests
13-
1412from vunnel .utils import http_wrapper as http
1513from vunnel .utils .archive import extract
1614
2119
2220VEX_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-
2722ARCHIVE_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
3030class 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