From b1d91192f05bb0a593fcfdb74f6900d9014587ff Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 21:31:02 +1100 Subject: [PATCH 1/7] feat!: replace v6 broker deployment with OIDC and no parameter access --- .github/workflows/security-tests.yml | 22 + .gitignore | 10 +- README.md | 56 +- awsconfiguration.sh | 55 +- build_image.py | 87 ++ buildenv.sh | 167 +-- buildproperties.sh | 109 +- docs/AUTHENTICATION.md | 38 + docs/MIGRATION.md | 58 + docs/SERVICES.md | 22 + examples/circleci-v2.yml | 104 ++ examples/deploy.dev.json | 17 + examples/deploy.prod.json | 19 + master_deploy.sh | 1319 +---------------- psvar-processor.sh | 218 +-- setup_ms/template/setupscr_v7.3.sh | 545 +------ tcdeploy.py | 448 ++++++ .../circleci_template/Sample-Java-Graddle.yml | 45 +- .../circleci_template/Sample-Java-Maven.yml | 44 +- template/circleci_template/Sample-Nodejs.yml | 44 +- tests/test_security.py | 532 +++++++ uploadjson-ps.sh | 23 +- uploadjsonso-ps.sh | 14 +- utilities/tooltoencdec.sh | 107 +- 24 files changed, 1473 insertions(+), 2630 deletions(-) create mode 100644 .github/workflows/security-tests.yml create mode 100644 build_image.py create mode 100644 docs/AUTHENTICATION.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/SERVICES.md create mode 100644 examples/circleci-v2.yml create mode 100644 examples/deploy.dev.json create mode 100644 examples/deploy.prod.json create mode 100644 tcdeploy.py create mode 100644 tests/test_security.py diff --git a/.github/workflows/security-tests.yml b/.github/workflows/security-tests.yml new file mode 100644 index 0000000..0548955 --- /dev/null +++ b/.github/workflows/security-tests.yml @@ -0,0 +1,22 @@ +name: Deployment security tests +on: + pull_request: + push: + branches: [master] +permissions: + contents: read +jobs: + offline-tests: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + with: + persist-credentials: false + - run: python3 -m unittest discover -s tests -v + - name: Check shell syntax + run: | + python3 - <<'PY' + import pathlib, subprocess + for path in pathlib.Path('.').rglob('*.sh'): + subprocess.run(['bash', '-n', str(path)], check=True) + PY diff --git a/.gitignore b/.gitignore index 33c971f..dacf59f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,12 @@ target/* *.iml /target/ .settings -*.swp \ No newline at end of file +*.swp +__pycache__/ +*.pyc +awsenvconf +*_env +.env +.env.* +.npmrc +*.tar diff --git a/README.md b/README.md index 2352bf4..ee12c54 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ -#Build Script - TC +# Topcoder deployment scripts v2 + +Deploy prebuilt images to an **existing** ECS/Fargate service without giving the deployment role access to Parameter Store or Secrets Manager. CircleCI OIDC v3 exchanges a short-lived job identity for an AWS STS session; the Auth0 client-secret broker is no longer used. + +**This is a breaking migration, not a drop-in v1 patch.** Do not repoint existing v1 tags. Pipelines that source `awsenvconf`, `deployvar_env`, or `buildvar_env` must migrate with their IAM roles. Old interfaces fail before network calls. EBS, S3, Lambda and secret-provisioning modes are not supported in v2. Historical `direct/` assets are outside the v2 deployer. + +Read [the migration guide](docs/MIGRATION.md), [the Auth0/AWS explanation](docs/AUTHENTICATION.md), and [the 19-service adoption checklist](docs/SERVICES.md) before testing. + +## Requirements + +- A reviewed, digest-pinned Linux executor containing Python >=3.12, AWS CLI v2, Docker/Buildx, Git and system CA certificates. No package installation in the credential-bearing job. This deployer has no external Python dependencies. +- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected `develop`/`master` refs, and restricted deployment contexts containing no broker/static AWS credentials. +- Separate per-service/per-environment deploy, task and execution roles, and a reviewed non-secret manifest. +- Existing immutable ECR repositories with scan-on-push, ECS circuit-breaker rollback, positive desired capacity, explicit task health checks, and pre-provisioned runtime secret references. + +## Usage + +Copy `examples/deploy.dev.json` / `examples/deploy.prod.json` into the service repository and replace the example identifiers with administrator-verified values. These are placeholders, not guessed Topcoder targets. Generate IAM documents offline: + +```sh +python3 -I tcdeploy.py policies --manifest deploy/dev.json > policies.json +``` + +An administrator installs the generated `trust` and `permissions` documents through reviewed infrastructure changes. Secret/KMS read permissions belong on the narrowly scoped execution role, never the deployment role. + +From the service checkout, in a build job without deployment context: + +```sh +python3 -I /tmp/tc-deploy-scripts/build_image.py --image example-v6:candidate +docker save --output /tmp/image.tar example-v6:candidate +``` + +The helper builds `git archive HEAD` in a temporary context, rejects credential/secret-file inputs and symlinks, accepts PEM files only when they contain public certificates, strips credentials from subprocess environments, and provides no secret/build-argument options. For nested Dockerfiles use `--dockerfile docker/Dockerfile`; for a sidecar use `--dockerfile clamav/Dockerfile --context clamav`. `--target migrate` can build a migration image; executing migrations remains a separately controlled operation. + +In a separately authorized deploy job, load the workflow-specific image archive, then: + +```sh +/tmp/tc-deploy-scripts/awsconfiguration.sh --manifest deploy/dev.json +/tmp/tc-deploy-scripts/master_deploy.sh --manifest deploy/dev.json \ + --image example-v6=example-v6:candidate +``` + +`awsconfiguration.sh` is an optional identity check, not a credential exporter. `master_deploy.sh` authenticates internally. Neither writes AWS credentials to the checkout/default profile, logs, or shell exports. Use the entire SHA-pinned suite, not the old four copied shell files. See `examples/circleci-v2.yml`; replace its explicit executor/commit placeholders before use. + +The deployer verifies account/role, live service configuration, source label, ECR immutability, local/remote configuration digest, a scan completed within 24 hours with no MEDIUM/HIGH/CRITICAL/UNDEFINED findings, and the exact healthy running revision/digests. Only selected images change; roles, secret references, sidecars, resources, volumes and health checks remain intact. Non-root runtime images are required and `RESET_DB` cannot be enabled. The source label is a consistency check, not cryptographic build provenance; protect the pipeline and artifact writers. + +Use a unique CircleCI build number when retrying publication to immutable tags. A failed rollout remains a failure even if ECS rolls back. The script never blindly reverts over another deployment. + +## Tests + +```sh +python3 -m unittest discover -s tests -v +``` + +Tests mock AWS/Docker and require no credentials, account, network or external Python packages. Passing them does not replace IAM simulation and a real dev rollout. GitHub CI runs them with read-only repository permissions. diff --git a/awsconfiguration.sh b/awsconfiguration.sh index e91c141..283b260 100755 --- a/awsconfiguration.sh +++ b/awsconfiguration.sh @@ -1,47 +1,10 @@ -#!/bin/bash -AWSENV=$1 -AWS_REGION=$2 -BASE64_DECODER="base64 -d" # option -d for Linux base64 tool -echo AAAA | base64 -d > /dev/null 2>&1 || BASE64_DECODER="base64 -D" # option -D on MacOS -decode_base64_url() { - local len=$((${#1} % 4)) - local result="$1" - if [ $len -eq 2 ]; then result="$1"'==' - elif [ $len -eq 3 ]; then result="$1"'=' - fi - echo "$result" | tr '_-' '/+' | $BASE64_DECODER -} - -if [ -z "$AWS_REGION" ]; -then - AWS_REGION="us-east-1" +#!/usr/bin/env bash +# Authentication check only. Credentials are never exported to the caller. +set +x +set -euo pipefail +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${1:-}" != --manifest ]]; then + echo 'The Auth0 broker/awsenvconf interface has been removed. Use --manifest FILE to check OIDC identity; master_deploy.sh authenticates internally. See docs/MIGRATION.md.' >&2 + exit 2 fi -auth0cmd=$(echo "curl -k -X POST $CI_AUTH0_URL -H 'Content-Type: application/json' -d '{ \"client_id\": \"$CI_AUTH0_CLIENTID\", \"client_secret\": \"$CI_AUTH0_CLIENTSECRET\", \"audience\": \"$CI_AUTH0_AUDIENCE\", \"grant_type\": \"client_credentials\" , \"environment\" : \"$AWSENV\" , \"username\" : \"$CIRCLE_PROJECT_USERNAME\" , \"reponame\" : \"$CIRCLE_PROJECT_REPONAME\", \"build_num\": \"$CIRCLE_BUILD_NUM\", \"branch\": \"$CIRCLE_BRANCH\"}'") -token=$( eval $auth0cmd | jq -r .access_token ) -tokenjsonformat=$( decode_base64_url $(echo -n $token | cut -d "." -f 2) ) -AWS_ACCESS_KEY_ID=$(echo $tokenjsonformat | jq -r . | grep AWS_ACCESS_KEY | cut -d '"' -f 4) -AWS_SECRET_ACCESS_KEY=$(echo $tokenjsonformat | jq -r . | grep AWS_SECRET_KEY | cut -d '"' -f 4) -AWS_ENVIRONMENT=$(echo $tokenjsonformat | jq -r . | grep AWS_ENVIRONMENT | cut -d '"' -f 4) -AWS_SESSION_TOKEN=$(echo $tokenjsonformat | jq -r . | grep AWS_SESSION_TOKEN | cut -d '"' -f 4) -AWS_ACCOUNT_ID=$(echo $tokenjsonformat | jq -r . | grep AWS_ACCOUNT_ID | cut -d '"' -f 4) -aws configure set default.region $AWS_REGION -aws configure set default.output json -aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID -aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY -aws configure set aws_session_token $AWS_SESSION_TOKEN - -echo "">awsenvconf -echo "export AWS_REGION=\"$AWS_REGION\"">>awsenvconf -echo "export AWS_ACCESS_KEY_ID=\"$AWS_ACCESS_KEY_ID\"">>awsenvconf -echo "export AWS_SECRET_ACCESS_KEY=\"$AWS_SECRET_ACCESS_KEY\"">>awsenvconf -echo "export AWS_ENVIRONMENT=\"$AWS_ENVIRONMENT\"">>awsenvconf -echo "export AWS_SESSION_TOKEN=\"$AWS_SESSION_TOKEN\"">>awsenvconf -echo "export AWS_ACCOUNT_ID=\"$AWS_ACCOUNT_ID\"">>awsenvconf - -if grep -Fxq "awsenvconf" .dockerignore -then - echo "awsenvconf exist in docker ignore file list" -else - echo "awsenvconf" >> .dockerignore -fi - +exec python3 -I "$script_dir/tcdeploy.py" check-auth "$@" diff --git a/build_image.py b/build_image.py new file mode 100644 index 0000000..88c83e1 --- /dev/null +++ b/build_image.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Build committed source in a clean context without deployment credentials. + +Run in a separate build job, before attaching any deployment context. No package +installation or repository-defined build command executes on the credential host. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import sys +import tarfile +import tempfile + + +def run(command, env): + return subprocess.run(command, env=env, capture_output=True, check=True) + + +def forbidden(path): + parts = Path(path).parts + name = parts[-1] + return (any(p in ('.aws', '.ssh', '.git') for p in parts) + or name in ('awsenvconf', 'buildenvvar', '.npmrc', '.netrc', 'fetched_parameters.json', + 'paramnames.json', 'paramwithvalue.json', 'cli-input.json', 'upload_object.json') + or name.endswith(('_env', '.key', '.p12', '.pfx')) + or name == '.env' or (name.startswith('.env.') and name not in ('.env.example', '.env.sample'))) + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--image', required=True) + parser.add_argument('--dockerfile', default='Dockerfile') + parser.add_argument('--context', default='.') + parser.add_argument('--target') + args = parser.parse_args(argv) + if not re.fullmatch(r'[a-z0-9][a-z0-9_.:/-]+', args.image): + raise ValueError('Invalid local image tag') + for value in (args.dockerfile, args.context): + if Path(value).is_absolute() or '..' in Path(value).parts: + raise ValueError('Build paths must stay within the checkout') + if args.target and not re.fullmatch(r'[A-Za-z0-9_-]+', args.target): + raise ValueError('Invalid build target') + env = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'LANG': 'C.UTF-8'} + for key in ('DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH'): + if key in os.environ: + env[key] = os.environ[key] + sha = run(['git', 'rev-parse', 'HEAD'], env).stdout.decode().strip() + if os.environ.get('CIRCLE_SHA1') and os.environ['CIRCLE_SHA1'] != sha: + raise ValueError('Checkout differs from CircleCI source SHA') + with tempfile.TemporaryDirectory(prefix='tc-build-') as temp: + archive = Path(temp) / 'source.tar' + run(['git', 'archive', '--format=tar', '--output=' + str(archive), 'HEAD'], env) + context = Path(temp) / 'context' + context.mkdir() + with tarfile.open(archive) as source: + members = source.getmembers() + if any(forbidden(m.name) or not (m.isfile() or m.isdir()) for m in members): + raise ValueError('Committed source includes credentials, secret-file names, symlinks or unsupported archive entries; sanitize before building') + for member in members: + if member.isfile() and member.name.endswith('.pem'): + content = source.extractfile(member).read() + if not re.fullmatch(rb'(?:\s*-----BEGIN CERTIFICATE-----[A-Za-z0-9+/=\s]+-----END CERTIFICATE-----\s*)+', content): + raise ValueError('PEM build inputs must contain public certificates only') + source.extractall(context, filter='data') + env['DOCKER_CONFIG'] = str(Path(temp) / 'docker-config') + Path(env['DOCKER_CONFIG']).mkdir(mode=0o700) + command = ['docker', 'buildx', 'build', '--load', '--pull', '--no-cache', + '--provenance=false', '--platform', 'linux/amd64', + '--label', 'org.opencontainers.image.revision=' + sha, + '--tag', args.image, '--file', str(context / args.dockerfile)] + if args.target: + command += ['--target', args.target] + command += [str(context / args.context)] + # No build-arg or secret option exists: fetch runtime values only in ECS. + # Build logs are suppressed on failure to avoid dumping accidental secrets. + run(command, env) + print('Built committed source ' + sha + ' as ' + args.image) + + +if __name__ == '__main__': + try: + main() + except (ValueError, OSError, subprocess.SubprocessError, tarfile.TarError): + print('Build failed; check committed build inputs and secret-file exclusions. No deployment credentials were passed to Docker.', file=sys.stderr) + sys.exit(1) diff --git a/buildenv.sh b/buildenv.sh index 5a3031d..25d6f8d 100755 --- a/buildenv.sh +++ b/buildenv.sh @@ -1,162 +1,5 @@ -#!/bin/bash -usage() -{ -cat << EOF -usage: $0 options - -This script needs to be executed with below options. - -OPTIONS: - -e environment - -b security file location GIT|AWS - -EOF -} -#log Function - Used to provide information of execution information with date and time -log() -{ - echo "`date +'%D %T'` : $1" -} -track_error() -{ - if [ $1 != "0" ]; then - log "$2 exited with error code $1" - log "completed execution IN ERROR at `date`" - exit $1 - fi - -} -download_buildenvfile() -{ - Buffer_seclist=$(echo $BUILDENV_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - aws s3 cp s3://tc-platform-${ENV_CONFIG}/securitymanager/$listname.json . - track_error $? "Environment setting" - jq 'keys[]' $listname.json - track_error $? "$listname.json" - done -} - -download_psfile() -{ - Buffer_seclist=$(echo $SECPS_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - aws s3 cp s3://tc-platform-${ENV_CONFIG}/securitymanager/$listname.json . - track_error $? "$listname.json download" - jq 'keys[]' $listname.json - track_error $? "$listname.json" - done -} - -uploading_buildenvvar() -{ - Buffer_seclist=$(echo $BUILDENV_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $listname.json | jq -r ' . ' | jq ' . | to_entries[] | { "name": .key , "value": .value } ' | jq -s . ) - for s in $(echo $envvars | jq -c ".[]" ); do - #echo $envvars - varname=$(echo $s| jq -r ".name") - varvalue=$(echo $s| jq -r ".value") - echo $varname - echo export "$varname"="'$varvalue'" >>"buildenvvar" - #export "$varname"="$varvalue" - #echo export "$varname"="$varvalue" >>"$BASH_ENV" - #echo export "$varname"="\"$varvalue\"" >> ~/.circlerc - done - IFS=$o - done -} - -uploading_buildpsenvar() -{ - if [ -z $SECPS_LIST ]; - then - log "No ps file provided" - else - Buffer_seclist=$(echo $SECPS_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - varpath=$( cat $listname.json | jq -r ' .ParmeterPathList[] ' ) - #log "vars are fetched" - for k in $varpath; - do - echo $k - aws ssm get-parameters-by-path --with-decryption --path $k --query "Parameters[*].{Name:Name, Value:Value}" > paramnames.json - ###paramnames=$(cat paramnames.json | jq -r .[].Name | rev | cut -d / -f 1 | rev) - for s in $(cat paramnames.json | jq -c .[] ) - do - varname=$(echo $s | jq -r .Name | rev | cut -d / -f 1 | rev) - varvalue=$(echo $s | jq -r .Value) - #varvalue="arn:aws:ssm:$AWS_REGION:$AWS_ACCOUNT_ID:parameter$s" - echo export "$varname"="'$varvalue'" >>"buildenvvar" - #echo "$varname" "$varvalue" - done - done - IFS=$o - done - fi -} - -configure_aws_cli() { - aws --version - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID - aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY - aws configure set default.region $AWS_REGION - aws configure set default.output json - log "Configured AWS CLI." -} - -while getopts .b:e:l:. OPTION -do - case $OPTION in - e) - ENV=$OPTARG - ;; - b) - BUILDENV_LIST=$OPTARG - ;; - l) - SECPS_LIST=$OPTARG - ;; - ?) - log "additional param required" - usage - exit - ;; - esac -done - -# AWS_ACCESS_KEY_ID=$(eval "echo \$${ENV}_AWS_ACCESS_KEY_ID") -# AWS_SECRET_ACCESS_KEY=$(eval "echo \$${ENV}_AWS_SECRET_ACCESS_KEY") -# AWS_REGION=$(eval "echo \$${ENV}_AWS_REGION") -# if [ -z $AWS_REGION ]; -# then -# AWS_REGION="us-east-1" -# fi -# if [ -z $AWS_ACCESS_KEY_ID ] || [ -z $AWS_SECRET_ACCESS_KEY ] ; -# then -# log "AWS Secret Parameters are not configured in circleci/environment" -# usage -# exit 1 -# else -# configure_aws_cli -# fi -ENV_CONFIG=`echo "$ENV" | tr '[:upper:]' '[:lower:]'` -download_buildenvfile -uploading_buildenvvar -download_psfile -uploading_buildpsenvar - -if grep -Fxq "buildenvvar" .dockerignore -then - log "buildenvvar exist in docker ignore file list" -else - echo "buildenvvar" >> .dockerignore -fi +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'This legacy secret-file helper is disabled in v2. Deployment must not fetch or source secret configuration. Use a reviewed non-secret manifest and ECS runtime secret references; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/buildproperties.sh b/buildproperties.sh index 2ff2037..25d6f8d 100755 --- a/buildproperties.sh +++ b/buildproperties.sh @@ -1,104 +1,5 @@ -#!/bin/bash -KEY_LOCATION="" -BUILDENV_LIST="" -usage() -{ -cat << EOF -usage: $0 options -This script needs to be executed with below options. -OPTIONS: - -e environment - -b security file location GIT|AWS - -k key location -EOF -} -#log Function - Used to provide information of execution information with date and time -log() -{ - echo "`date +'%D %T'` : $1" -} -track_error() -{ - if [ $1 != "0" ]; then - log "$2 exited with error code $1" - log "completed execution IN ERROR at `date`" - exit $1 - fi - -} -download_buildenvfile() -{ - if [ -z "$BUILDENV_LIST" ]; - then - if [ -z "$KEY_LOCATION" ]; - then - track_error $? "Please provide the file list using -b or file location -k or both -b and -k " - else - aws s3 sync s3://tc-buildproperties-${ENV_CONFIG}/$KEY_LOCATION . - track_error $? "Environment setting" - fi - - else - Buffer_seclist=$(echo $BUILDENV_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - if [ -z "$KEY_LOCATION" ]; - then - aws s3 cp s3://tc-buildproperties-${ENV_CONFIG}/$listname . - track_error $? "Environment setting" - else - aws s3 cp s3://tc-buildproperties-${ENV_CONFIG}/$KEY_LOCATION/$listname . - track_error $? "Environment setting" - fi - done - fi - -} - -configure_aws_cli() { - aws --version - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID - aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY - aws configure set default.region $AWS_REGION - aws configure set default.output json - log "Configured AWS CLI." -} - -while getopts .b:e:k:. OPTION -do - case $OPTION in - e) - ENV=$OPTARG - ;; - b) - BUILDENV_LIST=$OPTARG - ;; - k) - KEY_LOCATION=$OPTARG - ;; - ?) - log "additional param required" - usage - exit - ;; - esac -done - -#AWS_ACCESS_KEY_ID=$(eval "echo \$${ENV}_AWS_ACCESS_KEY_ID") -#AWS_SECRET_ACCESS_KEY=$(eval "echo \$${ENV}_AWS_SECRET_ACCESS_KEY") -#AWS_REGION=$(eval "echo \$${ENV}_AWS_REGION") -if [ -z $AWS_REGION ]; -then -AWS_REGION="us-east-1" -fi -if [ -z $AWS_ACCESS_KEY_ID ] || [ -z $AWS_SECRET_ACCESS_KEY ] ; -then - log "AWS Secret Parameters are not configured in circleci/environment" - usage - exit 1 -else - #configure_aws_cli - log "AWS configured" -fi -ENV_CONFIG=`echo "$ENV" | tr '[:upper:]' '[:lower:]'` -download_buildenvfile \ No newline at end of file +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'This legacy secret-file helper is disabled in v2. Deployment must not fetch or source secret configuration. Use a reviewed non-secret manifest and ECS runtime secret references; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..6d14646 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,38 @@ +# How Auth0 provided AWS access in v1 + +The old helper: + +1. Read `CI_AUTH0_URL`, `CI_AUTH0_CLIENTID`, `CI_AUTH0_CLIENTSECRET` and `CI_AUTH0_AUDIENCE` from the job environment. +2. POSTed a client-credentials request to that URL, with requested environment, organization/repository, branch and build number as caller-supplied strings. +3. Took `access_token`, base64-decoded its JWT payload, and extracted claims matching `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, `AWS_SESSION_TOKEN`, `AWS_ENVIRONMENT`, and `AWS_ACCOUNT_ID`. +4. Wrote the AWS keys to the default CLI configuration and `awsenvconf`. Later scripts called AWS, including SSM reads/decryption, under that identity. + +The **client ID identifies the client; it does not grant AWS permissions**. The client secret authenticated the caller to the configured endpoint. The audience selected the intended API. Returning AWS credentials inside JWT claims is custom broker behavior, not a standard Auth0 feature that automatically assigns AWS permissions. + +Effective AWS access comes from the IAM identity behind the returned keys, its identity/session policies, boundaries, resource policies and organization controls. A stolen reusable broker secret may obtain fresh AWS sessions after earlier sessions expire. Rotating the client ID alone does not address this. + +The server-side broker/Action implementation was not in this repository or found in accessible source search. This PR therefore does not assert which role it assumes, how long credentials last, or whether it independently enforces project/environment/branch authorization. Caller-supplied metadata is not proof of authorization. The old helper also disabled TLS verification and did not cryptographically verify the returned JWT. + +The incident owner must locate the configured endpoint and associated Auth0 application/API, inspect broker/Actions, client grants, issuance logs, AWS principal and CloudTrail. Use restricted administrative tooling; never put JWTs/client secrets/AWS keys in a PR. Rotate exposed broker secrets/keys, revoke affected sessions, and retire the old broker's deployment grants after migration. This PR cannot invalidate credentials or change another service's policies. + +## v2: CircleCI to STS directly + +CircleCI issues an OIDC v3 token. AWS STS verifies it against the OIDC provider and role trust policy. The generated trust binds organization, project, checkout repository/ref **and configuration repository/ref**. Dev/prod use distinct roles/accounts/manifests. There is no Auth0 broker exchange, custom JWT credential envelope, or static AWS fallback. + +The deployer requests a one-hour session, attaches a restrictive session policy, and verifies the returned account/assumed-role identity. Both the generated IAM role policy and session policy explicitly deny `ssm:*`, `secretsmanager:*`, `kms:Decrypt` and role chaining. Other permissions cover only required ECS/ECR operations and passing the two exact runtime roles. **The deny must be installed in IAM**: a hostile client could omit an optional session policy. Local code is not the durable authorization boundary. + +Non-secret targets live in reviewed JSON. Runtime parameter names/ARNs stay in the existing task definition. No deploy-time listing, fetching, decryption, shell export or parameter write occurs. + +The **task execution role** lets the ECS agent retrieve the service's allowed secret values when launching tasks. The **task role** grants the application's runtime permissions. Both differ from the **deployment role**. Give secret reads to the execution role unless application code explicitly needs to retrieve them itself; restrict exact parameter/secret ARNs and KMS keys, not a broad shared `/config` tree. + +A deployer that can ship arbitrary application code can still ship code that reads/exfiltrates secrets injected into the app. Removing direct SSM access reduces exposure but cannot eliminate this inherent power. Protect source/config branches, require reviewed releases, restrict contexts and image/artifact writers, and narrow each execution role. Splitting jobs alone does not make malicious protected-branch code trustworthy or prove it cannot obtain OIDC credentials. + +AWS currently requires `Resource: "*"` for `DescribeTaskDefinition`. This exposes task metadata, including any legacy plaintext environment secrets in other definitions in the account. Move those values to runtime secret references. v2 rejects secret-like plaintext fields in the task it promotes. + +## Primary references + +- [CircleCI OIDC v3 and trust conditions](https://circleci.com/docs/guides/permissions-authentication/openid-connect-tokens/) +- [STS web identity and session policy intersection](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html) +- [ECS execution roles and secrets](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_execution_IAM_role.html) +- [ECS action/resource authorization](https://docs.aws.amazon.com/service-authorization/latest/reference/list_ecs.html) +- [Revoke AWS role sessions](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html) diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..7038a2b --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,58 @@ +# Migrate before using v2 + +Merging this PR does not change pinned v1 consumers or installed IAM policies. **Do not simply replace a v1 tag with this commit in existing CircleCI configs.** The Auth0 credential-export/SSM/sourced-file interface is deliberately removed. Publish a new major release after dev verification; never move a v1 tag. + +## Prepare one dev service + +1. Start with a straightforward one-container service, for example lookups. Have an administrator inspect its existing ECS service/task, ECR settings and IAM roles without exporting decrypted SSM values. Record the previous revision/digest in a restricted release record. +2. Create a manifest with exact identifiers from that inspection. Generate IAM documents using `tcdeploy.py policies`; install them through infrastructure review. Keep the explicit secret-access/role-chaining deny in the role policy and preferably use the generated permissions as its permissions boundary too. Remove unrelated policies and audit resource-based grants. The deployer needs no infrastructure-creation permissions. +3. Restrict PassRole to the exact task/execution roles. Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback. +4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment. +5. Commit non-secret `deploy/dev.json`. Use `develop` for dev and `master` for prod source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. +6. Prepare a reviewed executor with Python >=3.12, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters. +7. Split build/test and deploy using `examples/circleci-v2.yml`. Remove `org-global`, old helper copies, all `source awsenvconf` / `source *_env`, SSM build/deploy/QA retrieval, and unpinned host installs. Use restricted dev/prod contexts with no broker/static AWS secrets, production approval and serialization. Verify OIDC v3 availability; restrict fork secret passing and SSH debugging. Pin/download the entire suite. +8. Build committed source separately with `build_image.py`. Transfer only image archives through job-specific workflow workspaces. Do not cache the checkout/credential directories. The helper rejects known secret filenames and excludes untracked generated credentials; it does not replace a full secret scanner. Never build after deployment authentication. + +For private packages, use a separate package-read-only identity and BuildKit secret mounts in an independently reviewed build path, never production deploy credentials. The reviewed challenge/member dependency manifests no longer referenced CodeArtifact packages: remove obsolete broker login/buildvar steps where unused. Do not add a general secret-fetch option back to this suite. + +## Dev acceptance gates + +- Run the offline tests and shell syntax checks. They do not prove installed IAM state. +- With an administrator identity, validate/simulate the generated policies. SSM listing/reads, Secrets Manager reads, direct KMS decryption and role chaining must be explicitly denied. Verify only the intended ECS service, ECR repositories, task family and two PassRole ARNs are allowed. Check OIDC audience/project/checkout/config origin/ref; a wrong project/ref must not assume the role. Do not weaken project restrictions to make a test pass. +- Run `awsconfiguration.sh --manifest deploy/dev.json` in the intended job. It must verify identity without files/credential output. Missing v3 token, wrong account/role and legacy `DEV` arguments must fail. +- Run a real dev release. Verify roles, runtime secret references, sidecars, resources, volumes and health checks remain unchanged. Compare reported image digests with healthy running tasks. CloudTrail should show no deployment-principal SSM/Secrets Manager calls; runtime retrieval belongs to the execution role. +- Test an intentionally unhealthy dev image and verify failed release plus ECS rollback to the previous revision. Wrong digest, failed scan, denied AWS call, concurrency conflict or wrong source label must stop promotion. Old steady-state events/zero capacity must not pass. +- Inspect logs, workspace artifacts and image history/layers privately for accidental secrets. Remove stale broker/static credentials from project settings as well as contexts. Credential rotation/session revocation are separate incident actions. + +## Production and concurrency + +Use a separately reviewed prod manifest/role/context, exact prod account, protected `master` source/config refs and approval gate. Validate IAM denies before use. Promote reviewed suite/executor commits and retain a previous healthy revision/digest. ECS performs circuit-breaker rollback; failed/timeout deployments require inspection rather than blindly overwriting a newer release. + +Serial groups prevent normal CI overlap. ECS UpdateService has no general compare-and-swap for revision, so before/after checks cannot eliminate every out-of-band race. Restrict other writers and coordinate operator changes. + +## External adoption work this PR cannot perform + +- Install/restrict OIDC/IAM/contexts; retire broker grants; rotate exposed secrets and revoke sessions. +- Change each service's CircleCI configuration/manifests, runtime bindings, scanner/immutability settings and ECS health/rollback configuration. +- Remove production DB-reset options/startup entrypoints from groups/identity/review and remove schema-reset privileges from runtime DB users. v2 rejects reset-enabled image/task environments and any RESET_DB secret binding in **both** environments. Controlled dev resets belong outside deployment. +- Preserve custom migration, CloudFormation, scan and monitoring checks; see `SERVICES.md`. Do not overwrite custom pipelines with the basic example. +- Pin forums' four cross-repository client clones. A generic builder cannot prove remote code downloaded inside a Dockerfile; Dockerfiles remain trusted build code. + +These are adoption gates, not claims that existing services or live IAM policies changed by merging the PR. + +## Audit coverage + +| Finding | v2 mitigation | Required adoption | +| --- | --- | --- | +| F01/F02 TLS/eval | Remove Auth0 HTTP/eval path; STS validates OIDC | Install narrow trust; retire broker | +| F03 SSM shell source | Disable parameter/export helpers; parse manifest as data | Remove caller helper/source steps | +| F04 ignored failures | Check process/API results and identity; no SSM reads | Negative dev tests | +| F05 execution role override | Preserve/verify exact roles; narrow PassRole | Install service-specific roles | +| F06 shared credentials | No broker/static fallback; scrub child environments | Restrict contexts/trust; remove stale secrets | +| F07 build context | Separate committed-source builds; reject secret files | Migrate build jobs | +| F08 mutable inputs | Reviewed full suite commit and executor digest | Pin Dockerfile remote dependencies | +| F09 rollout/artifact integrity | Digests, scans, actual revision/tasks/health | Scanner, immutable repos, circuit breaker | +| F10 DB reset | Reject reset-enabled images/tasks or secret binding | Remove legacy reset code/DB privileges | +| F11 credential files/logging | In-memory STS, stdin requests, private Docker config, sanitized errors | Ephemeral runners, controlled logs/debug | + +Legacy SSM upload/setup and password-encryption helpers also fail closed. Secret provisioning belongs in a separate administrator/IaC workflow. Historical v1 tags are migration references, not security workarounds. diff --git a/docs/SERVICES.md b/docs/SERVICES.md new file mode 100644 index 0000000..f93b7dd --- /dev/null +++ b/docs/SERVICES.md @@ -0,0 +1,22 @@ +# Service adoption checklist + +All 19 reviewed local v6 services require service-repository/infrastructure adoption. This shared-repository PR does not silently edit or deploy them. + +| Services | Current path | Migration considerations | +| --- | --- | --- | +| challenge-api-v6 | Shared v1.4.17 | Remove unused buildvar/CodeArtifact broker steps; use `--dockerfile docker/Dockerfile`; preserve tests/package hygiene | +| resource-api-v6 | Shared v1.4.17 | Existing APPNAME is `resources-api-v6`: verify actual ECS/container names. Remove buildvar/QA parameter reads; isolate QA triggers. Use `docker/Dockerfile` | +| review-api-v6 | Shared v1.4.17 | Remove production reset-db; preserve migrations with separately controlled privileges | +| engagements-api-v6 | Shared v1.4.19 | Separate schema migrations from runtime privileges; remove feature-branch deploy grants | +| forums-api-v6 | Shared v1.4.19 | Pin four remote API client clones; preserve generated Prisma clients | +| groups-api-v6, identity-api-v6 | Shared v1.4.19 | Remove production reset-db capability and feature-branch deploy grants; preserve controlled migrations | +| leave-api-v6, lookups-api-v6, reports-api-v6 | Shared v1.4.19 | Standard single-container migration; clean context, remove tag triggers, add serialization | +| member-api-v6 | Shared v1.4.20 | Use `docker/Dockerfile`; retain non-root/distroless image and only scoped package credentials if necessary | +| forms-api-v6 | Shared v1.4.20 + migration/activation | Preserve one-shot migration/activation gates. Replace production account test with exact account mapping. Bootstrap capacity separately; v2 cannot run migrations or scale from zero | +| marathon-match-api-v6, support-api-v6 | Shared v1.4.21 | Preserve service-specific runtime constraints; remove deployment-variable sourcing | +| av-scanner-v6 | Patched helper + CloudFormation | Preserve two containers/topology. v2 supports multiple images, but do not update ECS behind CloudFormation ownership without an infrastructure migration. Keep stack changes in a separate role. The old helper hardener cannot patch removed v1 code | +| forms-processor-v6 | Helper + Python release | Pre-provision managed secret bindings; remove parameter resolution from deployment; retain healthy-revision checks. Bootstrap separately | +| status-api-v6 | Helper + custom ECS release | Preserve digest, ALB health, rollback, sanitizer and monitoring checks. Basic v2 task health does not replace these gates; adapt OIDC/no-SSM configuration without discarding them | +| terms-service-v6, terms-receiver-v6 | Standalone Python broker deployers | They do not consume this repo today. Remove broker and SSM deployvar/validation reads; add exact account/role checks; preserve audience/scan/rollout gates and provision bindings via infrastructure | + +Inspect existing references without decrypting values. Do not drop required runtime settings to pass a no-SSM policy; provision them before launch and let the execution role resolve them. Do not reuse a broad shared execution role as a permission workaround. diff --git a/examples/circleci-v2.yml b/examples/circleci-v2.yml new file mode 100644 index 0000000..7cf4e8d --- /dev/null +++ b/examples/circleci-v2.yml @@ -0,0 +1,104 @@ +# TEMPLATE: replace both reviewed executor digests, suite commit, APPNAME, +# context names and manifest paths before adopting. Never use trigger parameters +# for code/executor pins. Custom release/migration/health gates must be retained. +version: 2.1 +executors: + reviewed: + docker: + - image: your-reviewed-public-ci-executor@sha256:REPLACE_WITH_REVIEWED_64_HEX_DIGEST + environment: + APPNAME: example-v6 + TC_DEPLOY_SCRIPTS_SHA: REPLACE_WITH_REVIEWED_40_HEX_COMMIT +commands: + checkout-suite: + steps: + - run: + name: Fetch and verify the reviewed deployment suite + command: | + set -euo pipefail + [[ "$TC_DEPLOY_SCRIPTS_SHA" =~ ^[0-9a-f]{40}$ ]] + git init --quiet /tmp/tc-deploy-scripts + git -C /tmp/tc-deploy-scripts remote add origin https://github.com/topcoder-platform/tc-deploy-scripts.git + git -C /tmp/tc-deploy-scripts fetch --quiet --depth 1 origin "$TC_DEPLOY_SCRIPTS_SHA" + git -C /tmp/tc-deploy-scripts checkout --quiet --detach FETCH_HEAD + test "$(git -C /tmp/tc-deploy-scripts rev-parse HEAD)" = "$TC_DEPLOY_SCRIPTS_SHA" +jobs: + build: + executor: reviewed + steps: + - checkout + - setup_remote_docker + - checkout-suite + # Add the service's existing quality gates here, without deploy secrets. + # Prefer tests inside the Docker build or another credential-free job. + - run: + name: Build committed source without deployment credentials + command: | + python3 -I /tmp/tc-deploy-scripts/build_image.py --image "${APPNAME}:candidate" + mkdir -p /tmp/release + docker save --output /tmp/release/image.tar "${APPNAME}:candidate" + - persist_to_workspace: + root: /tmp/release + paths: [image.tar] + deploy: + executor: reviewed + parameters: + environment: + type: enum + enum: [dev, prod] + environment: + DEPLOY_MANIFEST: deploy/<< parameters.environment >>.json + steps: + - checkout + - setup_remote_docker + - checkout-suite + - attach_workspace: + at: /tmp/release + - run: + name: Promote a scanned digest using OIDC and no parameter permissions + no_output_timeout: 45m + command: | + set +x + set -euo pipefail + docker load --input /tmp/release/image.tar + /tmp/tc-deploy-scripts/master_deploy.sh --manifest "$DEPLOY_MANIFEST" \ + --image "${APPNAME}=${APPNAME}:candidate" +workflows: + release: + jobs: + - build: + filters: + branches: + only: [develop, master] + tags: + ignore: /.*/ + - deploy: + name: deploy-dev + environment: dev + context: v6-dev-deploy + serial-group: << pipeline.project.slug >>/deploy-dev + requires: [build] + filters: + branches: + only: develop + tags: + ignore: /.*/ + - approve-prod: + type: approval + requires: [build] + filters: + branches: + only: master + tags: + ignore: /.*/ + - deploy: + name: deploy-prod + environment: prod + context: v6-prod-deploy + serial-group: << pipeline.project.slug >>/deploy-prod + requires: [approve-prod] + filters: + branches: + only: master + tags: + ignore: /.*/ diff --git a/examples/deploy.dev.json b/examples/deploy.dev.json new file mode 100644 index 0000000..4a82604 --- /dev/null +++ b/examples/deploy.dev.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 2, + "environment": "dev", + "accountId": "111111111111", + "region": "us-east-1", + "deployRoleArn": "arn:aws:iam::111111111111:role/example-v6-dev-deploy", + "taskRoleArn": "arn:aws:iam::111111111111:role/example-v6-dev-task", + "executionRoleArn": "arn:aws:iam::111111111111:role/example-v6-dev-execution", + "cluster": "example-cluster", + "service": "example-v6", + "taskFamily": "example-v6", + "circleciOrgId": "00000000-0000-0000-0000-000000000001", + "circleciProjectId": "00000000-0000-0000-0000-000000000002", + "repository": "github.com/example/example-v6", + "branch": "develop", + "images": {"example-v6": "example-v6"} +} diff --git a/examples/deploy.prod.json b/examples/deploy.prod.json new file mode 100644 index 0000000..3004b68 --- /dev/null +++ b/examples/deploy.prod.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 2, + "environment": "prod", + "accountId": "222222222222", + "region": "us-east-1", + "deployRoleArn": "arn:aws:iam::222222222222:role/example-v6-prod-deploy", + "taskRoleArn": "arn:aws:iam::222222222222:role/example-v6-prod-task", + "executionRoleArn": "arn:aws:iam::222222222222:role/example-v6-prod-execution", + "cluster": "example-cluster", + "service": "example-v6", + "taskFamily": "example-v6", + "circleciOrgId": "00000000-0000-0000-0000-000000000001", + "circleciProjectId": "00000000-0000-0000-0000-000000000002", + "repository": "github.com/example/example-v6", + "branch": "master", + "images": { + "example-v6": "example-v6" + } +} diff --git a/master_deploy.sh b/master_deploy.sh index 6a9521a..006b5cf 100755 --- a/master_deploy.sh +++ b/master_deploy.sh @@ -1,1311 +1,10 @@ -#!/bin/bash - -#Variable Declaration -JQ="jq --raw-output --exit-status" -DEPLOYMENT_TYPE="" -ENV="" -BUILD_VARIABLE_FILE_NAME="./buildvar.conf" -SECRET_FILE_NAME="./buildsecvar.conf" -SHARED_PROPERTY_FILENAME="" - -# Common variables - -#echo $AWS_ACCESS_KEY_ID -# AWS_ACCESS_KEY_ID="" -# AWS_SECRET_ACCESS_KEY="" -# AWS_ACCOUNT_ID="" -# AWS_REGION="" -TAG="" -SEC_LIST="" -SECPS_LIST="" -ARG_SECPS_LIST="" -#COUNTER_LIMIT=12 - -if [ -z "$COUNTER_LIMIT" ]; then - COUNTER_LIMIT=12 +#!/usr/bin/env bash +# v2 intentionally rejects the legacy SSM/secret-file CLI. See docs/MIGRATION.md. +set +x +set -euo pipefail +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${1:-}" != --manifest ]]; then + echo 'v2 requires --manifest FILE --image CONTAINER=LOCAL_IMAGE. Migrate the pipeline and IAM role first; see docs/MIGRATION.md.' >&2 + exit 2 fi - -# Variables specific to ECS - -#AWS_REPOSITORY="" -#AWS_ECS_CLUSTER="" -#AWS_ECS_SERVICE="" -#AWS_ECS_TASK_FAMILY="" -#AWS_ECS_CONTAINER_NAME="" -ECS_TAG="" -REVISION="" -ECS_TEMPLATE_TYPE="EC2" -task_def="" -CONTAINER_LOG_DRIVER="awslogs" -portcount=0 -envcount=0 -psenvcount=0 -volcount=0 -template="" -TEMPLATE_SKELETON_FILE="base_template_v2.json" -APP_IMAGE_NAME="" -DEPLOYCATEGORY="" -ECSCLI_ENVFILE="api.env" - -# Variables specific to EBS - -DOCKERRUN="Dockerrun.aws.json" -#EBS_EB_EXTENSTION_LOCATION="" -IMG_WITH_EBS_TAG="" -EBS_TEMPLATE_SKELETON_FILE="ebs_base_template_v3.json.template" -EBS_APPLICATION_NAME="" -EBS_APPVER="" -EBS_TAG="" -IMAGE="" -AWS_EBS_APPVER="" -#AWS_S3_BUCKET="" -AWS_S3_KEY="" -AWS_EB_ENV="" -EBS_TEMPLATE_FILE_NAME="" -#AWS_EBS_EB_DOCKERRUN_TEMPLATE_LOCATION=$(eval "echo \$${ENV}_AWS_EBS_EB_DOCKERRUN_TEMPLATE_LOCATION") -#AWS_EBS_DOCKERRUN_TEMPLATE=$(eval "echo \$${ENV}_AWS_EBS_DOCKERRUN_TEMPLATE") -#AWS_S3_KEY_LOCATION="" -ebsportcount=0 -ebstemplate="" -#variable for cloud front -#AWS_S3_BUCKET="" -#AWS_S3_SOURCE_SYNC_PATH="" -CFCACHE="false" -# AWS_CLOUD_FRONT_ID="" - -# Variables for Lambda -#AWS_LAMBDA_DEPLOY_TYPE="" -#AWS_LAMBDA_STAGE="" - -# FUNCTIONS -# usage Function - provides information about how to execute the script -usage() -{ -cat << EOF -usage: $0 options - -This script need to be executed with below option. - -OPTIONS: - -h Show this message - -d Deployment Type [ECS|EBS|CFRONT] - -e Environment [DEV|QA|PROD] - -t ECS Tag Name [mandatory if ECS ] - -v EBS version [mandatory if EBS deployment] - -i ECS Image name - -c cache option true [optional : value = true| false]i - -s Security file location GIT|AWS - -p ECS template type - -g Common property file which is uploaded to shared-properties folder -EOF -} - -# log Function - Used to provide information of execution information with date and time -log() -{ - echo "`date +'%D %T'` : $1" -} - -# track_error function - validates whether the application execute without any error -track_error() -{ - if [ $1 != "0" ]; then - log "$2 exited with error code $1" - log "completed execution IN ERROR at `date`" - exit $1 - fi -} - -# Function for AWS login -configure_aws_cli() { - aws --version - aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID - aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY - aws configure set default.region $AWS_REGION - aws configure set default.output json - log "Configured AWS CLI." -} - -# Function for private dcoker login -configure_docker_private_login() { - aws s3 cp "s3://appirio-platform-$ENV_CONFIG/services/common/dockercfg" ~/.dockercfg -} - -# ECS Deployment Functions -ECS_push_ecr_image() { - echo "\n\n" - if [ -z "$APP_IMAGE_NAME" ]; - then - log "ECS image follows the standard format" - else - log "ECS Image does not follow the standard format. Modifying the image and updating the ECS_TAG" - docker tag $APP_IMAGE_NAME:$ECS_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$AWS_REPOSITORY:$CIRCLE_BUILD_NUM - ECS_TAG=$CIRCLE_BUILD_NUM - fi - - CHECK_ECR_EXIST="" - CHECK_ECR_EXIST=$(aws ecr describe-repositories --repository-names ${AWS_REPOSITORY} 2>&1) - if [ $? -ne 0 ]; then - if echo ${CHECK_ECR_EXIST} | grep -q RepositoryNotFoundException; then - echo "ECR repo does not exist -- creating repo" - aws ecr create-repository --repository-name $AWS_REPOSITORY - track_error $? "ECS ECR repo creation" - log "ECR repo created successfully." - else - echo ${CHECK_ECR_EXIST} - fi - else - echo "$AWS_REPOSITORY ECR repository already exists" - fi - - log "Pushing Docker Image..." - eval $(aws ecr get-login --region $AWS_REGION --no-include-email) - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$AWS_REPOSITORY:$ECS_TAG - track_error $? "ECS ECR image push" - log "Docker Image published\n\n" -} - -ECSCLI_push_ecr_image() { - ECS_REPONAME=$1 - IMAGE_NAME=$2 - if [ -z "$IMAGE_NAME" ]; - then - log "ECS image follows the standard format" - else - log "ECS image does not follow the standard format. Modifying the image and updating the ECS_TAG" - docker tag $IMAGE_NAME:$ECS_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECS_REPONAME:$CIRCLE_BUILD_NUM - ECS_TAG=$CIRCLE_BUILD_NUM - fi - log "Pushing Docker Image..." - eval $(aws ecr get-login --region $AWS_REGION --no-include-email) - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$ECS_REPONAME:$ECS_TAG - track_error $? "ECS ECR image push" - log "Docker ECR Image published\n\n" -} - -ECSCLI_update_env() -{ - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $listname.json | jq -r ' . ' | jq ' . | to_entries[] | { "name": .key , "value": .value } ' | jq -s . ) - log "ECS env vars are fetched" - - for s in $(echo $envvars | jq -c ".[]" ); do - #echo $envvars - varname=$(echo $s| jq -r ".name") - varvalue=$(echo $s| jq -r ".value") - envaddition "$varname" "$varvalue" - echo "$varname"="\"$varvalue\"" >>$ECSCLI_ENVFILE - done - IFS=$o - done -} - -portmapping() { - hostport=$1 - containerport=$2 - containerprotocol=$3 - - template=$(echo $template | jq --argjson hostPort $hostport --argjson containerPort $containerport --arg protocol $containerprotocol --arg portcount $portcount '.containerDefinitions[0].portMappings[$portcount |tonumber] |= .+ { hostPort: $hostPort, containerPort: $containerPort, protocol: $protocol }') - let portcount=portcount+1 -} - -envaddition() { - #echo "envcount before " $envcount - envname=$1 - envvalue=$2 - #echo "env value before" $envvalue - set -f - template=$(echo $template | jq --arg name "$envname" --arg value "$envvalue" --arg envcount $envcount '.containerDefinitions[0].environment[$envcount |tonumber] |= .+ { name: $name, value: $value }') - set +f - let envcount=envcount+1 - #echo "envcount after ---------" $envcount - #echo "envvalue after ---------" $envvalue -} - -psenvaddition() { - #echo "psenvcount before " $psenvcount - envname=$1 - envvalue=$2 - #echo "env value before" $envvalue - isEnvExist=$( echo $template | grep -wc "$envname") - if [ $isEnvExist -eq 0 ]; then - set -f - template=$(echo $template | jq --arg name "$envname" --arg value "$envvalue" --arg psenvcount $psenvcount '.containerDefinitions[0].secrets[$psenvcount |tonumber] |= .+ { name: $name, valueFrom: $value }') - set +f - let psenvcount=psenvcount+1 - fi - #echo "psenvcount after ---------" $psenvcount - #echo "envvalue after ---------" $envvalue -} - -logconfiguration() { - template=$(echo $template | jq --arg logDriver $CONTAINER_LOG_DRIVER '.containerDefinitions[0].logConfiguration.logDriver=$logDriver') - template=$(echo $template | jq --arg awslogsgroup "/aws/ecs/$AWS_ECS_CLUSTER" '.containerDefinitions[0].logConfiguration.options."awslogs-group"=$awslogsgroup') - template=$(echo $template | jq --arg awslogsregion $AWS_REGION '.containerDefinitions[0].logConfiguration.options."awslogs-region"=$awslogsregion') - template=$(echo $template | jq --arg awslogsstreamprefix $ENV '.containerDefinitions[0].logConfiguration.options."awslogs-stream-prefix"=$awslogsstreamprefix') - template=$(echo $template | jq 'del(.containerDefinitions[0].logConfiguration.options.KeyName)') -} - -volumeupdate() { - volname=$1 - sourcepath=$2 - mountpath=$3 - #mntpermission=$4 - #echo $volname $sourcepath $mountpath $mntpermission - #volumes update - template=$(echo $template | jq --arg volname $volname --arg sourcepath $sourcepath --arg volcount $volcount '.volumes[$volcount |tonumber] |= .+ { name: $volname, host: { sourcePath: $sourcepath } }') - #mount point update - template=$(echo $template | jq --arg volname $volname --arg mountpath $mountpath --arg volcount $volcount '.containerDefinitions[0].mountPoints[$volcount |tonumber] |= .+ { sourceVolume: $volname, containerPath: $mountpath }') - - let volcount=volcount+1 -} - -efsvolumeupdate() { - volname=$1 - sourcepath=$2 - mountpath=$3 - filesystemid=$4 - #volumes update - template=$(echo $template | jq --arg volname $volname --arg sourcepath $sourcepath --arg volcount $volcount --arg filesystemid $filesystemid '.volumes[$volcount |tonumber] |= .+ { name: $volname, efsVolumeConfiguration: { fileSystemId: $filesystemid, rootDirectory: $sourcepath } }') - #mount point update - template=$(echo $template | jq --arg volname $volname --arg mountpath $mountpath --arg volcount $volcount '.containerDefinitions[0].mountPoints[$volcount |tonumber] |= .+ { sourceVolume: $volname, containerPath: $mountpath }') - - let volcount=volcount+1 -} - -ephemeralvolumeupdate() { - volname=$1 - #sourcepath=$2 - mountpath=$2 - #mntpermission=$4 - #echo $volname $sourcepath $mountpath $mntpermission - #volumes update - template=$(echo $template | jq --arg volname $volname --arg volcount $volcount '.volumes[$volcount |tonumber] |= .+ { name: $volname, host: { } }') - #mount point update - template=$(echo $template | jq --arg volname $volname --arg mountpath $mountpath --arg volcount $volcount '.containerDefinitions[0].mountPoints[$volcount |tonumber] |= .+ { sourceVolume: $volname, containerPath: $mountpath }') - - let volcount=volcount+1 -} - -ECS_Container_HealthCheck_integ() { - HealthCheckCmd="$1" - - template=$(echo $template | jq '.containerDefinitions[0].healthCheck.retries=3') - template=$(echo $template | jq '.containerDefinitions[0].healthCheck.timeout=15') - template=$(echo $template | jq '.containerDefinitions[0].healthCheck.interval=60') - template=$(echo $template | jq '.containerDefinitions[0].healthCheck.startPeriod=120') - template=$(echo $template | jq --arg HealthCheckCmd "$HealthCheckCmd" '.containerDefinitions[0].healthCheck.command=["CMD-SHELL",$HealthCheckCmd]') -} - -ECS_Container_cmd_integ() { - ContainerCmd="$1" - template=$(echo $template | jq --arg ContainerCmd "$ContainerCmd" '.containerDefinitions[0].command=[$ContainerCmd]') -} - -ECS_template_create_register() { - #Getting Template skeleton - #template=`aws ecs register-task-definition --generate-cli-skeleton` - template=$(cat $TEMPLATE_SKELETON_FILE) - - #Updating ECS task def file - template=$(echo $template | jq --arg family $AWS_ECS_TASK_FAMILY '.family=$family') - log "ECS Task Family updated" - - #taskrole and excution role has updated - if [ -z $AWS_ECS_TASK_ROLE_ARN ]; - then - log "No ECS Task Role defined" - else - template=$(echo $template | jq --arg taskRoleArn arn:aws:iam::$AWS_ACCOUNT_ID:role/$AWS_ECS_TASK_ROLE_ARN '.taskRoleArn=$taskRoleArn') - fi - - if [ -z $AWS_ECS_TASK_EXECUTION_ROLE_ARN ]; - then - log "No ECS Task Execution Role defined" - else - template=$(echo $template | jq --arg executionRoleArn arn:aws:iam::$AWS_ACCOUNT_ID:role/$AWS_ECS_TASK_EXECUTION_ROLE_ARN '.executionRoleArn=$executionRoleArn') - fi - - #Container Name update - template=$(echo $template | jq --arg name $AWS_ECS_CONTAINER_NAME '.containerDefinitions[0].name=$name') - log "ECS Container Name updated" - - #Container Image Name update - template=$(echo $template | jq --arg image $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$AWS_REPOSITORY:$ECS_TAG '.containerDefinitions[0].image=$image') - log "ECR Image name updated" - - #Container readonlyRootFilesystem - if [ -z $AWS_ECS_READONLY_ROOTFILESYSTEM ]; - then - log "No ECS readonlyRootFilesystem defined. Going with default value as true" - AWS_ECS_READONLY_ROOTFILESYSTEM=true - template=$(echo $template | jq --argjson readonlyRootFilesystem $AWS_ECS_READONLY_ROOTFILESYSTEM '.containerDefinitions[0].readonlyRootFilesystem=$readonlyRootFilesystem') - else - template=$(echo $template | jq --argjson readonlyRootFilesystem $AWS_ECS_READONLY_ROOTFILESYSTEM '.containerDefinitions[0].readonlyRootFilesystem=$readonlyRootFilesystem') - fi - log "ECS readonlyRootFilesystem updated." - - #Container Memory reservation - if [ -z $AWS_ECS_CONTAINER_MEMORY_RESERVATION ]; - then - log "No ECS reserved memory defined. Going with default value 500 MB" - AWS_ECS_CONTAINER_MEMORY_RESERVATION="1000" - template=$(echo $template | jq --argjson memoryReservation $AWS_ECS_CONTAINER_MEMORY_RESERVATION '.containerDefinitions[0].memoryReservation=$memoryReservation') - else - template=$(echo $template | jq --argjson memoryReservation $AWS_ECS_CONTAINER_MEMORY_RESERVATION '.containerDefinitions[0].memoryReservation=$memoryReservation') - fi - log "ECS memory reservation updated." - - #Container CPU reservation - if [ -z $AWS_ECS_CONTAINER_CPU ]; - then - echo "No ECS container CPU defined. Going with default value 100" - AWS_ECS_CONTAINER_CPU=100 - template=$(echo $template | jq --argjson cpu $AWS_ECS_CONTAINER_CPU '.containerDefinitions[0].cpu=$cpu') - else - template=$(echo $template | jq --argjson cpu $AWS_ECS_CONTAINER_CPU '.containerDefinitions[0].cpu=$cpu') - fi - log "ECS container CPU updated." - - #Port Mapping - Buffer_portmap=$(echo $AWS_ECS_PORTS | sed 's/,/ /g') - for b1 in $Buffer_portmap; - do - hostport=$( echo $b1 | cut -d ':' -f 1 ) - log "ECS host port: $hostport" - containerport=$( echo $b1 | cut -d ':' -f 2 ) - log "ECS container port: $containerport" - protocolmapped=$( echo $b1 | cut -d ':' -f 3 ) - log "ECS mapped protocol: $protocolmapped" - portmapping $hostport $containerport $protocolmapped - done - log "ECS container port mapping updated" - - # Environment addition - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $listname.json | jq -r ' . ' | jq ' . | to_entries[] | { "name": .key , "value": .value } ' | jq -s . ) - log "vars are fetched" - - for s in $(echo $envvars | jq -c ".[]" ); do - #echo $envvars - varname=$(echo $s| jq -r ".name") - varvalue=$(echo $s| jq -r ".value") - envaddition "$varname" "$varvalue" - done - IFS=$o - done - - if [ -z $SECPS_LIST ]; - then - log "No ps file provided" - else - Buffer_seclist=$(echo $SECPS_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - varpath=$( cat $listname.json | jq -r ' .ParmeterPathList[] ' ) - #log "vars are fetched" - for k in $varpath; - do - echo $k - aws ssm get-parameters-by-path --path $k --query "Parameters[*].{Name:Name}" > paramnames.json - ###paramnames=$(cat paramnames.json | jq -r .[].Name | rev | cut -d / -f 1 | rev) - for s in $(cat paramnames.json | jq -r .[].Name ) - do - varname=$(echo $s | rev | cut -d / -f 1 | rev) - varvalue="arn:aws:ssm:$AWS_REGION:$AWS_ACCOUNT_ID:parameter$s" - psenvaddition "$varname" "$varvalue" - #echo "$varname" "$varvalue" - done - done - IFS=$o - done - fi - if [ -z $ARG_SECPS_LIST ]; - then - log "No ps file provided" - else - Buffer_seclist=$(echo $ARG_SECPS_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - k=$listname - echo $k - aws ssm get-parameters-by-path --path $k --query "Parameters[*].{Name:Name}" > paramnames.json - ###paramnames=$(cat paramnames.json | jq -r .[].Name | rev | cut -d / -f 1 | rev) - for s in $(cat paramnames.json | jq -r .[].Name ) - do - varname=$(echo $s | rev | cut -d / -f 1 | rev) - varvalue="arn:aws:ssm:$AWS_REGION:$AWS_ACCOUNT_ID:parameter$s" - psenvaddition "$varname" "$varvalue" - #echo "$varname" "$varvalue" - done - IFS=$o - done - fi - log "Environment has updated" - - # Log Configuration - logconfiguration - log "Log configuration has updated" - - #volume update - if [ -z $AWS_ECS_VOLUMES ]; - then - echo "No ECS volume mapping defined" - else - Buffer_volumes=$(echo $AWS_ECS_VOLUMES | sed 's/,/ /g') - for v1 in $Buffer_volumes; - do - volname=$( echo $v1 | cut -d ':' -f 1 ) - sourcepath=$( echo $v1 | cut -d ':' -f 2 ) - mountpath=$( echo $v1 | cut -d ':' -f 3 ) - #mntpermission=$( echo $v1 | cut -d ':' -f 4 ) - #volumeupdate $volname $sourcepath $mountpath $mntpermission - volumeupdate $volname $sourcepath $mountpath - done - log "ECS volumes are mapped" - fi - - #efs volume update - if [ -z $AWS_ECS_VOLUMES_EFS ]; - then - echo "No ECS EFS volume mapping defined" - else - Buffer_volumes=$(echo $AWS_ECS_VOLUMES_EFS | sed 's/,/ /g') - for v1 in $Buffer_volumes; - do - volname=$( echo $v1 | cut -d ':' -f 1 ) - sourcepath=$( echo $v1 | cut -d ':' -f 2 ) - mountpath=$( echo $v1 | cut -d ':' -f 3 ) - filesystemid=$( echo $v1 | cut -d ':' -f 4 ) - #mntpermission=$( echo $v1 | cut -d ':' -f 4 ) - #volumeupdate $volname $sourcepath $mountpath $mntpermission fileSystemId - efsvolumeupdate $volname $sourcepath $mountpath $filesystemid - done - log "ECS EFS volumes are mapped" - fi - - # ephemeral volume update - if [ -z $AWS_ECS_EPHEMERAL_VOLUMES ]; - then - echo "No ECS EPHEMERAL volume mapping defined" - else - Buffer_volumes=$(echo $AWS_ECS_EPHEMERAL_VOLUMES | sed 's/,/ /g') - for v1 in $Buffer_volumes; - do - volname=$( echo $v1 | cut -d ':' -f 1 ) - mountpath=$( echo $v1 | cut -d ':' -f 2 ) - ephemeralvolumeupdate $volname $mountpath - done - log "ECS EPHEMERAL volumes are mapped" - fi - - #Container health check update - if [ -z "$AWS_ECS_CONTAINER_HEALTH_CMD" ]; - then - echo "No ECS container health check command defined" - else - ECS_Container_HealthCheck_integ "$AWS_ECS_CONTAINER_HEALTH_CMD" - fi - - #Container command integration - if [ -z "$AWS_ECS_CONTAINER_CMD" ]; - then - echo "No ECS container start-up command defined" - else - ECS_Container_cmd_integ "$AWS_ECS_CONTAINER_CMD" - fi - - # Updating ephemeral Storage - if [ -z $AWS_ECS_EPHEMERAL_STORAGE_SIZE ]; - then - echo "No AWS_ECS_EPHEMERAL_STORAGE_SIZE defined. " - else - template=$(echo $template | jq --argjson ephemeralStorageSize $AWS_ECS_EPHEMERAL_STORAGE_SIZE '.ephemeralStorage |= .+ { sizeInGiB: $ephemeralStorageSize}') - fi - - #updating data based on ECS deploy type - if [ "$ECS_TEMPLATE_TYPE" == "FARGATE" ] - then - #updating Network - ECS_NETWORKTYPE="awsvpc" - template=$(echo $template | jq --arg executionRoleArn arn:aws:iam::$AWS_ACCOUNT_ID:role/ecsTaskExecutionRole '.executionRoleArn=$executionRoleArn') - template=$(echo $template | jq --arg networkMode $ECS_NETWORKTYPE '.networkMode=$networkMode') - # Updating the compatibiltiy - #template=$(echo $template | jq --arg requiresCompatibilities EC2 '.requiresCompatibilities[0] |= .+ $requiresCompatibilities') - template=$(echo $template | jq --arg requiresCompatibilities FARGATE '.requiresCompatibilities[.requiresCompatibilities| length] |= .+ $requiresCompatibilities') - template=$(echo $template |jq 'del(.requiresCompatibilities[] | select(. == "EC2"))') - # Updating Fargate CPU - if [ -z $AWS_ECS_FARGATE_CPU ]; - then - echo "No FARGATE CPU defined. Going with default value 1024" - AWS_ECS_FARGATE_CPU="1024" - template=$(echo $template | jq --arg cpu $AWS_ECS_FARGATE_CPU '.cpu=$cpu') - else - template=$(echo $template | jq --arg cpu $AWS_ECS_FARGATE_CPU '.cpu=$cpu') - fi - - # Updating Fargate Memory - if [ -z $AWS_ECS_FARGATE_MEMORY ]; - then - echo "No FARGATE memory defined. Going with default value 2048" - AWS_ECS_FARGATE_MEMORY="2048" - template=$(echo $template | jq --arg memory $AWS_ECS_FARGATE_MEMORY '.memory=$memory') - else - template=$(echo $template | jq --arg memory $AWS_ECS_FARGATE_MEMORY '.memory=$memory') - fi - else - #CONTAINER_CPU - ECS_NETWORKTYPE="bridge" - template=$(echo $template | jq --arg networkMode $ECS_NETWORKTYPE '.networkMode=$networkMode') - - # Updating the compatibiltiy - template=$(echo $template | jq --arg requiresCompatibilities EC2 '.requiresCompatibilities[0] = $requiresCompatibilities') - fi - - if [ -z "$template" ]; - then - track_error 1 "Task Definition was not set by template variable" - exit 1 - else - # echo "template values ------:" $template - if REVISION=$(aws ecs register-task-definition --cli-input-json "$template" | $JQ '.taskDefinition.taskDefinitionArn'); then - log "Revision: $REVISION" - else - track_error 1 "Task Def registration" - log "Failed to register task definition" - return 1 - fi - fi -} - -ECS_deploy_cluster() { - AWS_ECS_SERVICE=$1 - - #checking if cluster exists - CHECK_CLUSTER_EXIST="" - CHECK_CLUSTER_EXIST=$(aws ecs describe-clusters --cluster $AWS_ECS_CLUSTER | jq --raw-output 'select(.clusters[].clusterName != null ) | .clusters[].clusterName') - if [ -z $CHECK_CLUSTER_EXIST ]; - then - echo "$AWS_ECS_CLUSTER cluster does not exist. Kindly check with DevOps team" - exit 1 - else - echo "$AWS_ECS_CLUSTER cluster exists" - fi - - #checking if service exists - CHECK_SERVICE_EXIST="" - CHECK_SERVICE_EXIST=$(aws ecs describe-services --service $AWS_ECS_SERVICE --cluster $AWS_ECS_CLUSTER | jq --raw-output 'select(.services[].status != null ) | .services[].status') - if [ -z $CHECK_SERVICE_EXIST ]; - then - if [ "$ECS_TEMPLATE_TYPE" == "FARGATE" ]; - then - echo "Fargate Service does not exist. Kindly check with DevOps team" - exit 1 - else - echo "Service does not exist. Creating service" - aws ecs create-service --cluster $AWS_ECS_CLUSTER --service-name $AWS_ECS_SERVICE --task-definition $REVISION --desired-count 1 - echo "Kindly work with DevOps team for routing" - fi - else - echo "ECS Service exists. Updating the service..." - update_result=$(aws ecs update-service --cluster $AWS_ECS_CLUSTER --service $AWS_ECS_SERVICE --task-definition $REVISION ) - result=$(echo $update_result | $JQ '.service.taskDefinition' ) - log $result - if [[ $result != $REVISION ]]; then - #echo "Error updating service." - track_error 1 "ECS updating service." - return 1 - fi - - echo "Updated service intialised successfully for deployment\n\n" - fi - - return 0 -} - -check_service_status() { - AWS_ECS_SERVICE=$1 - - counter=0 - sleep 60 - servicestatus=`aws ecs describe-services --service $AWS_ECS_SERVICE --cluster $AWS_ECS_CLUSTER | $JQ '.services[].events[0].message'` - while [[ $servicestatus != *"steady state"* ]] - do - echo "Current event message : $servicestatus" - echo "Waiting for 15 sec to check the service status..." - sleep 15 - servicestatus=`aws ecs describe-services --service $AWS_ECS_SERVICE --cluster $AWS_ECS_CLUSTER | $JQ '.services[].events[0].message'` - counter=`expr $counter + 1` - if [[ $counter -gt $COUNTER_LIMIT ]] ; then - echo "Service did not reach steady state with in 180 seconds. Please check the logs." - exit 1 - fi - done - echo "$servicestatus" -} - -validate_update_loggroup() -{ - log_group_fetch=$(aws logs describe-log-groups --log-group-name-prefix /aws/ecs/$AWS_ECS_CLUSTER | jq -r .logGroups[].logGroupName | grep "^/aws/ecs/$AWS_ECS_CLUSTER$") - #echo $log_group_fetch - if [ -z $log_group_fetch ]; - then - echo "\nLog group does not exist\n" - aws logs create-log-group --log-group-name /aws/ecs/$AWS_ECS_CLUSTER - track_error $? "aws log group" - else - echo "\nLog group exists\n" - fi -} - -# EBS integration -ebsportmapping() { - echo "Port map called\n" - containerport=$1 - hostport=$2 - - if [ -z $hostport ] - then - ebstemplate=$(echo $ebstemplate | jq --arg containerPort $containerport --arg ebsportcount $ebsportcount '.Ports[$ebsportcount |tonumber] |= .+ { ContainerPort: $containerPort }') - else - ebstemplate=$(echo $ebstemplate | jq --arg hostPort $hostport --arg containerPort $containerport --arg ebsportcount $ebsportcount '.Ports[$ebsportcount |tonumber] |= .+ { HostPort: $hostPort, ContainerPort: $containerPort }') - fi - - let ebsportcount=ebsportcount+1 -} - -EBS_push_docker_image() { - echo "Pushing Docker image: ${IMAGE}" - IMAGE="${DOCKER_REGISTRY_NAME}/${IMG_WITH_EBS_TAG}" - docker push $IMAGE - track_error $? "Docker push failed." -} - -creating_updating_ebs_docker_json() { - echo "Updating S3 auth bucket name" - sed -i.bak -e "s/@AWSS3AUTHBUCKET@/appirio-platform-$ENV_CONFIG/g" $EBS_TEMPLATE_SKELETON_FILE - rm ${EBS_TEMPLATE_SKELETON_FILE}.bak - - #EBS Port Mapping - ebstemplate=$(cat $EBS_TEMPLATE_SKELETON_FILE) - if [ -z $AWS_EBS_PORTS ]; - then - echo "No container port is defined. Configuring default 8080 port" - ebsportmapping 8080 - else - Buffer_portmap=$(echo $AWS_EBS_PORTS | sed 's/,/ /g') - for ebsportbuf in $Buffer_portmap; - do - containerport=$( echo $ebsportbuf | cut -d ':' -f 1 ) - if [[ $ebsportbuf = *:* ]]; then - hostport=$( echo $ebsportbuf | cut -d ':' -f 2 ) - fi - ebsportmapping $containerport $hostport - done - fi - echo "$ebstemplate" > $EBS_TEMPLATE_SKELETON_FILE - log "port mapping updated" - - if [ -z "$EBS_EB_EXTENSTION_LOCATION" ]; - then - cat $EBS_TEMPLATE_SKELETON_FILE | sed -e "s/@IMAGE@/${IMG_WITH_EBS_TAG}/g" > $DOCKERRUN - echo "Pushing $DOCKERRUN as ${IMG_WITH_EBS_TAG} to S3: ${AWS_S3_BUCKET}/${AWS_S3_KEY}" - aws s3api put-object --bucket "${AWS_S3_BUCKET}" --key "${AWS_S3_KEY}" --body $DOCKERRUN - track_error $? "aws s3api put-object failed." - else - cat $EBS_TEMPLATE_SKELETON_FILE | sed -e "s/@IMAGE@/${IMG_WITH_EBS_TAG}/g" > $DOCKERRUN - cp -rvf $EBS_EB_EXTENSTION_LOCATION/.ebextensions . - jar cMf ${IMG_WITH_EBS_TAG}.zip $DOCKERRUN .ebextensions - echo "Pushing ${IMG_WITH_EBS_TAG}.zip to S3: ${AWS_S3_BUCKET}/${AWS_S3_KEY}" - aws s3api put-object --bucket "${AWS_S3_BUCKET}" --key "${AWS_S3_KEY}" --body ${IMG_WITH_EBS_TAG}.zip - track_error $? "aws s3api put-object failed." - fi -} - -creating_updating_EBS_appversion() { - echo "Creating new application version $AWS_EBS_APPVER in ${AWS_EBS_APPLICATION_NAME} from s3:${AWS_S3_BUCKET}/${AWS_S3_KEY}" - aws elasticbeanstalk create-application-version --application-name $AWS_EBS_APPLICATION_NAME --version-label $AWS_EBS_APPVER --source-bundle S3Bucket="$AWS_S3_BUCKET",S3Key="$AWS_S3_KEY" - track_error $? "aws elasticbeanstalk create-application-version failed." - - echo "Updating elastic beanstalk environment ${AWS_EB_ENV} with the version ${AWS_EBS_APPVER}." - # assumes beanstalk app for this service has already been created and configured - aws elasticbeanstalk update-environment --environment-name $AWS_EBS_ENV_NAME --version-label $AWS_EBS_APPVER - track_error $? "aws elasticbeanstalk update-environment failed." -} - -#CloudFront deployment - -deploy_s3bucket() { - echo -e "application/font-woff\t\t\t\twoff2" >> /etc/mime.types - echo -e "application/font-sfnt\t\t\t\tttf" >> /etc/mime.types - echo -e "application/json\t\t\t\tmap" >> /etc/mime.types - - cat /etc/mime.types | grep -i woff - cat /etc/mime.types | grep -i ico - cat /etc/mime.types | grep -i map - cat /etc/mime.types | grep -i ttf - if [ "$CFCACHE" = "true" ]; then - # caching is enabled, so set the cache control's max age - S3_CACHE_OPTIONS="--cache-control max-age=0,s-maxage=86400" - echo "*** Deploying with Cloudfront Cache enabled ***" - else - # caching is disabled, so set the cache control to never cache - S3_CACHE_OPTIONS="--cache-control private,no-store,no-cache,must-revalidate,max-age=0" - echo "*** Deploying with Cloudfront Cache disabled ***" - fi - - S3_OPTIONS="--exclude '*.txt' --exclude '*.js' --exclude '*.css'" - echo aws s3 sync $AWS_S3_SOURCE_SYNC_PATH s3://${AWS_S3_BUCKET} ${S3_CACHE_OPTIONS} ${S3_OPTIONS} - eval "aws s3 sync --dryrun $AWS_S3_SOURCE_SYNC_PATH s3://${AWS_S3_BUCKET} ${S3_CACHE_OPTIONS} ${S3_OPTIONS}" - result=`eval "aws s3 sync $AWS_S3_SOURCE_SYNC_PATH s3://${AWS_S3_BUCKET} ${S3_CACHE_OPTIONS} ${S3_OPTIONS}"` - if [ $? -eq 0 ]; then - echo "All html, font, image, map and media files are Deployed without gzip encoding!" - else - echo "Deployment Failed - $result" - exit 1 - fi - - # S3_OPTIONS="--exclude '*' --include '*.txt' --include '*.js' --include '*.css' --content-encoding gzip" - searchpath=${AWS_S3_SOURCE_SYNC_PATH} - lengthofsearchpath=$(echo ${#searchpath}) - lengthofsearchpath=$((lengthofsearchpath+1)) - for syncfilepath in $(find ${searchpath} -name '*.js' -o -name '*.txt' -o -name '*.css'); - do - echo "$syncfilepath" - uploadpath=$(echo $syncfilepath | cut -b ${lengthofsearchpath}-) - echo $uploadpath - getformatdetails=$(file ${syncfilepath}) - if [[ $getformatdetails == *"ASCII"* ]] || [[ $getformatdetails == *"UTF"* ]] || [[ $getformatdetails == *"empty"* ]]; - then - echo "file format is ASCII and skipping gzip option" - S3_OPTIONS="" - else - echo $getformatdetails - S3_OPTIONS="--content-encoding gzip" - fi - - echo aws s3 cp --dryrun $syncfilepath s3://${AWS_S3_BUCKET}${uploadpath} ${S3_CACHE_OPTIONS} ${S3_OPTIONS} - eval "aws s3 cp --dryrun $syncfilepath s3://${AWS_S3_BUCKET}${uploadpath} ${S3_CACHE_OPTIONS} ${S3_OPTIONS}" - result=`eval "aws s3 cp $syncfilepath s3://${AWS_S3_BUCKET}${uploadpath} ${S3_CACHE_OPTIONS} ${S3_OPTIONS}"` - if [ $? -eq 0 ]; then - echo "File Deployed!" - else - echo "Deployment Failed - $result" - exit 1 - fi - done; -} - -check_invalidation_status() { - INVALIDATE_ID=$1 - counter=0 - echo "invalidating cache with ID $INVALIDATE_ID" - sleep 60 - invalidatestatus=`aws cloudfront get-invalidation --distribution-id $AWS_CLOUD_FRONT_ID --id $INVALIDATE_ID | $JQ '.Invalidation.Status'` - - while [[ $invalidatestatus != *"Completed"* ]] - do - echo $invalidatestatus - echo "Waiting for 15 sec and try to check the invalidation status..." - sleep 15 - invalidatestatus=`aws cloudfront get-invalidation --distribution-id $AWS_CLOUD_FRONT_ID --id $INVALIDATE_ID | $JQ '.Invalidation.Status'` - counter=`expr $counter + 1` - if [[ $counter -gt $COUNTER_LIMIT ]] ; then - echo "Invalidation does not complete with in 180 seconds. Please check the GUI mode." - exit 1 - fi - done - echo "Invalidation completed" -} - -invalidate_cf_cache() -{ - #if [ "$CFCACHE" = "true" ]; then - if [ -z $AWS_CLOUD_FRONT_ID ]; then - echo "Based on header applicaiton has invalidated" - echo "Skipped which is based on AWS cloudfront ID.Kindly raise request to configure cloud front ID in deployment configuration" - else - #aws cloudfront create-invalidation --distribution-id $AWS_CLOUD_FRONT_ID --paths '/*' - INVALIDATE_ID=`aws cloudfront create-invalidation --distribution-id $AWS_CLOUD_FRONT_ID --paths '/*' | $JQ '.Invalidation.Id'` - check_invalidation_status "$INVALIDATE_ID" - fi - #fi -} - -download_envfile() -{ - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - aws s3 cp s3://tc-platform-${ENV_CONFIG}/securitymanager/$listname.json . - track_error $? "$listname.json download" - jq 'keys[]' $listname.json - track_error $? "$listname.json" - #cp $HOME/buildscript/securitymanager/$listname.json.enc . - #SECPASSWD=$(eval "echo \$${listname}") - #openssl enc -aes-256-cbc -d -md MD5 -in $listname.json.enc -out $listname.json -k $SECPASSWD - done -} - -download_psfile() -{ - Buffer_seclist=$(echo $SECPS_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - aws s3 cp s3://tc-platform-${ENV_CONFIG}/securitymanager/$listname.json . - track_error $? "$listname.json download" - jq 'keys[]' $listname.json - track_error $? "$listname.json" - done -} - -decrypt_fileenc() -{ - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - #aws s3 cp s3://tc-platform-dev/securitymanager/$listname.json . - #cp $HOME/buildscript/securitymanager/$listname.json.enc . - SECPASSWD=$(eval "echo \$${listname}") - openssl enc -aes-256-cbc -d -md MD5 -in $listname.json.enc -out $listname.json -k $SECPASSWD - done -} - -uploading_envvar() -{ - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - # for envappvar in $( cat $listname.json | jq -r ' . ' | jq ' . | to_entries | map(select(.key | test("AWS.") ) ) | from_entries' | jq -r "to_entries|map(\"\(.key)=\(.value|tostring)\")|.[]" ); do - # export $envappvar - # done - o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $listname.json | jq -r ' .awsdeployvar ' | jq ' . | to_entries[] | { "name": .key , "value": .value } ' | jq -s . ) - for s in $(echo $envvars | jq -c ".[]" ); do - #echo $envvars - varname=$(echo $s| jq -r ".name") - varvalue=$(echo $s| jq -r ".value") - export "$varname"="$varvalue" - done - IFS=$o - done -} - -configure_Lambda_template() -{ - if [ "$AWS_LAMBDA_DEPLOY_TYPE" == "SLS" ] - then - mkdir -p /home/circleci/project/config - if [ -z $SEC_LIST ]; - then - log "No ps path provided" - else - Buffer_seclist=$(echo $SEC_LIST | sed 's/,/ /g') - #envvars=$( cat $listname.json | jq -c ' .app_var ') - - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $listname.json | jq -c ' . ') - echo "$envvars" > /home/circleci/project/config/$AWS_LAMBDA_STAGE.json - sed -i 's/\\n/\\\\n/g' /home/circleci/project/config/$AWS_LAMBDA_STAGE.json - IFS=$o - done - fi - - if [ -z $ARG_SECPS_LIST ]; - then - log "No ps path provided" - else - Buffer_seclist=$(echo $ARG_SECPS_LIST | sed 's/,/ /g') - for listname in $Buffer_seclist; - do - local o=$IFS - IFS=$(echo -en "\n\b") - k=$listname - echo $k - aws ssm get-parameters-by-path --with-decryption --path $k --query "Parameters[*].{Name:Name, Value:Value}" >fetched_parameters.json - cat fetched_parameters.json | jq -r ' . |= (map({ (.Name): .Value }) | add)' | sed -e "s~$k/~~" >paramwithvalue.json - envvars=$( cat paramwithvalue.json | jq -c ' . ') - echo "$envvars" > /home/circleci/project/config/$AWS_LAMBDA_STAGE.json - sed -i 's/\\n/\\\\n/g' /home/circleci/project/config/$AWS_LAMBDA_STAGE.json - ###paramnames=$(cat paramnames.json | jq -r .[].Name | rev | cut -d / -f 1 | rev) - IFS=$o - done - fi - - - fi - - - -} - -deploy_lambda_package() -{ - # sls deploy - if [ "$AWS_LAMBDA_DEPLOY_TYPE" == "SLS" ] - then - echo "Welcome to lambda SLS deploy" - sls deploy --stage $AWS_LAMBDA_STAGE - fi -} - -# decrypt_aws_sys_parameter() -# { - -# for future implmentation. -# } - -# Input Collection and validation -input_parsing_validation() -{ - while getopts .d:h:i:e:l:j:t:v:s:p:g:c:m:. OPTION - do - case $OPTION in - d) - DEPLOYMENT_TYPE=$OPTARG - ;; - h) - usage - exit 1 - ;; - i) - APP_IMAGE_NAME=$OPTARG - ;; - e) - ENV=$OPTARG - ;; - l) - SECPS_LIST=$OPTARG - ;; - j) - ARG_SECPS_LIST=$OPTARG - ;; - t) - TAG=$OPTARG - ;; - c) - CFCACHE=$OPTARG - ;; - v) - EBS_APPVER=$OPTARG - ;; - s) - SEC_LIST=$OPTARG - ;; - p) - ECS_TEMPLATE_TYPE=$OPTARG - ;; - g) - SHARED_PROPERTY_FILENAME=$OPTARG - ;; - m) - DEPLOYCATEGORY=$OPTARG - ;; - ?) - log "additional param required" - usage - exit - ;; - esac - done - - if [ -z $DEPLOYMENT_TYPE ] || [ -z $ENV ] ; - then - log "Param validation error" - usage - exit 1 - fi - - log "ENV : $ENV" - log "DEPLOYMENT_TYPE : $DEPLOYMENT_TYPE" - log "app variable list : $SEC_LIST" - ENV_CONFIG=`echo "$ENV" | tr '[:upper:]' '[:lower:]'` - - #Validating AWS configuration - - #Getting Deployment varaible only - - # AWS_ACCESS_KEY_ID=$(eval "echo \$${ENV}_AWS_ACCESS_KEY_ID") - # AWS_SECRET_ACCESS_KEY=$(eval "echo \$${ENV}_AWS_SECRET_ACCESS_KEY") - # AWS_ACCOUNT_ID=$(eval "echo \$${ENV}_AWS_ACCOUNT_ID") - # AWS_REGION=$(eval "echo \$${ENV}_AWS_REGION") - # if [ -z $AWS_ACCESS_KEY_ID ] || [ -z $AWS_SECRET_ACCESS_KEY ] || [ -z $AWS_ACCOUNT_ID ] || [ -z $AWS_REGION ]; - # then - # log "AWS Secret Parameters are not configured in circleci/environment" - # usage - # exit 1 - # else - # configure_aws_cli - # #aws configure list - # fi - - download_envfile - - if [ -z $SECPS_LIST ]; - then - log "No secret parameter file list provided" - else - download_psfile - fi - - #decrypt_fileenc - #uploading_envvar - - #Validating parameter based on Deployment type - #ECS parameter validation - if [ "$DEPLOYMENT_TYPE" == "ECS" ] - then - ECS_TAG=$TAG - if [ "$DEPLOYCATEGORY" == "CLI" ] - then - if [ -z $AWS_REPOSITORY ] || [ -z $AWS_ECS_CLUSTER ] || [ -z $AWS_ECS_SERVICE ] || [ -z $ECS_TAG ]; - then - log "Deployment varibale are not updated. Please check tag option was provided. Also ensure AWS_REPOSITORY, AWS_ECS_TASK_FAMILY,AWS_ECS_CONTAINER_NAME,AWS_ECS_PORTS,AWS_ECS_CLUSTER and AWS_ECS_SERVICE variables are configured on secret manager" - usage - exit 1 - fi - DEPLOYCATEGORYNAME="ECSCLI" - else - cp $HOME/buildscript/$TEMPLATE_SKELETON_FILE . - - if [ -z $AWS_REPOSITORY ] || [ -z $AWS_ECS_CLUSTER ] || [ -z $AWS_ECS_SERVICE ] || [ -z $AWS_ECS_TASK_FAMILY ] || [ -z $AWS_ECS_CONTAINER_NAME ] || [ -z $AWS_ECS_PORTS ] || [ -z $ECS_TAG ]; - then - log "Deployment varibale are not updated. Please check tag option was provided. Also ensure AWS_REPOSITORY, AWS_ECS_TASK_FAMILY,AWS_ECS_CONTAINER_NAME,AWS_ECS_PORTS,AWS_ECS_CLUSTER and AWS_ECS_SERVICE variables are configured on secret manager" - usage - exit 1 - fi - DEPLOYCATEGORYNAME="AWSCLI" - fi - - log "AWS_REPOSITORY : $AWS_REPOSITORY" - log "AWS_ECS_CLUSTER : $AWS_ECS_CLUSTER" - log "AWS_ECS_SERVICE_NAMES : $AWS_ECS_SERVICE" - log "AWS_ECS_TASK_FAMILY : $AWS_ECS_TASK_FAMILY" - log "AWS_ECS_CONTAINER_NAME : $AWS_ECS_CONTAINER_NAME" - log "AWS_ECS_PORTS : $AWS_ECS_PORTS" - log "ECS_TAG : $ECS_TAG" - log "DEPLOY TYPE : $DEPLOYCATEGORYNAME" - fi - - #EBS parameter validation - if [ "$DEPLOYMENT_TYPE" == "EBS" ] - then - # EBS_TAG = the docker image tag for example dev.201807051535 - cp $HOME/buildscript/$EBS_TEMPLATE_SKELETON_FILE . - EBS_TAG=$TAG - AWS_EBS_APPVER="${AWS_EBS_ENV_NAME}-${EBS_TAG}" - IMG_WITH_EBS_TAG="${DOCKER_IMAGE_NAME}:${EBS_TAG}" - # EBS_TAG="${IMAGE_NAME}:${ENV_CONFIG}.${EBS_APPVER}" - - if [ "$AWS_S3_KEY_LOCATION" = "" ] ; - then - AWS_S3_KEY="${IMG_WITH_EBS_TAG}" - else - AWS_S3_KEY="$AWS_S3_KEY_LOCATION/${IMG_WITH_EBS_TAG}" - fi - - #AWS_EBS_EB_DOCKERRUN_TEMPLATE_LOCATION=$(eval "echo \$${ENV}_AWS_EBS_EB_DOCKERRUN_TEMPLATE_LOCATION") - #AWS_EBS_DOCKERRUN_TEMPLATE=$(eval "echo \$${ENV}_AWS_EBS_DOCKERRUN_TEMPLATE") - if [ -z $AWS_EBS_APPLICATION_NAME ] || [ -z $DOCKER_IMAGE_NAME ] || [ -z $AWS_EBS_ENV_NAME ] || [ -z $EBS_TAG ] || [ -z $AWS_EBS_APPVER ] || [ -z $AWS_S3_BUCKET ] ; - then - log "Build variables are not updated. Please update the Build variable file" - usage - exit 1 - fi - - log "EBS_APPLICATION_NAME : $AWS_EBS_APPLICATION_NAME" - log "AWS_EBS_APPVER : $AWS_EBS_APPVER" - log "EBS_TAG : $EBS_TAG" - log "AWS_S3_BUCKET : $AWS_S3_BUCKET" - log "AWS_S3_KEY : $AWS_S3_KEY" - log "AWS_EB_ENV : $AWS_EBS_ENV_NAME" - fi - - #CloudFront parameter validation - if [ "$DEPLOYMENT_TYPE" == "CFRONT" ] - then - if [ -z $AWS_S3_BUCKET ] || [ -z $AWS_S3_SOURCE_SYNC_PATH ]; - then - log "Build variables are not updated. Please update the Build variable file" - usage - exit 1 - fi - log "AWS_S3_BUCKET : $AWS_S3_BUCKET" - log "AWS_S3_SOURCE_SYNC_PATH : $AWS_S3_SOURCE_SYNC_PATH" - fi - - #Lambda parameter validation - if [ "$DEPLOYMENT_TYPE" == "LAMBDA" ] - then - if [ -z $AWS_LAMBDA_DEPLOY_TYPE ] ; - then - log "Build variables are not updated. Please update the Build variable file" - usage - exit 1 - fi - log "AWS_LAMBDA_DEPLOY_TYPE : $AWS_LAMBDA_DEPLOY_TYPE" - - if [ -z $AWS_LAMBDA_STAGE ] ; - then - log "Build variables are not updated. Please update the Build variable file" - usage - exit 1 - fi - log "AWS_LAMBDA_STAGE : $AWS_LAMBDA_STAGE" - fi -} - -# Main -main() -{ - input_parsing_validation $@ - - if [ "$DEPLOYMENT_TYPE" == "ECS" ] - then - if [ "$DEPLOYCATEGORY" == "CLI" ] - then - eval $(aws ecr get-login --region $AWS_REGION --no-include-email) - - # Moving image to repository - if [ -z $APP_IMAGE_NAME ]; - then - echo "Value of AWS_REPOSITORY: " $AWS_REPOSITORY - AWS_REPOSITORY_NAMES=$(echo ${AWS_REPOSITORY} | sed 's/,/ /g') - echo "Value of AWS_REPOSITORY_NAMES: " $AWS_REPOSITORY_NAMES - - IFS=' ' read -a AWS_REPOSITORY_NAMES_ARRAY <<< $AWS_REPOSITORY_NAMES - if [ ${#AWS_REPOSITORY_NAMES_ARRAY[@]} -gt 0 ]; then - echo "${#AWS_REPOSITORY_NAMES_ARRAY[@]} repo push initalisation" - for AWS_ECS_REPO_NAME in "${AWS_REPOSITORY_NAMES_ARRAY[@]}" - do - echo "updating reposioty - $AWS_ECS_REPO_NAME" - ECSCLI_push_ecr_image $AWS_ECS_REPO_NAME - #echo $REVISION - done - else - echo "Kindly check the Repository name has Parameter" - usage - exit 1 - fi - else - #if appp images details are provided - - echo "value of AWS_REPOSITORY " $AWS_REPOSITORY - AWS_REPOSITORY_NAMES=$(echo ${AWS_REPOSITORY} | sed 's/,/ /g') - echo "value of AWS_REPOSITORY_NAMES " $AWS_REPOSITORY_NAMES - echo "value of image name provided " $APP_IMAGE_NAME - APP_IMAGE_NAMES=$(echo ${APP_IMAGE_NAME} | sed 's/,/ /g') - - IFS=' ' read -a AWS_REPOSITORY_NAMES_ARRAY <<< $AWS_REPOSITORY_NAMES - IFS=' ' read -a APP_IMAGE_NAMES_ARRAY <<< $APP_IMAGE_NAMES - echo "AWS ECR repo count needs to be updated ${#AWS_REPOSITORY_NAMES_ARRAY[@]}, APP image count provided in option ${#APP_IMAGE_NAMES_ARRAY[@]} " - - if [ "${#AWS_REPOSITORY_NAMES_ARRAY[@]}" = "${#APP_IMAGE_NAMES_ARRAY[@]}" ]; - then - ecstempcount=0 - while [ $ecstempcount -lt ${#AWS_REPOSITORY_NAMES_ARRAY[@]} ] - do - echo "${AWS_REPOSITORY_NAMES_ARRAY[$count]} , ${APP_IMAGE_NAMES_ARRAY[$count]}" - ECSCLI_push_ecr_image "${AWS_REPOSITORY_NAMES_ARRAY[$count]}" "${APP_IMAGE_NAMES_ARRAY[$count]}" - ecstempcount=`expr $ecstempcount + 1` - done - else - echo "Kindly check the image name in Parameter" - usage - exit 1 - fi - fi - - #env file updation - ECSCLI_update_env - - # Configuring cluster - ecs-cli configure --region us-east-1 --cluster $AWS_ECS_CLUSTER - - # updating service - echo "Value of AWS_ECS_SERVICE: " $AWS_ECS_SERVICE - AWS_ECS_SERVICE_NAMES=$(echo ${AWS_ECS_SERVICE} | sed 's/,/ /g') - #AWS_ECS_SERVICE_NAMES=$(echo ${AWS_ECS_SERVICE} | sed 's/,/ /g' | sed 'N;s/\n//') - echo "Value of AWS_ECS_SERVICE_NAMES: " $AWS_ECS_SERVICE_NAMES - - IFS=' ' read -a AWS_ECS_SERVICES <<< $AWS_ECS_SERVICE_NAMES - if [ ${#AWS_ECS_SERVICES[@]} -gt 0 ]; then - echo "${#AWS_ECS_SERVICES[@]} service(s) are going to be updated" - for AWS_ECS_SERVICE_NAME in "${AWS_ECS_SERVICES[@]}" - do - echo "updating ECS Cluster Service - $AWS_ECS_SERVICE_NAME" - ecs-cli compose --project-name "$AWS_ECS_SERVICE_NAME" service up - #echo $REVISION - done - else - echo "Kindly check the service name in Parameter" - usage - exit 1 - fi - else - validate_update_loggroup - ECS_push_ecr_image - ECS_template_create_register - - echo "Value of AWS_ECS_SERVICE: " $AWS_ECS_SERVICE - AWS_ECS_SERVICE_NAMES=$(echo ${AWS_ECS_SERVICE} | sed 's/,/ /g') - #AWS_ECS_SERVICE_NAMES=$(echo ${AWS_ECS_SERVICE} | sed 's/,/ /g' | sed 'N;s/\n//') - echo "Value of AWS_ECS_SERVICE_NAMES: " $AWS_ECS_SERVICE_NAMES - - IFS=' ' read -a AWS_ECS_SERVICES <<< $AWS_ECS_SERVICE_NAMES - if [ ${#AWS_ECS_SERVICES[@]} -gt 0 ]; then - echo "${#AWS_ECS_SERVICES[@]} service are going to be updated" - for AWS_ECS_SERVICE_NAME in "${AWS_ECS_SERVICES[@]}" - do - echo "Creating/updating ECS Cluster Service - $AWS_ECS_SERVICE_NAME" - ECS_deploy_cluster "$AWS_ECS_SERVICE_NAME" - check_service_status "$AWS_ECS_SERVICE_NAME" - #echo $REVISION - done - else - echo "Kindly check the service name parameter" - usage - exit 1 - fi - fi - fi - - if [ "$DEPLOYMENT_TYPE" == "EBS" ] - then - #configure_aws_cli - configure_docker_private_login - EBS_push_docker_image - creating_updating_ebs_docker_json - creating_updating_EBS_appversion - fi - - if [ "$DEPLOYMENT_TYPE" == "CFRONT" ] - then - deploy_s3bucket - invalidate_cf_cache - fi - - if [ "$DEPLOYMENT_TYPE" == "LAMBDA" ] - then - configure_Lambda_template - deploy_lambda_package - fi -} - -main $@ +exec python3 -I "$script_dir/tcdeploy.py" deploy "$@" diff --git a/psvar-processor.sh b/psvar-processor.sh index 10c0dd7..25d6f8d 100755 --- a/psvar-processor.sh +++ b/psvar-processor.sh @@ -1,213 +1,5 @@ -#!/bin/bash -set -eo pipefail -usage() -{ -cat << EOF -usage: $0 options - -This script needs to be executed with below options. - -OPTIONS: - -e environment - -t type appenv,appconf and appjson - -p parameter store path without final slash - -l parameter store list without final slash - -EOF -} - -create_env_file_format() -{ - file_name=$1 - fetch_path=$2 - echo $fetch_path - echo $file_name - aws ssm get-parameters-by-path --with-decryption --path $fetch_path --query "Parameters[*].{Name:Name, Value:Value}" >fetched_parameters.json - cat fetched_parameters.json | jq -r '.[] | "export " + .Name + "=\"" + .Value + "\"" ' | sed -e "s~$fetch_path/~~" >${file_name}_env - rm -rf fetched_parameters.json -} - -create_conf_file_format() -{ - file_name=$1 - fetch_path=$2 - aws ssm get-parameters-by-path --with-decryption --path $fetch_path --query "Parameters[*].{Name:Name, Value:Value}" >fetched_parameters.json - cat fetched_parameters.json | jq -r '.[] | .Name + "=\"" + .Value + "\"" ' | sed -e "s~$fetch_path/~~" >${file_name}.conf - rm -rf fetched_parameters.json -} - -create_json_file_format() -{ - file_name=$1 - fetch_path=$2 - echo $fetch_path - echo $file_name - echo "aws ssm get-parameters-by-path --with-decryption --path $fetch_path --query \"Parameters[*].{Name:Name, Value:Value}\"" - aws ssm get-parameters-by-path --with-decryption --path $fetch_path --query "Parameters[*].{Name:Name, Value:Value}" >fetched_parameters.json - cat fetched_parameters.json | jq -r ' . |= (map({ (.Name): .Value }) | add)' | sed -e "s~$fetch_path/~~" >${file_name}.json -# rm -rf fetched_parameters.json -} - -create_jsonso_file_format() -{ - file_name=$1 - fetch_path=$2 - echo $fetch_path - echo $file_name - echo "aws ssm get-parameters --with-decryption --name $fetch_path | jq '.Parameters | .[] | .Value' | jq '.|fromjson'" - aws ssm get-parameters --with-decryption --name $fetch_path | jq '.Parameters | .[] | .Value' | jq '.|fromjson' >${file_name}.json -# rm -rf fetched_parameters.json -} - -fetching_specific_path() -{ - type_to_fetch=$1 - PS_PATH=${PS_PATH%/} - fname=${PS_PATH##*/} - fpath=$PS_PATH - echo $fpath - echo $PS_PATH - if [ "$type_to_fetch" == "appenv" ] - then - create_env_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appconf" ] - then - create_conf_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appjson" ] - then - create_json_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appjsonso" ] - then - create_jsonso_file_format $fname $fpath - fi -} - -fetching_multiple_path() -{ - type_to_fetch=$1 - Buffer_seclist=$(echo $PS_PATH_LIST | sed 's/,/ /g' ) - for listname in $Buffer_seclist; - do - listname=${listname%/} - fname=${listname##*/} - fpath=$listname - if [ "$type_to_fetch" == "appenv" ] - then - create_env_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appconf" ] - then - create_conf_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appjson" ] - then - create_json_file_format $fname $fpath - fi - if [ "$type_to_fetch" == "appjsonso" ] - then - create_jsonso_file_format $fname $fpath - fi - done -} - - -while getopts .t:e:p:l:. OPTION -do - case $OPTION in - e) - ENV=$OPTARG - ;; - t) - APP_TYPE=$OPTARG - ;; - p) - PS_PATH=$OPTARG - ;; - l) - PS_PATH_LIST=$OPTARG - ;; - ?) - log "additional param required" - usage - exit - ;; - esac -done - -ENV_CONFIG=`echo "$ENV" | tr '[:upper:]' '[:lower:]'` -APP_TYPE_LOWERCASE=`echo "$APP_TYPE" | tr '[:upper:]' '[:lower:]'` - -echo "APP_TYPE: $APP_TYPE_LOWERCASE" -echo "PS_PATH: $PS_PATH" -echo "PS_PATH_LIST: $PS_PATH_LIST" - -if [ "$APP_TYPE_LOWERCASE" == "appenv" ] -then - echo "env configuration" - if [ -z $PS_PATH ]; - then - echo "Info: no ps path" - else - fetching_specific_path $APP_TYPE_LOWERCASE - fi - if [ -z $PS_PATH_LIST ]; - then - echo "Info: no path list provided. So skipping pathlist" - else - fetching_multiple_path $APP_TYPE_LOWERCASE - fi -fi - -if [ "$APP_TYPE_LOWERCASE" == "appconf" ] -then - echo "conf file configuration" - if [ -z $PS_PATH ]; - then - echo "Info: no ps path" - else - fetching_specific_path $APP_TYPE_LOWERCASE - fi - if [ -z $PS_PATH_LIST ]; - then - echo "Info: no path list provided. So skipping pathlist" - else - fetching_multiple_path $APP_TYPE_LOWERCASE - fi -fi - -if [ "$APP_TYPE_LOWERCASE" == "appjson" ] -then - echo "json file configuration" - if [ -z $PS_PATH ]; - then - echo "Info: no ps path" - else - fetching_specific_path $APP_TYPE_LOWERCASE - fi - if [ -z $PS_PATH_LIST ]; - then - echo "Info: no path list provided. So skipping pathlist" - else - fetching_multiple_path $APP_TYPE_LOWERCASE - fi -fi - -if [ "$APP_TYPE_LOWERCASE" == "appjsonso" ] -then - echo "json file configuration" - if [ -z $PS_PATH ]; - then - echo "Info: no ps path" - else - fetching_specific_path $APP_TYPE_LOWERCASE - fi - if [ -z $PS_PATH_LIST ]; - then - echo "Info: no path list provided. So skipping pathlist" - else - fetching_multiple_path $APP_TYPE_LOWERCASE - fi -fi +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'This legacy secret-file helper is disabled in v2. Deployment must not fetch or source secret configuration. Use a reviewed non-secret manifest and ECS runtime secret references; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/setup_ms/template/setupscr_v7.3.sh b/setup_ms/template/setupscr_v7.3.sh index 965e59c..5cc899e 100755 --- a/setup_ms/template/setupscr_v7.3.sh +++ b/setup_ms/template/setupscr_v7.3.sh @@ -1,540 +1,5 @@ -#!/bin/bash -#set -x -MODIFY=0 -KEYNAME="" -KEYVALUE="" -KEYTYPE="" -FILENAME="" -FILENAME_WITH_JSON_EXT="" -ENVTYPE="" -vartemplate="{}" -SECPASSWD="" -SEC_LIST5="" -DTYPE="" - -read_json() -{ - vartemplate=$( cat $FILENAME_WITH_JSON_EXT ) -} -write_to_json() -{ - echo $vartemplate > $FILENAME_WITH_JSON_EXT -} -add_number_type() -{ - VARNAME=$1 - VARVALUE=$2 - CATEGORY=$3 - read_json - vartemplate=$(echo $vartemplate | jq --arg VARNAME "$VARNAME" --argjson VARVALUE $VARVALUE --arg CATEGORY "$CATEGORY" '.[$CATEGORY] |= . + {($VARNAME): $VARVALUE}') - if [ "$?" = "0" ]; then - write_to_json - fi - #echo "adding number variable in json" -} -add_string_type() -{ - VARNAME=$1 - VARVALUE=$2 - CATEGORY=$3 - read_json - vartemplate=$(echo $vartemplate | jq --arg VARNAME "$VARNAME" --arg VARVALUE "$VARVALUE" --arg CATEGORY "$CATEGORY" '.[$CATEGORY] |= . + {($VARNAME): $VARVALUE}') - if [ "$?" = "0" ]; then - echo "testing write to json" - write_to_json - fi - #echo "adding string variable in json" -} -add_variable() -{ - MBUF=$1 - CATEGORY="" - if [ -z $MBUF ]; - then - CATEGORY="app_var" - else - read -e -p "Default modification will be app variable, if aws env var press e : " CTYPE - if [ "$CTYPE" = "e" ]; - then - CATEGORY="awsdeployvar" - else - CATEGORY="app_var" - fi - fi - KEYTYPE="" - read -e -p "Please enter the variable name : " KEYNAME - read -e -p "Default value type is string, if NUMBER press n or If certifcate press c or enter: " KEYTYPE - if [ "$KEYTYPE" = "c" ]; - then - read -d "~" -p "Enter the key comment (\"~\" when done):" KEYVALUE - else - read -e -n 2048 -p "Please enter the variable value : " KEYVALUE - fi - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper key and value" - else - if [ -z "$KEYTYPE" ]; - then - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - elif [ "$KEYTYPE" = "n" ]; - then - add_number_type "$KEYNAME" $KEYVALUE "$CATEGORY" - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - fi -} -modify_number_type() -{ - VARNAME=$1 - VARVALUE=$2 - CATEGORY=$3 - read_json - vartemplate=$(echo $vartemplate | jq --arg VARNAME "$VARNAME" --argjson VARVALUE $VARVALUE --arg CATEGORY "$CATEGORY" '.[$CATEGORY] |= . + {($VARNAME): $VARVALUE}') - if [ "$?" = "0" ]; then - write_to_json - fi - # echo "adding number variable in json" -} -modify_string_type() -{ - VARNAME=$1 - VARVALUE=$2 - CATEGORY=$3 - read_json - vartemplate=$(echo $vartemplate | jq --arg VARNAME "$VARNAME" --arg VARVALUE "$VARVALUE" --arg CATEGORY "$CATEGORY" '.[$CATEGORY] |= . + {($VARNAME): $VARVALUE}') - if [ "$?" = "0" ]; then - write_to_json - fi - # echo "adding string variable in json" -} -modify_var() -{ - echo "modify" - KEYTYPE="" - CATEGORY="" - read -e -p "Default modification is app variable, if aws env var - press e : " CTYPE - if [ "$CTYPE" = "e" ]; - then - CATEGORY="awsdeployvar" - else - CATEGORY="app_var" - fi - read -e -p "Please enter the modifying variable name : " KEYNAME - read -e -p "Press enter to set default value type as STRING, if NUMBER press n or CERTIFICATE(like kafka) press c : " KEYTYPE - if [ "$KEYTYPE" = "c" ]; - then - read -d "~" -p "Enter the modified key (\"~\" when done):" KEYVALUE - else - read -e -n 2048 -p "Please enter the modifying variable value : " KEYVALUE - fi - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper key and value" - else - if [ -z "$KEYTYPE" ]; - then - modify_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - elif [ "$KEYTYPE" = "n" ]; - then - modify_number_type "$KEYNAME" $KEYVALUE "$CATEGORY" - else - modify_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - fi -} -delete_var() -{ - echo "Delet var" - read_json - CATEGORY="" - read -e -p "Default deletion will be app variable, if aws env var press e : " CTYPE - if [ "$CTYPE" = "e" ]; - then - CATEGORY="awsdeployvar" - else - CATEGORY="app_var" - fi - read -e -p "Please enter the variable name to delete : " KEYNAME - vartemplate=$(echo $vartemplate | jq --arg CATEGORY "$CATEGORY" --arg KEYNAME "$KEYNAME" 'delpaths([[$CATEGORY,$KEYNAME]])' ) - if [ "$?" = "0" ]; then - write_to_json - fi -} -list_var() -{ - cat $FILENAME_WITH_JSON_EXT | jq -r "." -} -ecs_mandate_var() -{ - CATEGORY="awsdeployvar" - echo "adding ECS mandate var" - KEYNAME="AWS_REPOSITORY" - read -e -p "Please enter the Amazon ECR (repository) Name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper Repo Name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_ECS_CLUSTER" - read -e -p "Please enter the AWS Cluster Name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper Cluster name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_ECS_SERVICE" - read -e -p "Please enter the AWS Service Name if multiple it need to be comma seperated without space : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS Cluster Service names" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_ECS_TASK_FAMILY" - read -e -p "Please enter the AWS Task Definition Name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS Task Definition Name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_ECS_CONTAINER_NAME" - read -e -p "Please enter the AWS Container name as per task Definition : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS Container name as per task Definition" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_ECS_PORTS" - read -e -p "Please enter the Port info like hostport:containerport:protocol,hostport:containerport:protocol : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS proper port" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi -} -ebs_mandate_var() -{ - CATEGORY="awsdeployvar" - echo "adding EBS mandate var" - KEYNAME="AWS_EBS_APPLICATION_NAME" - read -e -p "Please enter the AWS EBS Application Name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper Application Name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_EBS_ENV_NAME" - read -e -p "Please enter the AWS EBS Environment Name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper EBS Environment name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_S3_BUCKET" - read -e -p "Please enter the AWS S3 Bucket name like where application json upload : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS S3 bucket name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_S3_KEY_LOCATION" - read -e -p "If you add application json in specific path in S3, please specify : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "No S3 key location has provided. So application json will upload in root folder of S3 bucket" - #exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="DOCKER_IMAGE_NAME" - read -e -p "Please provide the name docker image : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS container name as per task Definition" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="DOCKER_REGISTRY_NAME" - KEYVALUE="appiriodevops" - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS proper port" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="EBS_EB_EXTENSTION_LOCATION" - read -e -p "If you have .ebextension folder, please specify path where it exists in repository without .ebextension name : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "No ebextesion exist. So application json will upload of S3 bucket" - #exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - -} -cfront_mandate_var() -{ - CATEGORY="awsdeployvar" - echo "adding CFRONT mandate var" - KEYNAME="AWS_S3_BUCKET" - read -e -p "Please enter the AWS S3 Bucket name like where application upload : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper AWS S3 bucket name" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - KEYNAME="AWS_S3_SOURCE_SYNC_PATH" - read -e -p "Please enter the source path need to be synced : " KEYVALUE - if [ -z "$KEYNAME" ] || [ -z "$KEYVALUE" ] ; - then - echo "Please provide proper source path" - exit - else - add_string_type "$KEYNAME" "$KEYVALUE" "$CATEGORY" - fi - -} -add_mandate_var() -{ - DTYPE="" - read -e -p "Please enter the deployment type (ECS/EBS/CFRONT): " DTYPE - case $DTYPE in - ECS ) ecs_mandate_var - ;; - EBS ) ebs_mandate_var - ;; - CFRONT ) cfront_mandate_var - ;; - * ) echo "Please proper deploy type" - exit - ;; - esac -} -createenv() -{ - echo "creating new environment" - add_mandate_var - while true; do - read -e -p "Do you want to add environment variable(s) (y/n)?" yn - case $yn in - [Yy]* ) add_variable - ;; - [Nn]* ) break;; - * ) echo "Please answer yes or no.";; - esac - done -} -modifyenv() -{ - echo "Modifying the existing environment" - echo "Please find the existing variable details given below : " - while true; do - echo "Please choose the option" - echo "1 . Add variable" - echo "2 . Modify variable" - echo "3 . Delete variable" - echo "4 . List variable" - echo "5 . Exit the Modification" - read -e -p "Plese enter teh option number : " opt - case $opt in - 1 ) add_variable "MODIFY" - ;; - 2 ) modify_var - ;; - 3 ) delete_var - ;; - 4 ) list_var - ;; - 5 ) break;; - * ) echo "Please enter right option";; - esac - done -} - -encrypt_file() -{ - echo "Encypt option has enabled" - openssl enc -aes-256-cbc -salt -md MD5 -in $FILENAME_WITH_JSON_EXT -out $FILENAME_WITH_JSONENC_EXT -k "$SECPASSWD" - if [ "$?" != "0" ]; then - echo "$(tput setaf 1)File Encryption failed with error code $?$(tput setaf 7)" - exit 1 - else - echo "$(tput setaf 2)$FILENAME_WITH_JSON_EXT encrypted to $(tput setaf 3)$FILENAME_WITH_JSONENC_EXT.$(tput setaf 2) Use Decrypt option to view$(tput setaf 7)" - fi -} - -decrypt_file() -{ - echo "Decypt option has enabled" - openssl enc -aes-256-cbc -d -md MD5 -in $FILENAME_WITH_JSONENC_EXT -out $FILENAME_WITH_JSON_EXT -k "$SECPASSWD" - if [ "$?" != "0" ]; then - echo "$(tput setaf 1)File decryption failed with error code $?$(tput setaf 7)" - exit 1 - else - echo "$(tput setaf 2)File Decryption completed$. (tput setaf 7)" - fi -} -#nk- added - validate_file_json_schema() - { - - # schema_template.json - json validate --schema-file=template/$DTYPE/schema_template.json --document-file=$FILENAME.json - if [ "$?" != "0" ]; then - echo "$(tput setaf 1)=======Input file json schema validation failed=====$(tput setaf 7)" - exit 1 - else - echo "$(tput setaf 2)========Input file json schema is valid=========$(tput setaf 7)" - sleep 2 - fi - } - - #nk- added - validate_file_json_values() - { - - local o=$IFS - IFS=$(echo -en "\n\b") - envvars=$( cat $FILENAME.json | jq -r ' .app_var ' | jq ' . | to_entries[] | { "name": .key , "value": .value } ' | jq -s . ) - for s in $(echo $envvars | jq -c ".[]" ); do - #echo $envvars - varname=$(echo $s| jq -r ".name") - varvalue=$(echo $s| jq -r ".value") - echo " $varname : $varvalue" - done - IFS=$o - echo "$(tput setaf 2)================json syntax value valid==========$(tput setaf 7)" - sleep 2 - - } - -user_information() -{ - # echo "Next Step" - circleadd1="" - echo -e "\n$(tput setaf 3)NEXT STEP \n-A) i) Upload $FILENAMEBUF.json to Dev/Prod S3. Bucket tc-platform-/securitymanager.$(tput setaf 7)" - echo -e "$(tput setaf 3) ii) Don't forget to configure AWS access keys at Circleci ENV variable section. \n $(tput setaf 7)" - echo -e "$(tput setaf 3)NEXT STEP \n-B) Navigate to your app GitRepo. At $(tput setaf 4)- deploy $(tput setaf 7)$(tput setaf 3)Section, append the following line at .circleci/config.yml$(tput setaf 7)" - #echo -e "---1. COPY LINE 1 AS SHOWN BELOW (Before $(tput setaf 4)- checkout $(tput setaf 7)) section \n" - #circleadd1="- run: git clone --branch master https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript" - circleadd2=" ./master_deploy.sh -e DEV -t \$CIRCLE_SHA1 -s $FILENAMEBUF" - echo $(tput setaf 2)$circleadd1$(tput setaf 7) - echo -e "---1. COPY BELOW 6 LINES AS-IS. Change ENV as per deploy type (ex: DEV or PROD)" - { echo $(tput setaf 2) - echo -e "- deploy:" - echo -e " name: Running MasterScript" - echo -e " command: |" - echo -e " git clone --branch master https://github.com/topcoder-platform/tc-deploy-scripts ../buildscript" - echo -e " cp ./../buildscript/master_deploy.sh ." - echo -e " ./master_deploy.sh -d ECS -e DEV -t \$CIRCLE_SHA1 -s $FILENAMEBUF" - echo $(tput setaf 7) - echo -e "For ECS FARGATE deploy type add option -p FARGATE" - echo -e " $(tput setaf 2) ./master_deploy.sh -d ECS -e DEV -t \$CIRCLE_SHA1 -s $FILENAMEBUF -p FARGATE $(tput setaf 7)\n" - echo -e "For common variables file var-common.json. Example option shown below, omit .json file extn" - echo -e " $(tput setaf 2) ./master_deploy.sh -d ECS -e DEV -t \$CIRCLE_SHA1 -s $FILENAMEBUF,var-common -p FARGATE $(tput setaf 7)\n" - } - # sed -i "" "s|#msadd1|$circleadd1|" ../circleci_template/Sample-Nodejs.yml - # sed -i "" "s|#msadd2|$circleadd2|" ../circleci_template/Sample-Nodejs.yml - - echo -e "---2. Pointer to copy -$(tput setaf 3) ../circleci_template/Sample-Nodejs.yml $(tput setaf 7)Search '#msadd2' append Line respectively" - echo -e "\n$(tput setaf 3)FINAL STEP \n-C) i) At .config.yml, validate for other project need,Langauge,Format etc. Update workflow/branch details. \n ii) Check-in. MASTER SCRIPT automatically triggers. Monitor Circleci. \n $(tput setaf 7)" - -} - -validateprereq() -{ - jq --version - if [ $? != 0 ]; - then - echo "Please install jq in MAC using below command and proceed" - echo "brew install jq" - exit - fi - #curl -O https://bootstrap.pypa.io/get-pip.py - #python3 get-pip.py --user - #pip install json-spec - json --version - if [ $? != 0 ]; - then - echo 'Please install json spec by running below commands on MAC and proceed' - echo "curl -O https://bootstrap.pypa.io/get-pip.py" - echo 'brew install python3' - echo 'python3 get-pip.py --user' - echo 'pip3 install json-spec' - exit - fi -} - -validateprereq - -echo "$(tput setaf 2)Please choose the option below$(tput setaf 7)" -echo "1 . Create new environment" -echo "2 . Modify Existing Environment Variable" -echo "3 . Validate Variable JSON file" -echo "4 . Exit the script" -read -e -p "Please enter the option number : " opt -if [ "$opt" != "6" ]; -then - read -e -p "Please enter the file name without extension : " FILENAMEBUF - #read -e -s -p "Please enter the Encryption Password : " SECPASSWD - echo "" - #read -p "Please enter the environment type : " ENVBUFFER - #ENVTYPE=`echo "$ENVBUFFER" | tr '[:upper:]' '[:lower:]'` - #FILENAME="$FILENAMEBUF-$ENVTYPE" - FILENAME="$FILENAMEBUF" - FILENAME_WITH_JSON_EXT="$FILENAME.json" - FILENAME_WITH_JSONENC_EXT="$FILENAME_WITH_JSON_EXT.enc" -fi -case $opt in - 1 ) echo "call add fun" - echo $vartemplate > $FILENAME_WITH_JSON_EXT - createenv - #encrypt_file - #rm -f $FILENAME_WITH_JSON_EXT - #git clone --branch master https://github.com/topcoder-platform/tc-deploy-scripts.git - #cp $FILENAME_WITH_JSONENC_EXT tc-deploy-scripts/securitymanager/ - ;; - 2 ) echo "call modify fun" - #decrypt_file - #rm -f $FILENAME_WITH_JSONENC_EXT - modifyenv - #encrypt_file - #rm -f $FILENAME_WITH_JSON_EXT - ;; - 3 ) - echo -e "$(tput setaf 3) Adhere to ../setup_ms/variable_file_template/ json format. $(tput setaf 7) \n" - read -e -p "Please enter deploy type (ECS/EBS/CFRONT) : " DTYPE - validate_file_json_schema - validate_file_json_values - ;; - 4 ) break;; - * ) echo "Please enter right option" - exit - ;; -esac - -user_information +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'Secret provisioning is an administrator/IaC responsibility, separate from deployment. This legacy helper is disabled in v2; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/tcdeploy.py b/tcdeploy.py new file mode 100644 index 0000000..1c0e85e --- /dev/null +++ b/tcdeploy.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Deploy prebuilt images to an existing Fargate service without reading secrets. + +Python standard library + AWS CLI v2 + Docker. No shell evaluation, broker, +ambient AWS identity, parameter discovery, infrastructure creation or build. +""" +import argparse +import base64 +from copy import deepcopy +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time + + +class DeployError(Exception): + pass + + +def require(condition, message): + if not condition: + raise DeployError(message) + + +def compact(value): + return json.dumps(value, separators=(',', ':')) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, 'Duplicate JSON key') + result[key] = value + return result + + +def read_manifest(path): + m = json.loads(Path(path).read_text(), object_pairs_hook=unique_object) + required = {'schemaVersion', 'environment', 'accountId', 'region', 'deployRoleArn', + 'taskRoleArn', 'executionRoleArn', 'cluster', 'service', 'taskFamily', + 'circleciOrgId', 'circleciProjectId', 'repository', 'branch', 'images'} + require(isinstance(m, dict) and set(m) == required, 'Manifest fields must match the v2 schema') + require(m['schemaVersion'] == 2 and m['environment'] in ('dev', 'prod'), 'Unsupported manifest version/environment') + for key in required - {'schemaVersion', 'images'}: + require(isinstance(m[key], str) and m[key], 'Manifest requires nonempty string fields') + require(re.fullmatch(r'[0-9]{12}', m['accountId']), 'Invalid account ID') + require(re.fullmatch(r'(us|eu|ap|ca|sa|me|af|il|mx)-[a-z]+-[0-9]', m['region']), 'Unsupported AWS commercial region') + for key in ('deployRoleArn', 'taskRoleArn', 'executionRoleArn'): + require(re.fullmatch(r'arn:aws:iam::' + m['accountId'] + r':role/[A-Za-z0-9_+=,.@/-]+', m[key]), 'Role must belong to the configured account') + require(len({m[k] for k in ('deployRoleArn', 'taskRoleArn', 'executionRoleArn')}) == 3, 'Deployment, task and execution roles must be distinct') + for key in ('cluster', 'service', 'taskFamily'): + require(re.fullmatch(r'[A-Za-z0-9_-]{1,255}', m[key]), 'Invalid ECS resource name') + for key in ('circleciOrgId', 'circleciProjectId'): + require(re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', m[key]), 'CircleCI IDs must be UUIDs') + require(re.fullmatch(r'github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', m['repository']), 'Expected exact github.com/owner/repository') + require(m['branch'] == ('master' if m['environment'] == 'prod' else 'develop'), 'Deploy only develop to dev and master to prod') + require(isinstance(m['images'], dict) and 1 <= len(m['images']) <= 10, 'Configure one to ten container/repository pairs') + for container, repository in m['images'].items(): + require(re.fullmatch(r'[A-Za-z0-9_-]{1,255}', container), 'Invalid container name') + require(isinstance(repository, str) and re.fullmatch(r'[a-z0-9]+(?:[._/-][a-z0-9]+)*', repository), 'Invalid ECR repository name') + require(len(set(m['images'].values())) == len(m['images']), 'Each image needs a separate repository') + return m + + +def arn(m, service, resource): + return f"arn:aws:{service}:{m['region']}:{m['accountId']}:{resource}" + + +def role_policy(m): + """Administrator-installed policy, also used as the deploy role boundary.""" + return {'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Deny', 'Action': ['ssm:*', 'secretsmanager:*', 'kms:Decrypt', 'sts:AssumeRole*'], 'Resource': '*'}, + # DescribeTaskDefinition does not support resource-level authorization. + {'Effect': 'Allow', 'Action': ['sts:GetCallerIdentity', 'ecr:GetAuthorizationToken', + 'ecs:DescribeTaskDefinition'], 'Resource': '*'}, + {'Effect': 'Allow', 'Action': ['ecr:DescribeRepositories', 'ecr:DescribeImages', + 'ecr:BatchGetImage', 'ecr:BatchCheckLayerAvailability', 'ecr:InitiateLayerUpload', + 'ecr:UploadLayerPart', 'ecr:CompleteLayerUpload', 'ecr:PutImage', + 'ecr:DescribeImageScanFindings'], + 'Resource': [arn(m, 'ecr', 'repository/' + r) for r in m['images'].values()]}, + {'Effect': 'Allow', 'Action': ['ecs:DescribeServices'], 'Resource': arn(m, 'ecs', f"service/{m['cluster']}/{m['service']}")}, + {'Effect': 'Allow', 'Action': 'ecs:UpdateService', 'Resource': arn(m, 'ecs', f"service/{m['cluster']}/{m['service']}"), + 'Condition': {'ArnLike': {'ecs:task-definition': arn(m, 'ecs', 'task-definition/' + m['taskFamily'] + ':*')}}}, + {'Effect': 'Allow', 'Action': ['ecs:RegisterTaskDefinition', 'ecs:TagResource', 'ecs:ListTagsForResource'], + 'Resource': arn(m, 'ecs', 'task-definition/' + m['taskFamily'] + ':*')}, + {'Effect': 'Allow', 'Action': ['ecs:ListTasks', 'ecs:DescribeTasks'], 'Resource': '*', + 'Condition': {'ArnEquals': {'ecs:cluster': arn(m, 'ecs', 'cluster/' + m['cluster'])}}}, + {'Effect': 'Allow', 'Action': 'iam:PassRole', 'Resource': [m['taskRoleArn'], m['executionRoleArn']], + 'Condition': {'StringEquals': {'iam:PassedToService': 'ecs-tasks.amazonaws.com'}}} + ]} + + +def trust_policy(m): + issuer = 'oidc.circleci.com/org/' + m['circleciOrgId'] + subject = (f"org:{m['circleciOrgId']}:project:{m['circleciProjectId']}:user:*:" + f"checkout-source:{m['repository']}:checkout-ref:refs/heads/{m['branch']}:" + f"config-source:{m['repository']}:config-ref:refs/heads/{m['branch']}") + return {'Version': '2012-10-17', 'Statement': [{ + 'Effect': 'Allow', 'Principal': {'Federated': f"arn:aws:iam::{m['accountId']}:oidc-provider/{issuer}"}, + 'Action': 'sts:AssumeRoleWithWebIdentity', 'Condition': { + 'StringEquals': {issuer + ':aud': m['circleciOrgId']}, + 'StringLike': {issuer + ':sub': subject}} + }]} + + +# Compact STS policy (<2048 bytes). IAM role policy above restricts resources. +# Explicit deny also protects against resource policies and prevents role chaining. +SESSION_POLICY = {'Version': '2012-10-17', 'Statement': [ + {'Effect': 'Deny', 'Action': ['ssm:*', 'secretsmanager:*', 'kms:Decrypt', 'sts:AssumeRole*'], 'Resource': '*'}, + {'Effect': 'Allow', 'Action': ['sts:GetCallerIdentity', 'ecr:GetAuthorizationToken', + 'ecr:DescribeRepositories', 'ecr:DescribeImages', 'ecr:BatchGetImage', + 'ecr:BatchCheckLayerAvailability', 'ecr:InitiateLayerUpload', 'ecr:UploadLayerPart', + 'ecr:CompleteLayerUpload', 'ecr:PutImage', 'ecr:DescribeImageScanFindings', + 'ecs:DescribeServices', 'ecs:DescribeTaskDefinition', 'ecs:RegisterTaskDefinition', + 'ecs:TagResource', 'ecs:ListTagsForResource', 'ecs:UpdateService', 'ecs:ListTasks', 'ecs:DescribeTasks', 'iam:PassRole'], 'Resource': '*'}]} + + +def clean_environment(region): + # Never inherit profiles, endpoint overrides, SDK credential providers, proxies, + # BASH_ENV, Auth0 secrets, OIDC tokens, arbitrary executable search paths, or + # package-manager credentials in AWS/Docker subprocesses. + result = {'PATH': '/usr/local/bin:/usr/bin:/bin', 'LANG': 'C.UTF-8', + 'AWS_REGION': region, 'AWS_DEFAULT_REGION': region, + 'AWS_CONFIG_FILE': os.devnull, 'AWS_SHARED_CREDENTIALS_FILE': os.devnull, + 'AWS_EC2_METADATA_DISABLED': 'true', 'AWS_PAGER': '', + 'AWS_RETRY_MODE': 'standard', 'AWS_MAX_ATTEMPTS': '3'} + # CircleCI remote Docker uses these public connection settings / client certs. + for key in ('DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH'): + if key in os.environ: + result[key] = os.environ[key] + return result + + +def execute(command, env, input_text=None, timeout=120): + try: + result = subprocess.run(command, env=env, input=input_text, text=True, + capture_output=True, timeout=timeout, check=False) + except (OSError, subprocess.TimeoutExpired): + raise DeployError('External command failed or timed out; no credentials were printed') from None + # Never echo stdout/stderr or full argv on failures: AWS responses and Docker + # diagnostics can contain credentials, task configuration, or registry tokens. + if result.returncode: + code = re.search(r'An error occurred \(([A-Za-z0-9]+)\)', result.stderr) + suffix = ': ' + code[1] if code else '' + raise DeployError('External command failed' + suffix) + return result.stdout + + +class AWS: + def __init__(self, region, env=None): + self.env = dict(env or clean_environment(region)) + self.region = region + + def call(self, service, operation, **parameters): + require(service in ('sts', 'ecs', 'ecr'), 'This deployer cannot call secret or infrastructure APIs') + # JSON on stdin avoids credentials and task configuration in argv or files. + command = ['aws', service, operation, '--region', self.region, + '--output', 'json', '--no-cli-pager', '--cli-input-json', 'file:///dev/stdin'] + output = execute(command, self.env, compact(parameters), timeout=120) + return json.loads(output) if output.strip() else {} + + +def authenticate(m, token): + require(isinstance(token, str) and len(token) > 100, 'CIRCLE_OIDC_TOKEN_V3 is required; no Auth0 or ambient-credential fallback') + aws = AWS(m['region']) + response = aws.call('sts', 'assume-role-with-web-identity', RoleArn=m['deployRoleArn'], + RoleSessionName='tc-deploy-' + str(int(time.time())), WebIdentityToken=token, + DurationSeconds=3600, Policy=compact(SESSION_POLICY)) + credentials = response.get('Credentials', {}) + for key in ('AccessKeyId', 'SecretAccessKey', 'SessionToken', 'Expiration'): + require(isinstance(credentials.get(key), str) and credentials[key], 'STS returned incomplete temporary credentials') + require(re.fullmatch(r'ASIA[A-Z0-9]{16}', credentials['AccessKeyId']), 'Only temporary STS credentials are accepted') + expiry = datetime.fromisoformat(credentials['Expiration'].replace('Z', '+00:00')) + require(3300 <= (expiry - datetime.now(timezone.utc)).total_seconds() <= 3660, 'Unexpected STS session expiry') + aws.env.update(AWS_ACCESS_KEY_ID=credentials['AccessKeyId'], AWS_SECRET_ACCESS_KEY=credentials['SecretAccessKey'], + AWS_SESSION_TOKEN=credentials['SessionToken']) + identity = aws.call('sts', 'get-caller-identity') + role_name = m['deployRoleArn'].rsplit('/', 1)[1] + require(identity.get('Account') == m['accountId'] and + identity.get('Arn', '').startswith(f"arn:aws:sts::{m['accountId']}:assumed-role/{role_name}/"), + 'STS identity does not match the reviewed deployment account/role') + return aws + + +SECRET_ENV = re.compile(r'(PASSWORD|SECRET|TOKEN|ACCESS_KEY|PRIVATE_KEY|DATABASE_URL|POSTGRES_URL|IDENTITY_DB_URL)', re.I) +DIGEST = re.compile(r'sha256:[0-9a-f]{64}') +TASK_FIELDS = {'family', 'taskRoleArn', 'executionRoleArn', 'networkMode', 'containerDefinitions', 'volumes', + 'placementConstraints', 'requiresCompatibilities', 'cpu', 'memory', 'pidMode', 'ipcMode', + 'proxyConfiguration', 'inferenceAccelerators', 'ephemeralStorage', 'runtimePlatform', 'enableFaultInjection'} +TASK_METADATA = {'taskDefinitionArn', 'revision', 'status', 'requiresAttributes', 'compatibilities', + 'registeredAt', 'registeredBy', 'deregisteredAt'} + + +def validate_environment(entries): + for entry in entries: + name, value = entry['name'], entry['value'] + require(not (SECRET_ENV.search(name) and value), 'Plaintext secret-like environment values are forbidden') + require(name != 'RESET_DB' or value == 'false', 'Database reset must be explicitly false') + + +def non_root(user): + if not isinstance(user, str): + return False + name = user.split(':', 1)[0] + return bool(name) and name != 'root' and not (name.isdecimal() and int(name) == 0) + + +def task_request(m, task, tags, images): + require(set(task) <= TASK_FIELDS | TASK_METADATA, 'Unrecognized task fields; update the deployer before changing this definition') + require(task.get('family') == m['taskFamily'] and task.get('status') == 'ACTIVE', 'Unexpected/inactive task family') + require(task.get('taskRoleArn') == m['taskRoleArn'] and task.get('executionRoleArn') == m['executionRoleArn'], 'Task/execution role differs from reviewed manifest') + require(task.get('networkMode') == 'awsvpc' and 'FARGATE' in task.get('requiresCompatibilities', []), 'Expected a Fargate task') + platform = task.get('runtimePlatform', {}) + require(platform.get('cpuArchitecture', 'X86_64') == 'X86_64' and platform.get('operatingSystemFamily', 'LINUX') == 'LINUX', 'This release supports Linux/amd64 tasks only') + require(set(images) == set(m['images']), 'Supply exactly the manifest image set') + result = {k: deepcopy(v) for k, v in task.items() if k in TASK_FIELDS} + result['tags'] = deepcopy(tags) + counts = {name: 0 for name in images} + for container in result['containerDefinitions']: + validate_environment(container.get('environment', [])) + require(all(s['name'] != 'RESET_DB' for s in container.get('secrets', [])), 'RESET_DB cannot come from an unreadable runtime secret') + if container['name'] in images: + require(not container.get('privileged') and ('user' not in container or non_root(container['user'])), 'Application task must not override the image to root/privileged') + counts[container['name']] += 1 + require(DIGEST.fullmatch(images[container['name']].rsplit('@', 1)[-1]), 'Deploy only image digests') + require(container.get('healthCheck'), 'Updated containers require a task health check') + container['image'] = images[container['name']] + require(all(n == 1 for n in counts.values()), 'Each configured container must exist exactly once') + # All roles, secret references, sidecars, health checks, resources and volumes + # are preserved. Parameter values never enter this process. + return result + + +def read_service(aws, m): + response = aws.call('ecs', 'describe-services', cluster=m['cluster'], services=[m['service']]) + require(not response.get('failures') and len(response.get('services', [])) == 1, 'Expected one existing ECS service') + service = response['services'][0] + require(service.get('status') == 'ACTIVE' and service.get('desiredCount', 0) > 0, 'Service must be active with positive desired capacity; bootstrap separately') + require(service.get('deploymentController', {}).get('type', 'ECS') == 'ECS', 'Only ECS rolling deployments are supported') + require(service.get('deploymentConfiguration', {}).get('deploymentCircuitBreaker') == {'enable': True, 'rollback': True}, 'Enable ECS deployment circuit breaker with rollback before migration') + require(service.get('serviceArn') == arn(m, 'ecs', f"service/{m['cluster']}/{m['service']}"), 'Service ARN differs from manifest') + return service + + +def stable(service, definition): + require(service.get('taskDefinition') == definition, 'Service rolled back or was changed by another deployment') + deployments = service.get('deployments', []) + require(not any(d.get('rolloutState') == 'FAILED' for d in deployments), 'ECS rollout failed') + return (len(deployments) == 1 and deployments[0].get('rolloutState') == 'COMPLETED' + and deployments[0].get('taskDefinition') == definition + and service.get('runningCount') == service['desiredCount'] > 0 and service.get('pendingCount') == 0) + + +def verify_running(aws, m, definition, images, desired): + arns, token = [], None + while True: + args = {'cluster': m['cluster'], 'serviceName': m['service'], 'desiredStatus': 'RUNNING'} + if token: + args['nextToken'] = token + page = aws.call('ecs', 'list-tasks', **args) + arns.extend(page.get('taskArns', [])) + token = page.get('nextToken') + if not token: + break + if len(arns) < desired: + return False + tasks = [] + for start in range(0, len(arns), 100): + result = aws.call('ecs', 'describe-tasks', cluster=m['cluster'], tasks=arns[start:start + 100]) + require(not result.get('failures'), 'Unable to describe running tasks') + tasks.extend(result.get('tasks', [])) + for task in tasks: + if task.get('taskDefinitionArn') != definition or task.get('lastStatus') != 'RUNNING' or task.get('healthStatus') != 'HEALTHY': + return False + containers = {c['name']: c for c in task['containers']} + for name, image in images.items(): + if containers.get(name, {}).get('imageDigest') != image.rsplit('@', 1)[1]: + return False + return len(tasks) >= desired + + +class Publisher: + def __init__(self, aws, m, directory): + self.aws, self.m = aws, m + self.env = clean_environment(m['region']) # Docker must not receive AWS/OIDC credentials. + self.env['DOCKER_CONFIG'] = directory + self.registry = f"{m['accountId']}.dkr.ecr.{m['region']}.amazonaws.com" + + def docker(self, *args, input_text=None, timeout=120): + return execute(['docker', *args], self.env, input_text, timeout) + + def inspect(self, image): + require(not image.startswith('-') and re.fullmatch(r'[A-Za-z0-9_./:@-]+', image), 'Invalid local image reference') + records = json.loads(self.docker('image', 'inspect', image)) + require(len(records) == 1, 'Expected one local image') + info = records[0] + info['configDigest'] = info.get('Descriptor', {}).get('annotations', {}).get('config.digest', info.get('Id', '')) + require(DIGEST.fullmatch(info['configDigest']), 'Missing image configuration digest') + require(info.get('Os') == 'linux' and info.get('Architecture') == 'amd64', 'Only Linux/amd64 images are supported') + entries = [{'name': x.partition('=')[0], 'value': x.partition('=')[2]} for x in info.get('Config', {}).get('Env', [])] + validate_environment(entries) + require(non_root(info.get('Config', {}).get('User')), 'Application images must run as a non-root user') + return info + + def publish(self, name, local, tag, deadline): + info = self.inspect(local) + require(info.get('Config', {}).get('Labels', {}).get('org.opencontainers.image.revision') == tag[:40], 'Image source revision does not match this release') + repository = self.m['images'][name] + response = self.aws.call('ecr', 'describe-repositories', repositoryNames=[repository]) + repos = response.get('repositories', []) + require(len(repos) == 1 and repos[0].get('imageTagMutability') == 'IMMUTABLE', 'ECR repository must exist and use unconditional immutable tags') + uri = f'{self.registry}/{repository}' + require(repos[0].get('repositoryUri') == uri, 'Unexpected ECR registry') + auth = self.aws.call('ecr', 'get-authorization-token')['authorizationData'] + require(len(auth) == 1 and auth[0]['proxyEndpoint'] == 'https://' + self.registry, 'Unexpected ECR login endpoint') + username, password = base64.b64decode(auth[0]['authorizationToken'], validate=True).decode().split(':', 1) + require(username == 'AWS' and password, 'Invalid ECR authorization') + self.docker('login', '--username', username, '--password-stdin', self.registry, input_text=password) + # Unique, immutable release tags make a concurrent overwrite fail closed. + image = uri + ':' + tag + self.docker('tag', local, image) + self.docker('push', image, timeout=600) + details = self.aws.call('ecr', 'describe-images', repositoryName=repository, + imageIds=[{'imageTag': tag}]).get('imageDetails', []) + require(len(details) == 1 and DIGEST.fullmatch(details[0].get('imageDigest', '')), 'ECR did not return a single image digest') + digest = details[0]['imageDigest'] + remote = self.aws.call('ecr', 'batch-get-image', repositoryName=repository, + imageIds=[{'imageDigest': digest}], acceptedMediaTypes=[ + 'application/vnd.docker.distribution.manifest.v2+json', 'application/vnd.oci.image.manifest.v1+json']) + require(not remote.get('failures') and len(remote.get('images', [])) == 1, 'Cannot verify pushed image') + manifest = json.loads(remote['images'][0]['imageManifest']) + require(manifest.get('config', {}).get('digest') == info['configDigest'], 'Pushed artifact differs from local image (multi-platform indexes are unsupported)') + # Repositories must scan on push (basic or enhanced). No start_image_scan + # fallback that can bypass a failed/missing organizational scan policy. + while time.monotonic() < deadline: + try: + scan = self.aws.call('ecr', 'describe-image-scan-findings', repositoryName=repository, imageId={'imageDigest': digest}) + except DeployError as exc: + if str(exc).endswith(': ScanNotFoundException'): + time.sleep(10) + continue + raise + status = scan.get('imageScanStatus', {}).get('status') + findings = scan.get('imageScanFindings', {}) + if status in ('COMPLETE', 'ACTIVE') and findings.get('imageScanCompletedAt'): + timestamp = findings['imageScanCompletedAt'] + if isinstance(timestamp, (int, float)): + completed = datetime.fromtimestamp(timestamp, timezone.utc) + else: + completed = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + require(0 <= (datetime.now(timezone.utc) - completed).total_seconds() < 86400, 'Image scan must have completed within the last 24 hours') + counts = findings.get('findingSeverityCounts') + require(isinstance(counts, dict), 'Missing image scan severity counts') + require(not any(counts.get(level, 0) for level in ('CRITICAL', 'HIGH', 'MEDIUM', 'UNDEFINED')), 'Image scan exceeds the release threshold') + return uri + '@' + digest + require(status in ('IN_PROGRESS', 'PENDING', 'ACTIVE'), 'Image scan failed or is unsupported') + time.sleep(10) + raise DeployError('Image scan timed out; service was not updated') + + +def deploy(aws, m, locals_by_name, release, publisher): + require(set(locals_by_name) == set(m['images']), 'Supply exactly one prebuilt image per configured container') + require(re.fullmatch(r'[a-f0-9]{40}-[0-9]{1,12}', release), 'Release must be CIRCLE_SHA1-CIRCLE_BUILD_NUM') + previous = read_service(aws, m) + definition = previous['taskDefinition'] + require(stable(previous, definition), 'Existing service must be stable before deploying') + response = aws.call('ecs', 'describe-task-definition', taskDefinition=definition, include=['TAGS']) + task, tags = response['taskDefinition'], response.get('tags', []) + # Validate configuration before publishing anything. Placeholder digests are + # only used for validation and never sent to AWS. + task_request(m, task, tags, {name: 'preflight@sha256:' + '0' * 64 for name in m['images']}) + images = {} + scan_deadline = time.monotonic() + 900 + for name, local in locals_by_name.items(): + images[name] = publisher.publish(name, local, release, scan_deadline) + current = read_service(aws, m) + require(stable(current, definition), 'Service changed during image publication; retry after inspection') + request = task_request(m, task, tags, images) + registered = aws.call('ecs', 'register-task-definition', **request)['taskDefinition'] + target = registered['taskDefinitionArn'] + require(re.fullmatch(re.escape(arn(m, 'ecs', 'task-definition/' + m['taskFamily'])) + r':[0-9]+', target), 'Unexpected registered task ARN') + # Read back and compare the actual registered fields before promotion. + saved = aws.call('ecs', 'describe-task-definition', taskDefinition=target, include=['TAGS']) + saved_request = {k: v for k, v in saved['taskDefinition'].items() if k in TASK_FIELDS} + saved_request['tags'] = sorted(saved.get('tags', []), key=lambda t: t['key']) + request['tags'] = sorted(request['tags'], key=lambda t: t['key']) + require(saved_request == request, + 'Registered task configuration differs from requested configuration') + current = read_service(aws, m) + require(stable(current, definition), 'Concurrent deployment detected before service update') + updated = aws.call('ecs', 'update-service', cluster=m['cluster'], service=m['service'], taskDefinition=target) + require(updated.get('service', {}).get('taskDefinition') == target, 'ECS did not accept requested revision') + deadline = time.monotonic() + 1200 + while time.monotonic() < deadline: + service = read_service(aws, m) + if stable(service, target) and verify_running(aws, m, target, images, service['desiredCount']): + return {'service': m['service'], 'environment': m['environment'], 'taskDefinition': target, + 'previousTaskDefinition': definition, 'images': images} + time.sleep(15) + # Do not blindly revert over another release. ECS circuit breaker performs + # rollback; timeout remains a failed release requiring operator inspection. + raise DeployError('Rollout timed out; inspect ECS circuit-breaker rollback before retrying') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + sub = parser.add_subparsers(dest='command', required=True) + for name in ('deploy', 'policies', 'check-auth'): + p = sub.add_parser(name) + p.add_argument('--manifest', required=True) + if name == 'deploy': + p.add_argument('--image', action='append', required=True, metavar='CONTAINER=LOCAL_IMAGE') + args = parser.parse_args(argv) + m = read_manifest(args.manifest) + if args.command == 'policies': + print(json.dumps({'trust': trust_policy(m), 'permissions': role_policy(m)}, indent=2)) + return + require(os.environ.get('CIRCLE_BRANCH') == m['branch'] and not os.environ.get('CIRCLE_TAG'), 'Only the configured branch may deploy (tag pipelines are forbidden)') + require(os.environ.get('CIRCLE_PROJECT_USERNAME', '') + '/' + os.environ.get('CIRCLE_PROJECT_REPONAME', '') == m['repository'].removeprefix('github.com/'), 'CircleCI repository differs from manifest') + token = os.environ.get('CIRCLE_OIDC_TOKEN_V3') + aws = authenticate(m, token) + if args.command == 'check-auth': + print('Verified deployment account and role. No credentials exported or written.') + return + images = {} + for item in args.image: + name, separator, image = item.partition('=') + require(separator and name not in images and image, 'Images must be unique CONTAINER=LOCAL_IMAGE pairs') + images[name] = image + release = os.environ.get('CIRCLE_SHA1', '') + '-' + os.environ.get('CIRCLE_BUILD_NUM', '') + with tempfile.TemporaryDirectory(prefix='tc-deploy-docker-') as directory: + result = deploy(aws, m, images, release, Publisher(aws, m, directory)) + print(json.dumps(result, indent=2)) # Only non-secret rollout evidence. + + +if __name__ == '__main__': + try: + main() + except (DeployError, ValueError, KeyError, TypeError, OSError): + # Do not emit tracebacks/SDK payloads, which can contain secrets. + exc = sys.exc_info()[1] + print('Deployment stopped: ' + (str(exc) if isinstance(exc, DeployError) else 'invalid input or response'), file=sys.stderr) + sys.exit(1) diff --git a/template/circleci_template/Sample-Java-Graddle.yml b/template/circleci_template/Sample-Java-Graddle.yml index 76c1f0c..a7243a0 100644 --- a/template/circleci_template/Sample-Java-Graddle.yml +++ b/template/circleci_template/Sample-Java-Graddle.yml @@ -1,42 +1,3 @@ -# Java Gradle CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-java/ for more details -# -version: 2 -jobs: - build: - docker: - # specify the version you desire here - - image: circleci/openjdk:8-jdk - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/postgres:9.4 - - working_directory: ~/repo - - environment: - # Customize the JVM maximum heap limit - JVM_OPTS: -Xmx3200m - TERM: dumb - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "build.gradle" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: gradle dependencies - - - save_cache: - paths: - - ~/.gradle - key: v1-dependencies-{{ checksum "build.gradle" }} - - # run tests! - - run: gradle test +# The legacy credential-bearing build/deploy template has been retired. +# Adopt ../../examples/circleci-v2.yml with reviewed manifests and IAM roles. +# See ../../docs/MIGRATION.md; do not use the v1 helpers. diff --git a/template/circleci_template/Sample-Java-Maven.yml b/template/circleci_template/Sample-Java-Maven.yml index d095d5b..a7243a0 100644 --- a/template/circleci_template/Sample-Java-Maven.yml +++ b/template/circleci_template/Sample-Java-Maven.yml @@ -1,41 +1,3 @@ -# Java Maven CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-java/ for more details -# -version: 2 -jobs: - build: - docker: - # specify the version you desire here - - image: circleci/openjdk:8-jdk - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/postgres:9.4 - - working_directory: ~/repo - - environment: - # Customize the JVM maximum heap limit - MAVEN_OPTS: -Xmx3200m - - steps: - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "pom.xml" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: mvn dependency:go-offline - - - save_cache: - paths: - - ~/.m2 - key: v1-dependencies-{{ checksum "pom.xml" }} - - # run tests! - - run: mvn integration-test +# The legacy credential-bearing build/deploy template has been retired. +# Adopt ../../examples/circleci-v2.yml with reviewed manifests and IAM roles. +# See ../../docs/MIGRATION.md; do not use the v1 helpers. diff --git a/template/circleci_template/Sample-Nodejs.yml b/template/circleci_template/Sample-Nodejs.yml index c72d9b3..a7243a0 100644 --- a/template/circleci_template/Sample-Nodejs.yml +++ b/template/circleci_template/Sample-Nodejs.yml @@ -1,41 +1,3 @@ -# Javascript Node CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-javascript/ for more details -# -version: 2 -jobs: - build: - docker: - # specify the version you desire here - - image: circleci/node:7.10 - - # Specify service dependencies here if necessary - # CircleCI maintains a library of pre-built images - # documented at https://circleci.com/docs/2.0/circleci-images/ - # - image: circleci/mongo:3.4.4 - - working_directory: ~/repo - - steps: - #msadd1 - - checkout - - # Download and cache dependencies - - restore_cache: - keys: - - v1-dependencies-{{ checksum "package.json" }} - # fallback to using the latest cache if no exact match is found - - v1-dependencies- - - - run: yarn install - - - save_cache: - paths: - - node_modules - key: v1-dependencies-{{ checksum "package.json" }} - - # run tests! - - run: yarn test - #msadd2 - - +# The legacy credential-bearing build/deploy template has been retired. +# Adopt ../../examples/circleci-v2.yml with reviewed manifests and IAM roles. +# See ../../docs/MIGRATION.md; do not use the v1 helpers. diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..036044b --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,532 @@ +import base64 +from copy import deepcopy +from datetime import datetime, timedelta, timezone +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] + + +def load(name): + spec = importlib.util.spec_from_file_location(name, ROOT / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +d = load('tcdeploy') +b = load('build_image') +DIGEST = 'sha256:' + 'a' * 64 +CONFIG = 'sha256:' + 'b' * 64 +SHA = 'c' * 40 +RELEASE = SHA + '-123' + + +def manifest(): + return d.read_manifest(ROOT / 'examples/deploy.dev.json') + + +def task(m): + return {'family': m['taskFamily'], 'status': 'ACTIVE', 'revision': 1, + 'taskDefinitionArn': d.arn(m, 'ecs', 'task-definition/' + m['taskFamily'] + ':1'), + 'taskRoleArn': m['taskRoleArn'], 'executionRoleArn': m['executionRoleArn'], + 'networkMode': 'awsvpc', 'requiresCompatibilities': ['FARGATE'], 'cpu': '512', 'memory': '1024', + 'volumes': [{'name': 'scratch'}], 'runtimePlatform': {'cpuArchitecture': 'X86_64', 'operatingSystemFamily': 'LINUX'}, + 'containerDefinitions': [{'name': 'example-v6', 'image': 'old@' + DIGEST, + 'essential': True, 'readonlyRootFilesystem': True, + 'healthCheck': {'command': ['CMD', 'true']}, + 'environment': [{'name': 'PORT', 'value': '3000'}], + 'secrets': [{'name': 'DATABASE_URL', 'valueFrom': 'arn:aws:ssm:us-east-1:111111111111:parameter/config/example/appvar/DATABASE_URL'}], + 'mountPoints': [{'sourceVolume': 'scratch', 'containerPath': '/tmp'}]}]} + + +def service(m, definition=None): + definition = definition or task(m)['taskDefinitionArn'] + return {'serviceArn': d.arn(m, 'ecs', f"service/{m['cluster']}/{m['service']}"), 'status': 'ACTIVE', + 'desiredCount': 1, 'runningCount': 1, 'pendingCount': 0, + 'taskDefinition': definition, 'deploymentController': {'type': 'ECS'}, + 'deploymentConfiguration': {'deploymentCircuitBreaker': {'enable': True, 'rollback': True}}, + 'deployments': [{'taskDefinition': definition, 'rolloutState': 'COMPLETED'}]} + + +class FakeAWS: + def __init__(self, m): + self.m = m + self.calls = [] + self.task = task(m) + self.service = service(m) + self.registered = None + self.before = None + self.env = {} + + def call(self, api, operation, **params): + self.calls.append((api, operation, deepcopy(params))) + if self.before: + self.before(api, operation, params) + if (api, operation) == ('ecs', 'describe-services'): + return {'services': [deepcopy(self.service)]} + if (api, operation) == ('ecs', 'describe-task-definition'): + return {'taskDefinition': deepcopy(self.registered or self.task), 'tags': [{'key': 'owner', 'value': 'team'}]} + if (api, operation) == ('ecs', 'register-task-definition'): + self.registered = deepcopy(params) + self.registered.pop('tags') + self.registered.update(status='ACTIVE', revision=2, taskDefinitionArn=self.task['taskDefinitionArn'][:-1] + '2') + return {'taskDefinition': deepcopy(self.registered)} + if (api, operation) == ('ecs', 'update-service'): + self.service = service(self.m, params['taskDefinition']) + return {'service': deepcopy(self.service)} + if (api, operation) == ('ecs', 'list-tasks'): + return {'taskArns': ['task-1']} + if (api, operation) == ('ecs', 'describe-tasks'): + return {'tasks': [{'taskDefinitionArn': self.service['taskDefinition'], 'lastStatus': 'RUNNING', + 'healthStatus': 'HEALTHY', 'containers': [{'name': 'example-v6', 'imageDigest': DIGEST}]}]} + raise AssertionError((api, operation)) + + +class FakePublisher: + def __init__(self, m): + self.m = m + self.calls = [] + + def publish(self, name, local, release, deadline): + self.calls.append(name) + return f"{self.m['accountId']}.dkr.ecr.{self.m['region']}.amazonaws.com/{self.m['images'][name]}@{DIGEST}" + + +class ManifestTests(unittest.TestCase): + def check_invalid(self, key, value): + m = manifest() + m[key] = value + with tempfile.TemporaryDirectory() as temp: + p = Path(temp) / 'manifest.json' + p.write_text(json.dumps(m)) + with self.assertRaises(d.DeployError): + d.read_manifest(p) + + def test_expected_example(self): + self.assertEqual(manifest()['environment'], 'dev') + + def test_wrong_account_role(self): + self.check_invalid('executionRoleArn', 'arn:aws:iam::222222222222:role/other') + + def test_shell_injection_is_invalid_data(self): + self.check_invalid('service', 'x; touch /tmp/marker') + + def test_wildcard_role(self): + self.check_invalid('deployRoleArn', 'arn:aws:iam::111111111111:role/*') + + def test_production_branch_guard(self): + self.check_invalid('environment', 'prod') + + def test_unknown_fields(self): + self.check_invalid('parameterPath', '/config/app') + + def test_duplicate_json_key(self): + with self.assertRaises(d.DeployError): + json.loads('{"a":1,"a":2}', object_pairs_hook=d.unique_object) + + def test_policies_bind_identity_and_forbid_parameters(self): + m = manifest() + trust = d.trust_policy(m) + text = json.dumps(trust) + self.assertIn(':config-ref:refs/heads/develop', text) + self.assertIn(':checkout-source:github.com/example/example-v6:', text) + self.assertIn('project:' + m['circleciProjectId'], text) + for policy in (d.role_policy(m), d.SESSION_POLICY): + deny = policy['Statement'][0] + self.assertEqual(deny['Effect'], 'Deny') + self.assertTrue({'ssm:*', 'secretsmanager:*', 'kms:Decrypt', 'sts:AssumeRole*'} <= set(deny['Action'])) + self.assertLess(len(d.compact(d.SESSION_POLICY)), 2048) + passrole = d.role_policy(m)['Statement'][-1] + self.assertEqual(passrole['Resource'], [m['taskRoleArn'], m['executionRoleArn']]) + + +class CredentialTests(unittest.TestCase): + def response(self): + m = manifest() + return [{'Credentials': {'AccessKeyId': 'ASIA' + 'A' * 16, 'SecretAccessKey': 'dummy-secret', + 'SessionToken': 'dummy-session', 'Expiration': (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()}}, + {'Account': m['accountId'], 'Arn': 'arn:aws:sts::111111111111:assumed-role/example-v6-dev-deploy/session'}] + + def test_oidc_no_credential_file_and_restricted_session(self): + with patch.object(d.AWS, 'call', side_effect=self.response()) as call: + aws = d.authenticate(manifest(), 'x' * 200) + self.assertEqual(aws.env['AWS_SESSION_TOKEN'], 'dummy-session') + self.assertEqual(json.loads(call.call_args_list[0].kwargs['Policy']), d.SESSION_POLICY) + self.assertNotIn('CI_AUTH0_CLIENTSECRET', aws.env) + + def test_no_auth0_or_ambient_fallback(self): + with patch.dict(os.environ, {'CI_AUTH0_CLIENTSECRET': 'secret', 'AWS_ACCESS_KEY_ID': 'key'}), patch.object(d.AWS, 'call') as call: + with self.assertRaises(d.DeployError): + d.authenticate(manifest(), None) + call.assert_not_called() + + def test_auth_error_propagates(self): + with patch.object(d.AWS, 'call', side_effect=d.DeployError('AccessDenied')): + with self.assertRaises(d.DeployError): + d.authenticate(manifest(), 'x' * 200) + + def test_wrong_role_or_account_is_rejected(self): + for key, value in [('Account', '222222222222'), ('Arn', 'arn:aws:sts::111111111111:assumed-role/other/session')]: + response = self.response() + response[1][key] = value + with patch.object(d.AWS, 'call', side_effect=response), self.assertRaises(d.DeployError): + d.authenticate(manifest(), 'x' * 200) + + def test_missing_session_token(self): + response = self.response() + del response[0]['Credentials']['SessionToken'] + with patch.object(d.AWS, 'call', side_effect=response), self.assertRaises(d.DeployError): + d.authenticate(manifest(), 'x' * 200) + + def test_static_keys_rejected(self): + response = self.response() + response[0]['Credentials']['AccessKeyId'] = 'AKIA' + 'A' * 16 + with patch.object(d.AWS, 'call', side_effect=response), self.assertRaises(d.DeployError): + d.authenticate(manifest(), 'x' * 200) + + def test_expired_session(self): + response = self.response() + response[0]['Credentials']['Expiration'] = '2000-01-01T00:00:00Z' + with patch.object(d.AWS, 'call', side_effect=response), self.assertRaises(d.DeployError): + d.authenticate(manifest(), 'x' * 200) + + def test_subprocess_environment_excludes_provider_overrides(self): + with patch.dict(os.environ, {'AWS_PROFILE': 'admin', 'AWS_ENDPOINT_URL': 'https://evil.invalid', + 'CI_AUTH0_CLIENTSECRET': 'secret', 'CIRCLE_OIDC_TOKEN_V3': 'token', 'BASH_ENV': '/tmp/evil', + 'AWS_WEB_IDENTITY_TOKEN_FILE': '/tmp/evil', 'HTTPS_PROXY': 'https://evil.invalid'}): + clean = d.clean_environment('us-east-1') + for key in ('AWS_PROFILE', 'AWS_ENDPOINT_URL', 'CI_AUTH0_CLIENTSECRET', 'CIRCLE_OIDC_TOKEN_V3', + 'BASH_ENV', 'AWS_WEB_IDENTITY_TOKEN_FILE', 'HTTPS_PROXY'): + self.assertNotIn(key, clean) + + def test_aws_json_on_stdin_never_argv(self): + with patch.object(d, 'execute', return_value='{}') as execute: + d.AWS('us-east-1').call('sts', 'assume-role-with-web-identity', WebIdentityToken='secret-token') + self.assertNotIn('secret-token', str(execute.call_args.args[0])) + self.assertIn('secret-token', execute.call_args.args[2]) + + def test_no_secret_api_even_on_explicit_call(self): + with patch.object(d, 'execute') as execute: + for api in ('ssm', 'secretsmanager', 'kms', 'iam', 'cloudformation'): + with self.assertRaises(d.DeployError): + d.AWS('us-east-1').call(api, 'get-parameter') + execute.assert_not_called() + + def test_command_error_does_not_include_secrets(self): + result = subprocess.CompletedProcess([], 1, 'dummy-secret', 'An error occurred (AccessDenied) dummy-secret') + with patch.object(d.subprocess, 'run', return_value=result): + with self.assertRaises(d.DeployError) as caught: + d.execute(['aws'], {}, 'secret') + self.assertNotIn('dummy-secret', str(caught.exception)) + self.assertIn('AccessDenied', str(caught.exception)) + + +class DeploymentTests(unittest.TestCase): + def setUp(self): + self.m = manifest() + self.aws = FakeAWS(self.m) + self.publisher = FakePublisher(self.m) + + def deploy(self): + return d.deploy(self.aws, self.m, {'example-v6': 'example:build'}, RELEASE, self.publisher) + + def test_full_release_never_calls_ssm_and_preserves_roles_secrets(self): + original = deepcopy(self.aws.task) + result = self.deploy() + self.assertTrue(result['taskDefinition'].endswith(':2')) + self.assertEqual(set(a for a, _, _ in self.aws.calls), {'ecs'}) + self.assertEqual(self.aws.registered['executionRoleArn'], original['executionRoleArn']) + self.assertEqual(self.aws.registered['containerDefinitions'][0]['secrets'], original['containerDefinitions'][0]['secrets']) + self.assertEqual(self.aws.registered['volumes'], original['volumes']) + + def test_fargate_role_never_overwritten(self): + self.aws.task['executionRoleArn'] = 'arn:aws:iam::111111111111:role/ecsTaskExecutionRole' + with self.assertRaises(d.DeployError): + self.deploy() + self.assertEqual(self.publisher.calls, []) + + def test_reset_secret_or_literal_forbidden_before_publish(self): + for field, value in [('secrets', {'name': 'RESET_DB', 'valueFrom': '/config/RESET_DB'}), + ('environment', {'name': 'RESET_DB', 'value': 'true'})]: + with self.subTest(field=field): + self.aws.task = task(self.m) + self.aws.task['containerDefinitions'][0][field].append(value) + with self.assertRaises(d.DeployError): + self.deploy() + self.assertEqual(self.publisher.calls, []) + + def test_plaintext_runtime_secret_rejected(self): + self.aws.task['containerDefinitions'][0]['environment'].append({'name': 'DATABASE_URL', 'value': 'secret'}) + with self.assertRaises(d.DeployError): + self.deploy() + + def test_task_unknown_fields_fail_closed(self): + self.aws.task['newUnsupportedField'] = True + with self.assertRaises(d.DeployError): + self.deploy() + + def test_no_zero_capacity_false_success(self): + self.aws.service['desiredCount'] = self.aws.service['runningCount'] = 0 + with self.assertRaises(d.DeployError): + self.deploy() + + def test_steady_event_does_not_replace_revision_check(self): + state = service(self.m) + state['events'] = [{'message': 'has reached steady state'}] + with self.assertRaises(d.DeployError): + d.stable(state, 'different-revision') + + def test_required_circuit_breaker(self): + self.aws.service['deploymentConfiguration'] = {} + with self.assertRaises(d.DeployError): + self.deploy() + + def test_ssm_failure_cannot_be_silently_ignored_because_no_read_exists(self): + def fail_secret_calls(api, operation, params): + self.assertNotIn(api, ('ssm', 'secretsmanager', 'kms')) + self.aws.before = fail_secret_calls + self.deploy() + + def test_register_failure_stops_before_update(self): + def fail(api, operation, params): + if operation == 'register-task-definition': + raise d.DeployError('AccessDenied') + self.aws.before = fail + with self.assertRaises(d.DeployError): + self.deploy() + self.assertNotIn('update-service', [op for _, op, _ in self.aws.calls]) + + def test_concurrent_service_change_stops_promotion(self): + original_publish = self.publisher.publish + def publish(*args): + self.aws.service = service(self.m, 'other:3') + return original_publish(*args) + self.publisher.publish = publish + with self.assertRaises(d.DeployError): + self.deploy() + self.assertNotIn('register-task-definition', [op for _, op, _ in self.aws.calls]) + + def test_registered_image_tampering_stops_update(self): + def tamper(api, operation, params): + if operation == 'describe-task-definition' and self.aws.registered: + self.aws.registered['containerDefinitions'][0]['image'] = 'evil@' + DIGEST + self.aws.before = tamper + with self.assertRaises(d.DeployError): + self.deploy() + self.assertNotIn('update-service', [op for _, op, _ in self.aws.calls]) + + def test_rollout_rollback_fails(self): + def rollback(api, operation, params): + if operation == 'describe-services' and any(op == 'update-service' for _, op, _ in self.aws.calls): + self.aws.service = service(self.m) + self.aws.before = rollback + with self.assertRaises(d.DeployError): + self.deploy() + + def test_wrong_task_digest_is_not_healthy(self): + self.assertFalse(d.verify_running(self.aws, self.m, self.aws.service['taskDefinition'], {'example-v6': 'image@sha256:' + 'f' * 64}, 1)) + + def test_missing_image_fails(self): + with self.assertRaises(d.DeployError): + d.deploy(self.aws, self.m, {}, RELEASE, self.publisher) + + def test_sidecars_preserved(self): + sidecar = {'name': 'metrics', 'image': 'trusted@' + DIGEST, 'essential': False} + self.aws.task['containerDefinitions'].append(sidecar) + self.deploy() + self.assertEqual(self.aws.registered['containerDefinitions'][1], sidecar) + + +class PublisherTests(unittest.TestCase): + def setUp(self): + self.m = manifest() + self.aws = FakeAWS(self.m) + self.publisher = d.Publisher(self.aws, self.m, '/tmp/dummy-docker-config') + self.info = {'Id': CONFIG, 'Os': 'linux', 'Architecture': 'amd64', 'Config': {'User': '10001', 'Env': ['RESET_DB=false'], + 'Labels': {'org.opencontainers.image.revision': SHA}}} + self.scan = {'imageScanStatus': {'status': 'COMPLETE'}, 'imageScanFindings': {'imageScanCompletedAt': datetime.now(timezone.utc).isoformat(), 'findingSeverityCounts': {}}} + self.digest_config = CONFIG + self.mutability = 'IMMUTABLE' + self.calls = [] + + def ecr(self, api, op, **params): + self.calls.append(op) + self.assertEqual(api, 'ecr') + registry = self.publisher.registry + if op == 'describe-repositories': + return {'repositories': [{'repositoryUri': registry + '/example-v6', 'imageTagMutability': self.mutability}]} + if op == 'get-authorization-token': + return {'authorizationData': [{'proxyEndpoint': 'https://' + registry, 'authorizationToken': base64.b64encode(b'AWS:dummy-password').decode()}]} + if op == 'describe-images': + return {'imageDetails': [{'imageDigest': DIGEST}]} + if op == 'batch-get-image': + return {'images': [{'imageManifest': json.dumps({'config': {'digest': self.digest_config}})}]} + if op == 'describe-image-scan-findings': + return self.scan + raise AssertionError(op) + + def publish(self): + with patch.object(self.publisher, 'docker', return_value=json.dumps([self.info])) as docker, patch.object(self.aws, 'call', side_effect=self.ecr): + result = self.publisher.publish('example-v6', 'example:build', RELEASE, float('inf')) + return result, docker + + def test_publish_verifies_digest_and_password_stdin(self): + result, docker = self.publish() + self.assertTrue(result.endswith('@' + DIGEST)) + login = next(c for c in docker.call_args_list if c.args[0] == 'login') + self.assertNotIn('dummy-password', str(login.args)) + self.assertEqual(login.kwargs['input_text'], 'dummy-password') + self.assertNotIn('AWS_ACCESS_KEY_ID', self.publisher.env) + + def test_wrong_artifact_digest(self): + self.digest_config = 'sha256:' + 'f' * 64 + with self.assertRaises(d.DeployError): + self.publish() + + def test_containerd_configuration_digest(self): + self.info['Id'] = DIGEST + self.info['Descriptor'] = {'annotations': {'config.digest': CONFIG}} + self.publish() + + def test_wrong_source_revision(self): + self.info['Config']['Labels']['org.opencontainers.image.revision'] = 'f' * 40 + with self.assertRaises(d.DeployError): + self.publish() + self.assertEqual(self.calls, []) + + def test_mutable_tag_repository(self): + self.mutability = 'MUTABLE' + with self.assertRaises(d.DeployError): + self.publish() + + def test_reset_image_rejected(self): + self.info['Config']['Env'] = ['RESET_DB=true'] + with self.assertRaises(d.DeployError): + self.publish() + + def test_secret_image_environment_rejected(self): + self.info['Config']['Env'] = ['AWS_SECRET_ACCESS_KEY=dummy'] + with self.assertRaises(d.DeployError): + self.publish() + + def test_root_image_rejected(self): + for user in ('root', '0', '00', '000:1000', 'root:10001', '0:app', ''): + self.info['Config']['User'] = user + with self.assertRaises(d.DeployError): + self.publish() + + def test_stale_scan_rejected(self): + self.scan['imageScanFindings']['imageScanCompletedAt'] = '2000-01-01T00:00:00Z' + with self.assertRaises(d.DeployError): + self.publish() + + def test_failed_scan(self): + self.scan['imageScanStatus']['status'] = 'FAILED' + with self.assertRaises(d.DeployError): + self.publish() + + def test_severity_gate(self): + for level in ('CRITICAL', 'HIGH', 'MEDIUM', 'UNDEFINED'): + with self.subTest(level=level): + self.scan['imageScanFindings']['findingSeverityCounts'] = {level: 1} + with self.assertRaises(d.DeployError): + self.publish() + + +class EntrypointTests(unittest.TestCase): + def test_old_helpers_fail_before_any_network_or_credential_file(self): + for name in ('master_deploy.sh', 'awsconfiguration.sh', 'psvar-processor.sh', 'buildenv.sh', 'buildproperties.sh', 'utilities/tooltoencdec.sh', 'uploadjson-ps.sh', 'uploadjsonso-ps.sh', 'setup_ms/template/setupscr_v7.3.sh'): + with self.subTest(name=name), tempfile.TemporaryDirectory() as temp: + result = subprocess.run(['bash', str(ROOT / name), 'DEV'], cwd=temp, + env={'PATH': '/usr/bin:/bin'}, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(Path(temp).iterdir()), []) + + def test_secret_context_names(self): + for name in ('awsenvconf', 'buildvar_env', '.env', '.env.production', 'sub/.aws/config', '.npmrc', 'fetched_parameters.json'): + self.assertTrue(b.forbidden(name), name) + for name in ('Dockerfile', 'src/app.ts', '.env.example', 'deploy/certs/ca.pem'): + self.assertFalse(b.forbidden(name), name) + + def test_legacy_master_flags_cannot_bypass_new_contract(self): + result = subprocess.run(['bash', str(ROOT / 'master_deploy.sh'), '-d', 'ECS', '-j', '/config/test'], + capture_output=True, text=True, env={'PATH': '/usr/bin:/bin'}) + self.assertEqual(result.returncode, 2) + + def test_cli_missing_identity_fails_without_traceback_or_secrets(self): + result = subprocess.run(['python3', '-I', str(ROOT / 'tcdeploy.py'), 'check-auth', + '--manifest', str(ROOT / 'examples/deploy.dev.json')], + env={'PATH': '/usr/bin:/bin', 'CI_AUTH0_CLIENTSECRET': 'dummy-super-secret'}, + capture_output=True, text=True) + self.assertEqual(result.returncode, 1) + self.assertNotIn('Traceback', result.stderr) + self.assertNotIn('dummy-super-secret', result.stdout + result.stderr) + + +class BuildTests(unittest.TestCase): + def build(self, tracked, untracked=None): + captured = {} + original_run = b.run + original_cwd = Path.cwd() + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, content in {'Dockerfile': 'FROM scratch\n', **tracked}.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + subprocess.run(['git', 'init', '-q', directory], check=True, capture_output=True) + subprocess.run(['git', '-C', directory, 'add', '.'], check=True, capture_output=True) + subprocess.run(['git', '-C', directory, '-c', 'user.email=test@example.invalid', '-c', + 'user.name=Test', 'commit', '-qm', 'fixture'], check=True, capture_output=True) + for name, content in (untracked or {}).items(): + (root / name).write_text(content) + + def run(command, env): + if command[0] != 'docker': + return original_run(command, env) + context = Path(command[-1]) + captured['files'] = sorted(str(p.relative_to(context)) for p in context.rglob('*') if p.is_file()) + captured['env'] = env + captured['command'] = command + return subprocess.CompletedProcess(command, 0, b'', b'') + try: + os.chdir(root) + with patch.dict(os.environ, {'CI_AUTH0_CLIENTSECRET': 'dummy', 'AWS_SECRET_ACCESS_KEY': 'dummy', + 'CIRCLE_OIDC_TOKEN_V3': 'dummy'}, clear=True), patch.object(b, 'run', side_effect=run): + b.main(['--image', 'fixture:candidate']) + finally: + os.chdir(original_cwd) + return captured + + def test_untracked_secret_files_never_enter_context(self): + result = self.build({'src/app.txt': 'safe'}, {'.env': 'PASSWORD=dummy', 'buildvar_env': 'dummy'}) + self.assertEqual(result['files'], ['Dockerfile', 'src/app.txt']) + for key in ('CI_AUTH0_CLIENTSECRET', 'AWS_SECRET_ACCESS_KEY', 'CIRCLE_OIDC_TOKEN_V3'): + self.assertNotIn(key, result['env']) + self.assertIn('--provenance=false', result['command']) + + def test_tracked_secret_file_rejected(self): + with self.assertRaises(ValueError): + self.build({'buildvar_env': 'dummy'}) + + def test_public_ca_bundle_allowed(self): + self.build({'deploy/certs/ca.pem': '-----BEGIN CERTIFICATE-----\nYWJj\n-----END CERTIFICATE-----\n'}) + + def test_private_key_pem_rejected(self): + with self.assertRaises(ValueError): + self.build({'private.pem': '-----BEGIN PRIVATE KEY-----\nYWJj\n-----END PRIVATE KEY-----\n'}) + + def test_no_build_arguments_or_secret_options(self): + with self.assertRaises(SystemExit): + b.main(['--image', 'fixture:candidate', '--build-arg', 'RESET_DB=true']) + + +if __name__ == '__main__': + unittest.main() diff --git a/uploadjson-ps.sh b/uploadjson-ps.sh index daa1212..5cc899e 100755 --- a/uploadjson-ps.sh +++ b/uploadjson-ps.sh @@ -1,18 +1,5 @@ -#!/bin/bash -set -eo pipefail -UPLOAD_FILENAME=$1 -PARAMETER_PATH=$2 - -cat $UPLOAD_FILENAME | jq -r ' . ' | jq --arg PARAMETER_PATH $PARAMETER_PATH ' . | to_entries[] | { "Name": ($PARAMETER_PATH+"/"+.key) , "Value": .value, "Type" : "SecureString" } ' | jq -s . >upload_object.json -o=$IFS -IFS=$(echo -en "\n\b") - -for s in $(cat upload_object.json | jq -c .[] ) -do - echo $s>cli-input.json - aws ssm put-parameter --cli-input-json file://cli-input.json -done -IFS=$o - -[ -f upload_object.json ] && rm -f upload_object.json -[ -f cli-input.json ] && rm -f cli-input.json +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'Secret provisioning is an administrator/IaC responsibility, separate from deployment. This legacy helper is disabled in v2; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/uploadjsonso-ps.sh b/uploadjsonso-ps.sh index 3c7bdb0..5cc899e 100755 --- a/uploadjsonso-ps.sh +++ b/uploadjsonso-ps.sh @@ -1,9 +1,5 @@ -#!/bin/bash -set -eo pipefail -UPLOAD_FILENAME=$1 -PARAMETER_PATH=$2 - -aws ssm put-parameter \ - --name $PARAMETER_PATH \ - --type SecureString \ - --value file://$UPLOAD_FILENAME \ No newline at end of file +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'Secret provisioning is an administrator/IaC responsibility, separate from deployment. This legacy helper is disabled in v2; see docs/MIGRATION.md.' >&2 +exit 2 diff --git a/utilities/tooltoencdec.sh b/utilities/tooltoencdec.sh index 2827618..25d6f8d 100755 --- a/utilities/tooltoencdec.sh +++ b/utilities/tooltoencdec.sh @@ -1,102 +1,5 @@ -#!/bin/bash - -DECRYPT="" -ENCRYPT="" -INTERACTIVE=0 -FILENAME="" -SECPASSWD="" -USEROPTION="" - -while getopts .d:e:s:. OPTION -do - case $OPTION in - d) - DECRYPT=1 - FILENAME=$OPTARG - ;; - e) - ENCRYPT=1 - FILENAME=$OPTARG - ;; - s) - SECPASSWD=$OPTARG - ;; - esac -done - -openssl version -if [ $? != "0" ]; then - echo "Kindly install the openssl for executing the script" - exit 1 -fi - -if [ -z $DECRYPT ] && [ -z $ENCRYPT ] ; -then - INTERACTIVE=1 -fi - -if [ "$INTERACTIVE" != "0" ]; then - read -p "Are you going to decrypt/encrypt (d/e) : " USEROPTION - - case "$USEROPTION" in - d) - DECRYPT=1 - ;; - e) - ENCRYPT=1 - ;; - *) - echo "Please provide proper option" - exit 1 - ;; - esac - - read -p "Please enter the file name : " FILENAME - read -p "Please enter the Secret Password : " SECPASSWD -else - if [ ! -z $DECRYPT ] && [ ! -z $ENCRYPT ] ; - then - echo "Please provide encrypt or decrypt" - exit 1 - fi - - if [ -z $SECPASSWD ] ; - then - echo "Please provide Secret password with option -s" - exit 1 - fi -fi - -if [ -f $FILENAME ]; -then - echo "Input file exist" -else - echo "Input file does not exist. Please provide valid file name" - exit 1 -fi - -if [ "$DECRYPT" = "1" ]; then - echo "Decypt option has enabled" - outputname=$(basename "$FILENAME" .enc) - echo "$outputname" - openssl enc -aes-256-cbc -d -md MD5 -in $FILENAME -out $outputname -k "$SECPASSWD" - if [ $? != "0" ]; then - echo "File decryption failed with error code $?" - exit 1 - else - echo "File Decryption completed" - fi - - -fi - -if [ "$ENCRYPT" = "1" ]; then - echo "Encypt option has enabled" - openssl enc -aes-256-cbc -salt -md MD5 -in $FILENAME -out $FILENAME.enc -k "$SECPASSWD" - if [ $? != "0" ]; then - echo "File Encryption failed with error code $?" - exit 1 - else - echo "File Encryption completed" - fi -fi +#!/usr/bin/env bash +set +x +set -euo pipefail +echo 'This legacy secret-file helper is disabled in v2. Deployment must not fetch or source secret configuration. Use a reviewed non-secret manifest and ECS runtime secret references; see docs/MIGRATION.md.' >&2 +exit 2 From ca36baac22912b699bd7dd4d4bf1341fd4b65254 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 21:51:26 +1100 Subject: [PATCH 2/7] Support exact legacy dev refs and verify the pinned AWS executor --- README.md | 4 ++-- docs/MIGRATION.md | 8 ++++++-- tcdeploy.py | 3 ++- tests/test_security.py | 13 +++++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ee12c54..70a60c7 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ Read [the migration guide](docs/MIGRATION.md), [the Auth0/AWS explanation](docs/ ## Requirements -- A reviewed, digest-pinned Linux executor containing Python >=3.12, AWS CLI v2, Docker/Buildx, Git and system CA certificates. No package installation in the credential-bearing job. This deployer has no external Python dependencies. -- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected `develop`/`master` refs, and restricted deployment contexts containing no broker/static AWS credentials. +- A reviewed, digest-pinned Linux executor containing Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and system CA certificates. No package installation in the credential-bearing job. This deployer has no external Python dependencies. +- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected `develop` (or `dev`)/`master` refs, and restricted deployment contexts containing no broker/static AWS credentials. - Separate per-service/per-environment deploy, task and execution roles, and a reviewed non-secret manifest. - Existing immutable ECR repositories with scan-on-push, ECS circuit-breaker rollback, positive desired capacity, explicit task health checks, and pre-provisioned runtime secret references. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 7038a2b..36022dc 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -8,8 +8,8 @@ Merging this PR does not change pinned v1 consumers or installed IAM policies. * 2. Create a manifest with exact identifiers from that inspection. Generate IAM documents using `tcdeploy.py policies`; install them through infrastructure review. Keep the explicit secret-access/role-chaining deny in the role policy and preferably use the generated permissions as its permissions boundary too. Remove unrelated policies and audit resource-based grants. The deployer needs no infrastructure-creation permissions. 3. Restrict PassRole to the exact task/execution roles. Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback. 4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment. -5. Commit non-secret `deploy/dev.json`. Use `develop` for dev and `master` for prod source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. -6. Prepare a reviewed executor with Python >=3.12, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters. +5. Commit non-secret `deploy/dev.json`. Use an exact `develop` or `dev` branch for dev and `master` for prod source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. +6. Prepare a reviewed executor with Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters. 7. Split build/test and deploy using `examples/circleci-v2.yml`. Remove `org-global`, old helper copies, all `source awsenvconf` / `source *_env`, SSM build/deploy/QA retrieval, and unpinned host installs. Use restricted dev/prod contexts with no broker/static AWS secrets, production approval and serialization. Verify OIDC v3 availability; restrict fork secret passing and SSH debugging. Pin/download the entire suite. 8. Build committed source separately with `build_image.py`. Transfer only image archives through job-specific workflow workspaces. Do not cache the checkout/credential directories. The helper rejects known secret filenames and excludes untracked generated credentials; it does not replace a full secret scanner. Never build after deployment authentication. @@ -56,3 +56,7 @@ These are adoption gates, not claims that existing services or live IAM policies | F11 credential files/logging | In-memory STS, stdin requests, private Docker config, sanitized errors | Ephemeral runners, controlled logs/debug | Legacy SSM upload/setup and password-encryption helpers also fail closed. Secret provisioning belongs in a separate administrator/IaC workflow. Historical v1 tags are migration references, not security workarounds. + +## Verified executor for service adoption + +`cimg/aws@sha256:4cf3768db00ddf9bc439b8c84e8a60c11a14edf81c8baf76f74132144f8340d5` was resolved from `cimg/aws:2026.10` and inspected on 2026-11-03. It supplies Python 3.10.12 with `tarfile.extractall(filter=...)`, AWS CLI 2.38.4, Docker Buildx 0.38.0 and Git 2.55.0. The offline suite passes inside this exact image. This verifies tool compatibility, not the absence of executor vulnerabilities. Service configurations must use the digest, not the tag. diff --git a/tcdeploy.py b/tcdeploy.py index 1c0e85e..d05d9d5 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -58,7 +58,8 @@ def read_manifest(path): for key in ('circleciOrgId', 'circleciProjectId'): require(re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', m[key]), 'CircleCI IDs must be UUIDs') require(re.fullmatch(r'github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', m['repository']), 'Expected exact github.com/owner/repository') - require(m['branch'] == ('master' if m['environment'] == 'prod' else 'develop'), 'Deploy only develop to dev and master to prod') + allowed_branches = ('master',) if m['environment'] == 'prod' else ('develop', 'dev') + require(m['branch'] in allowed_branches, 'Deploy only the reviewed dev/develop branch to dev and master to prod') require(isinstance(m['images'], dict) and 1 <= len(m['images']) <= 10, 'Configure one to ten container/repository pairs') for container, repository in m['images'].items(): require(re.fullmatch(r'[A-Za-z0-9_-]{1,255}', container), 'Invalid container name') diff --git a/tests/test_security.py b/tests/test_security.py index 036044b..833d0ce 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -124,6 +124,19 @@ def test_wildcard_role(self): def test_production_branch_guard(self): self.check_invalid('environment', 'prod') + def test_legacy_dev_branch_has_exact_trust(self): + m = manifest() + m['branch'] = 'dev' + with tempfile.TemporaryDirectory() as temp: + p = Path(temp) / 'manifest.json' + p.write_text(json.dumps(m)) + self.assertEqual(d.read_manifest(p)['branch'], 'dev') + subject = next(iter(d.trust_policy(m)['Statement'][0]['Condition']['StringLike'].values())) + self.assertIn(':checkout-ref:refs/heads/dev:', subject) + self.assertTrue(subject.endswith(':config-ref:refs/heads/dev')) + self.check_invalid('branch', 'feature/deploy') + self.check_invalid('branch', 'master') + def test_unknown_fields(self): self.check_invalid('parameterPath', '/config/app') From 2a76ef3153bf966f29b66a007397e21affc5e169 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 21:52:07 +1100 Subject: [PATCH 3/7] Preserve services without an application task role --- docs/MIGRATION.md | 2 +- tcdeploy.py | 7 +++++-- tests/test_security.py | 10 ++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 36022dc..03106a8 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,7 +6,7 @@ Merging this PR does not change pinned v1 consumers or installed IAM policies. * 1. Start with a straightforward one-container service, for example lookups. Have an administrator inspect its existing ECS service/task, ECR settings and IAM roles without exporting decrypted SSM values. Record the previous revision/digest in a restricted release record. 2. Create a manifest with exact identifiers from that inspection. Generate IAM documents using `tcdeploy.py policies`; install them through infrastructure review. Keep the explicit secret-access/role-chaining deny in the role policy and preferably use the generated permissions as its permissions boundary too. Remove unrelated policies and audit resource-based grants. The deployer needs no infrastructure-creation permissions. -3. Restrict PassRole to the exact task/execution roles. Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback. +3. Restrict PassRole to the exact task/execution roles (use JSON `null` for `taskRoleArn` when the existing task has no application role; do not add one just to deploy). Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback. 4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment. 5. Commit non-secret `deploy/dev.json`. Use an exact `develop` or `dev` branch for dev and `master` for prod source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. 6. Prepare a reviewed executor with Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters. diff --git a/tcdeploy.py b/tcdeploy.py index d05d9d5..882757c 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -46,11 +46,14 @@ def read_manifest(path): 'circleciOrgId', 'circleciProjectId', 'repository', 'branch', 'images'} require(isinstance(m, dict) and set(m) == required, 'Manifest fields must match the v2 schema') require(m['schemaVersion'] == 2 and m['environment'] in ('dev', 'prod'), 'Unsupported manifest version/environment') - for key in required - {'schemaVersion', 'images'}: + for key in required - {'schemaVersion', 'images', 'taskRoleArn'}: require(isinstance(m[key], str) and m[key], 'Manifest requires nonempty string fields') require(re.fullmatch(r'[0-9]{12}', m['accountId']), 'Invalid account ID') require(re.fullmatch(r'(us|eu|ap|ca|sa|me|af|il|mx)-[a-z]+-[0-9]', m['region']), 'Unsupported AWS commercial region') for key in ('deployRoleArn', 'taskRoleArn', 'executionRoleArn'): + if key == 'taskRoleArn' and m[key] is None: + continue + require(isinstance(m[key], str), 'Role must be an ARN or an absent task role') require(re.fullmatch(r'arn:aws:iam::' + m['accountId'] + r':role/[A-Za-z0-9_+=,.@/-]+', m[key]), 'Role must belong to the configured account') require(len({m[k] for k in ('deployRoleArn', 'taskRoleArn', 'executionRoleArn')}) == 3, 'Deployment, task and execution roles must be distinct') for key in ('cluster', 'service', 'taskFamily'): @@ -91,7 +94,7 @@ def role_policy(m): 'Resource': arn(m, 'ecs', 'task-definition/' + m['taskFamily'] + ':*')}, {'Effect': 'Allow', 'Action': ['ecs:ListTasks', 'ecs:DescribeTasks'], 'Resource': '*', 'Condition': {'ArnEquals': {'ecs:cluster': arn(m, 'ecs', 'cluster/' + m['cluster'])}}}, - {'Effect': 'Allow', 'Action': 'iam:PassRole', 'Resource': [m['taskRoleArn'], m['executionRoleArn']], + {'Effect': 'Allow', 'Action': 'iam:PassRole', 'Resource': [r for r in (m['taskRoleArn'], m['executionRoleArn']) if r], 'Condition': {'StringEquals': {'iam:PassedToService': 'ecs-tasks.amazonaws.com'}}} ]} diff --git a/tests/test_security.py b/tests/test_security.py index 833d0ce..6a60222 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -115,6 +115,16 @@ def test_expected_example(self): def test_wrong_account_role(self): self.check_invalid('executionRoleArn', 'arn:aws:iam::222222222222:role/other') + def test_absent_task_role_does_not_grant_runtime_permissions(self): + m = manifest() + m['taskRoleArn'] = None + with tempfile.TemporaryDirectory() as temp: + p = Path(temp) / 'manifest.json' + p.write_text(json.dumps(m)) + self.assertIsNone(d.read_manifest(p)['taskRoleArn']) + self.assertEqual(d.role_policy(m)['Statement'][-1]['Resource'], [m['executionRoleArn']]) + self.check_invalid('taskRoleArn', '') + def test_shell_injection_is_invalid_data(self): self.check_invalid('service', 'x; touch /tmp/marker') From 3b50e99855c710212dcd7016b563957a4eb89e92 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 22:22:34 +1100 Subject: [PATCH 4/7] Retain reviewed custom migration and health gates without secret access --- docs/MIGRATION.md | 4 ++++ tcdeploy.py | 31 ++++++++++++++++++++++++++++--- tests/test_security.py | 13 +++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 03106a8..0a32d49 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -60,3 +60,7 @@ Legacy SSM upload/setup and password-encryption helpers also fail closed. Secret ## Verified executor for service adoption `cimg/aws@sha256:4cf3768db00ddf9bc439b8c84e8a60c11a14edf81c8baf76f74132144f8340d5` was resolved from `cimg/aws:2026.10` and inspected on 2026-11-03. It supplies Python 3.10.12 with `tarfile.extractall(filter=...)`, AWS CLI 2.38.4, Docker Buildx 0.38.0 and Git 2.55.0. The offline suite passes inside this exact image. This verifies tool compatibility, not the absence of executor vulnerabilities. Service configurations must use the digest, not the tag. + +## Reviewed adapters for custom release gates + +The default CLI and generated role policy still deploy an existing ECS service only. A checked-in service adapter may call `authenticate(..., additional_actions=...)` for the fixed allowlist in `ADAPTER_ACTIONS`: one-shot migration tasks, target health/Inspector reads, or updates to an existing CloudFormation stack. This is a Python API for reviewed code, not a pipeline parameter or CLI permission switch. The immutable session deny for SSM, Secrets Manager, KMS decryption and role chaining is retained. Each consumer must supply an administrator-installed role and permissions boundary with exact resource scopes for its additional actions. CloudFormation adapters must use the previous installed template and a separately scoped CloudFormation service role that also has no parameter/secret permissions; template installation belongs to infrastructure administration. No create-stack, arbitrary command, or secret-read capability is added to the default CLI. diff --git a/tcdeploy.py b/tcdeploy.py index 882757c..a5bb8c6 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -123,6 +123,25 @@ def trust_policy(m): 'ecs:DescribeServices', 'ecs:DescribeTaskDefinition', 'ecs:RegisterTaskDefinition', 'ecs:TagResource', 'ecs:ListTagsForResource', 'ecs:UpdateService', 'ecs:ListTasks', 'ecs:DescribeTasks', 'iam:PassRole'], 'Resource': '*'}]} +# Reviewed service adapters may retain migration/health/CloudFormation ownership +# gates. These capabilities are not exposed as deployment CLI flags. Their IAM +# role and boundary must separately scope every extra action to that service. +ADAPTER_ACTIONS = frozenset({ + 'ecs:RunTask', 'ecs:DeregisterTaskDefinition', + 'elasticloadbalancing:DescribeTargetHealth', + 'inspector2:ListCoverage', 'inspector2:ListFindings', + 'cloudformation:DescribeStacks', 'cloudformation:UpdateStack', + 'cloudformation:DescribeStackEvents', 'cloudformation:DescribeStackResources', +}) + + +def session_policy(additional_actions=()): + require(set(additional_actions) <= ADAPTER_ACTIONS, 'Unsupported custom deployment capability') + result = deepcopy(SESSION_POLICY) + result['Statement'][1]['Action'] += sorted(set(additional_actions)) + require(len(compact(result)) <= 2048, 'Session policy exceeds the STS limit') + return result + def clean_environment(region): # Never inherit profiles, endpoint overrides, SDK credential providers, proxies, @@ -161,7 +180,13 @@ def __init__(self, region, env=None): self.region = region def call(self, service, operation, **parameters): - require(service in ('sts', 'ecs', 'ecr'), 'This deployer cannot call secret or infrastructure APIs') + adapter_reads = { + 'cloudformation': {'describe-stacks', 'update-stack', 'describe-stack-events', 'describe-stack-resources'}, + 'elbv2': {'describe-target-health'}, + 'inspector2': {'list-coverage', 'list-findings'}, + } + require(service in ('sts', 'ecs', 'ecr') or operation in adapter_reads.get(service, set()), + 'This deployer cannot call secret or unreviewed infrastructure APIs') # JSON on stdin avoids credentials and task configuration in argv or files. command = ['aws', service, operation, '--region', self.region, '--output', 'json', '--no-cli-pager', '--cli-input-json', 'file:///dev/stdin'] @@ -169,12 +194,12 @@ def call(self, service, operation, **parameters): return json.loads(output) if output.strip() else {} -def authenticate(m, token): +def authenticate(m, token, additional_actions=()): require(isinstance(token, str) and len(token) > 100, 'CIRCLE_OIDC_TOKEN_V3 is required; no Auth0 or ambient-credential fallback') aws = AWS(m['region']) response = aws.call('sts', 'assume-role-with-web-identity', RoleArn=m['deployRoleArn'], RoleSessionName='tc-deploy-' + str(int(time.time())), WebIdentityToken=token, - DurationSeconds=3600, Policy=compact(SESSION_POLICY)) + DurationSeconds=3600, Policy=compact(session_policy(additional_actions))) credentials = response.get('Credentials', {}) for key in ('AccessKeyId', 'SecretAccessKey', 'SessionToken', 'Expiration'): require(isinstance(credentials.get(key), str) and credentials[key], 'STS returned incomplete temporary credentials') diff --git a/tests/test_security.py b/tests/test_security.py index 6a60222..eeac372 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -171,6 +171,19 @@ def test_policies_bind_identity_and_forbid_parameters(self): class CredentialTests(unittest.TestCase): + def test_adapter_capabilities_cannot_remove_secret_deny(self): + policy = d.session_policy(d.ADAPTER_ACTIONS) + self.assertEqual(policy['Statement'][0], d.SESSION_POLICY['Statement'][0]) + self.assertLessEqual(len(d.compact(policy)), 2048) + self.assertIn('ecs:RunTask', policy['Statement'][1]['Action']) + self.assertNotIn('ecs:RunTask', d.SESSION_POLICY['Statement'][1]['Action']) + + def test_adapter_cannot_request_secret_access_or_role_chaining(self): + for action in ('ssm:GetParameter', 'secretsmanager:GetSecretValue', 'sts:AssumeRole', + 'cloudformation:CreateStack', '*'): + with self.subTest(action=action), self.assertRaises(d.DeployError): + d.session_policy([action]) + def response(self): m = manifest() return [{'Credentials': {'AccessKeyId': 'ASIA' + 'A' * 16, 'SecretAccessKey': 'dummy-secret', From b900d58e38787e3397743412597c6563fe6e08dd Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 22:29:30 +1100 Subject: [PATCH 5/7] Fix ECS service argument forwarding in the AWS CLI adapter --- tcdeploy.py | 6 +++--- tests/test_security.py | 7 +++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tcdeploy.py b/tcdeploy.py index a5bb8c6..d2a4f60 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -179,16 +179,16 @@ def __init__(self, region, env=None): self.env = dict(env or clean_environment(region)) self.region = region - def call(self, service, operation, **parameters): + def call(self, api_service, operation, **parameters): adapter_reads = { 'cloudformation': {'describe-stacks', 'update-stack', 'describe-stack-events', 'describe-stack-resources'}, 'elbv2': {'describe-target-health'}, 'inspector2': {'list-coverage', 'list-findings'}, } - require(service in ('sts', 'ecs', 'ecr') or operation in adapter_reads.get(service, set()), + require(api_service in ('sts', 'ecs', 'ecr') or operation in adapter_reads.get(api_service, set()), 'This deployer cannot call secret or unreviewed infrastructure APIs') # JSON on stdin avoids credentials and task configuration in argv or files. - command = ['aws', service, operation, '--region', self.region, + command = ['aws', api_service, operation, '--region', self.region, '--output', 'json', '--no-cli-pager', '--cli-input-json', 'file:///dev/stdin'] output = execute(command, self.env, compact(parameters), timeout=120) return json.loads(output) if output.strip() else {} diff --git a/tests/test_security.py b/tests/test_security.py index eeac372..4a267b6 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -171,6 +171,13 @@ def test_policies_bind_identity_and_forbid_parameters(self): class CredentialTests(unittest.TestCase): + def test_ecs_service_parameter_reaches_the_cli_without_name_collision(self): + with patch.object(d, 'execute', return_value='{}') as execute: + d.AWS('us-east-1').call('ecs', 'update-service', cluster='cluster', service='application') + command, environment, payload = execute.call_args.args + self.assertEqual(command[:3], ['aws', 'ecs', 'update-service']) + self.assertEqual(json.loads(payload), {'cluster': 'cluster', 'service': 'application'}) + def test_adapter_capabilities_cannot_remove_secret_deny(self): policy = d.session_policy(d.ADAPTER_ACTIONS) self.assertEqual(policy['Statement'][0], d.SESSION_POLICY['Statement'][0]) From bbc9b3d0c702947d26b6d9686d7b29104ebe461a Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 22:50:23 +1100 Subject: [PATCH 6/7] Support exact legacy production branch with repository-bound OIDC --- README.md | 2 +- tcdeploy.py | 4 ++-- tests/test_security.py | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 70a60c7..182be8c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Read [the migration guide](docs/MIGRATION.md), [the Auth0/AWS explanation](docs/ ## Requirements - A reviewed, digest-pinned Linux executor containing Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and system CA certificates. No package installation in the credential-bearing job. This deployer has no external Python dependencies. -- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected `develop` (or `dev`)/`master` refs, and restricted deployment contexts containing no broker/static AWS credentials. +- CircleCI Cloud with `CIRCLE_OIDC_TOKEN_V3`, protected exact development (`develop` or `dev`) and production (`master` or legacy `prod`) refs, and restricted deployment contexts containing no broker/static AWS credentials. - Separate per-service/per-environment deploy, task and execution roles, and a reviewed non-secret manifest. - Existing immutable ECR repositories with scan-on-push, ECS circuit-breaker rollback, positive desired capacity, explicit task health checks, and pre-provisioned runtime secret references. diff --git a/tcdeploy.py b/tcdeploy.py index d2a4f60..f61ba96 100644 --- a/tcdeploy.py +++ b/tcdeploy.py @@ -61,8 +61,8 @@ def read_manifest(path): for key in ('circleciOrgId', 'circleciProjectId'): require(re.fullmatch(r'[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}', m[key]), 'CircleCI IDs must be UUIDs') require(re.fullmatch(r'github\.com/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+', m['repository']), 'Expected exact github.com/owner/repository') - allowed_branches = ('master',) if m['environment'] == 'prod' else ('develop', 'dev') - require(m['branch'] in allowed_branches, 'Deploy only the reviewed dev/develop branch to dev and master to prod') + allowed_branches = ('master', 'prod') if m['environment'] == 'prod' else ('develop', 'dev') + require(m['branch'] in allowed_branches, 'Deploy only the reviewed dev/develop branch to dev and the reviewed master/prod branch to prod') require(isinstance(m['images'], dict) and 1 <= len(m['images']) <= 10, 'Configure one to ten container/repository pairs') for container, repository in m['images'].items(): require(re.fullmatch(r'[A-Za-z0-9_-]{1,255}', container), 'Invalid container name') diff --git a/tests/test_security.py b/tests/test_security.py index 4a267b6..4a6a5e0 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -147,6 +147,22 @@ def test_legacy_dev_branch_has_exact_trust(self): self.check_invalid('branch', 'feature/deploy') self.check_invalid('branch', 'master') + def test_legacy_prod_branch_has_exact_trust(self): + m = manifest() + m.update(environment='prod', branch='prod') + with tempfile.TemporaryDirectory() as temp: + p = Path(temp) / 'manifest.json' + p.write_text(json.dumps(m)) + self.assertEqual(d.read_manifest(p)['branch'], 'prod') + subject = next(iter(d.trust_policy(m)['Statement'][0]['Condition']['StringLike'].values())) + self.assertIn(':checkout-ref:refs/heads/prod:', subject) + self.assertTrue(subject.endswith(':config-ref:refs/heads/prod')) + m['branch'] = 'develop' + with tempfile.TemporaryDirectory() as temp: + p = Path(temp) / 'manifest.json' + p.write_text(json.dumps(m)) + with self.assertRaises(d.DeployError): d.read_manifest(p) + def test_unknown_fields(self): self.check_invalid('parameterPath', '/config/app') From 379001a92a8df414c3394c2b65fcdab787a94590 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 3 Nov 2026 23:02:05 +1100 Subject: [PATCH 7/7] Document verified IAM broker grants and all consumer migration PRs --- docs/AUTHENTICATION.md | 6 +- docs/ECS_MIGRATION_PRS.md | 177 ++++++++++++++++++++++++++++++++++++++ docs/MIGRATION.md | 10 +-- 3 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 docs/ECS_MIGRATION_PRS.md diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index 6d14646..3ed3be0 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -11,7 +11,11 @@ The **client ID identifies the client; it does not grant AWS permissions**. The Effective AWS access comes from the IAM identity behind the returned keys, its identity/session policies, boundaries, resource policies and organization controls. A stolen reusable broker secret may obtain fresh AWS sessions after earlier sessions expire. Rotating the client ID alone does not address this. -The server-side broker/Action implementation was not in this repository or found in accessible source search. This PR therefore does not assert which role it assumes, how long credentials last, or whether it independently enforces project/environment/branch authorization. Caller-supplied metadata is not proof of authorization. The old helper also disabled TLS verification and did not cryptographically verify the returned JWT. +A read-only production IAM inspection on 2026-11-03 verified the legacy `oidc-circleci-deploy` role and its trust in the Auth0 OIDC provider `topcoder-newauth.auth0.com/`. The trust audience is an Auth0 client ID. Its attached policies include broad S3/CloudWatch/Step Functions access. The attached `code-deploy` policy grants `ssm:Describe*`, `ssm:Get*`, `ssm:List*`, `ssm:CreateDocument` and `ssm:UpdateDocument`; inline Payload/website grants add parameter reads/writes and KMS decryption. That IAM configuration explains why an accepted broker identity can have AWS parameter permissions; the client ID itself is not a permission policy. + +The configured HTTP broker endpoint is still **unconfirmed**. The script uses the exact value of `CI_AUTH0_URL`; the IAM issuer does not prove that value is `https://topcoder-newauth.auth0.com/oauth/token` or any other particular URL. The saved AWS environment files and accessible source do not contain it. Retrieve the URL alone from CircleCI/Jenkins credential settings; do not share the client secret or token. + +The server-side broker/Action implementation was not in this repository or found in accessible source search. The IAM relationship above is verified, but the implementation that returns keys, actual role/session selection for each request and independent project/environment/branch enforcement are not verified. Caller-supplied metadata is not proof of authorization. The old helper also disabled TLS verification and did not cryptographically verify the returned JWT. The incident owner must locate the configured endpoint and associated Auth0 application/API, inspect broker/Actions, client grants, issuance logs, AWS principal and CloudTrail. Use restricted administrative tooling; never put JWTs/client secrets/AWS keys in a PR. Rotate exposed broker secrets/keys, revoke affected sessions, and retire the old broker's deployment grants after migration. This PR cannot invalidate credentials or change another service's policies. diff --git a/docs/ECS_MIGRATION_PRS.md b/docs/ECS_MIGRATION_PRS.md new file mode 100644 index 0000000..028c1bf --- /dev/null +++ b/docs/ECS_MIGRATION_PRS.md @@ -0,0 +1,177 @@ +# Production ECS deployment PR index + +Prepared 2026-11-03. **46 open draft consumer PRs cover 48 inventoried ECS services:** 40 active service repositories, the inactive tc-mcp repository, and five legacy repositories covering seven additional inactive services. The shared implementation is [tc-deploy-scripts #40](https://github.com/topcoder-platform/tc-deploy-scripts/pull/40), targeting master. + +All 41 OIDC consumer PRs pin reviewed code commit `bbc9b3d0c702947d26b6d9686d7b29104ebe461a` and the same verified executor digest. The shared PR may have later documentation-only commits. Remote PR heads, draft state, clean local worktrees, suite pins and role/boundary consistency were checked after publication. The five inactive-containment PRs intentionally do not install or assume a replacement AWS role. + +## Scope and limitations + +Read-only inventory found **58 ECS services in production us-east-1**, of which 45 had positive desired capacity. Sixteen other enabled regions returned `AccessDeniedException`; they have **not** been verified empty. Ten inventoried services have no shared-script release path mapped from the accessible configuration; they are listed separately below. This is not a claim to have migrated unknown jobs or inaccessible regions. + +No AWS resource, IAM policy, CircleCI context or credential was changed. No image was pushed to ECR and no real dev/prod release was attempted. No PR was merged. Dev AWS credentials were expired, so exact dev resource manifests were deliberately not invented. These are reviewable migration PRs, **not ready-to-merge deployment configurations**. + +## Before merging consumer PRs + +1. Verify actual dev ECS/ECR/IAM identifiers and commit each dev manifest/policy. Populate each private project's exact CircleCI UUID where noted; no wildcard identity is accepted. +2. Install the CircleCI OIDC provider and service-specific role, matching permissions boundary and exact project/repository/source/config-ref trust. Role, boundary and requested session deny SSM, Secrets Manager, KMS decryption and role chaining. +3. Remove legacy broker/static AWS credentials from project settings and contexts; install restricted empty deployment contexts. Keep image builds separate from authenticated release jobs. +4. Apply immutable ECR tags/scanning, real container health checks, runtime secret references and circuit-breaker rollback through IaC. Remove RESET_DB bindings/plaintext secrets and limit execution-role reads to each service's runtime references. +5. Run a real dev release, wrong-identity denial checks and unhealthy-image rollback. Verify deployment-principal CloudTrail has no Parameter Store/Secrets Manager access. Retire shared broker permissions only after remaining consumers migrate. + +Missing CircleCI UUIDs: `infra-inventory`, `terms-receiver`, `standardized-skills-processor`, `opportunities-api-v6`, `status-api-v6`, `payload-cms`. All intentionally fail before authentication until their verified UUID is committed. + +Special review dependencies: + +- **Community App:** based on develop to include merged security PR #7296. Promote both security/runtime-binding and deployment changes to master together. Required server-side buildvar values must first become runtime references; retain the appvar precedence rule. Optional performance testing remains; the old undefined/recursive smoke workflow is removed and requires an explicit replacement if needed. +- **freeCodeCamp:** stacked on security PR #112, then promote through dev to the existing prod branch. API configuration moves from a baked S3 .env artifact to ECS runtime bindings. The old Jenkins frontend release needs a separate identity restricted to its S3/CloudFront resources before merging; this PR migrates the ECS API only. +- **AV Scanner:** administrator installs the digest-capable existing-stack template and detaches eight retained SSM deployment pointers first. CI uses the previous template and two digest parameters through a separate restricted CloudFormation role; both roles deny secret access. Never activate the old and replacement scanners together. +- **Forms:** API retains the one-shot migration and reviewed first-activation gate; processor deployment follows the API schema migration. Processor activation remains an administrator operation. +- **Payload / Status:** preserve their rollout, scan, migration/monitoring, ALB/public-health and rollback adapters. Payload's scan wait is bounded to 15 minutes within its one-hour OIDC session; delayed scans require retry and long rollout/rollback timing needs dev rehearsal. +- **nginx:** production public CloudFront/DNS values were verified without SSM. Dev promotion is explicitly blocked until its routing settings and separate build target are verified. +- **Learning Paths:** ECS release is separated from the old MongoDB-trigger Lambda release, which requires its own infrastructure-owner release path. +- **Resource API:** retain Newman QA checks using a separate test-only context, with no AWS/broker credentials or SSM retrieval. +- **Opportunities:** no master branch exists; the draft targets develop and the production job remains disabled by its exact master filter pending a reviewed production-branch decision. +- **Inactive legacy services:** the five containment PRs disable the broker pipelines and local release entry points. Zero capacity is an observation, not a declaration of permanent retirement. Apply the disablement to all still-enabled branches/jobs; reactivation requires a separate reviewed migration. + +## Consumer PRs + +| Repository | PR | Review base | Change | +| --- | --- | --- | --- | +| `topcoder-platform/autopilot-v6` | [#75](https://github.com/topcoder-platform/autopilot-v6/pull/75) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/av-scanner-v6` | [#13](https://github.com/topcoder-platform/av-scanner-v6/pull/13) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/billing-accounts-api-v6` | [#35](https://github.com/topcoder-platform/billing-accounts-api-v6/pull/35) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/bus-api-v6` | [#12](https://github.com/topcoder-platform/bus-api-v6/pull/12) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/challenge-api-v6` | [#178](https://github.com/topcoder-platform/challenge-api-v6/pull/178) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/community-app` | [#7297](https://github.com/topcoder-platform/community-app/pull/7297) | `develop` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/docker-nginx-supply` | [#193](https://github.com/topcoder-platform/docker-nginx-supply/pull/193) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/email-service-v6` | [#10](https://github.com/topcoder-platform/email-service-v6/pull/10) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/engagements-api-v6` | [#98](https://github.com/topcoder-platform/engagements-api-v6/pull/98) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/forms-api-v6` | [#17](https://github.com/topcoder-platform/forms-api-v6/pull/17) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/forms-processor-v6` | [#12](https://github.com/topcoder-platform/forms-processor-v6/pull/12) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/forums-api-v6` | [#27](https://github.com/topcoder-platform/forums-api-v6/pull/27) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/freeCodeCamp` | [#113](https://github.com/topcoder-platform/freeCodeCamp/pull/113) | `fix/security-findings-2026-11` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/gamification-api` | [#32](https://github.com/topcoder-platform/gamification-api/pull/32) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/groups-api-v6` | [#19](https://github.com/topcoder-platform/groups-api-v6/pull/19) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/identity-api-v6` | [#58](https://github.com/topcoder-platform/identity-api-v6/pull/58) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/infra-inventory` | [#5](https://github.com/topcoder-platform/infra-inventory/pull/5) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/learning-paths-api` | [#270](https://github.com/topcoder-platform/learning-paths-api/pull/270) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/leave-api-v6` | [#16](https://github.com/topcoder-platform/leave-api-v6/pull/16) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/lookups-api-v6` | [#14](https://github.com/topcoder-platform/lookups-api-v6/pull/14) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/marathon-match-api-v6` | [#459](https://github.com/topcoder-platform/marathon-match-api-v6/pull/459) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/member-api-v6` | [#172](https://github.com/topcoder-platform/member-api-v6/pull/172) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/opportunities-api-v6` | [#8](https://github.com/topcoder-platform/opportunities-api-v6/pull/8) | `develop` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/payload-cms` | [#50](https://github.com/topcoder-platform/payload-cms/pull/50) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/procurement-api-v6` | [#8](https://github.com/topcoder-platform/procurement-api-v6/pull/8) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/project-billing-account-update-processor` | [#13](https://github.com/topcoder-platform/project-billing-account-update-processor/pull/13) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/projects-api-v6` | [#62](https://github.com/topcoder-platform/projects-api-v6/pull/62) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/reports-api-v6` | [#205](https://github.com/topcoder-platform/reports-api-v6/pull/205) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/resource-api-v6` | [#38](https://github.com/topcoder-platform/resource-api-v6/pull/38) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/review-api-v6` | [#382](https://github.com/topcoder-platform/review-api-v6/pull/382) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/standardized-skills-api` | [#141](https://github.com/topcoder-platform/standardized-skills-api/pull/141) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/standardized-skills-processor` | [#17](https://github.com/topcoder-platform/standardized-skills-processor/pull/17) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/status-api-v6` | [#10](https://github.com/topcoder-platform/status-api-v6/pull/10) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-archive/submission-scanner-processor` | [#81](https://github.com/topcoder-archive/submission-scanner-processor/pull/81) | `master` | Disable inactive deployment | +| `topcoder-platform/support-api-v6` | [#22](https://github.com/topcoder-platform/support-api-v6/pull/22) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/tc-ai-api` | [#93](https://github.com/topcoder-platform/tc-ai-api/pull/93) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-archive/tc-bus-api` | [#77](https://github.com/topcoder-archive/tc-bus-api/pull/77) | `master` | Disable inactive deployment | +| `topcoder-platform/tc-discord-bot` | [#29](https://github.com/topcoder-platform/tc-discord-bot/pull/29) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-archive/tc-email-service` | [#103](https://github.com/topcoder-archive/tc-email-service/pull/103) | `master` | Disable inactive deployment | +| `topcoder-platform/tc-finance-api` | [#216](https://github.com/topcoder-platform/tc-finance-api/pull/216) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/tc-mcp` | [#20](https://github.com/topcoder-platform/tc-mcp/pull/20) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-archive/tc-notifications` | [#259](https://github.com/topcoder-archive/tc-notifications/pull/259) | `master` | Disable inactive deployment | +| `topcoder-archive/tc-smtp-service` | [#2](https://github.com/topcoder-archive/tc-smtp-service/pull/2) | `master` | Disable inactive deployment | +| `topcoder-archive/terms-receiver` | [#23](https://github.com/topcoder-archive/terms-receiver/pull/23) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-archive/terms-service` | [#115](https://github.com/topcoder-archive/terms-service/pull/115) | `master` | Scoped OIDC; no deployment parameter reads | +| `topcoder-platform/timeline-wall-api` | [#45](https://github.com/topcoder-platform/timeline-wall-api/pull/45) | `master` | Scoped OIDC; no deployment parameter reads | + +## ECS service mapping + +Counts are observations from the read-only inventory, not changes proposed by the PRs. + +| Cluster / service | Desired | Repository / PR | Treatment | +| --- | ---: | --- | --- | +| `Clickstream-Ingestion-s3-df7e8666e6fd4b4caf-IngestionServerC0100NestedStackIngestionSe-ATYAZ2YUX48O-IngestionServerclickstreamingestionserviceecscluster02FAD1F6-qJc4FY5MoXzU` / `Clickstream-Ingestion-s3-df7e8666e6fd4b4caf-IngestionServerC0100NestedStackIngestionSe-ATYAZ2YUX48O-FargateService-QQD6wsdZtZv4` | 1 | Not mapped to the shared deployment suite | See exclusions below | +| `Conduktor-Prod` / `console` | 1 | Not mapped to the shared deployment suite | See exclusions below | +| `av-scanner-service-serverless` / `av-scanner-v6` | 1 | [av-scanner-v6 #13](https://github.com/topcoder-platform/av-scanner-v6/pull/13) | OIDC migration | +| `av-scanner-service-serverless` / `file-scanning-processor-svc` | 0 | [submission-scanner-processor #81](https://github.com/topcoder-archive/submission-scanner-processor/pull/81) | Disable inactive legacy deployment | +| `community-app-serverless` / `community-app-svc` | 6 | [community-app #7297](https://github.com/topcoder-platform/community-app/pull/7297) | OIDC migration | +| `engagements-api-serverless` / `engagements-api-v6` | 1 | [engagements-api-v6 #98](https://github.com/topcoder-platform/engagements-api-v6/pull/98) | OIDC migration | +| `gravitee-serverless` / `gravitee-apim-gateway-service` | 0 | Not mapped to the shared deployment suite | See exclusions below | +| `gravitee-serverless` / `gravitee-apim-management-api-service` | 0 | Not mapped to the shared deployment suite | See exclusions below | +| `gravitee-serverless` / `gravitee-apim-management-ui-service` | 0 | Not mapped to the shared deployment suite | See exclusions below | +| `groups-api-serverless` / `groups-api-v6` | 1 | [groups-api-v6 #19](https://github.com/topcoder-platform/groups-api-v6/pull/19) | OIDC migration | +| `identity-service-serverless` / `identity-api-v6` | 4 | [identity-api-v6 #58](https://github.com/topcoder-platform/identity-api-v6/pull/58) | OIDC migration | +| `internal-api-serverless` / `leave-api-v6` | 1 | [leave-api-v6 #16](https://github.com/topcoder-platform/leave-api-v6/pull/16) | OIDC migration | +| `internal-api-serverless` / `procurement-api-v6` | 1 | [procurement-api-v6 #8](https://github.com/topcoder-platform/procurement-api-v6/pull/8) | OIDC migration | +| `lookups-serverless` / `lookups-api-v6` | 1 | [lookups-api-v6 #14](https://github.com/topcoder-platform/lookups-api-v6/pull/14) | OIDC migration | +| `member-api-serverless` / `member-api-v6` | 1 | [member-api-v6 #172](https://github.com/topcoder-platform/member-api-v6/pull/172) | OIDC migration | +| `nginx-supply-serverless` / `nginx-supply-svc` | 4 | [docker-nginx-supply #193](https://github.com/topcoder-platform/docker-nginx-supply/pull/193) | OIDC migration | +| `standardized-skills-serverless` / `standardized-skills-api` | 1 | [standardized-skills-api #141](https://github.com/topcoder-platform/standardized-skills-api/pull/141) | OIDC migration | +| `standardized-skills-serverless` / `standardized-skills-processor` | 1 | [standardized-skills-processor #17](https://github.com/topcoder-platform/standardized-skills-processor/pull/17) | OIDC migration | +| `tc-ai` / `tc-ai-api` | 1 | [tc-ai-api #93](https://github.com/topcoder-platform/tc-ai-api/pull/93) | OIDC migration | +| `tc-ai` / `tc-mcp` | 0 | [tc-mcp #20](https://github.com/topcoder-platform/tc-mcp/pull/20) | OIDC migration; remains inactive | +| `tc-autopilot-serverless` / `autopilot-v6` | 1 | [autopilot-v6 #75](https://github.com/topcoder-platform/autopilot-v6/pull/75) | OIDC migration | +| `tc-billing-account-serverless` / `billing-accounts-api-v6` | 1 | [billing-accounts-api-v6 #35](https://github.com/topcoder-platform/billing-accounts-api-v6/pull/35) | OIDC migration | +| `tc-billing-account-serverless` / `project-billing-account-update-processor` | 1 | [project-billing-account-update-processor #13](https://github.com/topcoder-platform/project-billing-account-update-processor/pull/13) | OIDC migration | +| `tc-bus-api-serverless` / `tc-bus-api-svc` | 0 | [tc-bus-api #77](https://github.com/topcoder-archive/tc-bus-api/pull/77) | Disable inactive legacy deployment | +| `tc-challenge-serverless` / `challenge-api-v6` | 4 | [challenge-api-v6 #178](https://github.com/topcoder-platform/challenge-api-v6/pull/178) | OIDC migration | +| `tc-challenge-serverless` / `opportunities-api-v6` | 1 | [opportunities-api-v6 #8](https://github.com/topcoder-platform/opportunities-api-v6/pull/8) | OIDC migration | +| `tc-challenge-serverless` / `resources-api-v6` | 3 | [resource-api-v6 #38](https://github.com/topcoder-platform/resource-api-v6/pull/38) | OIDC migration | +| `tc-discord-bot-serverless` / `tc-discord-bot` | 1 | [tc-discord-bot #29](https://github.com/topcoder-platform/tc-discord-bot/pull/29) | OIDC migration | +| `tc-docker-daemon` / `tc-docker-daemon` | 1 | Not mapped to the shared deployment suite | See exclusions below | +| `tc-email-service-serverless` / `tc-email-service-svc` | 0 | [tc-email-service #103](https://github.com/topcoder-archive/tc-email-service/pull/103) | Disable inactive legacy deployment | +| `tc-finance-serverless` / `finance-api-v6` | 1 | [tc-finance-api #216](https://github.com/topcoder-platform/tc-finance-api/pull/216) | OIDC migration | +| `tc-forums-serverless` / `forums-api-v6` | 1 | [forums-api-v6 #27](https://github.com/topcoder-platform/forums-api-v6/pull/27) | OIDC migration | +| `tc-freecodecamp-serverless` / `freecodecamp-api` | 1 | [freeCodeCamp #113](https://github.com/topcoder-platform/freeCodeCamp/pull/113) | OIDC migration | +| `tc-gamification-serverless` / `tc-gamification` | 1 | [gamification-api #32](https://github.com/topcoder-platform/gamification-api/pull/32) | OIDC migration | +| `tc-gitea` / `tc-gitea` | 1 | Not mapped to the shared deployment suite | See exclusions below | +| `tc-gitea-runner` / `tc-gitea-runner` | 1 | Not mapped to the shared deployment suite | See exclusions below | +| `tc-infra-services` / `infra-inventory` | 1 | [infra-inventory #5](https://github.com/topcoder-platform/infra-inventory/pull/5) | OIDC migration | +| `tc-learning-paths-serverless` / `learning-paths-api` | 1 | [learning-paths-api #270](https://github.com/topcoder-platform/learning-paths-api/pull/270) | OIDC migration | +| `tc-mm-serverless` / `marathon-match-api-v6` | 1 | [marathon-match-api-v6 #459](https://github.com/topcoder-platform/marathon-match-api-v6/pull/459) | OIDC migration | +| `tc-notifications-serverless` / `tc-notifications-api` | 0 | [tc-notifications #259](https://github.com/topcoder-archive/tc-notifications/pull/259) | Disable inactive legacy deployment | +| `tc-notifications-serverless` / `tc-notifications-consumers` | 0 | [tc-notifications #259](https://github.com/topcoder-archive/tc-notifications/pull/259) | Disable inactive legacy deployment | +| `tc-notifications-serverless` / `tc-notifications-general-processor` | 0 | [tc-notifications #259](https://github.com/topcoder-archive/tc-notifications/pull/259) | Disable inactive legacy deployment | +| `tc-ollama` / `tc-ollama` | 0 | Not mapped to the shared deployment suite | See exclusions below | +| `tc-ollama` / `tc-ollama-runner-gpu` | 0 | Not mapped to the shared deployment suite | See exclusions below | +| `tc-project-service-serverless` / `projects-api-v6` | 1 | [projects-api-v6 #62](https://github.com/topcoder-platform/projects-api-v6/pull/62) | OIDC migration | +| `tc-reporting-serverless` / `reports-api-v6` | 1 | [reports-api-v6 #205](https://github.com/topcoder-platform/reports-api-v6/pull/205) | OIDC migration | +| `tc-review-serverless` / `review-api-v6` | 4 | [review-api-v6 #382](https://github.com/topcoder-platform/review-api-v6/pull/382) | OIDC migration | +| `tc-smtp-serverless` / `tc-smtp-service` | 0 | [tc-smtp-service #2](https://github.com/topcoder-archive/tc-smtp-service/pull/2) | Disable inactive legacy deployment | +| `terms-service-serverless` / `terms-receiver` | 1 | [terms-receiver #23](https://github.com/topcoder-archive/terms-receiver/pull/23) | OIDC migration | +| `terms-service-serverless` / `terms-svc` | 1 | [terms-service #115](https://github.com/topcoder-archive/terms-service/pull/115) | OIDC migration | +| `timeline-wall-serverless` / `timeline-wall-api` | 1 | [timeline-wall-api #45](https://github.com/topcoder-platform/timeline-wall-api/pull/45) | OIDC migration | +| `topcoder-infrastructure` / `bus-api-v6` | 1 | [bus-api-v6 #12](https://github.com/topcoder-platform/bus-api-v6/pull/12) | OIDC migration | +| `topcoder-infrastructure` / `email-service-v6` | 1 | [email-service-v6 #10](https://github.com/topcoder-platform/email-service-v6/pull/10) | OIDC migration | +| `topcoder-infrastructure` / `forms-api-v6` | 1 | [forms-api-v6 #17](https://github.com/topcoder-platform/forms-api-v6/pull/17) | OIDC migration | +| `topcoder-infrastructure` / `forms-processor-v6` | 1 | [forms-processor-v6 #12](https://github.com/topcoder-platform/forms-processor-v6/pull/12) | OIDC migration | +| `topcoder-infrastructure` / `payload-cms` | 1 | [payload-cms #50](https://github.com/topcoder-platform/payload-cms/pull/50) | OIDC migration | +| `topcoder-infrastructure` / `status-api-v6` | 1 | [status-api-v6 #10](https://github.com/topcoder-platform/status-api-v6/pull/10) | OIDC migration | +| `topcoder-infrastructure` / `support-api-v6` | 1 | [support-api-v6 #22](https://github.com/topcoder-platform/support-api-v6/pull/22) | OIDC migration | + +## Services without a mapped shared-script release path + +- **Clickstream ingestion:** nested AWS solution service with proxy/worker images; no matching shared-script repository found in source search. +- **Conduktor console:** vendor console/monitoring images; accessible code search found application references but no Conduktor shared-script release pipeline. +- **Gitea:** no source-management deployment tag or matching release repository found. +- **Docker daemon / Gitea runner / two Ollama services:** source tags point to tc-mcp, but the inspected tc-mcp CircleCI workflow releases only APPNAME tc-mcp. Its PR does not update these separate services. Two Ollama services were inactive. +- **Three Gravitee services:** inactive vendor services; source metadata links an operations installation document, not a shared-script application release repository. + +These exclusions require owner confirmation if a separate legacy pipeline exists; no credentials or configuration were guessed for them. Searches cover accessible indexed source, not every private/unindexed job. + +## Validation evidence + +- 61 shared offline tests pass locally and inside the pinned deployment executor, including exact legacy dev/prod branch trust and the ECS service-argument regression. +- All 46 consumer CircleCI configurations validate. IAM Access Analyzer reports no permission-policy errors/security warnings for the reviewed role policies; trust policies have only the single-valued audience reminder. CloudFormation role templates validate; private UUID templates still require actual identities before installation. +- Forms API: 14 deployment tests; AV Scanner: six adapter tests; Payload and Status: four OIDC-wrapper tests each; Payload: 52 deployment/operator tests. +- Project-billing processor: formatting, lint, typecheck, 17 tests and compilation. Standardized-skills processor: async readiness regression, compilation and lint. +- Lookups: clean-source image build and non-root dependency smoke. ClamAV: non-root image build, PONG, clean-file scan and process-exit check. Nginx: image build and non-root health check using local mappings for its private DNS names (not a production routing test). +- freeCodeCamp: complete API image build, ten API/compatibility security tests, and non-root runtime-config smoke verifying no baked .env/build-fixture config and no private fields in generated public JSON. Community App: eight focused security tests. + +These checks do not replace image scanning in the configured ECR repositories, full application builds for every repository, real OIDC trust tests or dev integration/rollback acceptance. + +## Auth0 broker endpoint and AWS permission flow + +The configured HTTP endpoint is the runtime value of **CI_AUTH0_URL**, which was not available in accessible source or the saved AWS environment files. Production IAM verifies the legacy **oidc-circleci-deploy** role trusts issuer **https://topcoder-newauth.auth0.com/** and an Auth0 client-ID audience. The issuer is not proof of the broker URL or token path. + +The old script sends client ID + secret to that configured broker and extracts AWS credentials from custom JWT claims. The client ID identifies the allowed client; the AWS role's IAM policies supply its permissions, including the legacy parameter/decryption grants. New deployments use CircleCI OIDC directly with AWS STS and deny direct parameter access. ECS's separately scoped execution role continues fetching runtime secrets when tasks start. See [the detailed authentication explanation](AUTHENTICATION.md). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0a32d49..b6409ff 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -8,7 +8,7 @@ Merging this PR does not change pinned v1 consumers or installed IAM policies. * 2. Create a manifest with exact identifiers from that inspection. Generate IAM documents using `tcdeploy.py policies`; install them through infrastructure review. Keep the explicit secret-access/role-chaining deny in the role policy and preferably use the generated permissions as its permissions boundary too. Remove unrelated policies and audit resource-based grants. The deployer needs no infrastructure-creation permissions. 3. Restrict PassRole to the exact task/execution roles (use JSON `null` for `taskRoleArn` when the existing task has no application role; do not add one just to deploy). Give the execution role only its runtime parameter/secret ARNs and necessary KMS keys. Pre-provision runtime bindings and defaults through administrator-owned infrastructure. v2 preserves references without fetching values/types: missing bindings fail at startup and trigger rollback. 4. Enable unconditional ECR tag immutability and supported basic/enhanced scan-on-push. Enable ECS circuit-breaker rollback, positive desired capacity and explicit task health checks. Remove plaintext secret environment values and `RESET_DB` secret bindings. Use non-root runtime images. Bootstrap/infrastructure updates remain separate from code deployment. -5. Commit non-secret `deploy/dev.json`. Use an exact `develop` or `dev` branch for dev and `master` for prod source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. +5. Commit non-secret `deploy/dev.json`. Use an exact `develop` or `dev` branch for dev and the exact reviewed `master` or legacy `prod` branch for production source/config refs. Examples intentionally do not guess real targets. Do not put keys, client secrets, passwords or parameter values in manifests. 6. Prepare a reviewed executor with Python >=3.10 with the `tarfile` extraction-filter security backport, AWS CLI v2, Docker/Buildx, Git and trusted CA certificates. Pin its digest and the suite's full reviewed commit in service configuration. No runtime tool/package installation on the credential host. Replace template placeholders with literal reviewed values, not caller-overridable pipeline parameters. 7. Split build/test and deploy using `examples/circleci-v2.yml`. Remove `org-global`, old helper copies, all `source awsenvconf` / `source *_env`, SSM build/deploy/QA retrieval, and unpinned host installs. Use restricted dev/prod contexts with no broker/static AWS secrets, production approval and serialization. Verify OIDC v3 availability; restrict fork secret passing and SSH debugging. Pin/download the entire suite. 8. Build committed source separately with `build_image.py`. Transfer only image archives through job-specific workflow workspaces. Do not cache the checkout/credential directories. The helper rejects known secret filenames and excludes untracked generated credentials; it does not replace a full secret scanner. Never build after deployment authentication. @@ -26,19 +26,19 @@ For private packages, use a separate package-read-only identity and BuildKit sec ## Production and concurrency -Use a separately reviewed prod manifest/role/context, exact prod account, protected `master` source/config refs and approval gate. Validate IAM denies before use. Promote reviewed suite/executor commits and retain a previous healthy revision/digest. ECS performs circuit-breaker rollback; failed/timeout deployments require inspection rather than blindly overwriting a newer release. +Use a separately reviewed prod manifest/role/context, exact prod account, protected exact `master` or legacy `prod` source/config refs and approval gate. Validate IAM denies before use. Promote reviewed suite/executor commits and retain a previous healthy revision/digest. ECS performs circuit-breaker rollback; failed/timeout deployments require inspection rather than blindly overwriting a newer release. Serial groups prevent normal CI overlap. ECS UpdateService has no general compare-and-swap for revision, so before/after checks cannot eliminate every out-of-band race. Restrict other writers and coordinate operator changes. -## External adoption work this PR cannot perform +## Adoption work and consumer PRs - Install/restrict OIDC/IAM/contexts; retire broker grants; rotate exposed secrets and revoke sessions. -- Change each service's CircleCI configuration/manifests, runtime bindings, scanner/immutability settings and ECS health/rollback configuration. +- Review and merge the [consumer PRs](ECS_MIGRATION_PRS.md), then apply runtime bindings, scanner/immutability settings and ECS health/rollback changes through infrastructure administration. - Remove production DB-reset options/startup entrypoints from groups/identity/review and remove schema-reset privileges from runtime DB users. v2 rejects reset-enabled image/task environments and any RESET_DB secret binding in **both** environments. Controlled dev resets belong outside deployment. - Preserve custom migration, CloudFormation, scan and monitoring checks; see `SERVICES.md`. Do not overwrite custom pipelines with the basic example. - Pin forums' four cross-repository client clones. A generic builder cannot prove remote code downloaded inside a Dockerfile; Dockerfiles remain trusted build code. -These are adoption gates, not claims that existing services or live IAM policies changed by merging the PR. +The linked consumer PRs prepare repository changes; none is a claim that live ECS/IAM/CircleCI state has changed. Infrastructure and real dev acceptance remain adoption gates. ## Audit coverage