Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 1f570a6

Browse files
Alexander Melnykheitorlessa
Alexander Melnyk
andauthoredJul 7, 2022
chore(layers): add release pipeline in GitHub Actions (#1278)
* chore: add layer project * reduce to 1 region for dev * chore: shorter name for the workflow * fix ignore markdown lint for now * fix: more f strings * ignore mdlint * add reusable workflow for both beta and prod * Update layer/layer/canary/app.py Co-authored-by: Heitor Lessa <[email protected]> * Update layer/layer/canary/app.py Co-authored-by: Heitor Lessa <[email protected]> * readme review * rephrase canary stack ssm parameter usage * add default RELEASE_TAG_VERSION assignment based on the input (release or manual trigger) * add reference to layer docs * wording * move version trackign arn to canary stack * remove outdated npm caching, add release tag resolution for manual workflow trigger * review: fix layer name and remove dependencies from reusable workflow * remove debug statement, add default working dir * pin versions and hashes for requirements with pip-compile * rename reusable workflow * pass artefact name to the reusable workflow to prevent potential future conflicts Co-authored-by: Heitor Lessa <[email protected]>
1 parent c0bb85f commit 1f570a6

12 files changed

+533
-0
lines changed
 

‎.github/workflows/publish_layer.yml

+80
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Deploy layer to all regions
2+
3+
permissions:
4+
id-token: write
5+
contents: read
6+
7+
on:
8+
workflow_dispatch:
9+
inputs:
10+
latest_published_version:
11+
description: "Latest PyPi published version to rebuild latest docs for, e.g. v1.22.0"
12+
default: "v1.22.0"
13+
required: true
14+
workflow_run:
15+
workflows: [ "Publish to PyPi" ]
16+
types:
17+
- completed
18+
19+
20+
jobs:
21+
build-layer:
22+
runs-on: ubuntu-latest
23+
defaults:
24+
run:
25+
working-directory: ./layer
26+
steps:
27+
- name: checkout
28+
uses: actions/checkout@v2
29+
- name: Setup Node.js
30+
uses: actions/setup-node@v2
31+
with:
32+
node-version: '16.12'
33+
cache: 'npm'
34+
- name: Setup python
35+
uses: actions/setup-python@v4
36+
with:
37+
python-version: '3.9'
38+
cache: 'pip'
39+
- name: Set release notes tag
40+
run: |
41+
RELEASE_INPUT=${{ inputs.latest_published_version }}
42+
GITHUB_EVENT_RELEASE_TAG=${{ github.event.release.tag_name }}
43+
RELEASE_TAG_VERSION=${GITHUB_EVENT_RELEASE_TAG:-$RELEASE_INPUT}
44+
echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION:1}" >> $GITHUB_ENV
45+
- name: install cdk and deps
46+
run: |
47+
npm install -g aws-cdk@2.29.0
48+
cdk --version
49+
- name: install deps
50+
run: |
51+
pip install -r requirements.txt
52+
- name: CDK build
53+
run: cdk synth --context version=$RELEASE_TAG_VERSION -o cdk.out
54+
- name: zip output
55+
run: zip -r cdk.out.zip cdk.out
56+
- name: Archive CDK artifacts
57+
uses: actions/upload-artifact@v3
58+
with:
59+
name: cdk-layer-artefact
60+
path: cdk.out.zip
61+
62+
deploy-beta:
63+
needs:
64+
- build-layer
65+
uses: ./.github/workflows/reusable_deploy_layer_stack.yml
66+
with:
67+
stage: "BETA"
68+
artifact-name: "cdk-layer-artefact"
69+
secrets:
70+
target-account: ${{ secrets.LAYERS_BETA_ACCOUNT }}
71+
72+
deploy-prod:
73+
needs:
74+
- deploy-beta
75+
uses: ./.github/workflows/reusable_deploy_layer_stack.yml
76+
with:
77+
stage: "PROD"
78+
artifact-name: "cdk-layer-artefact"
79+
secrets:
80+
target-account: ${{ secrets.LAYERS_PROD_ACCOUNT }}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Deploy cdk stack
2+
3+
permissions:
4+
id-token: write
5+
contents: read
6+
7+
on:
8+
workflow_call:
9+
inputs:
10+
stage:
11+
required: true
12+
type: string
13+
artefact-name:
14+
required: true
15+
type: string
16+
secrets:
17+
target-account:
18+
required: true
19+
20+
jobs:
21+
deploy-cdk-stack:
22+
runs-on: ubuntu-latest
23+
defaults:
24+
run:
25+
working-directory: ./layer
26+
strategy:
27+
fail-fast: false
28+
matrix:
29+
region: [
30+
"af-south-1",
31+
# "eu-central-1",
32+
# "us-east-1",
33+
# "us-east-2",
34+
# "us-west-1",
35+
# "us-west-2",
36+
# "ap-east-1",
37+
# "ap-south-1",
38+
# "ap-northeast-1",
39+
# "ap-northeast-2",
40+
# "ap-southeast-1",
41+
# "ap-southeast-2",
42+
# "ca-central-1",
43+
# "eu-west-1",
44+
# "eu-west-2",
45+
# "eu-west-3",
46+
# "eu-south-1",
47+
# "eu-north-1",
48+
# "sa-east-1",
49+
# "ap-southeast-3",
50+
# "ap-northeast-3",
51+
# "me-south-1"
52+
]
53+
steps:
54+
- name: checkout
55+
uses: actions/checkout@v2
56+
- name: aws credentials
57+
uses: aws-actions/configure-aws-credentials@v1
58+
with:
59+
aws-region: ${{ matrix.region }}
60+
role-to-assume: arn:aws:iam::${{ secrets.target-account }}:role/${{ secrets.AWS_GITHUB_OIDC_ROLE }}
61+
- name: Setup Node.js
62+
uses: actions/setup-node@v2
63+
with:
64+
node-version: '16.12'
65+
cache: 'npm'
66+
- name: Setup python
67+
uses: actions/setup-python@v4
68+
with:
69+
python-version: '3.9'
70+
cache: 'pip'
71+
- name: install cdk and deps
72+
run: |
73+
npm install -g aws-cdk@2.29.0
74+
cdk --version
75+
- name: install deps
76+
run: |
77+
pip install -r requirements.txt
78+
- name: Download artifact
79+
uses: actions/download-artifact@v3
80+
with:
81+
name: ${{ inputs.artefact-name }}
82+
- name: unzip artefact
83+
run: unzip cdk.out.zip
84+
- name: CDK Deploy Layer
85+
run: cdk deploy --app cdk.out --context region=${{ matrix.region }} 'LayerStack ' --require-approval never --verbose
86+
- name: CDK Deploy Canary
87+
run: cdk deploy --app cdk.out --context region=${{ matrix.region}} --parameters DeployStage="${{ input.stage }}" 'CanaryStack' --require-approval never --verbose

‎layer/.gitignore

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
*.swp
2+
package-lock.json
3+
__pycache__
4+
.pytest_cache
5+
.venv
6+
*.egg-info
7+
8+
# CDK asset staging directory
9+
.cdk.staging
10+
cdk.out

‎layer/README.md

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<!-- markdownlint-disable MD041 MD043-->
2+
# CDK Powertools layer
3+
4+
This is a CDK project to build and deploy AWS Lambda Powertools [Lambda layer](https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-concepts.html#gettingstarted-concepts-layer) to multiple commercial regions.
5+
6+
## Build the layer
7+
8+
To build the layer construct you need to provide the Powertools version that is [available in PyPi](https://pypi.org/project/aws-lambda-powertools/).
9+
You can pass it as a context variable when running `synth` or `deploy`,
10+
11+
```shell
12+
cdk synth --context version=1.25.1
13+
```
14+
15+
## Canary stack
16+
17+
We use a canary stack to verify that the deployment is successful and we can use the layer by adding it to a newly created Lambda function.
18+
The canary is deployed after the layer construct. Because the layer ARN is created during the deploy we need to pass this information async via SSM parameter.
19+
To achieve that we use SSM parameter store to pass the layer ARN to the canary.
20+
The layer stack writes the layer ARN after the deployment as SSM parameter and the canary stacks reads this information and adds the layer to the function.
21+
22+
## Version tracking
23+
24+
AWS Lambda versions Lambda layers by incrementing a number at the end of the ARN.
25+
This makes it challenging to know which Powertools version a layer contains.
26+
For better tracking of the ARNs and the corresponding version we need to keep track which powertools version was deployed to which layer.
27+
To achieve that we created two components. First, we created a version tracking app which receives events via EventBridge. Second, after a successful canary deployment we send the layer ARN, Powertools version, and the region to this EventBridge.

‎layer/app.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#!/usr/bin/env python3
2+
3+
import aws_cdk as cdk
4+
5+
from layer.canary_stack import CanaryStack
6+
from layer.layer_stack import LayerStack
7+
8+
app = cdk.App()
9+
10+
POWERTOOLS_VERSION: str = app.node.try_get_context("version")
11+
SSM_PARAM_LAYER_ARN: str = "/layers/powertools-layer-arn"
12+
13+
if not POWERTOOLS_VERSION:
14+
raise ValueError(
15+
"Please set the version for Powertools by passing the '--context=version:<version>' parameter to the CDK "
16+
"synth step."
17+
)
18+
19+
LayerStack(app, "LayerStack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN)
20+
21+
CanaryStack(app, "CanaryStack", powertools_version=POWERTOOLS_VERSION, ssm_paramter_layer_arn=SSM_PARAM_LAYER_ARN)
22+
23+
app.synth()

‎layer/cdk.json

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"app": "python3 app.py",
3+
"watch": {
4+
"include": [
5+
"**"
6+
],
7+
"exclude": [
8+
"README.md",
9+
"cdk*.json",
10+
"requirements*.txt",
11+
"source.bat",
12+
"**/__init__.py",
13+
"python/__pycache__",
14+
"tests"
15+
]
16+
},
17+
"context": {
18+
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
19+
"@aws-cdk/core:stackRelativeExports": true,
20+
"@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
21+
"@aws-cdk/aws-lambda:recognizeVersionProps": true,
22+
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
23+
"@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true,
24+
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
25+
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
26+
"@aws-cdk/core:checkSecretUsage": true,
27+
"@aws-cdk/aws-iam:minimizePolicies": true,
28+
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
29+
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
30+
"@aws-cdk/core:target-partitions": [
31+
"aws",
32+
"aws-cn"
33+
]
34+
}
35+
}

‎layer/layer/__init__.py

Whitespace-only changes.

‎layer/layer/canary/app.py

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import datetime
2+
import json
3+
import os
4+
from importlib.metadata import version
5+
6+
import boto3
7+
8+
from aws_lambda_powertools import Logger, Metrics, Tracer
9+
10+
logger = Logger(service="version-track")
11+
tracer = Tracer()
12+
metrics = Metrics(namespace="powertools-layer-canary", service="PowertoolsLayerCanary")
13+
14+
layer_arn = os.getenv("POWERTOOLS_LAYER_ARN")
15+
powertools_version = os.getenv("POWERTOOLS_VERSION")
16+
stage = os.getenv("LAYER_PIPELINE_STAGE")
17+
event_bus_arn = os.getenv("VERSION_TRACKING_EVENT_BUS_ARN")
18+
19+
20+
def handler(event):
21+
logger.info("Running checks")
22+
check_envs()
23+
verify_powertools_version()
24+
send_notification()
25+
return True
26+
27+
28+
@logger.inject_lambda_context(log_event=True)
29+
def on_event(event, context):
30+
request_type = event["RequestType"]
31+
# we handle only create events, because we recreate the canary on each run
32+
if request_type == "Create":
33+
return on_create(event)
34+
35+
return "Nothing to be processed"
36+
37+
38+
def on_create(event):
39+
props = event["ResourceProperties"]
40+
logger.info("create new resource with properties %s" % props)
41+
handler(event)
42+
43+
44+
def check_envs():
45+
logger.info('Checking required envs ["POWERTOOLS_LAYER_ARN", "AWS_REGION", "STAGE"]')
46+
if not layer_arn:
47+
raise ValueError("POWERTOOLS_LAYER_ARN is not set. Aborting...")
48+
if not powertools_version:
49+
raise ValueError("POWERTOOLS_VERSION is not set. Aborting...")
50+
if not stage:
51+
raise ValueError("LAYER_PIPELINE_STAGE is not set. Aborting...")
52+
if not event_bus_arn:
53+
raise ValueError("VERSION_TRACKING_EVENT_BUS_ARN is not set. Aborting...")
54+
logger.info("All envs configured, continue...")
55+
56+
57+
def verify_powertools_version() -> None:
58+
"""
59+
fetches the version that we import from the powertools layer and compares
60+
it with expected version set in environment variable, which we pass during deployment.
61+
:raise ValueError if the expected version is not the same as the version we get from the layer
62+
"""
63+
logger.info("Checking Powertools version in library...")
64+
current_version = version("aws_lambda_powertools")
65+
if powertools_version != current_version:
66+
raise ValueError(
67+
f'Expected powertoosl version is "{powertools_version}", but layer contains version "{current_version}"'
68+
)
69+
logger.info(f"Current Powertools version is: {current_version}")
70+
71+
72+
def send_notification():
73+
"""
74+
sends an event to version tracking event bridge
75+
"""
76+
event = {
77+
"Time": datetime.datetime.now(),
78+
"Source": "powertools.layer.canary",
79+
"EventBusName": event_bus_arn,
80+
"DetailType": "deployment",
81+
"Detail": json.dumps(
82+
{
83+
"id": "powertools-python",
84+
"stage": stage,
85+
"region": os.environ["AWS_REGION"],
86+
"version": powertools_version,
87+
"layerArn": layer_arn,
88+
}
89+
),
90+
}
91+
92+
logger.info(f"sending notification event: {event}")
93+
94+
client = boto3.client("events", region_name="eu-central-1")
95+
resp = client.put_events(Entries=[event])
96+
logger.info(resp)
97+
if resp["FailedEntryCount"] != 0:
98+
logger.error(resp)
99+
raise ValueError("Failed to send deployment notification to version tracking")

‎layer/layer/canary_stack.py

+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import uuid
2+
3+
from aws_cdk import CfnParameter, CustomResource, Duration, Stack
4+
from aws_cdk.aws_iam import Effect, ManagedPolicy, PolicyStatement, Role, ServicePrincipal
5+
from aws_cdk.aws_lambda import Code, Function, LayerVersion, Runtime
6+
from aws_cdk.aws_logs import RetentionDays
7+
from aws_cdk.aws_ssm import StringParameter
8+
from aws_cdk.custom_resources import Provider
9+
from constructs import Construct
10+
11+
12+
class CanaryStack(Stack):
13+
def __init__(
14+
self,
15+
scope: Construct,
16+
construct_id: str,
17+
powertools_version: str,
18+
ssm_paramter_layer_arn: str,
19+
**kwargs,
20+
) -> None:
21+
super().__init__(scope, construct_id, **kwargs)
22+
23+
VERSION_TRACKING_EVENT_BUS_ARN: str = (
24+
"arn:aws:events:eu-central-1:027876851704:event-bus/VersionTrackingEventBus"
25+
)
26+
27+
layer_arn = StringParameter.from_string_parameter_attributes(
28+
self, "LayerVersionArnParam", parameter_name=ssm_paramter_layer_arn
29+
).string_value
30+
31+
layer = LayerVersion.from_layer_version_arn(self, "PowertoolsLayer", layer_version_arn=layer_arn)
32+
deploy_stage = CfnParameter(self, "DeployStage", description="Deployment stage for canary").value_as_string
33+
34+
execution_role = Role(self, "LambdaExecutionRole", assumed_by=ServicePrincipal("lambda.amazonaws.com"))
35+
36+
execution_role.add_managed_policy(
37+
ManagedPolicy.from_aws_managed_policy_name("service-role/AWSLambdaBasicExecutionRole")
38+
)
39+
40+
execution_role.add_to_policy(
41+
PolicyStatement(effect=Effect.ALLOW, actions=["lambda:GetFunction"], resources=["*"])
42+
)
43+
44+
canary_lambda = Function(
45+
self,
46+
"CanaryLambdaFunction",
47+
function_name="CanaryLambdaFunction",
48+
code=Code.from_asset("layer/canary"),
49+
handler="app.on_event",
50+
layers=[layer],
51+
memory_size=512,
52+
timeout=Duration.seconds(10),
53+
runtime=Runtime.PYTHON_3_9,
54+
log_retention=RetentionDays.ONE_MONTH,
55+
role=execution_role,
56+
environment={
57+
"POWERTOOLS_VERSION": powertools_version,
58+
"POWERTOOLS_LAYER_ARN": layer_arn,
59+
"VERSION_TRACKING_EVENT_BUS_ARN": VERSION_TRACKING_EVENT_BUS_ARN,
60+
"LAYER_PIPELINE_STAGE": deploy_stage,
61+
},
62+
)
63+
64+
canary_lambda.add_to_role_policy(
65+
PolicyStatement(
66+
effect=Effect.ALLOW, actions=["events:PutEvents"], resources=[VERSION_TRACKING_EVENT_BUS_ARN]
67+
)
68+
)
69+
70+
# custom resource provider configuration
71+
provider = Provider(
72+
self, "CanaryCustomResource", on_event_handler=canary_lambda, log_retention=RetentionDays.ONE_MONTH
73+
)
74+
# force to recreate resource on each deployment with randomized name
75+
CustomResource(self, f"CanaryTrigger-{str(uuid.uuid4())[0:7]}", service_token=provider.service_token)

‎layer/layer/layer_stack.py

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from aws_cdk import Stack
2+
from aws_cdk.aws_ssm import StringParameter
3+
from cdk_lambda_powertools_python_layer import LambdaPowertoolsLayer
4+
from constructs import Construct
5+
6+
7+
class LayerStack(Stack):
8+
def __init__(
9+
self, scope: Construct, construct_id: str, powertools_version: str, ssm_paramter_layer_arn: str, **kwargs
10+
) -> None:
11+
super().__init__(scope, construct_id, **kwargs)
12+
13+
layer = LambdaPowertoolsLayer(
14+
self, "Layer", layer_version_name="AWSLambdaPowertoolsPython", version=powertools_version
15+
)
16+
17+
layer.add_permission("PublicLayerAccess", account_id="*")
18+
19+
StringParameter(self, "VersionArn", parameter_name=ssm_paramter_layer_arn, string_value=layer.layer_version_arn)

‎layer/requirements-dev.txt

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pytest==6.2.5
2+
boto3==1.24.22

‎layer/requirements.txt

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# This file is autogenerated by pip-compile with python 3.9
3+
# To update, run:
4+
#
5+
# pip-compile --generate-hashes requirements.txt
6+
#
7+
attrs==21.4.0 \
8+
--hash=sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4 \
9+
--hash=sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd
10+
# via
11+
# -r requirements.txt
12+
# cattrs
13+
# jsii
14+
aws-cdk-lib==2.29.0 \
15+
--hash=sha256:4f852105cafd28a2bbd9bd2c6d24a2e1ab503bba923fd49a1782390b235af999 \
16+
--hash=sha256:53a78788219d9bf3a998211223225b34a10f066124e2812adcd40fd0a2058572
17+
# via
18+
# -r requirements.txt
19+
# cdk-lambda-powertools-python-layer
20+
cattrs==22.1.0 \
21+
--hash=sha256:94b67b64cf92c994f8784c40c082177dc916e0489a73a9a36b24eb18a9db40c6 \
22+
--hash=sha256:d55c477b4672f93606e992049f15d526dc7867e6c756cd6256d4af92e2b1e364
23+
# via
24+
# -r requirements.txt
25+
# jsii
26+
cdk-lambda-powertools-python-layer==2.0.48 \
27+
--hash=sha256:7bdd5a196e74b48d403223722f2838d1d10064d02e960a5565482cc0b7aad18d \
28+
--hash=sha256:9afeacea31eba14d67360db71af385c654c9e0af9b29a0d4e0922b52f862ae03
29+
# via -r requirements.txt
30+
constructs==10.1.43 \
31+
--hash=sha256:69fd6da574c9506f44ca61e112af7d5db08ebb29b4bedc67b6d200b616f4abce \
32+
--hash=sha256:f37e8c3432f94f403b50bf69476bea55719bcc3fa0d3a0e60bf0975dfe492867
33+
# via
34+
# -r requirements.txt
35+
# aws-cdk-lib
36+
# cdk-lambda-powertools-python-layer
37+
exceptiongroup==1.0.0rc8 \
38+
--hash=sha256:6990c24f06b8d33c8065cfe43e5e8a4bfa384e0358be036af9cc60b6321bd11a \
39+
--hash=sha256:ab0a968e1ef769e55d9a596f4a89f7be9ffedbc9fdefdb77cc68cf5c33ce1035
40+
# via
41+
# -r requirements.txt
42+
# cattrs
43+
jsii==1.61.0 \
44+
--hash=sha256:542a72cd1a144d36fa530dc359b5295b82d9e7ecdd76d5c7b4b61195f132a746 \
45+
--hash=sha256:b2899f24bcc95ce009bc256558c81cde8cff9f830eddbe9b0d581c40558a1ff0
46+
# via
47+
# -r requirements.txt
48+
# aws-cdk-lib
49+
# cdk-lambda-powertools-python-layer
50+
# constructs
51+
publication==0.0.3 \
52+
--hash=sha256:0248885351febc11d8a1098d5c8e3ab2dabcf3e8c0c96db1e17ecd12b53afbe6 \
53+
--hash=sha256:68416a0de76dddcdd2930d1c8ef853a743cc96c82416c4e4d3b5d901c6276dc4
54+
# via
55+
# -r requirements.txt
56+
# aws-cdk-lib
57+
# cdk-lambda-powertools-python-layer
58+
# constructs
59+
python-dateutil==2.8.2 \
60+
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
61+
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
62+
# via
63+
# -r requirements.txt
64+
# jsii
65+
six==1.16.0 \
66+
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
67+
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
68+
# via
69+
# -r requirements.txt
70+
# python-dateutil
71+
typing-extensions==4.3.0 \
72+
--hash=sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02 \
73+
--hash=sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6
74+
# via
75+
# -r requirements.txt
76+
# jsii

0 commit comments

Comments
 (0)
Please sign in to comment.