Skip to content

Commit 9f0c946

Browse files
committed
Fix syncing cosign signature tags when using remote include_tags field
fixes: #2096 Assisted by: claude-opus-4.6
1 parent ab9f204 commit 9f0c946

2 files changed

Lines changed: 54 additions & 21 deletions

File tree

CHANGES/2096.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed syncing of cosign signatures, attestations, and SBOMs (stored as companion tags) being silently skipped when `include_tags` was set on the remote.

pulp_container/app/tasks/sync_stages.py

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040

4141
log = logging.getLogger(__name__)
4242

43+
COSIGN_TAG_SUFFIXES = (".sig", ".att", ".sbom")
44+
4345

4446
class ContainerFirstStage(Stage):
4547
"""
@@ -60,6 +62,8 @@ def __init__(self, remote, signed_only):
6062
self.manifest_list_dcs = []
6163
self.manifest_dcs = []
6264
self.signature_dcs = []
65+
self._synced_digests = set()
66+
self._full_tag_list = []
6367

6468
async def _download_manifest_data(self, manifest_url):
6569
downloader = self.remote.get_downloader(url=manifest_url)
@@ -92,24 +96,57 @@ async def run(self):
9296
"""
9397
ContainerFirstStage.
9498
"""
95-
96-
to_download = []
97-
BATCH_SIZE = 500
98-
99-
# it can be whether a separate sigstore location or registry with extended signatures API
10099
signature_source = await self.get_signature_source()
101100

102101
async with ProgressReport(
103102
message="Downloading tag list", code="sync.downloading.tag_list", total=1
104103
) as pb:
105104
repo_name = self.remote.namespaced_upstream_name
106105
tag_list_url = "/v2/{name}/tags/list".format(name=repo_name)
107-
tag_list = await self.get_paginated_tag_list(tag_list_url, repo_name)
106+
self._full_tag_list = await self.get_paginated_tag_list(tag_list_url, repo_name)
108107
tag_list = filter_resources(
109-
tag_list, self.remote.include_tags, self.remote.exclude_tags
108+
self._full_tag_list, self.remote.include_tags, self.remote.exclude_tags
110109
)
111110
await pb.aincrement()
112111

112+
await self._process_tags(tag_list, signature_source)
113+
114+
if self.remote.include_tags or self.remote.exclude_tags:
115+
companion_tags = self._find_cosign_companion_tags(tag_list)
116+
if companion_tags:
117+
log.info(
118+
"Syncing %d cosign companion tag(s) for filtered images",
119+
len(companion_tags),
120+
)
121+
await self._process_tags(
122+
companion_tags, signature_source, msg="Processing Cosign Companion Tags"
123+
)
124+
125+
await self.resolve_flush()
126+
127+
def _find_cosign_companion_tags(self, filtered_tag_list):
128+
"""Find cosign companion tags for synced digests that were excluded by tag filtering."""
129+
rest_of_tags = filter_resources(self._full_tag_list, [], filtered_tag_list)
130+
rest_of_tags = filter_resources(rest_of_tags, [], self.remote.exclude_tags)
131+
companion_tags = []
132+
for tag in rest_of_tags:
133+
if not tag.startswith("sha256-"):
134+
continue
135+
if not any(tag.endswith(suffix) for suffix in COSIGN_TAG_SUFFIXES):
136+
continue
137+
# Derive the image digest from the cosign tag name:
138+
# sha256-<hex>.<suffix> -> sha256:<hex>
139+
tag_without_suffix = tag.rsplit(".", 1)[0]
140+
digest = tag_without_suffix.replace("-", ":", 1)
141+
if digest in self._synced_digests:
142+
companion_tags.append(tag)
143+
return companion_tags
144+
145+
async def _process_tags(self, tag_list, signature_source, msg="Processing Tags"):
146+
"""Download and process a batch of tags, creating declarative content objects."""
147+
BATCH_SIZE = 500
148+
to_download = []
149+
113150
for tag_name in tag_list:
114151
relative_url = "/v2/{name}/manifests/{tag}".format(
115152
name=self.remote.namespaced_upstream_name, tag=tag_name
@@ -121,7 +158,7 @@ async def run(self):
121158
)
122159

123160
async with ProgressReport(
124-
message="Processing Tags",
161+
message=msg,
125162
code="sync.processing.tag",
126163
total=len(tag_list),
127164
) as pb_parsed_tags:
@@ -134,22 +171,23 @@ async def run(self):
134171
content_data, raw_text_data, response = await artifact
135172

136173
digest = calculate_digest(raw_text_data)
174+
self._synced_digests.add(digest)
137175
tag_name = response.url.split("/")[-1]
138176

139-
# Look for cosign signatures
140-
# cosign signature has a tag convention 'sha256-1234.sig'
141177
if self.signed_only and not signature_source:
178+
is_cosign_companion = tag_name.startswith("sha256-") and any(
179+
tag_name.endswith(s) for s in COSIGN_TAG_SUFFIXES
180+
)
142181
if (
143-
not (tag_name.endswith(".sig") and tag_name.startswith("sha256-"))
144-
and f"sha256-{digest.removeprefix('sha256:')}.sig" not in tag_list
182+
not is_cosign_companion
183+
and f"sha256-{digest.removeprefix('sha256:')}.sig"
184+
not in self._full_tag_list
145185
):
146-
# skip this tag, there is no corresponding signature
147186
log.info(
148187
"The unsigned image {digest} can't be synced "
149188
"due to a requirement to sync signed content "
150189
"only.".format(digest=digest)
151190
)
152-
# Count the skipped tagks as parsed too.
153191
await pb_parsed_tags.aincrement()
154192
continue
155193

@@ -170,6 +208,7 @@ async def run(self):
170208
):
171209
listed_manifest = await listed_manifest_task
172210
man_dc = listed_manifest["manifest_dc"]
211+
self._synced_digests.add(man_dc.content.digest)
173212
if signature_source is not None:
174213
man_sig_dcs = await self.create_signatures(man_dc, signature_source)
175214
if self.signed_only and not man_sig_dcs:
@@ -183,8 +222,6 @@ async def run(self):
183222
tag=tag_name,
184223
)
185224
)
186-
# do not pass down the pipeline a manifest list with unsigned
187-
# manifests.
188225
break
189226
self.signature_dcs.extend(man_sig_dcs)
190227
list_dc.extra_data["listed_manifests"].append(listed_manifest)
@@ -196,8 +233,6 @@ async def run(self):
196233
list_sig_dcs = await self.create_signatures(list_dc, signature_source)
197234
if list_sig_dcs:
198235
self.signature_dcs.extend(list_sig_dcs)
199-
# only pass the manifest list and tag down the pipeline if there were no
200-
# issues with signatures (no `break` in the `for` loop)
201236
tag_dc.extra_data["tagged_manifest_dc"] = list_dc
202237
for listed_manifest in list_dc.extra_data["listed_manifests"]:
203238
await self.handle_blobs(
@@ -215,7 +250,6 @@ async def run(self):
215250
if signature_source is not None:
216251
man_sig_dcs = await self.create_signatures(man_dc, signature_source)
217252
if self.signed_only and not man_sig_dcs:
218-
# do not pass down the pipeline unsigned manifests
219253
continue
220254
self.signature_dcs.extend(man_sig_dcs)
221255
tag_dc.extra_data["tagged_manifest_dc"] = man_dc
@@ -237,8 +271,6 @@ async def run(self):
237271
):
238272
await self.resolve_flush()
239273

240-
await self.resolve_flush()
241-
242274
async def get_signature_source(self):
243275
"""
244276
Find out where signatures come from: sigstore, extension API or not available at all.

0 commit comments

Comments
 (0)