Skip to content

Commit 508db72

Browse files
committed
Feat: Support to accept the response of signing result from RADAS
1 parent ae19c1b commit 508db72

File tree

6 files changed

+340
-5
lines changed

6 files changed

+340
-5
lines changed

charon/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ def __init__(self, data: Dict):
3939
self.__ignore_signature_suffix: Dict = data.get("ignore_signature_suffix", None)
4040
self.__signature_command: str = data.get("detach_signature_command", None)
4141
self.__aws_cf_enable: bool = data.get("aws_cf_enable", False)
42+
self.__amqp_queue: str = data.get("amqp_queue", None)
43+
self.__sign_result_loc: str = data.get("sign_result_loc", None)
44+
self.__quay_radas_auth_enabled: bool = data.get("quay_radas_auth_enabled", False)
45+
self.__quay_radas_registry: str = data.get("quay_radas_registry", None)
46+
self.__quay_radas_username: str = data.get("quay_radas_username", None)
47+
self.__quay_radas_password: str = data.get("quay_radas_password", None)
48+
self.__radas_sign_timeout_count: int = data.get("radas_sign_timeout_count", 10)
49+
self.__radas_sign_wait_interval_sec: int = data.get("radas_sign_wait_interval_sec", 60)
4250

4351
def get_ignore_patterns(self) -> List[str]:
4452
return self.__ignore_patterns
@@ -67,6 +75,29 @@ def get_detach_signature_command(self) -> str:
6775
def is_aws_cf_enable(self) -> bool:
6876
return self.__aws_cf_enable
6977

78+
def get_amqp_queue(self) -> str:
79+
return self.__amqp_queue
80+
81+
def get_sign_result_loc(self) -> str:
82+
return self.__sign_result_loc
83+
84+
def is_quay_radas_auth_enabled(self) -> bool:
85+
return self.__quay_radas_auth_enabled
86+
87+
def get_quay_radas_registry(self) -> str:
88+
return self.__quay_radas_registry
89+
90+
def get_quay_radas_username(self) -> str:
91+
return self.__quay_radas_username
92+
93+
def get_quay_radas_password(self) -> str:
94+
return self.__quay_radas_password
95+
96+
def get_radas_sign_timeout_count(self) -> int:
97+
return self.__radas_sign_timeout_count
98+
99+
def get_radas_sign_wait_interval_sec(self) -> int:
100+
return self.__radas_sign_wait_interval_sec
70101

71102
def get_config(cfgPath=None) -> CharonConfig:
72103
config_file_path = cfgPath

charon/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,6 @@
175175
DEFAULT_ERRORS_LOG = "errors.log"
176176

177177
DEFAULT_REGISTRY = "localhost"
178+
DEFAULT_SIGN_RESULT_LOC = "/tmp/sign"
179+
DEFAULT_RADAS_SIGN_TIMEOUT_COUNT = 10
180+
DEFAULT_RADAS_SIGN_WAIT_INTERVAL_SEC = 60

charon/pkgs/maven.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from charon.utils.files import HashType
1717
import charon.pkgs.indexing as indexing
1818
import charon.pkgs.signature as signature
19+
import charon.pkgs.radas_signature_handler as radas_signature
1920
from charon.utils.files import overwrite_file, digest, write_manifest
2021
from charon.utils.archive import extract_zip_all
2122
from charon.utils.strings import remove_prefix
@@ -408,11 +409,35 @@ def handle_maven_uploading(
408409
if cf_enable:
409410
cf_invalidate_paths.extend(archetype_files)
410411

411-
# 10. Generate signature file if contain_signature is set to True
412-
if gen_sign:
413-
conf = get_config(config)
414-
if not conf:
415-
sys.exit(1)
412+
# 10. Generate signature file if radas sign is enabled, or do detached sign if contain_signature is set to True
413+
conf = get_config(config)
414+
if not conf:
415+
sys.exit(1)
416+
417+
if conf.get_radas_sign_enabled():
418+
logger.info("Start generating radas signature files for s3 bucket %s\n", bucket_name)
419+
(_failed_metas, _generated_signs) = radas_signature.generate_radas_sign(top_level)
420+
if not _generated_signs:
421+
logger.error(
422+
"No sign result files were downloaded, "
423+
"please make sure the sign process is already done and without timeout")
424+
return (tmp_root, False)
425+
426+
failed_metas.extend(_failed_metas)
427+
generated_signs.extend(_generated_signs)
428+
logger.info("Singature generation against radas done.\n")
429+
430+
logger.info("Start upload radas singature files to s3 bucket %s\n", bucket_name)
431+
_failed_metas = s3_client.upload_signatures(
432+
meta_file_paths=generated_signs,
433+
target=(bucket_name, prefix),
434+
product=None,
435+
root=top_level
436+
)
437+
failed_metas.extend(_failed_metas)
438+
logger.info("Signature files uploading against radas done.\n")
439+
440+
elif gen_sign:
416441
suffix_list = __get_suffix(PACKAGE_TYPE_MAVEN, conf)
417442
command = conf.get_detach_signature_command()
418443
artifacts = [s for s in valid_mvn_paths if not s.endswith(tuple(suffix_list))]

charon/pkgs/oras_client.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Copyright (C) 2022 Red Hat, Inc. (https://github.com/Commonjava/charon)
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
import oras.client
17+
import logging
18+
from charon.config import get_config
19+
from typing import List
20+
21+
logger = logging.getLogger(__name__)
22+
23+
class OrasClient:
24+
"""
25+
Wrapper for oras‑py’s OrasClient, deciding whether to login based on config.
26+
"""
27+
28+
def __init__(self):
29+
self.conf = get_config()
30+
self.client = oras.client.OrasClient()
31+
32+
def login_if_needed(self) -> None:
33+
"""
34+
If quay_radas_auth_enabled is true, call login to authenticate.
35+
"""
36+
37+
if self.conf and self.conf.is_quay_radas_auth_enabled():
38+
logger.info("Logging in to registry.")
39+
res = self.client.login(
40+
hostname=self.conf.get_quay_radas_registry(),
41+
username=self.conf.get_quay_radas_username(),
42+
password=self.conf.get_quay_radas_password(),
43+
)
44+
logger.info(res)
45+
else:
46+
logger.info("Registry auth not enabled, skip login.")
47+
48+
def pull(self, result_reference_url: str, sign_result_loc: str) -> List[str]:
49+
"""
50+
Call oras‑py’s pull method to pull the remote file to local.
51+
Args:
52+
result_reference_url (str): Reference of the remote file (e.g. “quay.io/repository/signing/radas@hash”).
53+
sign_result_loc (str): Local save path (e.g. “/tmp/sign”).
54+
"""
55+
files = []
56+
try:
57+
self.login_if_needed()
58+
# the filename should be possibly named by the digest hash value based on the oras source code
59+
files = self.client.pull(target=result_reference_url, outdir=sign_result_loc)
60+
logger.info("Pull file from %s to %s", result_reference_url, sign_result_loc)
61+
except Exception as e:
62+
logger.error("Failed to pull file from %s to %s: %s", result_reference_url, sign_result_loc, e)
63+
finally:
64+
return files
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""
2+
Copyright (C) 2022 Red Hat, Inc. (https://github.com/Commonjava/charon)
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
import proton
17+
import proton.handlers
18+
import threading
19+
import logging
20+
import json
21+
import os
22+
import asyncio
23+
from typing import List, Any, Tuple, Callable, Dict
24+
from charon.config import get_config
25+
from charon.constants import DEFAULT_SIGN_RESULT_LOC
26+
from charon.constants import DEFAULT_RADAS_SIGN_TIMEOUT_COUNT
27+
from charon.constants import DEFAULT_RADAS_SIGN_WAIT_INTERVAL_SEC
28+
from charon.pkgs.oras_client import OrasClient
29+
30+
logger = logging.getLogger(__name__)
31+
32+
class SignHandler:
33+
"""
34+
Handle the sign result status management
35+
"""
36+
_is_processing: bool = True
37+
_downloaded_files: List[str] = []
38+
39+
@classmethod
40+
def is_processing(cls) -> bool:
41+
return cls._is_processing
42+
43+
@classmethod
44+
def get_downloaded_files(cls) -> List[str]:
45+
return cls._downloaded_files.copy()
46+
47+
@classmethod
48+
def set_processing(cls, value: bool) -> None:
49+
cls._is_processing = value
50+
51+
@classmethod
52+
def set_downloaded_files(cls, files: List[str]) -> None:
53+
cls._downloaded_files = files
54+
55+
class UmbListener(proton.handlers.MessagingHandler):
56+
"""
57+
UmbListener class (AMQP version), register this when setup UmbClient
58+
"""
59+
60+
def __init__(self) -> None:
61+
super().__init__()
62+
63+
def on_start(self, event: proton.Event) -> None:
64+
"""
65+
On start callback
66+
"""
67+
conf = get_config()
68+
if not conf:
69+
sys.exit(1)
70+
event.container.create_receiver(conf.get_amqp_queue())
71+
72+
def on_message(self, event: proton.Event) -> None:
73+
"""
74+
On message callback
75+
"""
76+
# handle response from radas in a thread
77+
thread = threading.Thread(
78+
target=self._process_message,
79+
args=[event.message.body]
80+
)
81+
thread.start()
82+
83+
def on_error(self, event: proton.Event) -> None:
84+
"""
85+
On error callback
86+
"""
87+
logger.error("Received an error event:\n%s", event)
88+
89+
def on_disconnected(self, event: proton.Event) -> None:
90+
"""
91+
On disconnected callback
92+
"""
93+
logger.error("Disconnected from AMQP broker.")
94+
95+
def _process_message(msg: Any) -> None:
96+
"""
97+
Process a message received from UMB
98+
Args:
99+
msg: The message body received
100+
"""
101+
try:
102+
msg_dict = json.loads(msg)
103+
result_reference_url = msg_dict.get("result_reference")
104+
105+
if not result_reference_url:
106+
logger.warning("Not found result_reference in message,ignore.")
107+
return
108+
109+
conf = get_config()
110+
if not conf:
111+
sign_result_loc = DEFAULT_SIGN_RESULT_LOC
112+
sign_result_loc = os.getenv("SIGN_RESULT_LOC") or conf.get_sign_result_loc()
113+
logger.info("Using SIGN RESULT LOC: %s", sign_result_loc)
114+
115+
sign_result_parent_dir = os.path.dirname(sign_result_loc)
116+
os.makedirs(sign_result_parent_dir, exist_ok=True)
117+
118+
oras_client = OrasClient()
119+
files = oras_client.pull(
120+
result_reference_url=result_reference_url,
121+
sign_result_loc=sign_result_loc
122+
)
123+
SignHandler.set_downloaded_files(files)
124+
finally:
125+
SignHandler.set_processing(False)
126+
127+
def generate_radas_sign(top_level: str) -> Tuple[List[str], List[str]]:
128+
"""
129+
Generate .asc files based on RADAS sign result json file
130+
"""
131+
conf = get_config()
132+
timeout_count = conf.get_radas_sign_timeout_count() if conf else DEFAULT_RADAS_SIGN_TIMEOUT_COUNT
133+
wait_interval_sec = conf.get_radas_sign_wait_interval_sec() if conf else DEFAULT_RADAS_SIGN_WAIT_INTERVAL_SEC
134+
wait_count = 0
135+
while SignHandler.is_processing():
136+
wait_count += 1
137+
if wait_count > timeout_count:
138+
logger.warning("Timeout when waiting for sign response.")
139+
break
140+
time.sleep(wait_interval_sec)
141+
142+
files = SignHandler.get_downloaded_files()
143+
if not files:
144+
return [], []
145+
146+
# should only have the single sign result json file from the radas registry
147+
json_file_path = files[0]
148+
try:
149+
with open(json_file_path, 'r') as f:
150+
data = json.load(f)
151+
except Exception as e:
152+
logger.error(f"Failed to read or parse the JSON file: {e}")
153+
raise
154+
155+
async def generate_single_sign_file(
156+
file_path: str, signature: str, failed_paths: List[str], generated_signs: List[str],
157+
sem: asyncio.BoundedSemaphore
158+
):
159+
async with sem:
160+
if not file_path or not signature:
161+
logger.error(f"Invalid JSON entry")
162+
return
163+
# remove the root path maven-repository
164+
filename = file_path.split("/", 1)[1]
165+
signature = item.get("signature")
166+
167+
artifact_path = os.path.join(top_level, filename)
168+
asc_filename = f"{filename}.asc"
169+
signature_path = os.path.join(top_level, asc_filename)
170+
171+
if not os.path.isfile(artifact_path):
172+
logger.warning("Artifact missing, skip signature file generation")
173+
return
174+
175+
try:
176+
with open(signature_path, 'w') as asc_file:
177+
asc_file.write(signature)
178+
generated_signs.append(signature_path)
179+
logger.info(f"Generated .asc file: {signature_path}")
180+
except Exception as e:
181+
failed_paths.append(signature_path)
182+
logger.error(f"Failed to write .asc file for {artifact_path}: {e}")
183+
184+
result = data.get("result", [])
185+
return __do_path_cut_and(
186+
path_handler=generate_single_sign_file,
187+
data=result
188+
)
189+
190+
def __do_path_cut_and(
191+
path_handler: Callable,
192+
data: List[Dict[str, str]]
193+
) -> Tuple[List[str], List[str]]:
194+
195+
failed_paths: List[str] = []
196+
generated_signs: List[str] = []
197+
tasks = []
198+
sem = asyncio.BoundedSemaphore(10)
199+
for item in data:
200+
file_path = item.get("file")
201+
signature = item.get("signature")
202+
tasks.append(
203+
asyncio.ensure_future(
204+
path_handler(file_path, signature, failed_paths, generated_signs, sem)
205+
)
206+
)
207+
208+
loop = asyncio.get_event_loop()
209+
loop.run_until_complete(asyncio.gather(*tasks))
210+
return (failed_paths, generated_signs)

requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ subresource-integrity>=0.2
99
jsonschema>=4.9.1
1010
urllib3>=1.25.10
1111
semantic-version>=2.10.0
12+
oras>=0.2.31
13+
python-qpid-proton>=0.39.0

0 commit comments

Comments
 (0)