From b4f084e928b56f43c9afa5773b761bbfcd7e83e2 Mon Sep 17 00:00:00 2001 From: Arjun Raman Date: Mon, 13 Jun 2022 14:53:54 -0700 Subject: [PATCH] feat: added ECR Public Registry support --- README.md | 246 ++++++++++++++++++++++++--------- action.yml | 22 ++- cleanup.js | 13 +- cleanup.test.js | 86 +++++++----- dist/cleanup/index.js | 13 +- dist/index.js | 103 +++++++++++--- index.js | 103 +++++++++++--- index.test.js | 310 +++++++++++++++++++++++++++++++++--------- 8 files changed, 680 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index 17239e42..6a4b1a2c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ ## Amazon ECR "Login" Action for GitHub Actions -Logs in the local Docker client to one or more Amazon ECR registries. +Logs in the local Docker client to one or more Amazon ECR Private registries or an Amazon ECR Public registry. **Table of Contents** -- [Usage](#usage) +- [Example of Usage](#example-of-usage) - [Credentials and Region](#credentials-and-region) - [Permissions](#permissions) - [License Summary](#license-summary) @@ -14,117 +14,231 @@ Logs in the local Docker client to one or more Amazon ECR registries. -## Usage +## Example of Usage +Logging in to Amazon ECR Private, then building and pushing a Docker image: ```yaml - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 - - name: Build, tag, and push image to Amazon ECR + - name: Build, tag, and push docker image to Amazon ECR env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: my-ecr-repo + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: my-ecr-repo IMAGE_TAG: ${{ github.sha }} run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG + docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . + docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG ``` +Logging in to Amazon ECR Public, then building and pushing a Docker image: +```yaml + - name: Login to Amazon ECR Public + id: login-ecr-public + uses: aws-actions/amazon-ecr-login@v1 + with: + registry-type: 'public' + + - name: Build, tag, and push docker image to Amazon ECR Public + env: + REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} + REGISTRY_ALIAS: my-ecr-public-registry-alias + REPOSITORY: my-ecr-public-repo + IMAGE_TAG: ${{ github.sha }} + run: | + docker build -t $REGISTRY/$REGISTRY_ALIAS/$REPOSITORY:$IMAGE_TAG . + docker push $REGISTRY/$REGISTRY_ALIAS/$REPOSITORY:$IMAGE_TAG +``` + +Logging in to Amazon ECR Private, then packaging and pushing a Helm chart: +```yaml + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Package and push helm chart to Amazon ECR + env: + REGISTRY: ${{ steps.login-ecr.outputs.registry }} + REPOSITORY: my-ecr-repo + run: | + helm package $REPOSITORY + helm push $REPOSITORY-0.1.0.tgz oci://$REGISTRY +``` + +Logging in to Amazon ECR Public, then packaging and pushing a Helm chart: +```yaml + - name: Login to Amazon ECR Public + id: login-ecr-public + uses: aws-actions/amazon-ecr-login@v1 + with: + registry-type: 'public' + + - name: Package and push helm chart to Amazon ECR Public + env: + REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} + REGISTRY_ALIAS: my-ecr-public-registry-alias + REPOSITORY: my-ecr-public-repo + run: | + helm package $REPOSITORY + helm push $REPOSITORY-0.1.0.tgz oci://$REGISTRY/$REGISTRY_ALIAS +``` + +Helm uses the same credential store as Docker. So Helm can authenticate with the same credentials that you use for Docker. + See [action.yml](action.yml) for the full documentation for this action's inputs and outputs. ## Credentials and Region -This action relies on the [default behavior of the AWS SDK for Javascript](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html) to determine AWS credentials and region. Use [the `aws-actions/configure-aws-credentials` action](https://github.com/aws-actions/configure-aws-credentials) to configure the GitHub Actions environment with environment variables containing AWS credentials and your desired region. +### AWS Credentials + +This action relies on the [default behavior of the AWS SDK for Javascript](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/setting-credentials-node.html) to determine AWS credentials and region. Use [the `aws-actions/configure-aws-credentials` action](https://github.com/aws-actions/configure-aws-credentials) to configure the GitHub Actions environment with a role using GitHub's OIDC provider and your desired region. ```yaml - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-2 + role-to-assume: arn:aws:iam::123456789012:role/my-github-actions-role + aws-region: us-east-1 - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v1 ``` -We recommend following [Amazon IAM best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) for the AWS credentials used in GitHub Actions workflows, including: -* Do not store credentials in your repository's code. You may use [GitHub Actions secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets) to store credentials and redact credentials from GitHub Actions workflow logs. -* [Create an individual IAM user](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#create-iam-users) with an access key for use in GitHub Actions workflows, preferably one per repository. Do not use the AWS account root user access key. -* [Grant least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege) to the credentials used in GitHub Actions workflows. Grant only the permissions required to perform the actions in your GitHub Actions workflows. See the Permissions section below for the permissions required by this action. -* [Rotate the credentials](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#rotate-credentials) used in GitHub Actions workflows regularly. -* [Monitor the activity](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#keep-a-log) of the credentials used in GitHub Actions workflows. +We recommend following [Amazon IAM best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) when using AWS services in GitHub Actions workflows, including: +* [Assume an IAM role](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#delegate-using-roles) to receive temporary credentials. See the [Sample IAM Role CloudFormation Template](https://github.com/aws-actions/configure-aws-credentials#sample-iam-role-cloudformation-template) in the `aws-actions/configure-aws-credentials` action to get an example. +* [Grant least privilege](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#grant-least-privilege) to the IAM role used in GitHub Actions workflows. Grant only the permissions required to perform the actions in your GitHub Actions workflows. See the Permissions section below for the permissions required by this action. +* [Monitor the activity](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html#keep-a-log) of the IAM role used in GitHub Actions workflows. -### Docker credentials +### Docker Credentials After the authentication, you can access the docker username and password via Action outputs using the following format: -- Registry URL: `111111111111.dkr.ecr.aws-region-1.amazonaws.com` -- Docker username output: `docker_username_111111111111_dkr_ecr_aws_region_1_amazonaws_com` -- Docker password output: `docker_password_111111111111_dkr_ecr_aws_region_1_amazonaws_com` +- Registry URI for ECR Private: `123456789012.dkr.ecr.aws-region-1.amazonaws.com` +- Registry URI for ECR Public: `public.ecr.aws` + +If using ECR Private: +- Docker username output: `docker_username_123456789012_dkr_ecr_aws_region_1_amazonaws_com` +- Docker password output: `docker_password_123456789012_dkr_ecr_aws_region_1_amazonaws_com` + +If using ECR Public: +- Docker username output: `docker_username_public_ecr_aws` +- Docker password output: `docker_password_public_ecr_aws` + +To push Helm charts, you can also login through Docker. By default, Helm can authenticate with the same credentials that you use for Docker. ## Permissions -This action requires the following minimum set of permissions: +### ECR Private + +To see how and where to implement the permissions below, see the [IAM section in the Amazon ECR User Guide](https://docs.aws.amazon.com/AmazonECR/latest/userguide/security-iam.html). + +This action requires the following minimum set of permissions to login to ECR Private: ```json { - "Version":"2012-10-17", - "Statement":[ - { - "Sid":"GetAuthorizationToken", - "Effect":"Allow", - "Action":[ - "ecr:GetAuthorizationToken" - ], - "Resource":"*" - } - ] + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "GetAuthorizationToken", + "Effect": "Allow", + "Action": [ + "ecr:GetAuthorizationToken" + ], + "Resource": "*" + } + ] } ``` Docker commands in your GitHub Actions workflow, like `docker pull` and `docker push`, may require additional permissions attached to the credentials used by this action. -The following minimum permissions are required for pulling an image from an ECR repository: + +The following minimum permissions are required for pulling an image from an ECR Private repository: ```json { - "Version":"2012-10-17", - "Statement":[ - { - "Sid":"AllowPull", - "Effect":"Allow", - "Action":[ - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage", - "ecr:BatchCheckLayerAvailability" - ], - "Resource":"arn:aws:ecr:us-east-1:123456789012:repository/my-repo" - } - ] + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowPull", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:GetDownloadUrlForLayer" + ], + "Resource": "arn:aws:ecr:us-east-1:123456789012:repository/my-ecr-repo" + } + ] } ``` -The following minimum permissions are required for pushing and pulling images in an ECR repository: +The following minimum permissions are required for pushing and pulling images in an ECR Private repository: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowPushPull", + "Effect": "Allow", + "Action": [ + "ecr:BatchGetImage", + "ecr:BatchCheckLayerAvailability", + "ecr:CompleteLayerUpload", + "ecr:GetDownloadUrlForLayer", + "ecr:InitiateLayerUpload", + "ecr:PutImage", + "ecr:UploadLayerPart" + ], + "Resource": "arn:aws:ecr:us-east-1:123456789012:repository/my-ecr-repo" + } + ] +} +``` + +### ECR Public + +To see how and where to implement the permissions below, see the [IAM section in the Amazon ECR Public User Guide](https://docs.aws.amazon.com/AmazonECR/latest/public/security-iam.html). + + +This action requires the following minimum set of permissions to login to ECR Public: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "GetAuthorizationToken", + "Effect": "Allow", + "Action": [ + "ecr-public:GetAuthorizationToken" + ], + "Resource": "*" + } + ] +} +``` + +Docker commands in your GitHub Actions workflow, like `docker push`, may require additional permissions attached to the credentials used by this action. There are no permissions needed for pulling images from ECR Public. + +The following minimum permissions are required for pushing an image to an ECR Public repository: ```json { - "Version":"2012-10-17", - "Statement":[ - { - "Sid":"AllowPush", - "Effect":"Allow", - "Action":[ - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage", - "ecr:BatchCheckLayerAvailability", - "ecr:PutImage", - "ecr:InitiateLayerUpload", - "ecr:UploadLayerPart", - "ecr:CompleteLayerUpload" - ], - "Resource":"arn:aws:ecr:us-east-1:123456789012:repository/my-repo" - } - ] + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowPush", + "Effect": "Allow", + "Action": [ + "ecr-public:BatchCheckLayerAvailability", + "ecr-public:CompleteLayerUpload", + "ecr-public:InitiateLayerUpload", + "ecr-public:PutImage", + "ecr-public:UploadLayerPart" + ], + "Resource": "arn:aws:ecr-public:us-east-1:123456789012:repository/my-ecr-public-repo" + } + ] } ``` diff --git a/action.yml b/action.yml index 6669b377..bdf0ef6c 100644 --- a/action.yml +++ b/action.yml @@ -1,19 +1,33 @@ name: 'Amazon ECR "Login" Action for GitHub Actions' -description: 'Logs in the local Docker client to one or more ECR registries' +description: 'Logs in the local Docker client to one or more Amazon ECR Private registries or an Amazon ECR Public registry' branding: icon: 'cloud' color: 'orange' inputs: registries: - description: 'A comma-delimited list of AWS account IDs that are associated with the ECR registries. If you do not specify a registry, the default ECR registry is assumed.' + description: >- + A comma-delimited list of AWS account IDs that are associated with the ECR Private registries. + If you do not specify a registry, the default ECR Private registry is assumed. + If 'public' is given as input to 'registry-type', this input is ignored. required: false skip-logout: - description: 'Whether to skip explicit logout of the registries during post-job cleanup. Exists for backward compatibility on self-hosted runners. Not recommended.' + description: >- + Whether to skip explicit logout of the registries during post-job cleanup. + Exists for backward compatibility on self-hosted runners. + Not recommended. required: false default: 'false' + registry-type: + description: >- + Which ECR registry type to log into. + Options: 'private', 'public' + required: false + default: 'private' outputs: registry: - description: 'The URI of the ECR registry i.e. aws_account_id.dkr.ecr.region.amazonaws.com. If multiple registries are provided as inputs, this output will not be set.' + description: >- + The URI of the ECR Private or ECR Public registry. + If logging into multiple registries on ECR Private, this output will not be set. runs: using: 'node12' main: 'dist/index.js' diff --git a/cleanup.js b/cleanup.js index 67848f7c..70e407c2 100644 --- a/cleanup.js +++ b/cleanup.js @@ -2,13 +2,16 @@ const core = require('@actions/core'); const exec = require('@actions/exec'); /** - * When the GitHub Actions job is done, remove saved ECR credentials from the - * local Docker engine in the job's environment. + * When the GitHub Actions job is done, logout of ECR Private/Public. */ +const STATES = { + registries: 'registries' +}; + async function cleanup() { try { - const registriesState = core.getState('registries'); + const registriesState = core.getState(STATES.registries); if (registriesState) { const registries = registriesState.split(','); @@ -16,7 +19,7 @@ async function cleanup() { // Logout of each registry for (const registry of registries) { - core.debug(`Logging out of registry ${registry}`); + core.info(`Logging out of registry ${registry}`); // Execute the docker logout command let doLogoutStdout = ''; @@ -35,7 +38,7 @@ async function cleanup() { }); if (exitCode !== 0) { core.debug(doLogoutStdout); - core.error(`Could not logout registry ${registry}: ${doLogoutStderr}`); + core.error(`Could not logout of registry ${registry}: ${doLogoutStderr}`); failedLogouts.push(registry); } } diff --git a/cleanup.test.js b/cleanup.test.js index 78b9dbc8..9e04027d 100644 --- a/cleanup.test.js +++ b/cleanup.test.js @@ -5,13 +5,22 @@ const exec = require('@actions/exec'); jest.mock('@actions/core'); jest.mock('@actions/exec'); +function mockGetState(requestResponse) { + return function (name, options) { // eslint-disable-line no-unused-vars + return requestResponse[name] + } +} + +const ECR_STATES = { + 'registries': '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com' +}; + describe('Logout from ECR', () => { beforeEach(() => { jest.clearAllMocks(); - core.getState.mockReturnValue( - '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com'); + core.getState = jest.fn().mockImplementation(mockGetState(ECR_STATES)); exec.exec.mockReturnValue(0); }); @@ -19,8 +28,6 @@ describe('Logout from ECR', () => { await cleanup(); expect(core.getState).toHaveBeenCalledWith('registries'); - - expect(exec.exec).toHaveBeenCalledTimes(2); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', ['logout', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'], @@ -29,17 +36,36 @@ describe('Logout from ECR', () => { 'docker', ['logout', '111111111111.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); + expect(exec.exec).toHaveBeenCalledTimes(2); + expect(core.setFailed).toHaveBeenCalledTimes(0); + }); + + test('logs out docker client for public registry in action state', async () => { + const mockStates = { + 'registries': 'public.ecr.aws' + }; + core.getState = jest.fn().mockImplementation(mockGetState(mockStates)); + await cleanup(); + + expect(core.getState).toHaveBeenCalledWith('registries'); + expect(exec.exec).toHaveBeenNthCalledWith(1, + 'docker', + ['logout', 'public.ecr.aws'], + expect.anything()); + expect(exec.exec).toHaveBeenCalledTimes(1); expect(core.setFailed).toHaveBeenCalledTimes(0); }); test('handles zero registries', async () => { - core.getState.mockReturnValue(''); + const mockStates = { + 'registries' : '' + }; + core.getState = jest.fn().mockImplementation(mockGetState(mockStates)); await cleanup(); expect(core.getState).toHaveBeenCalledWith('registries'); - expect(exec.exec).toHaveBeenCalledTimes(0); expect(core.setFailed).toHaveBeenCalledTimes(0); }); @@ -49,34 +75,31 @@ describe('Logout from ECR', () => { await cleanup(); - expect(core.setFailed).toBeCalled(); + expect(core.setFailed).toHaveBeenCalled(); }); test('continues to attempt logouts after a failed logout', async () => { - core.getState.mockReturnValue( - '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com,222222222222.dkr.ecr.aws-region-1.amazonaws.com'); - exec.exec - .mockImplementationOnce((commandLine, args, options) => { - options.listeners.stdout('stdout of '); - options.listeners.stdout('registry 1'); - options.listeners.stderr('stderr of '); - options.listeners.stderr('registry 1'); - return(1); - }) - .mockImplementationOnce((commandLine, args, options) => { - options.listeners.stdout('stdout of '); - options.listeners.stdout('registry 2'); - options.listeners.stderr('stderr of '); - options.listeners.stderr('registry 2'); - return(1); - }) - .mockReturnValueOnce(0); + const mockStates = { + 'registries' : '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com,222222222222.dkr.ecr.aws-region-1.amazonaws.com' + }; + core.getState = jest.fn().mockImplementation(mockGetState(mockStates)); + exec.exec.mockImplementationOnce((commandLine, args, options) => { + options.listeners.stdout('stdout of '); + options.listeners.stdout('registry 1'); + options.listeners.stderr('stderr of '); + options.listeners.stderr('registry 1'); + return(1); + }).mockImplementationOnce((commandLine, args, options) => { + options.listeners.stdout('stdout of '); + options.listeners.stdout('registry 2'); + options.listeners.stderr('stderr of '); + options.listeners.stderr('registry 2'); + return(1); + }).mockReturnValueOnce(0); await cleanup(); expect(core.getState).toHaveBeenCalledWith('registries'); - - expect(exec.exec).toHaveBeenCalledTimes(3); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', ['logout', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'], @@ -89,12 +112,11 @@ describe('Logout from ECR', () => { 'docker', ['logout', '222222222222.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); - + expect(core.error).toHaveBeenNthCalledWith(1, 'Could not logout of registry 123456789012.dkr.ecr.aws-region-1.amazonaws.com: stderr of registry 1'); + expect(core.error).toHaveBeenNthCalledWith(2, 'Could not logout of registry 111111111111.dkr.ecr.aws-region-1.amazonaws.com: stderr of registry 2'); + expect(core.setFailed).toHaveBeenCalledWith('Failed to logout: 123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com'); + expect(exec.exec).toHaveBeenCalledTimes(3); expect(core.error).toHaveBeenCalledTimes(2); - expect(core.error).toHaveBeenNthCalledWith(1, 'Could not logout registry 123456789012.dkr.ecr.aws-region-1.amazonaws.com: stderr of registry 1'); - expect(core.error).toHaveBeenNthCalledWith(2, 'Could not logout registry 111111111111.dkr.ecr.aws-region-1.amazonaws.com: stderr of registry 2'); - expect(core.setFailed).toHaveBeenCalledTimes(1); - expect(core.setFailed).toHaveBeenCalledWith('Failed to logout: 123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com'); }); }); diff --git a/dist/cleanup/index.js b/dist/cleanup/index.js index d854cb8d..3b9c78fe 100644 --- a/dist/cleanup/index.js +++ b/dist/cleanup/index.js @@ -1418,13 +1418,16 @@ const core = __webpack_require__(470); const exec = __webpack_require__(986); /** - * When the GitHub Actions job is done, remove saved ECR credentials from the - * local Docker engine in the job's environment. + * When the GitHub Actions job is done, logout of ECR Private/Public. */ +const STATES = { + registries: 'registries' +}; + async function cleanup() { try { - const registriesState = core.getState('registries'); + const registriesState = core.getState(STATES.registries); if (registriesState) { const registries = registriesState.split(','); @@ -1432,7 +1435,7 @@ async function cleanup() { // Logout of each registry for (const registry of registries) { - core.debug(`Logging out of registry ${registry}`); + core.info(`Logging out of registry ${registry}`); // Execute the docker logout command let doLogoutStdout = ''; @@ -1451,7 +1454,7 @@ async function cleanup() { }); if (exitCode !== 0) { core.debug(doLogoutStdout); - core.error(`Could not logout registry ${registry}: ${doLogoutStderr}`); + core.error(`Could not logout of registry ${registry}: ${doLogoutStderr}`); failedLogouts.push(registry); } } diff --git a/dist/index.js b/dist/index.js index 39c8a588..e56f5bd3 100644 --- a/dist/index.js +++ b/dist/index.js @@ -522,24 +522,90 @@ const core = __webpack_require__(6470); const exec = __webpack_require__(4986); const aws = __webpack_require__(9350); +const ECR_LOGIN_GITHUB_ACTION_USER_AGENT = 'amazon-ecr-login-for-github-actions'; +const ECR_PUBLIC_REGISTRY_URI = 'public.ecr.aws'; + +const INPUTS = { + skipLogout: 'skip-logout', + registries: 'registries', + registryType: 'registry-type' +}; + +const OUTPUTS = { + registry: 'registry', + dockerUsername: 'docker_username', + dockerPassword: 'docker_password' +}; + +const STATES = { + registries: 'registries' +}; + +const REGISTRY_TYPES = { + private: 'private', + public: 'public' +}; + + function replaceSpecialCharacters(registryUri) { return registryUri.replace(/[^a-zA-Z0-9_]+/g, '_'); } +async function getEcrAuthTokenWrapper(authTokenRequest) { + const ecr = new aws.ECR({ + customUserAgent: ECR_LOGIN_GITHUB_ACTION_USER_AGENT + }); + const authTokenResponse = await ecr.getAuthorizationToken(authTokenRequest).promise(); + if (!authTokenResponse) { + throw new Error('Amazon ECR authorization token returned no data'); + } else if (!authTokenResponse.authorizationData || !Array.isArray(authTokenResponse.authorizationData)) { + throw new Error('Amazon ECR authorization token is invalid'); + } else if (!authTokenResponse.authorizationData.length) { + throw new Error('Amazon ECR authorization token does not contain any authorization data'); + } + + return authTokenResponse; +} + +async function getEcrPublicAuthTokenWrapper(authTokenRequest) { + const ecrPublic = new aws.ECRPUBLIC({ + customUserAgent: ECR_LOGIN_GITHUB_ACTION_USER_AGENT + }); + const authTokenResponse = await ecrPublic.getAuthorizationToken(authTokenRequest).promise(); + if (!authTokenResponse) { + throw new Error('Amazon ECR Public authorization token returned no data'); + } else if (!authTokenResponse.authorizationData) { + throw new Error('Amazon ECR Public authorization token is invalid'); + } else if (Object.keys(authTokenResponse.authorizationData).length === 0) { + throw new Error('Amazon ECR Public authorization token does not contain any authorization data'); + } + + return { + authorizationData: [ + { + authorizationToken: authTokenResponse.authorizationData.authorizationToken, + proxyEndpoint: ECR_PUBLIC_REGISTRY_URI + } + ] + }; +} + async function run() { // Get inputs - const skipLogout = core.getInput('skip-logout', { required: false }) === 'true'; - const registries = core.getInput('registries', { required: false }); + const skipLogout = core.getInput(INPUTS.skipLogout, { required: false }).toLowerCase() === 'true'; + const registries = core.getInput(INPUTS.registries, { required: false }); + const registryType = core.getInput(INPUTS.registryType, { required: false }).toLowerCase() || REGISTRY_TYPES.private; const registryUriState = []; try { - // Get the ECR authorization token(s) - const ecr = new aws.ECR({ - customUserAgent: 'amazon-ecr-login-for-github-actions' - }); + if (registryType !== REGISTRY_TYPES.private && registryType !== REGISTRY_TYPES.public) { + throw new Error(`Invalid input for '${INPUTS.registryType}', possible options are [${REGISTRY_TYPES.private}, ${REGISTRY_TYPES.public}]`); + } + + // Get the ECR/ECR Public authorization token(s) const authTokenRequest = {}; - if (registries) { + if (registryType === REGISTRY_TYPES.private && registries) { const registryIds = registries.split(','); core.debug(`Requesting auth token for ${registryIds.length} registries:`); for (const id of registryIds) { @@ -547,10 +613,9 @@ async function run() { } authTokenRequest.registryIds = registryIds; } - const authTokenResponse = await ecr.getAuthorizationToken(authTokenRequest).promise(); - if (!authTokenResponse || !Array.isArray(authTokenResponse.authorizationData) || !authTokenResponse.authorizationData.length) { - throw new Error('Could not retrieve an authorization token from Amazon ECR'); - } + const authTokenResponse = registryType === REGISTRY_TYPES.private ? + await getEcrAuthTokenWrapper(authTokenRequest) : + await getEcrPublicAuthTokenWrapper(authTokenRequest); // Login to each registry for (const authData of authTokenResponse.authorizationData) { @@ -559,11 +624,11 @@ async function run() { const proxyEndpoint = authData.proxyEndpoint; const registryUri = proxyEndpoint.replace(/^https?:\/\//,''); - core.debug(`Logging in to registry ${registryUri}`); + core.info(`Logging into registry ${registryUri}`); // output the registry URI if this action is doing a single registry login if (authTokenResponse.authorizationData.length === 1) { - core.setOutput('registry', registryUri); + core.setOutput(OUTPUTS.registry, registryUri); } // Execute the docker login command @@ -583,14 +648,14 @@ async function run() { }); if (exitCode !== 0) { core.debug(doLoginStdout); - throw new Error(`Could not login to ${proxyEndpoint}: ${doLoginStderr}`); + throw new Error(`Could not login to registry ${registryUri}: ${doLoginStderr}`); } // Output docker username and password - const secretSuffix = replaceSpecialCharacters(registryUri) + const secretSuffix = replaceSpecialCharacters(registryUri); core.setSecret(creds[1]); - core.setOutput(`docker_username_${secretSuffix}`, creds[0]); - core.setOutput(`docker_password_${secretSuffix}`, creds[1]); + core.setOutput(`${OUTPUTS.dockerUsername}_${secretSuffix}`, creds[0]); + core.setOutput(`${OUTPUTS.dockerPassword}_${secretSuffix}`, creds[1]); registryUriState.push(registryUri); } @@ -602,9 +667,9 @@ async function run() { // Pass the logged-in registry URIs to the post action for logout if (registryUriState.length) { if (!skipLogout) { - core.saveState('registries', registryUriState.join()); + core.saveState(STATES.registries, registryUriState.join()); } - core.debug(`'skip-logout' is ${skipLogout} for ${registryUriState.length} registries.`); + core.debug(`'${INPUTS.skipLogout}' is ${skipLogout} for ${registryUriState.length} registries.`); } } diff --git a/index.js b/index.js index a43315c4..3e7f562d 100644 --- a/index.js +++ b/index.js @@ -2,24 +2,90 @@ const core = require('@actions/core'); const exec = require('@actions/exec'); const aws = require('aws-sdk'); +const ECR_LOGIN_GITHUB_ACTION_USER_AGENT = 'amazon-ecr-login-for-github-actions'; +const ECR_PUBLIC_REGISTRY_URI = 'public.ecr.aws'; + +const INPUTS = { + skipLogout: 'skip-logout', + registries: 'registries', + registryType: 'registry-type' +}; + +const OUTPUTS = { + registry: 'registry', + dockerUsername: 'docker_username', + dockerPassword: 'docker_password' +}; + +const STATES = { + registries: 'registries' +}; + +const REGISTRY_TYPES = { + private: 'private', + public: 'public' +}; + + function replaceSpecialCharacters(registryUri) { return registryUri.replace(/[^a-zA-Z0-9_]+/g, '_'); } +async function getEcrAuthTokenWrapper(authTokenRequest) { + const ecr = new aws.ECR({ + customUserAgent: ECR_LOGIN_GITHUB_ACTION_USER_AGENT + }); + const authTokenResponse = await ecr.getAuthorizationToken(authTokenRequest).promise(); + if (!authTokenResponse) { + throw new Error('Amazon ECR authorization token returned no data'); + } else if (!authTokenResponse.authorizationData || !Array.isArray(authTokenResponse.authorizationData)) { + throw new Error('Amazon ECR authorization token is invalid'); + } else if (!authTokenResponse.authorizationData.length) { + throw new Error('Amazon ECR authorization token does not contain any authorization data'); + } + + return authTokenResponse; +} + +async function getEcrPublicAuthTokenWrapper(authTokenRequest) { + const ecrPublic = new aws.ECRPUBLIC({ + customUserAgent: ECR_LOGIN_GITHUB_ACTION_USER_AGENT + }); + const authTokenResponse = await ecrPublic.getAuthorizationToken(authTokenRequest).promise(); + if (!authTokenResponse) { + throw new Error('Amazon ECR Public authorization token returned no data'); + } else if (!authTokenResponse.authorizationData) { + throw new Error('Amazon ECR Public authorization token is invalid'); + } else if (Object.keys(authTokenResponse.authorizationData).length === 0) { + throw new Error('Amazon ECR Public authorization token does not contain any authorization data'); + } + + return { + authorizationData: [ + { + authorizationToken: authTokenResponse.authorizationData.authorizationToken, + proxyEndpoint: ECR_PUBLIC_REGISTRY_URI + } + ] + }; +} + async function run() { // Get inputs - const skipLogout = core.getInput('skip-logout', { required: false }) === 'true'; - const registries = core.getInput('registries', { required: false }); + const skipLogout = core.getInput(INPUTS.skipLogout, { required: false }).toLowerCase() === 'true'; + const registries = core.getInput(INPUTS.registries, { required: false }); + const registryType = core.getInput(INPUTS.registryType, { required: false }).toLowerCase() || REGISTRY_TYPES.private; const registryUriState = []; try { - // Get the ECR authorization token(s) - const ecr = new aws.ECR({ - customUserAgent: 'amazon-ecr-login-for-github-actions' - }); + if (registryType !== REGISTRY_TYPES.private && registryType !== REGISTRY_TYPES.public) { + throw new Error(`Invalid input for '${INPUTS.registryType}', possible options are [${REGISTRY_TYPES.private}, ${REGISTRY_TYPES.public}]`); + } + + // Get the ECR/ECR Public authorization token(s) const authTokenRequest = {}; - if (registries) { + if (registryType === REGISTRY_TYPES.private && registries) { const registryIds = registries.split(','); core.debug(`Requesting auth token for ${registryIds.length} registries:`); for (const id of registryIds) { @@ -27,10 +93,9 @@ async function run() { } authTokenRequest.registryIds = registryIds; } - const authTokenResponse = await ecr.getAuthorizationToken(authTokenRequest).promise(); - if (!authTokenResponse || !Array.isArray(authTokenResponse.authorizationData) || !authTokenResponse.authorizationData.length) { - throw new Error('Could not retrieve an authorization token from Amazon ECR'); - } + const authTokenResponse = registryType === REGISTRY_TYPES.private ? + await getEcrAuthTokenWrapper(authTokenRequest) : + await getEcrPublicAuthTokenWrapper(authTokenRequest); // Login to each registry for (const authData of authTokenResponse.authorizationData) { @@ -39,11 +104,11 @@ async function run() { const proxyEndpoint = authData.proxyEndpoint; const registryUri = proxyEndpoint.replace(/^https?:\/\//,''); - core.debug(`Logging in to registry ${registryUri}`); + core.info(`Logging into registry ${registryUri}`); // output the registry URI if this action is doing a single registry login if (authTokenResponse.authorizationData.length === 1) { - core.setOutput('registry', registryUri); + core.setOutput(OUTPUTS.registry, registryUri); } // Execute the docker login command @@ -63,14 +128,14 @@ async function run() { }); if (exitCode !== 0) { core.debug(doLoginStdout); - throw new Error(`Could not login to ${proxyEndpoint}: ${doLoginStderr}`); + throw new Error(`Could not login to registry ${registryUri}: ${doLoginStderr}`); } // Output docker username and password - const secretSuffix = replaceSpecialCharacters(registryUri) + const secretSuffix = replaceSpecialCharacters(registryUri); core.setSecret(creds[1]); - core.setOutput(`docker_username_${secretSuffix}`, creds[0]); - core.setOutput(`docker_password_${secretSuffix}`, creds[1]); + core.setOutput(`${OUTPUTS.dockerUsername}_${secretSuffix}`, creds[0]); + core.setOutput(`${OUTPUTS.dockerPassword}_${secretSuffix}`, creds[1]); registryUriState.push(registryUri); } @@ -82,9 +147,9 @@ async function run() { // Pass the logged-in registry URIs to the post action for logout if (registryUriState.length) { if (!skipLogout) { - core.saveState('registries', registryUriState.join()); + core.saveState(STATES.registries, registryUriState.join()); } - core.debug(`'skip-logout' is ${skipLogout} for ${registryUriState.length} registries.`); + core.debug(`'${INPUTS.skipLogout}' is ${skipLogout} for ${registryUriState.length} registries.`); } } diff --git a/index.test.js b/index.test.js index 283ecbe4..d6f3acef 100644 --- a/index.test.js +++ b/index.test.js @@ -11,16 +11,27 @@ function mockGetInput(requestResponse) { } } -const DEFAULT_INPUTS = { - 'registries': undefined, - 'skip-logout': undefined +const ECR_DEFAULT_INPUTS = { + 'registries': '', + 'skip-logout': '', + 'registry-type': '' +}; + +const ECR_PUBLIC_DEFAULT_INPUTS = { + 'registries': '', + 'skip-logout': '', + 'registry-type': 'public' }; const mockEcrGetAuthToken = jest.fn(); +const mockEcrPublicGetAuthToken = jest.fn(); jest.mock('aws-sdk', () => { return { ECR: jest.fn(() => ({ getAuthorizationToken: mockEcrGetAuthToken + })), + ECRPUBLIC: jest.fn(() => ({ + getAuthorizationToken: mockEcrPublicGetAuthToken })) }; }); @@ -29,9 +40,7 @@ describe('Login to ECR', () => { beforeEach(() => { jest.clearAllMocks(); - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(DEFAULT_INPUTS)); + core.getInput = jest.fn().mockImplementation(mockGetInput(ECR_DEFAULT_INPUTS)); mockEcrGetAuthToken.mockImplementation(() => { return { @@ -54,23 +63,26 @@ describe('Login to ECR', () => { test('gets auth token from ECR and logins the Docker client for the default registry', async () => { await run(); - expect(mockEcrGetAuthToken).toHaveBeenCalled(); + expect(mockEcrGetAuthToken).toHaveBeenCalledWith({}); expect(core.setOutput).toHaveBeenNthCalledWith(1, 'registry', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', ['login', '-u', 'hello', '-p', 'world', 'https://123456789012.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); + expect(core.saveState).toHaveBeenNthCalledWith(1, 'registries', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); + expect(exec.exec).toHaveBeenCalledTimes(1); expect(core.setSecret).toHaveBeenCalledTimes(1); expect(core.setOutput).toHaveBeenCalledTimes(3); expect(core.saveState).toHaveBeenCalledTimes(1); - expect(core.saveState).toHaveBeenCalledWith('registries', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); }); test('gets auth token from ECR and logins the Docker client for each provided registry', async () => { - const mockInputs = {'registries' : '123456789012,111111111111'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '123456789012,111111111111', + 'skip-logout': '', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); mockEcrGetAuthToken.mockImplementation(() => { return { promise() { @@ -95,7 +107,6 @@ describe('Login to ECR', () => { expect(mockEcrGetAuthToken).toHaveBeenCalledWith({ registryIds: ['123456789012','111111111111'] }); - expect(exec.exec).toHaveBeenCalledTimes(2); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', ['login', '-u', 'hello', '-p', 'world', 'https://123456789012.dkr.ecr.aws-region-1.amazonaws.com'], @@ -104,17 +115,20 @@ describe('Login to ECR', () => { 'docker', ['login', '-u', 'foo', '-p', 'bar', 'https://111111111111.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); + expect(core.saveState).toHaveBeenNthCalledWith(1, 'registries', '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com'); + expect(exec.exec).toHaveBeenCalledTimes(2); expect(core.setSecret).toHaveBeenCalledTimes(2); expect(core.setOutput).toHaveBeenCalledTimes(4); expect(core.saveState).toHaveBeenCalledTimes(1); - expect(core.saveState).toHaveBeenCalledWith('registries', '123456789012.dkr.ecr.aws-region-1.amazonaws.com,111111111111.dkr.ecr.aws-region-1.amazonaws.com'); }); test('outputs the registry ID if a single registry is provided in the input', async () => { - const mockInputs = {'registries' : '111111111111'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '111111111111', + 'skip-logout': '', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); mockEcrGetAuthToken.mockImplementation(() => { return { promise() { @@ -141,10 +155,11 @@ describe('Login to ECR', () => { 'docker', ['login', '-u', 'foo', '-p', 'bar', 'https://111111111111.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); + expect(core.saveState).toHaveBeenCalledWith('registries', '111111111111.dkr.ecr.aws-region-1.amazonaws.com'); + expect(exec.exec).toHaveBeenCalledTimes(1); expect(core.setSecret).toHaveBeenCalledTimes(1); expect(core.setOutput).toHaveBeenCalledTimes(3); expect(core.saveState).toHaveBeenCalledTimes(1); - expect(core.saveState).toHaveBeenCalledWith('registries', '111111111111.dkr.ecr.aws-region-1.amazonaws.com'); }); test('error is caught by core.setFailed for failed docker login', async () => { @@ -152,26 +167,18 @@ describe('Login to ECR', () => { await run(); - expect(core.setFailed).toBeCalled(); + expect(core.setFailed).toHaveBeenCalled(); expect(core.setOutput).toHaveBeenCalledWith('registry', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); expect(core.saveState).toHaveBeenCalledTimes(0); }); test('logged-in registries are saved as state even if the action fails', async () => { - exec.exec - .mockImplementation((commandLine, args, options) => { - options.listeners.stdout('Hello World '); - options.listeners.stdout('on stdout\n'); - options.listeners.stderr('Some fancy error '); - options.listeners.stderr('from docker login stderr'); - return(1); - }) - .mockReturnValueOnce(0); - - const mockInputs = {'registries' : '123456789012,111111111111'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '123456789012,111111111111', + 'skip-logout': '', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); mockEcrGetAuthToken.mockImplementation(() => { return { promise() { @@ -190,14 +197,19 @@ describe('Login to ECR', () => { } }; }); + exec.exec.mockImplementation((commandLine, args, options) => { + options.listeners.stdout('Hello World '); + options.listeners.stdout('on stdout\n'); + options.listeners.stderr('Some fancy error '); + options.listeners.stderr('from docker login stderr'); + return(1); + }).mockReturnValueOnce(0); await run(); expect(mockEcrGetAuthToken).toHaveBeenCalledWith({ registryIds: ['123456789012','111111111111'] }); - expect(core.setOutput).toHaveBeenCalledTimes(2); - expect(exec.exec).toHaveBeenCalledTimes(2); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', ['login', '-u', 'hello', '-p', 'world', 'https://123456789012.dkr.ecr.aws-region-1.amazonaws.com'], @@ -206,48 +218,53 @@ describe('Login to ECR', () => { 'docker', ['login', '-u', 'foo', '-p', 'bar', 'https://111111111111.dkr.ecr.aws-region-1.amazonaws.com'], expect.anything()); - - expect(core.setFailed).toBeCalled(); - expect(core.setFailed).toHaveBeenCalledWith('Could not login to https://111111111111.dkr.ecr.aws-region-1.amazonaws.com: Some fancy error from docker login stderr'); - expect(core.saveState).toHaveBeenCalledTimes(1); expect(core.saveState).toHaveBeenCalledWith('registries', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); + expect(core.setFailed).toHaveBeenCalledWith('Could not login to registry 111111111111.dkr.ecr.aws-region-1.amazonaws.com: Some fancy error from docker login stderr'); + expect(exec.exec).toHaveBeenCalledTimes(2); + expect(core.setOutput).toHaveBeenCalledTimes(2); + expect(core.saveState).toHaveBeenCalledTimes(1); }); test(`throws error when getAuthorizationToken does return an empty authorization data`, async () => { mockEcrGetAuthToken.mockImplementation(() => { return { promise() { - return Promise.resolve({authorizationData: []}); + return Promise.resolve({ + authorizationData: [] + }); } }; }); await run(); - expect(mockEcrGetAuthToken).toHaveBeenCalled(); - expect(core.setOutput).toHaveBeenCalledTimes(0); + expect(mockEcrGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR authorization token does not contain any authorization data'); expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); expect(core.saveState).toHaveBeenCalledTimes(0); expect(core.setFailed).toHaveBeenCalledTimes(1); - expect(core.setFailed).toHaveBeenCalledWith('Could not retrieve an authorization token from Amazon ECR'); }); test(`throws error when getAuthorizationToken does not contain authorization data`, async () => { mockEcrGetAuthToken.mockImplementation(() => { return { promise() { - return Promise.resolve({foo: 'bar'}); + return Promise.resolve({ + foo: 'bar' + }); } }; }); await run(); - expect(mockEcrGetAuthToken).toHaveBeenCalled(); - expect(core.setOutput).toHaveBeenCalledTimes(0); + + expect(mockEcrGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR authorization token is invalid'); expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); expect(core.saveState).toHaveBeenCalledTimes(0); expect(core.setFailed).toHaveBeenCalledTimes(1); - expect(core.setFailed).toHaveBeenCalledWith('Could not retrieve an authorization token from Amazon ECR'); }); test(`throws error when getAuthorizationToken does not return data`, async () => { @@ -262,12 +279,13 @@ describe('Login to ECR', () => { }); await run(); - expect(mockEcrGetAuthToken).toHaveBeenCalled(); - expect(core.setOutput).toHaveBeenCalledTimes(0); + + expect(mockEcrGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR authorization token returned no data'); expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); expect(core.saveState).toHaveBeenCalledTimes(0); expect(core.setFailed).toHaveBeenCalledTimes(1); - expect(core.setFailed).toHaveBeenCalledWith('Could not retrieve an authorization token from Amazon ECR'); }); test('error is caught by core.setFailed for ECR call', async () => { @@ -277,19 +295,22 @@ describe('Login to ECR', () => { await run(); - expect(core.setFailed).toBeCalled(); expect(core.setOutput).toHaveBeenCalledTimes(0); expect(core.saveState).toHaveBeenCalledTimes(0); + expect(core.setFailed).toHaveBeenCalled(); }); test('skips logout when specified and logging into default registry', async () => { - const mockInputs = {'skip-logout' : 'true'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '', + 'skip-logout': 'true', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); await run(); - expect(mockEcrGetAuthToken).toHaveBeenCalled(); + + expect(mockEcrGetAuthToken).toHaveBeenCalledWith({}); expect(core.setOutput).toHaveBeenNthCalledWith(1, 'registry', '123456789012.dkr.ecr.aws-region-1.amazonaws.com'); expect(exec.exec).toHaveBeenNthCalledWith(1, 'docker', @@ -299,10 +320,12 @@ describe('Login to ECR', () => { }); test('skips logout when specified and logging into multiple registries', async () => { - const mockInputs = {'registries' : '123456789012,111111111111', 'skip-logout' : 'true'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '123456789012,111111111111', + 'skip-logout': 'true', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); mockEcrGetAuthToken.mockImplementation(() => { return { promise() { @@ -327,7 +350,6 @@ describe('Login to ECR', () => { expect(mockEcrGetAuthToken).toHaveBeenCalledWith({ registryIds: ['123456789012','111111111111'] }); - expect(core.setOutput).toHaveBeenCalledTimes(4); expect(exec.exec).toHaveBeenCalledTimes(2); expect(core.saveState).toHaveBeenCalledTimes(0); }); @@ -338,10 +360,12 @@ describe('Login to ECR', () => { }); test('sets the Actions outputs to the docker credentials', async () => { - const mockInputs = {'registries' : '123456789012,111111111111', 'skip-logout' : 'true'}; - core.getInput = jest - .fn() - .mockImplementation(mockGetInput(mockInputs)); + const mockInputs = { + 'registries' : '123456789012,111111111111', + 'skip-logout': 'true', + 'registry-type': '' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); mockEcrGetAuthToken.mockImplementation(() => { return { promise() { @@ -373,3 +397,157 @@ describe('Login to ECR', () => { expect(core.setOutput).toHaveBeenNthCalledWith(4, 'docker_password_111111111111_dkr_ecr_aws_region_1_amazonaws_com', 'bar'); }); }); + +describe('Login to ECR Public', () => { + beforeEach(() => { + jest.clearAllMocks(); + + core.getInput = jest.fn().mockImplementation(mockGetInput(ECR_PUBLIC_DEFAULT_INPUTS)); + + mockEcrPublicGetAuthToken.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + authorizationData: { + authorizationToken: Buffer.from('hello:world').toString('base64') + } + }); + } + }; + }); + + exec.exec.mockReturnValue(0); + }); + + test('gets auth token from ECR Public and logins the Docker client for the default registry', async () => { + await run(); + + expect(mockEcrPublicGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'registry', 'public.ecr.aws'); + expect(exec.exec).toHaveBeenNthCalledWith(1, + 'docker', + ['login', '-u', 'hello', '-p', 'world', 'public.ecr.aws'], + expect.anything()); + expect(core.saveState).toHaveBeenNthCalledWith(1, 'registries', 'public.ecr.aws'); + expect(exec.exec).toHaveBeenCalledTimes(1); + expect(core.setSecret).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledTimes(3); + expect(core.saveState).toHaveBeenCalledTimes(1); + }); + + test('error is caught by core.setFailed for invalid registry-type input', async () => { + const mockInputs = { + 'registries' : '', + 'skip-logout': '', + 'registry-type': 'invalid' + }; + core.getInput = jest.fn().mockImplementation(mockGetInput(mockInputs)); + + await run(); + + expect(core.setFailed).toHaveBeenCalledWith(`Invalid input for 'registry-type', possible options are [private, public]`); + expect(core.saveState).toHaveBeenCalledTimes(0); + }); + + test('outputs the registry URI', async () => { + await run(); + + expect(mockEcrPublicGetAuthToken).toHaveBeenCalledWith({}) + expect(core.setOutput).toHaveBeenNthCalledWith(1, 'registry', 'public.ecr.aws'); + expect(exec.exec).toHaveBeenCalledTimes(1); + expect(exec.exec).toHaveBeenNthCalledWith(1, + 'docker', + ['login', '-u', 'hello', '-p', 'world', 'public.ecr.aws'], + expect.anything()); + expect(core.saveState).toHaveBeenCalledWith('registries', 'public.ecr.aws'); + expect(exec.exec).toHaveBeenCalledTimes(1); + expect(core.setSecret).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledTimes(3); + expect(core.saveState).toHaveBeenCalledTimes(1); + }); + + test(`throws error when getAuthorizationToken does return an empty authorization data`, async () => { + mockEcrPublicGetAuthToken.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + authorizationData: {} + }); + } + }; + }); + + await run(); + + expect(mockEcrPublicGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR Public authorization token does not contain any authorization data'); + expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); + expect(core.saveState).toHaveBeenCalledTimes(0); + expect(core.setFailed).toHaveBeenCalledTimes(1); + }); + + test(`throws error when getAuthorizationToken does not contain authorization data`, async () => { + mockEcrPublicGetAuthToken.mockImplementation(() => { + return { + promise() { + return Promise.resolve({ + hello: 'world' + }); + } + }; + }); + + await run(); + + expect(mockEcrPublicGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR Public authorization token is invalid'); + expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); + expect(core.saveState).toHaveBeenCalledTimes(0); + expect(core.setFailed).toHaveBeenCalledTimes(1); + }); + + test(`throws error when getAuthorizationToken does not return data`, async () => { + mockEcrPublicGetAuthToken.mockImplementation(() => { + return { + promise() { + // https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/ECR.html#getAuthorizationToken-property + // data (Object) — the de-serialized data returned from the request. Set to null if a request error occurs. + return Promise.resolve(null); + } + }; + }); + + await run(); + + expect(mockEcrPublicGetAuthToken).toHaveBeenCalledWith({}); + expect(core.setFailed).toHaveBeenCalledWith('Amazon ECR Public authorization token returned no data'); + expect(exec.exec).toHaveBeenCalledTimes(0); + expect(core.setOutput).toHaveBeenCalledTimes(0); + expect(core.saveState).toHaveBeenCalledTimes(0); + expect(core.setFailed).toHaveBeenCalledTimes(1); + }); + + test('error is caught by core.setFailed for ECR call', async () => { + mockEcrPublicGetAuthToken.mockImplementation(() => { + throw new Error(); + }); + + await run(); + + expect(core.setOutput).toHaveBeenCalledTimes(0); + expect(core.saveState).toHaveBeenCalledTimes(0); + expect(core.setFailed).toHaveBeenCalled(); + }); + + test('sets the Actions outputs to the docker credentials', async () => { + await run(); + + expect(core.setSecret).toHaveBeenCalledTimes(1); + expect(core.setOutput).toHaveBeenCalledTimes(3); + expect(core.setSecret).toHaveBeenNthCalledWith(1, 'world'); + expect(core.setOutput).toHaveBeenNthCalledWith(2, 'docker_username_public_ecr_aws', 'hello'); + expect(core.setOutput).toHaveBeenNthCalledWith(3, 'docker_password_public_ecr_aws', 'world'); + }); +});