Skip to content

Commit 80f9840

Browse files
committed
Add support for CloudFront invalidating
1 parent b9cd1b5 commit 80f9840

37 files changed

+649
-108
lines changed

charon.spec

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ export LANG=en_US.UTF-8 LANGUAGE=en_US.en LC_ALL=en_US.UTF-8
8080

8181

8282
%changelog
83+
* Fri Mar 29 2024 Gang Li <[email protected]>
84+
- 1.3.0 release
85+
- Add validate command: validate the checksum for maven artifacts
86+
- Add index command: support to re-index of the speicified folder
87+
- Add CF invalidating features:
88+
- Invalidate generated metadata files (maven-metadata*/package.json/index.html) after product uploading/deleting in CloudFront
89+
- Add command to do CF invalidating and checking
90+
8391
* Mon Sep 18 2023 Harsh Modi <[email protected]>
8492
- 1.2.2 release
8593
- hot fix for "dist_tags" derived issue

charon/cache.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
from boto3 import session
2+
from botocore.exceptions import ClientError
3+
from typing import Dict, List
4+
import os
5+
import logging
6+
import uuid
7+
8+
logger = logging.getLogger(__name__)
9+
10+
ENDPOINT_ENV = "aws_endpoint_url"
11+
12+
DEFAULT_BUCKET_TO_DOMAIN = {
13+
"prod-maven-ga": "maven.repository.redhat.com",
14+
"prod-maven-ea": "maven.repository.redhat.com",
15+
"stage-maven-ga": "maven.strage.repository.redhat.com",
16+
"stage-maven-ea": "maven.strage.repository.redhat.com",
17+
"prod-npm": "npm.repository.redhat.com",
18+
"stage-npm": "npm.stage.repository.redhat.com"
19+
}
20+
21+
22+
class CFClient(object):
23+
"""The CFClient is a wrapper of the original boto3 clouldfrong client,
24+
which will provide CloudFront functions to be used in the charon.
25+
"""
26+
27+
def __init__(
28+
self,
29+
aws_profile=None,
30+
extra_conf=None
31+
) -> None:
32+
self.__client = self.__init_aws_client(aws_profile, extra_conf)
33+
34+
def __init_aws_client(
35+
self, aws_profile=None, extra_conf=None
36+
):
37+
if aws_profile:
38+
logger.debug("Using aws profile: %s", aws_profile)
39+
cf_session = session.Session(profile_name=aws_profile)
40+
else:
41+
cf_session = session.Session()
42+
endpoint_url = self.__get_endpoint(extra_conf)
43+
return cf_session.client(
44+
'cloudfront',
45+
endpoint_url=endpoint_url
46+
)
47+
48+
def __get_endpoint(self, extra_conf) -> str:
49+
endpoint_url = os.getenv(ENDPOINT_ENV)
50+
if not endpoint_url or not endpoint_url.strip():
51+
if isinstance(extra_conf, Dict):
52+
endpoint_url = extra_conf.get(ENDPOINT_ENV, None)
53+
if endpoint_url:
54+
logger.info("Using endpoint url for aws client: %s", endpoint_url)
55+
else:
56+
logger.debug("No user-specified endpoint url is used.")
57+
return endpoint_url
58+
59+
def invalidate_paths(self, distr_id: str, paths: List[str]) -> Dict[str, str]:
60+
"""Send a invalidating requests for the paths in distribution to CloudFront.
61+
This will invalidate the paths in the distribution to enforce the refreshment
62+
from backend S3 bucket for these paths. For details see:
63+
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html
64+
* The distr_id is the id for the distribution. This id can be get through
65+
get_dist_id_by_domain(domain) function
66+
* Can specify the invalidating paths through paths param.
67+
"""
68+
caller_ref = str(uuid.uuid4())
69+
logger.debug("[CloudFront] Creating invalidation for paths: %s", paths)
70+
try:
71+
response = self.__client.create_invalidation(
72+
DistributionId=distr_id,
73+
InvalidationBatch={
74+
'CallerReference': caller_ref,
75+
'Paths': {
76+
'Quantity': len(paths),
77+
'Items': paths
78+
}
79+
}
80+
)
81+
if response:
82+
invalidation = response.get('Invalidation', {})
83+
return {
84+
'Id': invalidation.get('Id', None),
85+
'Status': invalidation.get('Status', None)
86+
}
87+
except Exception as err:
88+
logger.error(
89+
"[CloudFront] Error occurred while creating invalidation, error: %s", err
90+
)
91+
92+
def check_invalidation(self, distr_id: str, invalidation_id: str) -> dict:
93+
try:
94+
response = self.__client.get_invalidation(
95+
DistributionId=distr_id,
96+
Id=invalidation_id
97+
)
98+
if response:
99+
invalidation = response.get('Invalidation', {})
100+
return {
101+
'Id': invalidation.get('Id', None),
102+
'CreateTime': invalidation.get('CreateTime', None),
103+
'Status': invalidation.get('Status', None)
104+
}
105+
except Exception as err:
106+
logger.error(
107+
"[CloudFront] Error occurred while check invalidation of id %s, "
108+
"error: %s", invalidation_id, err
109+
)
110+
111+
def get_dist_id_by_domain(self, domain: str) -> str:
112+
"""Get distribution id by a domain name. The id can be used to send invalidating
113+
request through #invalidate_paths function
114+
* Domain are Ronda domains, like "maven.repository.redhat.com"
115+
or "npm.repository.redhat.com"
116+
"""
117+
try:
118+
response = self.__client.list_distributions()
119+
if response:
120+
dist_list_items = response.get("DistributionList", {}).get("Items", [])
121+
for distr in dist_list_items:
122+
aliases_items = distr.get('Aliases', {}).get('Items', [])
123+
if aliases_items and domain in aliases_items:
124+
return distr['Id']
125+
logger.error("[CloudFront]: Distribution not found for domain %s", domain)
126+
except ClientError as err:
127+
logger.error(
128+
"[CloudFront]: Error occurred while get distribution for domain %s: %s",
129+
domain, err
130+
)
131+
return None
132+
133+
def get_domain_by_bucket(self, bucket: str) -> str:
134+
return DEFAULT_BUCKET_TO_DOMAIN.get(bucket, None)

charon/cmd/cmd_delete.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ def delete(
158158
buckets=buckets,
159159
aws_profile=aws_profile,
160160
dir_=work_dir,
161+
cf_enable=conf.is_aws_cf_enable(),
161162
dry_run=dryrun,
162163
manifest_bucket_name=manifest_bucket_name
163164
)
@@ -178,6 +179,7 @@ def delete(
178179
buckets=buckets,
179180
aws_profile=aws_profile,
180181
dir_=work_dir,
182+
cf_enable=conf.is_aws_cf_enable(),
181183
dry_run=dryrun,
182184
manifest_bucket_name=manifest_bucket_name
183185
)

charon/cmd/cmd_index.py

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -87,34 +87,31 @@ def index(
8787
# log is recorded get_target
8888
sys.exit(1)
8989

90-
aws_bucket = None
91-
prefix = None
92-
for b in conf.get_target(target):
90+
for b in tgt:
9391
aws_bucket = b.get('bucket')
94-
prefix = b.get('prefix', '')
9592

96-
package_type = None
97-
if "maven" in aws_bucket:
98-
logger.info(
99-
"The target is a maven repository. Will refresh the index as maven package type"
100-
)
101-
package_type = PACKAGE_TYPE_MAVEN
102-
elif "npm" in aws_bucket:
103-
package_type = PACKAGE_TYPE_NPM
104-
logger.info(
105-
"The target is a npm repository. Will refresh the index as npm package type"
106-
)
107-
else:
108-
logger.error(
109-
"The target is not supported. Only maven or npm target is supported."
110-
)
111-
sys.exit(1)
93+
package_type = None
94+
if "maven" in aws_bucket:
95+
logger.info(
96+
"The target is a maven repository. Will refresh the index as maven package type"
97+
)
98+
package_type = PACKAGE_TYPE_MAVEN
99+
elif "npm" in aws_bucket:
100+
package_type = PACKAGE_TYPE_NPM
101+
logger.info(
102+
"The target is a npm repository. Will refresh the index as npm package type"
103+
)
104+
else:
105+
logger.error(
106+
"The target %s is not supported. Only maven or npm target is supported.",
107+
target
108+
)
112109

113-
if not aws_bucket:
114-
logger.error("No bucket specified!")
115-
sys.exit(1)
110+
if not aws_bucket:
111+
logger.error("No bucket specified for target %s!", target)
112+
else:
113+
re_index(b, path, package_type, aws_profile, dryrun)
116114

117-
re_index(aws_bucket, prefix, path, package_type, aws_profile, dryrun)
118115
except Exception:
119116
print(traceback.format_exc())
120117
sys.exit(2) # distinguish between exception and bad config or bad state

charon/cmd/cmd_upload.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def upload(
178178
aws_profile=aws_profile,
179179
dir_=work_dir,
180180
gen_sign=contain_signature,
181+
cf_enable=conf.is_aws_cf_enable(),
181182
key=sign_key,
182183
dry_run=dryrun,
183184
manifest_bucket_name=manifest_bucket_name
@@ -200,6 +201,7 @@ def upload(
200201
aws_profile=aws_profile,
201202
dir_=work_dir,
202203
gen_sign=contain_signature,
204+
cf_enable=conf.is_aws_cf_enable(),
203205
key=sign_key,
204206
dry_run=dryrun,
205207
manifest_bucket_name=manifest_bucket_name

charon/cmd/internal.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ def _get_buckets(targets: List[str], conf: CharonConfig) -> List[Tuple[str, str,
3535
aws_bucket = bucket.get('bucket')
3636
prefix = bucket.get('prefix', '')
3737
registry = bucket.get('registry', DEFAULT_REGISTRY)
38-
buckets.append((target, aws_bucket, prefix, registry))
38+
cf_domain = bucket.get('domain', None)
39+
buckets.append((target, aws_bucket, prefix, registry, cf_domain))
3940
return buckets
4041

4142

charon/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ def __init__(self, data: Dict):
3838
self.__manifest_bucket: str = data.get("manifest_bucket", None)
3939
self.__ignore_signature_suffix: Dict = data.get("ignore_signature_suffix", None)
4040
self.__signature_command: str = data.get("detach_signature_command", None)
41+
self.__aws_cf_enable: bool = data.get("aws_cf_enable", False)
4142

4243
def get_ignore_patterns(self) -> List[str]:
4344
return self.__ignore_patterns
@@ -63,6 +64,9 @@ def get_ignore_signature_suffix(self, package_type: str) -> List[str]:
6364
def get_detach_signature_command(self) -> str:
6465
return self.__signature_command
6566

67+
def is_aws_cf_enable(self) -> bool:
68+
return self.__aws_cf_enable
69+
6670

6771
def get_config() -> Optional[CharonConfig]:
6872
config_file_path = os.path.join(os.getenv("HOME"), ".charon", CONFIG_FILE)

charon/pkgs/indexing.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
"""
1616
from charon.config import get_template
1717
from charon.storage import S3Client
18+
from charon.cache import CFClient
19+
from charon.pkgs.pkg_utils import invalidate_cf_paths
1820
from charon.constants import (INDEX_HTML_TEMPLATE, NPM_INDEX_HTML_TEMPLATE,
1921
PACKAGE_TYPE_MAVEN, PACKAGE_TYPE_NPM, PROD_INFO_SUFFIX)
2022
from charon.utils.files import digest_content
2123
from jinja2 import Template
2224
import os
2325
import logging
24-
from typing import List, Set
26+
from typing import List, Set, Tuple
2527

2628
from charon.utils.strings import remove_prefix
2729

@@ -259,21 +261,23 @@ def __compare(self, other) -> int:
259261

260262

261263
def re_index(
262-
bucket: str,
263-
prefix: str,
264+
bucket: Tuple[str, str, str, str, str],
264265
path: str,
265266
package_type: str,
266267
aws_profile: str = None,
268+
cf_enable: bool = False,
267269
dry_run: bool = False
268270
):
269271
"""Refresh the index.html for the specified folder in the bucket.
270272
"""
273+
bucket_name = bucket[1]
274+
prefix = bucket[2]
271275
s3_client = S3Client(aws_profile=aws_profile, dry_run=dry_run)
272276
real_prefix = prefix if prefix.strip() != "/" else ""
273277
s3_folder = os.path.join(real_prefix, path)
274278
if path.strip() == "" or path.strip() == "/":
275279
s3_folder = prefix
276-
items: List[str] = s3_client.list_folder_content(bucket, s3_folder)
280+
items: List[str] = s3_client.list_folder_content(bucket_name, s3_folder)
277281
contents = [i for i in items if not i.endswith(PROD_INFO_SUFFIX)]
278282
if PACKAGE_TYPE_NPM == package_type:
279283
if any([True if "package.json" in c else False for c in contents]):
@@ -303,14 +307,17 @@ def re_index(
303307
index_path = os.path.join(path, "index.html")
304308
if path == "/":
305309
index_path = "index.html"
306-
s3_client.simple_delete_file(index_path, (bucket, real_prefix))
310+
s3_client.simple_delete_file(index_path, (bucket_name, real_prefix))
307311
s3_client.simple_upload_file(
308-
index_path, index_content, (bucket, real_prefix),
312+
index_path, index_content, (bucket_name, real_prefix),
309313
"text/html", digest_content(index_content)
310314
)
315+
if cf_enable:
316+
cf_client = CFClient(aws_profile=aws_profile)
317+
invalidate_cf_paths(cf_client, bucket, [index_path])
311318
else:
312319
logger.warning(
313320
"The path %s does not contain any contents in bucket %s. "
314321
"Will not do any re-indexing",
315-
path, bucket
322+
path, bucket_name
316323
)

0 commit comments

Comments
 (0)