Skip to content

Commit 17f9a87

Browse files
authored
Use docker login when possible (#2265)
Login to ECR when credentials are available to improve CI performance
1 parent ac7fdf2 commit 17f9a87

File tree

3 files changed

+109
-20
lines changed

3 files changed

+109
-20
lines changed

.github/scripts/acquire-build-image

Lines changed: 63 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import subprocess
1111
import sys
1212
import time
1313
import unittest
14+
import base64
1415

1516
REMOTE_BASE_IMAGE_NAME = "public.ecr.aws/w0m4q9l7/github-awslabs-smithy-rs-ci"
1617
LOCAL_BASE_IMAGE_NAME = "smithy-rs-base-image"
@@ -41,7 +42,8 @@ class Platform(Enum):
4142

4243
# Script context
4344
class Context:
44-
def __init__(self, start_path, script_path, tools_path, user_id, image_tag, allow_local_build, github_actions):
45+
def __init__(self, start_path, script_path, tools_path, user_id, image_tag, allow_local_build, github_actions,
46+
encrypted_docker_password, docker_passphrase):
4547
self.start_path = start_path
4648
self.script_path = script_path
4749
self.tools_path = tools_path
@@ -50,6 +52,8 @@ class Context:
5052
self.image_tag = image_tag
5153
self.allow_local_build = allow_local_build
5254
self.github_actions = github_actions
55+
self.encrypted_docker_password = encrypted_docker_password
56+
self.docker_passphrase = docker_passphrase
5357

5458
@staticmethod
5559
def default():
@@ -60,14 +64,19 @@ class Context:
6064
image_tag = get_cmd_output("./docker-image-hash", cwd=script_path)[1]
6165
allow_local_build = os.getenv("ALLOW_LOCAL_BUILD") != "false"
6266
github_actions = os.getenv("GITHUB_ACTIONS") == "true"
67+
encrypted_docker_password = os.getenv("ENCRYPTED_DOCKER_PASSWORD")
68+
docker_passphrase = os.getenv("DOCKER_LOGIN_TOKEN_PASSPHRASE")
69+
6370
print(f"Start path: {start_path}")
6471
print(f"Script path: {script_path}")
6572
print(f"Tools path: {tools_path}")
6673
print(f"User ID: {user_id}")
6774
print(f"Required base image tag: {image_tag}")
6875
print(f"Allow local build: {allow_local_build}")
6976
print(f"Running in GitHub Actions: {github_actions}")
70-
return Context(start_path, script_path, tools_path, user_id, image_tag, allow_local_build, github_actions)
77+
return Context(start_path=start_path, script_path=script_path, tools_path=tools_path, user_id=user_id,
78+
image_tag=image_tag, allow_local_build=allow_local_build, github_actions=github_actions,
79+
encrypted_docker_password=encrypted_docker_password, docker_passphrase=docker_passphrase)
7180

7281

7382
def output_contains_any(stdout, stderr, messages):
@@ -76,7 +85,6 @@ def output_contains_any(stdout, stderr, messages):
7685
return True
7786
return False
7887

79-
8088
# Mockable shell commands
8189
class Shell:
8290
# Returns the platform that this script is running on
@@ -91,6 +99,9 @@ class Shell:
9199
(status, _, _) = get_cmd_output(f"docker inspect \"{image_name}:{image_tag}\"", check=False)
92100
return status == 0
93101

102+
def docker_login(self, password):
103+
get_cmd_output("docker login --username AWS --password-stdin public.ecr.aws", input=password.encode('utf-8'))
104+
94105
# Pulls the requested `image_name` with `image_tag`. Returns `DockerPullResult`.
95106
def docker_pull(self, image_name, image_tag):
96107
(status, stdout, stderr) = get_cmd_output(f"docker pull \"{image_name}:{image_tag}\"", check=False)
@@ -102,7 +113,7 @@ class Shell:
102113
print("-------------------")
103114

104115
not_found_messages = ["not found: manifest unknown"]
105-
throttle_messages = ["toomanyrequests: Rate exceeded", "toomanyrequests: Data limit exceeded"]
116+
throttle_messages = ["toomanyrequests:"]
106117
retryable_messages = ["net/http: TLS handshake timeout"]
107118
if status == 0:
108119
return DockerPullResult.SUCCESS
@@ -160,17 +171,39 @@ def run(command, cwd=None):
160171

161172

162173
# Returns (status, output) from a shell command
163-
def get_cmd_output(command, cwd=None, check=True):
174+
def get_cmd_output(command, cwd=None, check=True, **kwargs):
175+
if isinstance(command, str):
176+
command = shlex.split(command)
177+
164178
result = subprocess.run(
165-
shlex.split(command),
179+
command,
166180
capture_output=True,
167-
check=check,
168-
cwd=cwd
181+
check=False,
182+
cwd=cwd,
183+
**kwargs
169184
)
170-
return (result.returncode, result.stdout.decode("utf-8").strip(), result.stderr.decode("utf-8").strip())
185+
stdout = result.stdout.decode("utf-8").strip()
186+
stderr = result.stderr.decode("utf-8").strip()
187+
if check and result.returncode != 0:
188+
raise Exception(f"failed to run '{command}.\n{stdout}\n{stderr}")
189+
190+
return result.returncode, stdout, stderr
191+
192+
193+
def decrypt_and_login(shell, secret, passphrase):
194+
decoded = base64.b64decode(secret, validate=True)
195+
if not passphrase:
196+
raise Exception("a secret was set but no passphrase was set (or it was empty)")
197+
(code, password, err) = get_cmd_output(
198+
["gpg", "--decrypt", "--batch", "--quiet", "--passphrase", passphrase, "--output", "-"],
199+
input=decoded)
200+
shell.docker_login(password)
201+
print("Docker login success!")
171202

172203

173204
def acquire_build_image(context=Context.default(), shell=Shell()):
205+
if context.encrypted_docker_password is not None:
206+
decrypt_and_login(shell, context.encrypted_docker_password, context.docker_passphrase)
174207
# If the image doesn't already exist locally, then look remotely
175208
if not shell.docker_image_exists_locally(LOCAL_BASE_IMAGE_NAME, context.image_tag):
176209
announce("Base image not found locally.")
@@ -211,15 +244,18 @@ def acquire_build_image(context=Context.default(), shell=Shell()):
211244

212245

213246
class SelfTest(unittest.TestCase):
214-
def test_context(self, allow_local_build=False, github_actions=False):
247+
def test_context(self, github_actions=False, allow_local_build=False, encrypted_docker_password=None,
248+
docker_passphrase=None):
215249
return Context(
216250
start_path="/tmp/test/start-path",
217251
script_path="/tmp/test/script-path",
218252
tools_path="/tmp/test/tools-path",
219253
user_id="123",
220254
image_tag="someimagetag",
255+
encrypted_docker_password=encrypted_docker_password,
256+
docker_passphrase=docker_passphrase,
257+
github_actions=github_actions,
221258
allow_local_build=allow_local_build,
222-
github_actions=github_actions
223259
)
224260

225261
def mock_shell(self):
@@ -231,6 +267,7 @@ class SelfTest(unittest.TestCase):
231267
shell.docker_pull = MagicMock()
232268
shell.docker_save = MagicMock()
233269
shell.docker_tag = MagicMock()
270+
shell.docker_login = MagicMock()
234271
return shell
235272

236273
def test_retry_architecture_mismatch(self):
@@ -247,6 +284,13 @@ class SelfTest(unittest.TestCase):
247284
)
248285
)
249286

287+
def test_docker_login(self):
288+
shell = self.mock_shell()
289+
acquire_build_image(self.test_context(
290+
encrypted_docker_password="jA0ECQMCvYU/JxsX3g/70j0BxbLLW8QaFWWb/DqY9gPhTuEN/xdYVxaoDnV6Fha+lAWdT7xN0qZr5DHPBalLfVvvM1SEXRBI8qnfXyGI",
291+
docker_passphrase="secret"), shell)
292+
shell.docker_login.assert_called_with("payload")
293+
250294
def test_retry_immediate_success(self):
251295
shell = self.mock_shell()
252296
shell.docker_pull.side_effect = [DockerPullResult.SUCCESS]
@@ -374,7 +418,7 @@ class SelfTest(unittest.TestCase):
374418

375419
shell.docker_image_exists_locally.assert_called_once()
376420
shell.docker_tag.assert_called_with(LOCAL_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, LOCAL_TAG)
377-
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path")
421+
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path/ci-build")
378422

379423
# When:
380424
# - the base image doesn't exist locally
@@ -391,10 +435,10 @@ class SelfTest(unittest.TestCase):
391435

392436
self.assertEqual(0, acquire_build_image(context, shell))
393437
shell.docker_image_exists_locally.assert_called_once()
394-
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path")
438+
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path/ci-build")
395439
shell.docker_save.assert_not_called()
396440
shell.docker_tag.assert_called_with(LOCAL_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, LOCAL_TAG)
397-
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path")
441+
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path/ci-build")
398442

399443
# When:
400444
# - the base image doesn't exist locally
@@ -411,10 +455,10 @@ class SelfTest(unittest.TestCase):
411455

412456
self.assertEqual(0, acquire_build_image(context, shell))
413457
shell.docker_image_exists_locally.assert_called_once()
414-
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path")
458+
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path/ci-build")
415459
shell.docker_save.assert_not_called()
416460
shell.docker_tag.assert_called_with(LOCAL_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, LOCAL_TAG)
417-
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path")
461+
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path/ci-build")
418462

419463
# When:
420464
# - the base image doesn't exist locally
@@ -431,14 +475,14 @@ class SelfTest(unittest.TestCase):
431475

432476
self.assertEqual(0, acquire_build_image(context, shell))
433477
shell.docker_image_exists_locally.assert_called_once()
434-
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path")
478+
shell.docker_build_base_image.assert_called_with("someimagetag", "/tmp/test/tools-path/ci-build")
435479
shell.docker_save.assert_called_with(
436480
LOCAL_BASE_IMAGE_NAME,
437481
"someimagetag",
438482
"/tmp/test/start-path/smithy-rs-base-image"
439483
)
440484
shell.docker_tag.assert_called_with(LOCAL_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, LOCAL_TAG)
441-
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path")
485+
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path/ci-build")
442486

443487
# When:
444488
# - the base image doesn't exist locally
@@ -478,7 +522,7 @@ class SelfTest(unittest.TestCase):
478522
call(REMOTE_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, "someimagetag"),
479523
call(LOCAL_BASE_IMAGE_NAME, "someimagetag", LOCAL_BASE_IMAGE_NAME, LOCAL_TAG)
480524
])
481-
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path")
525+
shell.docker_build_build_image.assert_called_with("123", "/tmp/test/tools-path/ci-build")
482526

483527

484528
def main():

.github/workflows/ci-pr.yml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,45 @@ env:
1616
ecr_repository: public.ecr.aws/w0m4q9l7/github-awslabs-smithy-rs-ci
1717

1818
jobs:
19+
# This job will, if possible, save a docker login password to the job outputs. The token will
20+
# be encrypted with the passphrase stored as a GitHub secret. The login password expires after 12h.
21+
# The login password is encrypted with the repo secret DOCKER_LOGIN_TOKEN_PASSPHRASE
22+
save-docker-login-token:
23+
outputs:
24+
docker-login-password: ${{ steps.set-token.outputs.docker-login-password }}
25+
permissions:
26+
id-token: write
27+
contents: read
28+
continue-on-error: true
29+
name: Save a docker login token
30+
runs-on: ubuntu-latest
31+
steps:
32+
- name: Attempt to load a docker login password
33+
uses: aws-actions/configure-aws-credentials@v1-node16
34+
with:
35+
role-to-assume: ${{ secrets.SMITHY_RS_PUBLIC_ECR_PUSH_ROLE_ARN }}
36+
role-session-name: GitHubActions
37+
aws-region: us-west-2
38+
- name: Save the docker login password to the output
39+
id: set-token
40+
run: |
41+
ENCRYPTED_PAYLOAD=$(
42+
gpg --symmetric --batch --passphrase "${{ secrets.DOCKER_LOGIN_TOKEN_PASSPHRASE }}" --output - <(aws ecr-public get-login-password --region us-east-1) | base64 -w0
43+
)
44+
echo "docker-login-password=$ENCRYPTED_PAYLOAD" >> $GITHUB_OUTPUT
45+
46+
1947
# This job detects if the PR made changes to build tools. If it did, then it builds a new
2048
# build Docker image. Otherwise, it downloads a build image from Public ECR. In both cases,
2149
# it uploads the image as a build artifact for other jobs to download and use.
2250
acquire-base-image:
2351
name: Acquire Base Image
52+
needs: save-docker-login-token
2453
if: ${{ github.event.pull_request.head.repo.full_name == 'awslabs/smithy-rs' }}
2554
runs-on: ubuntu-latest
55+
env:
56+
ENCRYPTED_DOCKER_PASSWORD: ${{ needs.save-docker-login-token.outputs.docker-login-password }}
57+
DOCKER_LOGIN_TOKEN_PASSPHRASE: ${{ secrets.DOCKER_LOGIN_TOKEN_PASSPHRASE }}
2658
permissions:
2759
id-token: write
2860
contents: read
@@ -50,11 +82,16 @@ jobs:
5082
5183
# Run shared CI after the Docker build image has either been rebuilt or found in ECR
5284
ci:
53-
needs: acquire-base-image
85+
needs:
86+
- save-docker-login-token
87+
- acquire-base-image
5488
if: ${{ github.event.pull_request.head.repo.full_name == 'awslabs/smithy-rs' }}
5589
uses: ./.github/workflows/ci.yml
5690
with:
5791
run_sdk_examples: true
92+
secrets:
93+
ENCRYPTED_DOCKER_PASSWORD: ${{ needs.save-docker-login-token.outputs.docker-login-password }}
94+
DOCKER_LOGIN_TOKEN_PASSPHRASE: ${{ secrets.DOCKER_LOGIN_TOKEN_PASSPHRASE }}
5895

5996
# The PR bot requires a Docker build image, so make it depend on the `acquire-base-image` job.
6097
pr_bot:

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,18 @@ on:
2121
required: false
2222
type: string
2323
default: ''
24+
secrets:
25+
# the docker login password for ECR. This is
26+
ENCRYPTED_DOCKER_PASSWORD:
27+
required: false
28+
DOCKER_LOGIN_TOKEN_PASSPHRASE:
29+
required: false
2430

2531
env:
2632
rust_version: 1.62.1
2733
rust_toolchain_components: clippy,rustfmt
34+
ENCRYPTED_DOCKER_PASSWORD: ${{ secrets.ENCRYPTED_DOCKER_PASSWORD }}
35+
DOCKER_LOGIN_TOKEN_PASSPHRASE: ${{ secrets.DOCKER_LOGIN_TOKEN_PASSPHRASE }}
2836

2937
jobs:
3038
# The `generate` job runs scripts that produce artifacts that are required by the `test` job,

0 commit comments

Comments
 (0)