diff --git a/package-and-upload-artifact/README.md b/package-and-upload-artifact/README.md index 6377c053..c9b8c966 100644 --- a/package-and-upload-artifact/README.md +++ b/package-and-upload-artifact/README.md @@ -3,7 +3,8 @@ # Package and upload artifact -Packages and uploads an artifact +Packages Docker images or files for every environment listed in the JSON config, using the AWS account, bucket, role, and repository data defined there. Docker sources are retagged and pushed to ECR so Terraform can deploy them to ECS. Files or folders are zipped if needed, copied to S3, and later published through the Terraform → Lambda → CloudFront flow. + ## Usage diff --git a/package-and-upload-artifact/action.yml b/package-and-upload-artifact/action.yml index ceae7cc2..f161a763 100644 --- a/package-and-upload-artifact/action.yml +++ b/package-and-upload-artifact/action.yml @@ -1,5 +1,10 @@ name: "Package and upload artifact" -description: Packages and uploads an artifact +description: > + Packages Docker images or files for every environment listed in the JSON config, + using the AWS account, bucket, role, and repository data defined there. Docker + sources are retagged and pushed to ECR so Terraform can deploy them to ECS. Files + or folders are zipped if needed, copied to S3, and later published through the + Terraform → Lambda → CloudFront flow. inputs: config: description: "JSON-encoded config (.gp.cicd.json)" @@ -22,112 +27,40 @@ outputs: runs: using: composite steps: - - name: Configure AWS credentials in dev - if: ${{ fromJSON(inputs.config).dev != null }} - id: aws-credentials-dev - uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5.1.0 - with: - aws-region: ${{ fromJSON(inputs.config).dev.defaultRegion }} - role-to-assume: ${{ fromJSON(inputs.config).dev.artifactRoleArn }} - output-env-credentials: false - output-credentials: true - - - name: Configure AWS credentials in prod - if: ${{ fromJSON(inputs.config).prod != null }} - id: aws-credentials-prod - uses: aws-actions/configure-aws-credentials@00943011d9042930efac3dcd3a170e4273319bc8 # v5.1.0 - with: - aws-region: ${{ fromJSON(inputs.config).prod.defaultRegion }} - role-to-assume: ${{ fromJSON(inputs.config).prod.artifactRoleArn }} - output-env-credentials: false - output-credentials: true - - - name: Package and upload artifact + - name: Upload artifact to S3 + if: ${{ inputs.source-type == 'file' || inputs.source-type == 'folder' }} shell: bash --noprofile --norc -euo pipefail {0} - id: upload + id: upload-s3-artifact env: - AWSCREDS: | - [profile dev] - aws_access_key_id=${{ steps.aws-credentials-dev.outputs.aws-access-key-id }} - aws_secret_access_key=${{ steps.aws-credentials-dev.outputs.aws-secret-access-key }} - aws_session_token=${{ steps.aws-credentials-dev.outputs.aws-session-token }} - - [profile prod] - aws_access_key_id=${{ steps.aws-credentials-prod.outputs.aws-access-key-id }} - aws_secret_access_key=${{ steps.aws-credentials-prod.outputs.aws-secret-access-key }} - aws_session_token=${{ steps.aws-credentials-prod.outputs.aws-session-token }} + ACTION_PATH: ${{ github.action_path }} TZ: "Europe/Oslo" CONFIG: ${{ inputs.config }} PARTIAL_WORKFLOW_DISPATCH_URL: "${{ github.server_url}}/${{ github.repository }}/actions/workflows" SOURCE_TYPE: ${{ inputs.source-type }} SOURCE_LOCATION: ${{ inputs.source-location }} TAG: ${{ inputs.tag }} - run: | - tag="$TAG" - echo "$AWSCREDS" > /tmp/awscreds - export AWS_CONFIG_FILE="/tmp/awscreds" - - if [ "$SOURCE_TYPE" = "folder" ]; then - (cd "$SOURCE_LOCATION" && zip -r ../archive.zip . ;) - SOURCE_LOCATION="archive.zip" - SOURCE_TYPE="file" - fi - - if [ "$SOURCE_TYPE" = "file" ]; then - file_extension="$(echo "$SOURCE_LOCATION" | sed -n 's/^.*\.\(.*\)$/\1/p')" - tag="$tag.$file_extension" - fi - - echo "$CONFIG" | jq -c '{dev,prod} | to_entries | .[]' | while read -r item; do ( - if [ "$SOURCE_TYPE" = "file" ]; then - environment="$(echo "$item" | jq -e -r .key)" - account_id="$(echo "$item" | jq -e -r .value.accountId)" - bucket_name="$(echo "$item" | jq -e -r .value.artifactBucketName)" - - echo "Uploading $SOURCE_LOCATION to S3 with key $tag in $environment" - - export AWS_PROFILE="$environment" - aws s3 cp "$SOURCE_LOCATION" "s3://$bucket_name/$tag" - elif [ "$SOURCE_TYPE" = "docker-image" ]; then - environment="$(echo "$item" | jq -e -r .key)" - account_id="$(echo "$item" | jq -e -r .value.accountId)" - ecr_repository_name="$(echo "$item" | jq -e -r .value.artifactEcrRepositoryName)" - default_region="$(echo "$item" | jq -e -r .value.defaultRegion)" - - export AWS_PROFILE="$environment" - login_password="$(aws ecr get-login-password --region "$default_region")" - echo "::add-mask::$login_password" - - ecr_repository_uri="$account_id.dkr.ecr.$default_region.amazonaws.com" - image_tag="$ecr_repository_uri/$ecr_repository_name:$tag" - - echo "$login_password" | docker login --username AWS --password-stdin "$ecr_repository_uri" - echo "Tagging image with image tag: $image_tag" - docker tag "$SOURCE_LOCATION" "$ecr_repository_uri/$ecr_repository_name:$tag" - echo "Pushing image with tag: $image_tag" - docker push "$image_tag" - else - echo "Unrecognized source type '$SOURCE_TYPE' - skipping" >&2 - fi - ); done - - rm /tmp/awscreds + run: "$ACTION_PATH/scripts/upload-s3-artifact.sh" - # $GITHUB_WORKFLOW_REF looks like this: //.github/workflows/@ - workflow_filename="$(basename "${GITHUB_WORKFLOW_REF%%@*}")" - workflow_dispatch_url="$PARTIAL_WORKFLOW_DISPATCH_URL/$workflow_filename" - - echo "tag=$tag" >> "$GITHUB_OUTPUT" - cat <> "$GITHUB_STEP_SUMMARY" - Built and uploaded artifact with tag: - \`\`\` - $tag - \`\`\` - - --- + - name: Push Docker image to ECR + if: ${{ inputs.source-type == 'docker-image' }} + shell: bash --noprofile --norc -euo pipefail {0} + id: push-ecr-image + env: + ACTION_PATH: ${{ github.action_path }} + TZ: "Europe/Oslo" + CONFIG: ${{ inputs.config }} + PARTIAL_WORKFLOW_DISPATCH_URL: "${{ github.server_url}}/${{ github.repository }}/actions/workflows" + SOURCE_TYPE: ${{ inputs.source-type }} + SOURCE_LOCATION: ${{ inputs.source-location }} + TAG: ${{ inputs.tag }} + run: "$ACTION_PATH/scripts/push-ecr-image.sh" - _To manually deploy the artifact, copy the tag and pass it in through a [workflow dispatch]($workflow_dispatch_url)_ - EOF + - name: Record artifact tag + shell: bash --noprofile --norc -euo pipefail {0} + id: upload + run: | + : "${ARTIFACT_TAG:?Missing ARTIFACT_TAG}" + printf 'tag=%s\n' "$ARTIFACT_TAG" >>"$GITHUB_OUTPUT" - name: Store artifact tag in commit status shell: bash --noprofile --norc -euo pipefail {0} env: diff --git a/package-and-upload-artifact/lib/common.sh b/package-and-upload-artifact/lib/common.sh new file mode 100644 index 00000000..23059199 --- /dev/null +++ b/package-and-upload-artifact/lib/common.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Common helpers shared between artifact scripts. + +log_info() { + printf '[INFO] %s\n' "$*" +} + +log_error() { + printf '[ERROR] %s\n' "$*" >&2 +} + +die() { + log_error "$1" + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd" +} + +require_cmd jq +require_cmd aws + +authenticate_via_oidc() { + local role_arn="$1" + local aws_region="${2:-eu-north-1}" + local session_name + session_name="GitHubAction-$(date +%s)" + + : "${ACTIONS_ID_TOKEN_REQUEST_URL:?Missing ACTIONS_ID_TOKEN_REQUEST_URL}" + : "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:?Missing ACTIONS_ID_TOKEN_REQUEST_TOKEN}" + + require_cmd curl + + log_info "Authenticating to $role_arn via OIDC..." + + local oidc_response oidc_token + if ! oidc_response="$(curl -sSLS "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=sts.amazonaws.com" \ + -H "User-Agent: actions/oidc-client" \ + -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN")"; then + die "Failed to reach GitHub OIDC endpoint" + fi + + oidc_token="$(printf '%s' "$oidc_response" | jq -r '.value')" + if [[ -z "$oidc_token" || "$oidc_token" == "null" ]]; then + die "Failed to obtain OIDC token from GitHub. Is 'permissions: id-token: write' set?" + fi + + local credentials_json + if ! credentials_json="$(aws sts assume-role-with-web-identity \ + --role-arn "$role_arn" \ + --role-session-name "$session_name" \ + --web-identity-token "$oidc_token" \ + --duration-seconds 900 \ + --region "$aws_region" \ + --output json)"; then + die "Failed to assume role $role_arn with web identity" + fi + + AWS_ACCESS_KEY_ID="$(printf '%s' "$credentials_json" | jq -r '.Credentials.AccessKeyId')" + AWS_SECRET_ACCESS_KEY="$(printf '%s' "$credentials_json" | jq -r '.Credentials.SecretAccessKey')" + AWS_SESSION_TOKEN="$(printf '%s' "$credentials_json" | jq -r '.Credentials.SessionToken')" + + if [[ -z "$AWS_ACCESS_KEY_ID" || "$AWS_ACCESS_KEY_ID" == "null" ]]; then + die "Failed to parse AWS credentials from STS response" + fi + + export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN +} + +clear_credentials() { + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN AWS_PROFILE AWS_REGION AWS_DEFAULT_REGION +} + +environment_defined() { + : "${CONFIG:?Missing CONFIG}" + local environment="$1" + printf '%s' "$CONFIG" | jq -e --arg env "$environment" '.[$env] != null' >/dev/null 2>&1 +} + +environment_value() { + : "${CONFIG:?Missing CONFIG}" + local environment="$1" key="$2" + printf '%s' "$CONFIG" | jq -e -r --arg env "$environment" --arg k "$key" '.[$env][$k]' +} + +write_github_summary() { + local final_tag="$1" + : "${PARTIAL_WORKFLOW_DISPATCH_URL:?Missing PARTIAL_WORKFLOW_DISPATCH_URL}" + : "${GITHUB_WORKFLOW_REF:?Missing GITHUB_WORKFLOW_REF}" + : "${GITHUB_OUTPUT:?Missing GITHUB_OUTPUT}" + : "${GITHUB_STEP_SUMMARY:?Missing GITHUB_STEP_SUMMARY}" + : "${GITHUB_ENV:?Missing GITHUB_ENV}" + + local workflow_filename workflow_dispatch_url + workflow_filename="$(basename "${GITHUB_WORKFLOW_REF%%@*}")" + workflow_dispatch_url="$PARTIAL_WORKFLOW_DISPATCH_URL/$workflow_filename" + + printf 'tag=%s\n' "$final_tag" >>"$GITHUB_OUTPUT" + printf 'ARTIFACT_TAG=%s\n' "$final_tag" >>"$GITHUB_ENV" + cat <>"$GITHUB_STEP_SUMMARY" +Built and uploaded artifact with tag: +\`\`\` +$final_tag +\`\`\` + +--- + +_To manually deploy the artifact, copy the tag and pass it in through a [workflow dispatch]($workflow_dispatch_url)_ +EOF +} diff --git a/package-and-upload-artifact/scripts/push-ecr-image.sh b/package-and-upload-artifact/scripts/push-ecr-image.sh new file mode 100644 index 00000000..153f8816 --- /dev/null +++ b/package-and-upload-artifact/scripts/push-ecr-image.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +: "${CONFIG:?Missing CONFIG}" +: "${SOURCE_TYPE:?Missing SOURCE_TYPE}" +: "${SOURCE_LOCATION:?Missing SOURCE_LOCATION}" +: "${TAG:?Missing TAG}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +source "$repo_root/lib/common.sh" + +source_type="$SOURCE_TYPE" +source_location="$SOURCE_LOCATION" +tag="$TAG" + +if [[ "$source_type" != "docker-image" ]]; then + die "push-ecr-image.sh only supports docker-image sources (received $source_type)" +fi + +upload_image_artifact() { + local environment="$1" + local account_id ecr_repository_name default_region + local login_password ecr_repository_uri image_tag + + account_id="$(environment_value "$environment" accountId)" + ecr_repository_name="$(environment_value "$environment" artifactEcrRepositoryName)" + default_region="$(environment_value "$environment" defaultRegion)" + + login_password="$(aws ecr get-login-password --region "$default_region")" + printf '::add-mask::%s\n' "$login_password" + + ecr_repository_uri="$account_id.dkr.ecr.$default_region.amazonaws.com" + image_tag="$ecr_repository_uri/$ecr_repository_name:$tag" + + printf '%s\n' "$login_password" | docker login --username AWS --password-stdin "$ecr_repository_uri" + log_info "Tagging image with image tag: $image_tag" + docker tag "$source_location" "$image_tag" + log_info "Pushing image with tag: $image_tag" + docker push "$image_tag" + docker logout "$ecr_repository_uri" +} + +environments="$(printf '%s' "$CONFIG" | jq -r 'to_entries[] | select(.value.artifactRoleArn != null) | .key')" +if [[ -z "$environments" ]]; then + die "No environments with artifactRoleArn defined in config" +fi + +for environment in $environments; do + if ! environment_defined "$environment"; then + continue + fi + + role_arn="$(environment_value "$environment" artifactRoleArn)" + default_region="$(environment_value "$environment" defaultRegion)" + + authenticate_via_oidc "$role_arn" "$default_region" + export AWS_REGION="$default_region" + export AWS_DEFAULT_REGION="$default_region" + + upload_image_artifact "$environment" + clear_credentials +done + +write_github_summary "$tag" diff --git a/package-and-upload-artifact/scripts/upload-s3-artifact.sh b/package-and-upload-artifact/scripts/upload-s3-artifact.sh new file mode 100644 index 00000000..147631d7 --- /dev/null +++ b/package-and-upload-artifact/scripts/upload-s3-artifact.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +: "${CONFIG:?Missing CONFIG}" +: "${SOURCE_TYPE:?Missing SOURCE_TYPE}" +: "${SOURCE_LOCATION:?Missing SOURCE_LOCATION}" +: "${TAG:?Missing TAG}" + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +source "$repo_root/lib/common.sh" + +source_type="$SOURCE_TYPE" +source_location="$SOURCE_LOCATION" +tag="$TAG" +package_tmp_dir="" + +cleanup() { + if [[ -n "$package_tmp_dir" && -d "$package_tmp_dir" ]]; then + rm -rf "$package_tmp_dir" + fi +} +trap cleanup EXIT + +archive_folder_source() { + package_tmp_dir="$(mktemp -d)" + (cd "$source_location" && zip -r "$package_tmp_dir/archive.zip" .) + source_location="$package_tmp_dir/archive.zip" + source_type="file" +} + +append_file_extension_suffix() { + local extension="" + if [[ "$source_location" == *.* ]]; then + extension="${source_location##*.}" + fi + if [[ -n "$extension" && "$tag" != *."$extension" ]]; then + tag="$tag.$extension" + fi +} + +upload_file_artifact() { + local environment="$1" + local bucket_name + + bucket_name="$(environment_value "$environment" artifactBucketName)" + + log_info "Uploading $source_location to S3 as $tag in $environment" + + aws s3 cp "$source_location" "s3://$bucket_name/$tag" +} + +case "$source_type" in +folder) + log_info "Compressing folder artifact into archive for upload" + archive_folder_source + ;; +file) ;; +*) + die "Unsupported source type: $source_type" + ;; +esac + +log_info "Preparing file artifact for upload to S3" +append_file_extension_suffix + +environments="$(printf '%s' "$CONFIG" | jq -r 'to_entries[] | select(.value.artifactRoleArn != null) | .key')" +if [[ -z "$environments" ]]; then + die "No environments with artifactRoleArn defined in config" +fi + +for environment in $environments; do + if ! environment_defined "$environment"; then + continue + fi + + role_arn="$(environment_value "$environment" artifactRoleArn)" + default_region="$(environment_value "$environment" defaultRegion)" || die "Missing defaultRegion for $environment" + + authenticate_via_oidc "$role_arn" "$default_region" + export AWS_REGION="$default_region" + export AWS_DEFAULT_REGION="$default_region" + + upload_file_artifact "$environment" + clear_credentials +done + +write_github_summary "$tag"