Skip to content

Commit f2c866b

Browse files
Allow signing repodata while exporting products (#1127)
Resolves: AlmaLinux/build-system#444
1 parent b7b2235 commit f2c866b

File tree

3 files changed

+162
-114
lines changed

3 files changed

+162
-114
lines changed

scripts/exporters/base_exporter.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import asyncio
22
import logging
3+
import os
34
import re
45
import shutil
56
import sys
67
import urllib.parse
78
from pathlib import Path
8-
from typing import List, Literal, Optional, Tuple
9+
from typing import List, Literal, Optional, Tuple, Union
910

11+
import aiohttp
1012
from plumbum import local
1113
from sqlalchemy import select
1214

@@ -54,6 +56,9 @@ def __init__(
5456
logging.StreamHandler(stream=sys.stdout),
5557
],
5658
)
59+
self.web_server_headers = {
60+
"Authorization": f"Bearer {settings.albs_jwt_token}",
61+
}
5762

5863
def regenerate_repo_metadata(self, repo_path: str):
5964
partial_path = re.sub(
@@ -198,3 +203,107 @@ async def export_repositories(self, repo_ids: List[int]) -> List[str]:
198203
*(self._export_repository(e) for e in exporters)
199204
)
200205
return [path for path in results if path]
206+
207+
async def get_sign_keys(self):
208+
endpoint = "sign-keys/"
209+
return await self.make_request("GET", endpoint)
210+
211+
async def get_sign_server_token(self) -> str:
212+
body = {
213+
'email': settings.sign_server_username,
214+
'password': settings.sign_server_password,
215+
}
216+
endpoint = 'token'
217+
method = 'POST'
218+
response = await self.make_request(
219+
method=method,
220+
endpoint=endpoint,
221+
body=body,
222+
send_to='sign_server',
223+
)
224+
return response['token']
225+
226+
async def sign_repomd_xml(self, path_to_file: str, key_id: str, token: str):
227+
endpoint = "sign"
228+
result = {"asc_content": None, "error": None}
229+
try:
230+
response = await self.make_request(
231+
"POST",
232+
endpoint,
233+
params={"keyid": key_id},
234+
data={"file": Path(path_to_file).read_bytes()},
235+
user_headers={"Authorization": f"Bearer {token}"},
236+
send_to="sign_server",
237+
)
238+
result["asc_content"] = response
239+
except Exception as err:
240+
result['error'] = err
241+
return result
242+
243+
async def repomd_signer(self, repodata_path, key_id, token):
244+
string_repodata_path = str(repodata_path)
245+
if key_id is None:
246+
self.logger.info(
247+
"Cannot sign repomd.xml in %s, missing GPG key",
248+
string_repodata_path,
249+
)
250+
return
251+
252+
file_path = os.path.join(repodata_path, "repomd.xml")
253+
result = await self.sign_repomd_xml(file_path, key_id, token)
254+
self.logger.info('PGP key id: %s', key_id)
255+
result_data = result.get("asc_content")
256+
if result_data is None:
257+
self.logger.error(
258+
"repomd.xml in %s is failed to sign:\n%s",
259+
string_repodata_path,
260+
result["error"],
261+
)
262+
return
263+
264+
repodata_path = os.path.join(repodata_path, "repomd.xml.asc")
265+
with open(repodata_path, "w") as file:
266+
file.writelines(result_data)
267+
self.logger.info("repomd.xml in %s is signed", string_repodata_path)
268+
269+
async def make_request(
270+
self,
271+
method: str,
272+
endpoint: str,
273+
params: Optional[dict] = None,
274+
body: Optional[dict] = None,
275+
user_headers: Optional[dict] = None,
276+
data: Optional[list] = None,
277+
send_to: Literal['web_server', 'sign_server'] = 'web_server',
278+
) -> Union[dict, str]:
279+
if send_to == 'web_server':
280+
headers = {**self.web_server_headers}
281+
full_url = urllib.parse.urljoin(settings.albs_api_url, endpoint)
282+
elif send_to == 'sign_server':
283+
headers = {}
284+
full_url = urllib.parse.urljoin(
285+
settings.sign_server_api_url,
286+
endpoint,
287+
)
288+
else:
289+
raise ValueError(
290+
"'send_to' param must be either 'web_server' or 'sign_server'"
291+
)
292+
293+
if user_headers:
294+
headers.update(user_headers)
295+
296+
async with aiohttp.ClientSession(
297+
headers=headers,
298+
raise_for_status=True,
299+
) as session:
300+
async with session.request(
301+
method,
302+
full_url,
303+
json=body,
304+
params=params,
305+
data=data,
306+
) as response:
307+
if response.headers['Content-Type'] == 'application/json':
308+
return await response.json()
309+
return await response.text()

scripts/exporters/packages_exporter.py

Lines changed: 1 addition & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66
import pwd
77
import re
88
import sys
9-
import urllib.parse
109
from concurrent.futures import ThreadPoolExecutor, as_completed
1110
from datetime import datetime, timezone
1211
from pathlib import Path
1312
from time import time
14-
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
13+
from typing import Any, Dict, List, Literal, Optional, Tuple
1514

1615
import aiohttp
1716
import jmespath
@@ -157,9 +156,6 @@ def __init__(
157156
export_path=export_path,
158157
)
159158

160-
self.web_server_headers = {
161-
"Authorization": f"Bearer {settings.albs_jwt_token}",
162-
}
163159
self.osv_dir = osv_dir
164160
self.current_user = self.get_current_username()
165161
self.export_error_file = os.path.abspath(
@@ -200,69 +196,6 @@ def process_osv_data(
200196
)
201197
self.logger.debug("OSV data are generated")
202198

203-
async def make_request(
204-
self,
205-
method: str,
206-
endpoint: str,
207-
params: Optional[dict] = None,
208-
body: Optional[dict] = None,
209-
user_headers: Optional[dict] = None,
210-
data: Optional[list] = None,
211-
send_to: Literal['web_server', 'sign_server'] = 'web_server',
212-
) -> Union[dict, str]:
213-
if send_to == 'web_server':
214-
headers = {**self.web_server_headers}
215-
full_url = urllib.parse.urljoin(settings.albs_api_url, endpoint)
216-
elif send_to == 'sign_server':
217-
headers = {}
218-
full_url = urllib.parse.urljoin(
219-
settings.sign_server_api_url,
220-
endpoint,
221-
)
222-
else:
223-
raise ValueError(
224-
"send_to parameter must be either web_server or sign_server"
225-
)
226-
227-
if user_headers:
228-
headers.update(user_headers)
229-
230-
async with aiohttp.ClientSession(
231-
headers=headers,
232-
raise_for_status=True,
233-
) as session:
234-
async with session.request(
235-
method,
236-
full_url,
237-
json=body,
238-
params=params,
239-
data=data,
240-
) as response:
241-
if response.headers['Content-Type'] == 'application/json':
242-
return await response.json()
243-
return await response.text()
244-
245-
async def sign_repomd_xml(self, path_to_file: str, key_id: str, token: str):
246-
endpoint = "sign"
247-
result = {"asc_content": None, "error": None}
248-
try:
249-
response = await self.make_request(
250-
"POST",
251-
endpoint,
252-
params={"keyid": key_id},
253-
data={"file": Path(path_to_file).read_bytes()},
254-
user_headers={"Authorization": f"Bearer {token}"},
255-
send_to="sign_server",
256-
)
257-
result["asc_content"] = response
258-
except Exception as err:
259-
result['error'] = err
260-
return result
261-
262-
async def get_sign_keys(self):
263-
endpoint = "sign-keys/"
264-
return await self.make_request("GET", endpoint)
265-
266199
# TODO: Use direct function call to alws.crud.errata_get_oval_xml
267200
async def get_oval_xml(
268201
self,
@@ -315,32 +248,6 @@ async def generate_rss(self, platform, modern_cache):
315248

316249
return feed.rss_str(pretty=True).decode('utf-8')
317250

318-
async def repomd_signer(self, repodata_path, key_id, token):
319-
string_repodata_path = str(repodata_path)
320-
if key_id is None:
321-
self.logger.info(
322-
"Cannot sign repomd.xml in %s, missing GPG key",
323-
string_repodata_path,
324-
)
325-
return
326-
327-
file_path = os.path.join(repodata_path, "repomd.xml")
328-
result = await self.sign_repomd_xml(file_path, key_id, token)
329-
self.logger.info('PGP key id: %s', key_id)
330-
result_data = result.get("asc_content")
331-
if result_data is None:
332-
self.logger.error(
333-
"repomd.xml in %s is failed to sign:\n%s",
334-
string_repodata_path,
335-
result["error"],
336-
)
337-
return
338-
339-
repodata_path = os.path.join(repodata_path, "repomd.xml.asc")
340-
with open(repodata_path, "w") as file:
341-
file.writelines(result_data)
342-
self.logger.info("repomd.xml in %s is signed", string_repodata_path)
343-
344251
def check_rpms_signature(self, repository_path: str, sign_keys: list):
345252
self.logger.info("Checking signature for %s repo", repository_path)
346253
key_ids_lower = [i.keyid.lower() for i in sign_keys]
@@ -525,21 +432,6 @@ async def export_repos_from_release(
525432
exported_paths = await self.export_repositories(repo_ids)
526433
return exported_paths, db_release.platform_id
527434

528-
async def get_sign_server_token(self) -> str:
529-
body = {
530-
'email': settings.sign_server_username,
531-
'password': settings.sign_server_password,
532-
}
533-
endpoint = 'token'
534-
method = 'POST'
535-
response = await self.make_request(
536-
method=method,
537-
endpoint=endpoint,
538-
body=body,
539-
send_to='sign_server',
540-
)
541-
return response['token']
542-
543435

544436
async def sign_repodata(
545437
exporter: PackagesExporter,
@@ -548,8 +440,6 @@ async def sign_repodata(
548440
db_sign_keys: list,
549441
key_id_by_platform: Optional[str] = None,
550442
):
551-
repodata_paths = []
552-
553443
tasks = []
554444
token = await exporter.get_sign_server_token()
555445

@@ -560,8 +450,6 @@ async def sign_repodata(
560450
if not os.path.exists(repo_path):
561451
continue
562452

563-
repodata_paths.append(repodata)
564-
565453
key_id = key_id_by_platform or None
566454
for platform_id, platform_repos in platforms_dict.items():
567455
for repo_export_path in platform_repos:

scripts/exporters/products_exporter.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import argparse
22
import asyncio
3+
import os
34
import sys
45
from pathlib import Path
56
from typing import List, Literal
@@ -66,6 +67,13 @@ def parse_args():
6667
required=False,
6768
help="Method of exporting (choices: write, hardlink, symlink)",
6869
)
70+
parser.add_argument(
71+
"-sw",
72+
"--sign-with",
73+
type=str,
74+
required=True,
75+
help="GPG key name to use when signing repodata.",
76+
)
6977
parser.add_argument(
7078
"-l",
7179
"--log",
@@ -137,6 +145,17 @@ async def export_product_repos(
137145
list({repo.id for repo in product.repositories})
138146
)
139147

148+
async def get_sign_key_id(self, key_id):
149+
sign_keys = await self.get_sign_keys()
150+
return next(
151+
(
152+
sign_key['keyid']
153+
for sign_key in sign_keys
154+
if sign_key['keyid'] == key_id
155+
),
156+
None,
157+
)
158+
140159

141160
async def repo_post_processing(
142161
exporter: ProductExporter,
@@ -152,6 +171,27 @@ async def repo_post_processing(
152171
return result
153172

154173

174+
async def sign_repodata(
175+
exporter: ProductExporter,
176+
exported_paths: List[str],
177+
key_id: str,
178+
):
179+
tasks = []
180+
token = await exporter.get_sign_server_token()
181+
182+
for repo_path in exported_paths:
183+
path = Path(repo_path)
184+
parent_dir = path.parent
185+
repodata = parent_dir / "repodata"
186+
if not os.path.exists(repo_path):
187+
continue
188+
189+
exporter.logger.info('Key ID: %s', str(key_id))
190+
tasks.append(exporter.repomd_signer(repodata, key_id, token))
191+
192+
await asyncio.gather(*tasks)
193+
194+
155195
async def main():
156196
args = parse_args()
157197
await setup_all()
@@ -161,6 +201,14 @@ async def main():
161201
export_method=args.export_method,
162202
log_file_path=args.log,
163203
)
204+
205+
sign_key_id = None
206+
if args.sign_with:
207+
sign_key_id = await exporter.get_sign_key_id(args.sign_with)
208+
if not sign_key_id:
209+
err = "Couldn't retrieve the '{args.sign_with}' sign key"
210+
raise Exception(f'Aborting product export, error was: {err}')
211+
164212
pulp_client.PULP_SEMAPHORE = asyncio.Semaphore(10)
165213
exported_paths = await exporter.export_product_repos(
166214
product_name=args.product,
@@ -171,6 +219,9 @@ async def main():
171219
*(repo_post_processing(exporter, path) for path in exported_paths)
172220
)
173221

222+
if sign_key_id:
223+
await sign_repodata(exporter, exported_paths, sign_key_id)
224+
174225

175226
if __name__ == '__main__':
176227
asyncio.run(main())

0 commit comments

Comments
 (0)