From 30d7f4f6da164008826510e212a0c27e4764bac7 Mon Sep 17 00:00:00 2001 From: Ashish Jullia Date: Thu, 23 Jan 2025 18:08:59 -0500 Subject: [PATCH 1/5] aws-cli - add support for arm --- Dockerfile | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0a4e7af..322af3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,52 @@ +# FROM ubuntu:20.04 + +# ENV NVM_DIR /usr/local/nvm + +# # Install all dependencies, NVM, GitHub CLI, and other tools in a single RUN command +# RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ +# git \ +# jq \ +# unzip \ +# curl \ +# wget \ +# tar \ +# openssl \ +# python3 \ +# python3-pip \ +# && curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ +# && unzip awscliv2.zip \ +# && ./aws/install \ +# && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ +# && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \ +# && curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ +# && chmod 700 get_helm.sh \ +# && ./get_helm.sh \ +# && git clone https://github.com/tfutils/tfenv.git ~/.tfenv \ +# && pip3 install urllib3==1.26.7 print-env \ +# && mkdir -p $NVM_DIR \ +# && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \ +# && . $NVM_DIR/nvm.sh \ +# # Install GitHub CLI +# && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ +# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ +# && apt update \ +# && apt install gh \ +# # Clean up +# && apt-get clean \ +# && rm -rf /var/lib/apt/lists/* /awscliv2.zip + +# # Set environment path for tfenv and NVM +# ENV PATH="/root/.tfenv/bin:$NVM_DIR/versions/node/$(nvm version)/bin:$PATH" + +# # Create and set the working directory +# WORKDIR /work + +# # Copy the script into the container +# COPY ./script.sh / +# RUN chmod u+x /script.sh + + +# Base image FROM ubuntu:20.04 ENV NVM_DIR /usr/local/nvm @@ -13,16 +62,30 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ openssl \ python3 \ python3-pip \ - && curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ + # Install AWS CLI based on architecture + && ARCH=$(dpkg --print-architecture) \ + && if [ "$ARCH" = "amd64" ]; then \ + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"; \ + elif [ "$ARCH" = "arm64" ]; then \ + curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"; \ + fi \ && unzip awscliv2.zip \ && ./aws/install \ - && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ + # Install kubectl based on architecture + && if [ "$ARCH" = "amd64" ]; then \ + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"; \ + elif [ "$ARCH" = "arm64" ]; then \ + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/arm64/kubectl"; \ + fi \ && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \ + # Install Helm && curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ && chmod 700 get_helm.sh \ && ./get_helm.sh \ + # Install tfenv && git clone https://github.com/tfutils/tfenv.git ~/.tfenv \ && pip3 install urllib3==1.26.7 print-env \ + # Install NVM && mkdir -p $NVM_DIR \ && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \ && . $NVM_DIR/nvm.sh \ From 79fc8db20ca0cd227473ecb9e73d0746bf4dd1ad Mon Sep 17 00:00:00 2001 From: Ashish Jullia Date: Fri, 25 Sep 2026 12:52:16 -0400 Subject: [PATCH 2/5] Fix the multi-arch image so pull requests can build. Install arch-specific CLIs, drop Flux, fail when Portunus cannot be read, and run the required build check without publishing. --- .github/workflows/docker-build-push.yml | 16 ++- Dockerfile | 124 +++++++----------------- script.sh | 20 ++-- 3 files changed, 54 insertions(+), 106 deletions(-) diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index edba90a..a3d2335 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -1,5 +1,6 @@ name: Build and Push Docker Image to Docker Hub on: + pull_request: push: branches: [ "main" ] tags: @@ -12,11 +13,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 + with: + driver: docker-container - name: Log in to Docker Hub + if: github.event_name == 'push' uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USERNAME }} @@ -26,6 +33,9 @@ jobs: uses: docker/build-push-action@v4 with: context: . - push: true - tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest platforms: linux/amd64,linux/arm64 + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + # Pull requests must produce the required "build" check without + # publishing. Multi-arch builds cannot be loaded into the local + # Docker engine, so they are cached only until a main/tag push. + outputs: ${{ github.event_name == 'push' && 'type=registry' || 'type=cacheonly' }} diff --git a/Dockerfile b/Dockerfile index 322af3c..7102261 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,109 +1,51 @@ -# FROM ubuntu:20.04 - -# ENV NVM_DIR /usr/local/nvm - -# # Install all dependencies, NVM, GitHub CLI, and other tools in a single RUN command -# RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ -# git \ -# jq \ -# unzip \ -# curl \ -# wget \ -# tar \ -# openssl \ -# python3 \ -# python3-pip \ -# && curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \ -# && unzip awscliv2.zip \ -# && ./aws/install \ -# && curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ -# && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \ -# && curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ -# && chmod 700 get_helm.sh \ -# && ./get_helm.sh \ -# && git clone https://github.com/tfutils/tfenv.git ~/.tfenv \ -# && pip3 install urllib3==1.26.7 print-env \ -# && mkdir -p $NVM_DIR \ -# && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \ -# && . $NVM_DIR/nvm.sh \ -# # Install GitHub CLI -# && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ -# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ -# && apt update \ -# && apt install gh \ -# # Clean up -# && apt-get clean \ -# && rm -rf /var/lib/apt/lists/* /awscliv2.zip - -# # Set environment path for tfenv and NVM -# ENV PATH="/root/.tfenv/bin:$NVM_DIR/versions/node/$(nvm version)/bin:$PATH" - -# # Create and set the working directory -# WORKDIR /work - -# # Copy the script into the container -# COPY ./script.sh / -# RUN chmod u+x /script.sh - - -# Base image FROM ubuntu:20.04 -ENV NVM_DIR /usr/local/nvm +ENV NVM_DIR=/usr/local/nvm -# Install all dependencies, NVM, GitHub CLI, and other tools in a single RUN command +# Install dependencies, AWS CLI, kubectl, Helm, tfenv, NVM, and GitHub CLI. +# Node itself is installed at runtime by script.sh when NODE_VERSION is set. RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ - git \ - jq \ - unzip \ - curl \ - wget \ - tar \ - openssl \ - python3 \ - python3-pip \ - # Install AWS CLI based on architecture - && ARCH=$(dpkg --print-architecture) \ - && if [ "$ARCH" = "amd64" ]; then \ - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"; \ - elif [ "$ARCH" = "arm64" ]; then \ - curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"; \ - fi \ + git \ + jq \ + unzip \ + curl \ + wget \ + tar \ + openssl \ + python3 \ + python3-pip \ + && arch=$(dpkg --print-architecture) \ + && case "$arch" in \ + amd64) aws_cli_url="https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"; kubectl_arch="amd64" ;; \ + arm64) aws_cli_url="https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"; kubectl_arch="arm64" ;; \ + *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; \ + esac \ + && curl -fsSL "$aws_cli_url" -o awscliv2.zip \ && unzip awscliv2.zip \ && ./aws/install \ - # Install kubectl based on architecture - && if [ "$ARCH" = "amd64" ]; then \ - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"; \ - elif [ "$ARCH" = "arm64" ]; then \ - curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/arm64/kubectl"; \ - fi \ + && curl -fsSL -o kubectl "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/${kubectl_arch}/kubectl" \ && install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl \ - # Install Helm && curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 \ && chmod 700 get_helm.sh \ && ./get_helm.sh \ - # Install tfenv - && git clone https://github.com/tfutils/tfenv.git ~/.tfenv \ + && git clone https://github.com/tfutils/tfenv.git /root/.tfenv \ && pip3 install urllib3==1.26.7 print-env \ - # Install NVM - && mkdir -p $NVM_DIR \ - && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash \ - && . $NVM_DIR/nvm.sh \ - # Install GitHub CLI - && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ - && apt update \ - && apt install gh \ - # Clean up + && mkdir -p "$NVM_DIR" \ + && curl -fsSL -o /tmp/nvm-install.sh https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh \ + && bash /tmp/nvm-install.sh \ + && rm /tmp/nvm-install.sh \ + && curl -fsSL -o /usr/share/keyrings/githubcli-archive-keyring.gpg https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y gh \ && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /awscliv2.zip + && rm -rf /var/lib/apt/lists/* awscliv2.zip aws kubectl get_helm.sh -# Set environment path for tfenv and NVM -ENV PATH="/root/.tfenv/bin:$NVM_DIR/versions/node/$(nvm version)/bin:$PATH" +# tfenv is a real binary path. nvm is a shell function loaded by script.sh, +# so a node version directory cannot be added here. +ENV PATH="/root/.tfenv/bin:$PATH" -# Create and set the working directory WORKDIR /work -# Copy the script into the container COPY ./script.sh / RUN chmod u+x /script.sh diff --git a/script.sh b/script.sh index a4006cd..e19745c 100644 --- a/script.sh +++ b/script.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# Exit on any error -set -e +# Exit on any error, including a failed command in a pipeline +set -eo pipefail # Function to print an error and exit function error_exit { @@ -13,9 +13,11 @@ function error_exit { if [ -n "${PORTUNUS_TOKEN}" ]; then # Fetch and export environment variables from a given API - while IFS="=" read -r key value; do + portunus_env=$(print-env --api "https://portunusapiprod.ashishjullia.com/env" --format json | jq -r 'to_entries[] | "\(.key)=\(.value)"') || error_exit "Failed to fetch environment from Portunus" + while IFS="=" read -r key value; do + [ -n "$key" ] || continue export "$key=$(printf %b "$value")" - done < <(print-env --api "https://portunusapiprod.ashishjullia.com/env" --format json | jq -r 'to_entries[] | "\(.key)=\(.value)"') + done <<< "$portunus_env" # Conditionally install Terraform version if TF_VERSION is set if [ -n "${TF_VERSION}" ]; then @@ -41,18 +43,12 @@ if [ -n "${PORTUNUS_TOKEN}" ]; then aws eks update-kubeconfig --region "$AWS_REGION" --name "$NAME_OF_CLUSTER" || error_exit "Failed to update kubeconfig for cluster: ${NAME_OF_CLUSTER}" fi - # Install specified Flux version if provided - if [[ -n "$FLUX_VERSION" ]]; then - echo "Installing Flux version: ${FLUX_VERSION}" - curl -s https://fluxcd.io/install.sh | bash || error_exit "Failed to install Flux version: ${FLUX_VERSION}" - fi - # Conditionally install Node.js version if NODE_VERSION is set if [ -n "${NODE_VERSION}" ]; then echo "NODE_VERSION is set to ${NODE_VERSION}. Installing Node.js version: ${NODE_VERSION}" export NVM_DIR="/usr/local/nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm - nvm install $NODE_VERSION + nvm install "$NODE_VERSION" else echo "NODE_VERSION is not set. Skipping Node.js installation." fi @@ -60,7 +56,7 @@ if [ -n "${PORTUNUS_TOKEN}" ]; then # Authenticate GitHub CLI if GITHUB_TOKEN is provided if [ -n "${GH_CLI_TOKEN}" ]; then echo "Authenticating GitHub CLI..." - gh auth login --with-token <<< $GH_CLI_TOKEN + gh auth login --with-token <<< "$GH_CLI_TOKEN" gh auth setup-git else echo "GH_CLI_TOKEN is not set. Skipping GitHub CLI authentication." From b74e8174d406174f7ebadce612bcff6ef3208f25 Mon Sep 17 00:00:00 2001 From: Ashish Jullia Date: Fri, 25 Sep 2026 13:01:23 -0400 Subject: [PATCH 3/5] Add an MFA command that stays available inside the dev container. The project directory is mounted over /work, so a copied mfa.sh cannot live there and still be sourced on every dev run. --- Dockerfile | 4 ++- Readme.md | 12 ++++++++ mfa.sh | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 mfa.sh diff --git a/Dockerfile b/Dockerfile index 7102261..1df4b59 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,4 +48,6 @@ ENV PATH="/root/.tfenv/bin:$PATH" WORKDIR /work COPY ./script.sh / -RUN chmod u+x /script.sh +COPY ./mfa.sh /usr/local/bin/mfa.sh +RUN chmod u+x /script.sh /usr/local/bin/mfa.sh \ + && printf '\nmfa() { source /usr/local/bin/mfa.sh "$@"; }\n' >> /root/.bashrc diff --git a/Readme.md b/Readme.md index e2255fd..9ac1971 100644 --- a/Readme.md +++ b/Readme.md @@ -42,3 +42,15 @@ Notes: ```bash dev ``` + +## AWS MFA + +Inside the container, after AWS credentials are configured: + +```bash +mfa 123456 +``` + +`123456` is the code from your authenticator app. That command sources `/usr/local/bin/mfa.sh`, writes a session token to `~/.aws/credentials`, and exports `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` in the current shell. + +`source ./mfa.sh` does not work here. `dev` mounts the project on `/work`, so a script copied into the project is hidden or left behind in that repository. `mfa` stays in the image, outside that mount. diff --git a/mfa.sh b/mfa.sh new file mode 100644 index 0000000..4dc7ae4 --- /dev/null +++ b/mfa.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Temporary AWS credentials from an MFA code. +# +# Source this file so the new credentials stay in the current shell. +# The container shell provides `mfa` for that: +# mfa 123456 +# source /usr/local/bin/mfa.sh 123456 +# +# Running this file as a program cannot update the shell you are already in. + +if [ -z "${1:-}" ]; then + echo "Usage: mfa " >&2 + return 1 2>/dev/null || exit 1 +fi + +session_duration=129600 # 36 hours, the STS maximum for GetSessionToken +mfa_code=$1 +aws_dir="${HOME}/.aws" +aws_creds_file="${aws_dir}/credentials" +orig_creds_file="${aws_dir}/origcreds" +tmp_creds_file="${aws_dir}/tempcreds" + +mkdir -p "$aws_dir" + +if [ ! -f "$aws_creds_file" ]; then + echo "AWS credentials not found at ${aws_creds_file}." >&2 + echo "Long-lived keys have to be configured before requesting an MFA session." >&2 + return 1 2>/dev/null || exit 1 +fi + +if [ ! -f "$orig_creds_file" ]; then + echo "Backing up current credentials to ${orig_creds_file}" + cp "$aws_creds_file" "$orig_creds_file" + chmod 600 "$orig_creds_file" +fi + +# Always call STS with the long-lived keys, not a previous session token. +cp "$orig_creds_file" "$aws_creds_file" +chmod 600 "$aws_creds_file" + +mfa_device_code=$(aws iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty') +if [ -z "$mfa_device_code" ]; then + echo "Failed to retrieve an MFA device. Check that the AWS CLI is using the long-lived credentials." >&2 + return 1 2>/dev/null || exit 1 +fi + +echo "aws sts get-session-token --duration-seconds ${session_duration} --serial-number ${mfa_device_code} --token-code ${mfa_code}" +if ! aws sts get-session-token \ + --duration-seconds "$session_duration" \ + --serial-number "$mfa_device_code" \ + --token-code "$mfa_code" > "$tmp_creds_file"; then + echo "Request failed" >&2 + return 1 2>/dev/null || exit 1 +fi + +access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' "$tmp_creds_file") +secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' "$tmp_creds_file") +session_token=$(jq -r '.Credentials.SessionToken // empty' "$tmp_creds_file") +expiry=$(jq -r '.Credentials.Expiration // empty' "$tmp_creds_file") + +if [ -z "$access_key_id" ] || [ -z "$secret_access_key" ] || [ -z "$session_token" ]; then + echo "Request failed" >&2 + return 1 2>/dev/null || exit 1 +fi + +cat > "$aws_creds_file" << EOF +[default] +aws_access_key_id = ${access_key_id} +aws_secret_access_key = ${secret_access_key} +aws_session_token = ${session_token} +EOF +chmod 600 "$aws_creds_file" +rm -f "$tmp_creds_file" + +# Environment variables override the credentials file, including keys that +# Portunus already exported, so these have to be set in this shell. +export AWS_ACCESS_KEY_ID="$access_key_id" +export AWS_SECRET_ACCESS_KEY="$secret_access_key" +export AWS_SESSION_TOKEN="$session_token" + +if expiry_local=$(date -d "$expiry" 2>/dev/null); then + echo "All set. Expiry at: ${expiry_local}" +else + echo "All set. Expiry at: ${expiry}" +fi +echo "Session credentials are exported and written to ${aws_creds_file}." From ce26e621768eaed97a77f89ce6e2152c51a9ea8d Mon Sep 17 00:00:00 2001 From: Ashish Jullia Date: Fri, 25 Sep 2026 13:14:04 -0400 Subject: [PATCH 4/5] Assume AWS_ROLE_TO_ASSUME on container start when IAM user keys are present. The AWS CLI refreshes that role session on its own, and a plain dev run still skips Portunus. --- Readme.md | 6 ++++++ script.sh | 45 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/Readme.md b/Readme.md index 9ac1971..38d1d87 100644 --- a/Readme.md +++ b/Readme.md @@ -43,6 +43,12 @@ Notes: dev ``` +## AWS role + +If the Portunus project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens and prints the role and session expiry. The AWS CLI assumes it again after expiry, using the IAM user keys saved in the `source` profile. + +`dev` with no project does not load Portunus, so that container does not assume a role. + ## AWS MFA Inside the container, after AWS credentials are configured: diff --git a/script.sh b/script.sh index e19745c..f8aaa2b 100644 --- a/script.sh +++ b/script.sh @@ -28,12 +28,49 @@ if [ -n "${PORTUNUS_TOKEN}" ]; then echo "TF_VERSION is not set. Skipping Terraform installation." fi - # Conditionally configure AWS if AWS_REGION, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY are set + # Conditionally configure AWS if AWS_REGION, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY are set. + # When AWS_ROLE_TO_ASSUME is also set, keep the IAM user keys in the "source" + # profile and make the default profile assume that role. The AWS CLI then + # assumes the role again on its own after the session expires. if [ -n "${AWS_REGION}" ] && [ -n "${AWS_ACCESS_KEY_ID}" ] && [ -n "${AWS_SECRET_ACCESS_KEY}" ]; then echo "Configuring AWS with region: ${AWS_REGION}" - aws configure set region "$AWS_REGION" || error_exit "Failed to set AWS region: ${AWS_REGION}" - aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" || error_exit "Failed to set AWS access key." - aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" || error_exit "Failed to set AWS secret access key." + if [ -n "${AWS_ROLE_TO_ASSUME}" ]; then + aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile source || error_exit "Failed to set AWS access key." + aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile source || error_exit "Failed to set AWS secret access key." + aws configure set region "$AWS_REGION" --profile source || error_exit "Failed to set AWS region: ${AWS_REGION}" + if [ -n "${AWS_SESSION_TOKEN:-}" ]; then + aws configure set aws_session_token "$AWS_SESSION_TOKEN" --profile source || error_exit "Failed to set AWS session token." + fi + aws configure set role_arn "$AWS_ROLE_TO_ASSUME" || error_exit "Failed to set role: ${AWS_ROLE_TO_ASSUME}" + aws configure set source_profile source || error_exit "Failed to set source profile for role: ${AWS_ROLE_TO_ASSUME}" + aws configure set region "$AWS_REGION" || error_exit "Failed to set AWS region: ${AWS_REGION}" + # Environment credentials override the role profile, so drop the IAM user keys. + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + caller_arn=$(aws sts get-caller-identity --query Arn --output text) || error_exit "Failed to assume role: ${AWS_ROLE_TO_ASSUME}" + expiry="" + if [ -d "${HOME}/.aws/cli/cache" ]; then + for cache_file in "${HOME}/.aws/cli/cache"/*.json; do + [ -f "$cache_file" ] || continue + candidate=$(jq -r '.Credentials.Expiration // empty' "$cache_file") + [ -n "$candidate" ] && expiry=$candidate + done + fi + if [ -n "$expiry" ] && expiry_local=$(date -d "$expiry" 2>/dev/null); then + expiry_display=$expiry_local + else + expiry_display=${expiry:-unknown} + fi + echo "Assumed role ${AWS_ROLE_TO_ASSUME}" + echo "Caller: ${caller_arn}" + echo "Session expires at: ${expiry_display}. The AWS CLI assumes this role again when it expires." + echo "Run dev with no project to start a container that does not assume this role." + else + aws configure set region "$AWS_REGION" || error_exit "Failed to set AWS region: ${AWS_REGION}" + aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" || error_exit "Failed to set AWS access key." + aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" || error_exit "Failed to set AWS secret access key." + fi + elif [ -n "${AWS_ROLE_TO_ASSUME:-}" ]; then + error_exit "AWS_ROLE_TO_ASSUME is set, but AWS_REGION, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY are required to assume it." else echo "AWS configuration variables are not fully set. Skipping AWS configuration." fi From 0abf91f9b0a8f42f4c9e6a8b540ce334612a6601 Mon Sep 17 00:00:00 2001 From: Ashish Jullia Date: Fri, 25 Sep 2026 13:27:49 -0400 Subject: [PATCH 5/5] Keep IAM user keys from being used when a role is configured. The default AWS profile can only assume that role, and a failed assume fails the command instead of running as the user. --- Dockerfile | 3 +- Readme.md | 4 +- aws-role-credentials | 57 ++++++++++++++++++++++++++ mfa.sh | 96 ++++++++++++++++++++++++++++++++++++++++++++ script.sh | 54 ++++++++++++++++--------- 5 files changed, 191 insertions(+), 23 deletions(-) create mode 100755 aws-role-credentials diff --git a/Dockerfile b/Dockerfile index 1df4b59..a922284 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,5 +49,6 @@ WORKDIR /work COPY ./script.sh / COPY ./mfa.sh /usr/local/bin/mfa.sh -RUN chmod u+x /script.sh /usr/local/bin/mfa.sh \ +COPY ./aws-role-credentials /usr/local/bin/aws-role-credentials +RUN chmod u+x /script.sh /usr/local/bin/mfa.sh /usr/local/bin/aws-role-credentials \ && printf '\nmfa() { source /usr/local/bin/mfa.sh "$@"; }\n' >> /root/.bashrc diff --git a/Readme.md b/Readme.md index 38d1d87..ec61c95 100644 --- a/Readme.md +++ b/Readme.md @@ -45,9 +45,9 @@ dev ## AWS role -If the Portunus project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens and prints the role and session expiry. The AWS CLI assumes it again after expiry, using the IAM user keys saved in the `source` profile. +If the Portunus project has `AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_ROLE_TO_ASSUME`, the container assumes that role before the shell opens and prints the role and session expiry. The IAM user keys are not put in the environment or the default AWS profile. Commands use the assumed role, and if that role cannot be assumed the command fails instead of running as the IAM user. The AWS CLI assumes the role again after the session expires. -`dev` with no project does not load Portunus, so that container does not assume a role. +`dev` with no project does not load Portunus, so that container does not assume a role. Keys without `AWS_ROLE_TO_ASSUME` still configure the default profile as the IAM user. ## AWS MFA diff --git a/aws-role-credentials b/aws-role-credentials new file mode 100755 index 0000000..d91aa43 --- /dev/null +++ b/aws-role-credentials @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# credential_process for the default AWS profile. +# Prints assumed-role credentials, or exits non-zero. It never prints the IAM user keys. +set -euo pipefail + +key_file="${HOME}/.aws/role-source.json" +if [ ! -f "$key_file" ]; then + echo "Missing ${key_file}" >&2 + exit 1 +fi + +role_arn=$(jq -er '.RoleArn // empty' "$key_file") +access_key_id=$(jq -er '.AccessKeyId // empty' "$key_file") +secret_access_key=$(jq -er '.SecretAccessKey // empty' "$key_file") +session_token=$(jq -r '.SessionToken // empty' "$key_file") +if [ -z "$role_arn" ] || [ -z "$access_key_id" ] || [ -z "$secret_access_key" ]; then + echo "Role source file is incomplete" >&2 + exit 1 +fi + +# The user keys are passed only to this AssumeRole call. Clearing the config +# and profile variables stops this aws process from calling itself. +sts_json=$( + unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE \ + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI \ + AWS_WEB_IDENTITY_TOKEN_FILE AWS_ROLE_ARN + export AWS_ACCESS_KEY_ID="$access_key_id" + export AWS_SECRET_ACCESS_KEY="$secret_access_key" + if [ -n "$session_token" ]; then + export AWS_SESSION_TOKEN="$session_token" + else + unset AWS_SESSION_TOKEN + fi + aws sts assume-role \ + --role-arn "$role_arn" \ + --role-session-name docker-dev-env \ + --output json +) + +expiry=$(jq -er '.Credentials.Expiration // empty' <<<"$sts_json") +access_key_out=$(jq -er '.Credentials.AccessKeyId // empty' <<<"$sts_json") +secret_out=$(jq -er '.Credentials.SecretAccessKey // empty' <<<"$sts_json") +token_out=$(jq -er '.Credentials.SessionToken // empty' <<<"$sts_json") +if [ -z "$expiry" ] || [ -z "$access_key_out" ] || [ -z "$secret_out" ] || [ -z "$token_out" ]; then + echo "AssumeRole response was incomplete" >&2 + exit 1 +fi + +printf '%s\n' "$expiry" > "${HOME}/.aws/role-expiration" +chmod 600 "${HOME}/.aws/role-expiration" + +jq -nc \ + --arg AccessKeyId "$access_key_out" \ + --arg SecretAccessKey "$secret_out" \ + --arg SessionToken "$token_out" \ + --arg Expiration "$expiry" \ + '{Version: 1, AccessKeyId: $AccessKeyId, SecretAccessKey: $SecretAccessKey, SessionToken: $SessionToken, Expiration: $Expiration}' diff --git a/mfa.sh b/mfa.sh index 4dc7ae4..4694293 100644 --- a/mfa.sh +++ b/mfa.sh @@ -19,6 +19,102 @@ aws_dir="${HOME}/.aws" aws_creds_file="${aws_dir}/credentials" orig_creds_file="${aws_dir}/origcreds" tmp_creds_file="${aws_dir}/tempcreds" +role_source_file="${aws_dir}/role-source.json" +role_source_orig_file="${aws_dir}/role-source-orig.json" + +# A configured role must stay the only identity used for commands. MFA refreshes +# the keys that call AssumeRole and does not export the IAM user into this shell. +if [ -f "$role_source_file" ]; then + mkdir -p "$aws_dir" + if [ ! -f "$role_source_orig_file" ]; then + cp "$role_source_file" "$role_source_orig_file" + chmod 600 "$role_source_orig_file" + fi + + role_arn=$(jq -r '.RoleArn // empty' "$role_source_orig_file") + base_access_key_id=$(jq -r '.AccessKeyId // empty' "$role_source_orig_file") + base_secret_access_key=$(jq -r '.SecretAccessKey // empty' "$role_source_orig_file") + base_session_token=$(jq -r '.SessionToken // empty' "$role_source_orig_file") + if [ -z "$role_arn" ] || [ -z "$base_access_key_id" ] || [ -z "$base_secret_access_key" ]; then + echo "Role source file is incomplete." >&2 + return 1 2>/dev/null || exit 1 + fi + + run_as_user() { + unset AWS_PROFILE AWS_DEFAULT_PROFILE AWS_CONFIG_FILE AWS_SHARED_CREDENTIALS_FILE + export AWS_ACCESS_KEY_ID="$base_access_key_id" + export AWS_SECRET_ACCESS_KEY="$base_secret_access_key" + if [ -n "$base_session_token" ]; then + export AWS_SESSION_TOKEN="$base_session_token" + else + unset AWS_SESSION_TOKEN + fi + aws "$@" + } + + mfa_device_code=$(run_as_user iam list-mfa-devices | jq -r '.MFADevices[0].SerialNumber // empty') + if [ -z "$mfa_device_code" ]; then + unset -f run_as_user + echo "Failed to retrieve an MFA device. Check that the long-lived IAM user keys are valid." >&2 + return 1 2>/dev/null || exit 1 + fi + + echo "aws sts get-session-token --duration-seconds ${session_duration} --serial-number ${mfa_device_code} --token-code ${mfa_code}" + if ! ( + run_as_user sts get-session-token \ + --duration-seconds "$session_duration" \ + --serial-number "$mfa_device_code" \ + --token-code "$mfa_code" > "$tmp_creds_file" + ); then + unset -f run_as_user + echo "Request failed" >&2 + return 1 2>/dev/null || exit 1 + fi + unset -f run_as_user + + access_key_id=$(jq -r '.Credentials.AccessKeyId // empty' "$tmp_creds_file") + secret_access_key=$(jq -r '.Credentials.SecretAccessKey // empty' "$tmp_creds_file") + session_token=$(jq -r '.Credentials.SessionToken // empty' "$tmp_creds_file") + expiry=$(jq -r '.Credentials.Expiration // empty' "$tmp_creds_file") + rm -f "$tmp_creds_file" + if [ -z "$access_key_id" ] || [ -z "$secret_access_key" ] || [ -z "$session_token" ]; then + echo "Request failed" >&2 + return 1 2>/dev/null || exit 1 + fi + + jq -n \ + --arg RoleArn "$role_arn" \ + --arg AccessKeyId "$access_key_id" \ + --arg SecretAccessKey "$secret_access_key" \ + --arg SessionToken "$session_token" \ + '{RoleArn:$RoleArn, AccessKeyId:$AccessKeyId, SecretAccessKey:$SecretAccessKey, SessionToken:$SessionToken}' \ + > "$role_source_file" + chmod 600 "$role_source_file" + rm -f "$aws_creds_file" + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \ + AWS_PROFILE AWS_DEFAULT_PROFILE AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE + + caller_arn=$(aws sts get-caller-identity --query Arn --output text) || { + echo "Failed to assume role ${role_arn} after MFA." >&2 + return 1 2>/dev/null || exit 1 + } + role_name=${role_arn##*/} + case "$caller_arn" in + arn:aws:sts::*:assumed-role/${role_name}/*) ;; + *) + echo "Refusing to use ${caller_arn}. Commands must run as assumed role ${role_arn}." >&2 + return 1 2>/dev/null || exit 1 + ;; + esac + + if expiry_local=$(date -d "$expiry" 2>/dev/null); then + echo "MFA session refreshed. Role ${role_arn} remains the identity used for commands. MFA expiry at: ${expiry_local}" + else + echo "MFA session refreshed. Role ${role_arn} remains the identity used for commands. MFA expiry at: ${expiry}" + fi + echo "Caller: ${caller_arn}" + return 0 2>/dev/null || exit 0 +fi mkdir -p "$aws_dir" diff --git a/script.sh b/script.sh index f8aaa2b..8662add 100644 --- a/script.sh +++ b/script.sh @@ -29,31 +29,44 @@ if [ -n "${PORTUNUS_TOKEN}" ]; then fi # Conditionally configure AWS if AWS_REGION, AWS_ACCESS_KEY_ID, and AWS_SECRET_ACCESS_KEY are set. - # When AWS_ROLE_TO_ASSUME is also set, keep the IAM user keys in the "source" - # profile and make the default profile assume that role. The AWS CLI then - # assumes the role again on its own after the session expires. + # When AWS_ROLE_TO_ASSUME is also set, the IAM user keys are stored outside the + # AWS credentials file. The default profile can only obtain credentials by + # assuming that role, and a failed assume fails the command. if [ -n "${AWS_REGION}" ] && [ -n "${AWS_ACCESS_KEY_ID}" ] && [ -n "${AWS_SECRET_ACCESS_KEY}" ]; then echo "Configuring AWS with region: ${AWS_REGION}" if [ -n "${AWS_ROLE_TO_ASSUME}" ]; then - aws configure set aws_access_key_id "$AWS_ACCESS_KEY_ID" --profile source || error_exit "Failed to set AWS access key." - aws configure set aws_secret_access_key "$AWS_SECRET_ACCESS_KEY" --profile source || error_exit "Failed to set AWS secret access key." - aws configure set region "$AWS_REGION" --profile source || error_exit "Failed to set AWS region: ${AWS_REGION}" - if [ -n "${AWS_SESSION_TOKEN:-}" ]; then - aws configure set aws_session_token "$AWS_SESSION_TOKEN" --profile source || error_exit "Failed to set AWS session token." - fi - aws configure set role_arn "$AWS_ROLE_TO_ASSUME" || error_exit "Failed to set role: ${AWS_ROLE_TO_ASSUME}" - aws configure set source_profile source || error_exit "Failed to set source profile for role: ${AWS_ROLE_TO_ASSUME}" - aws configure set region "$AWS_REGION" || error_exit "Failed to set AWS region: ${AWS_REGION}" - # Environment credentials override the role profile, so drop the IAM user keys. - unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN + mkdir -p "${HOME}/.aws" + chmod 700 "${HOME}/.aws" + jq -n \ + --arg RoleArn "$AWS_ROLE_TO_ASSUME" \ + --arg AccessKeyId "$AWS_ACCESS_KEY_ID" \ + --arg SecretAccessKey "$AWS_SECRET_ACCESS_KEY" \ + --arg SessionToken "${AWS_SESSION_TOKEN:-}" \ + '{RoleArn:$RoleArn, AccessKeyId:$AccessKeyId, SecretAccessKey:$SecretAccessKey, SessionToken:$SessionToken}' \ + > "${HOME}/.aws/role-source.json" || error_exit "Failed to store role source credentials." + chmod 600 "${HOME}/.aws/role-source.json" + # A default access key in this file wins over the role and would run commands as the IAM user. + rm -f "${HOME}/.aws/credentials" + cat > "${HOME}/.aws/config" << EOF +[default] +region = ${AWS_REGION} +credential_process = /usr/local/bin/aws-role-credentials +EOF + chmod 600 "${HOME}/.aws/config" + unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN \ + AWS_PROFILE AWS_DEFAULT_PROFILE AWS_SHARED_CREDENTIALS_FILE AWS_CONFIG_FILE \ + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI AWS_CONTAINER_CREDENTIALS_FULL_URI \ + AWS_CONTAINER_AUTHORIZATION_TOKEN AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE \ + AWS_WEB_IDENTITY_TOKEN_FILE AWS_ROLE_ARN AWS_ROLE_SESSION_NAME AWS_CREDENTIAL_EXPIRATION caller_arn=$(aws sts get-caller-identity --query Arn --output text) || error_exit "Failed to assume role: ${AWS_ROLE_TO_ASSUME}" + role_name=${AWS_ROLE_TO_ASSUME##*/} + case "$caller_arn" in + arn:aws:sts::*:assumed-role/${role_name}/*) ;; + *) error_exit "Refusing to open a shell. AWS CLI resolved to ${caller_arn}, not assumed role ${AWS_ROLE_TO_ASSUME}." ;; + esac expiry="" - if [ -d "${HOME}/.aws/cli/cache" ]; then - for cache_file in "${HOME}/.aws/cli/cache"/*.json; do - [ -f "$cache_file" ] || continue - candidate=$(jq -r '.Credentials.Expiration // empty' "$cache_file") - [ -n "$candidate" ] && expiry=$candidate - done + if [ -f "${HOME}/.aws/role-expiration" ]; then + expiry=$(cat "${HOME}/.aws/role-expiration") fi if [ -n "$expiry" ] && expiry_local=$(date -d "$expiry" 2>/dev/null); then expiry_display=$expiry_local @@ -63,6 +76,7 @@ if [ -n "${PORTUNUS_TOKEN}" ]; then echo "Assumed role ${AWS_ROLE_TO_ASSUME}" echo "Caller: ${caller_arn}" echo "Session expires at: ${expiry_display}. The AWS CLI assumes this role again when it expires." + echo "Commands will not run with the IAM user keys. If the role cannot be assumed, the command fails." echo "Run dev with no project to start a container that does not assume this role." else aws configure set region "$AWS_REGION" || error_exit "Failed to set AWS region: ${AWS_REGION}"