From 412d5209ff181b4a904e07d2119dc51cd1c13f51 Mon Sep 17 00:00:00 2001 From: "fm-gh-file-sync[bot]" <112097603+fm-gh-file-sync[bot]@users.noreply.github.com> Date: Fri, 7 Feb 2025 15:00:48 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=83=20File=20Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/api.tests.yml | 52 +++++ .../workflows/devops.cancel-ongoing-sync.yml | 45 ++++ .github/workflows/devops.ci.yml | 83 +++++++ .github/workflows/devops.docker-build.yml | 117 ++++++++++ .github/workflows/devops.get-pr-envs.yml | 71 ++++++ .github/workflows/devops.promote-docker.yml | 38 ++++ .github/workflows/devops.re-enable-sync.yml | 45 ++++ .github/workflows/devops.validate-values.yml | 213 ++++++++++++++++++ .../workflows/push-generated-clients.ci.yml | 84 +++++++ .github/workflows/push-openapi.ci.yml | 51 +++++ .github/workflows/security.image-scan.yml | 136 +++++++++++ .github/workflows/wait-for-deployment.yml | 92 ++++++++ 12 files changed, 1027 insertions(+) create mode 100644 .github/workflows/api.tests.yml create mode 100644 .github/workflows/devops.cancel-ongoing-sync.yml create mode 100644 .github/workflows/devops.ci.yml create mode 100644 .github/workflows/devops.docker-build.yml create mode 100644 .github/workflows/devops.get-pr-envs.yml create mode 100644 .github/workflows/devops.promote-docker.yml create mode 100644 .github/workflows/devops.re-enable-sync.yml create mode 100644 .github/workflows/devops.validate-values.yml create mode 100644 .github/workflows/push-generated-clients.ci.yml create mode 100644 .github/workflows/push-openapi.ci.yml create mode 100644 .github/workflows/security.image-scan.yml create mode 100644 .github/workflows/wait-for-deployment.yml diff --git a/.github/workflows/api.tests.yml b/.github/workflows/api.tests.yml new file mode 100644 index 0000000..5a5107c --- /dev/null +++ b/.github/workflows/api.tests.yml @@ -0,0 +1,52 @@ +name: Backend API Tests +on: + workflow_call: + inputs: + tests_path: + required: true + type: string + default: spec + continue_on_error: + required: false + type: boolean + default: false + image: + required: true + type: string + +env: + TESTER_IMAGE: 027159582536.dkr.ecr.eu-west-1.amazonaws.com/backend-api-tests:stable + SECRET_ID: arn:aws:secretsmanager:eu-west-1:027159582536:secret:github-actions/repos/backend-api-tests-P0XRKc + +jobs: + wait_for_deploy: + name: Wait for deploy + uses: ./.github/workflows/wait-for-deployment.yml + secrets: inherit + with: + image: ${{ inputs.image }} + + api-tests: + name: Run test suite + needs: + - wait_for_deploy + runs-on: [self-hosted, large-runner] + steps: + - name: Generate AWS config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: Get required secrets + run: | + aws secretsmanager get-secret-value --secret-id=${{ env.SECRET_ID }} --profile non-prod | \ + jq -r '.SecretString | fromjson? | to_entries | map("\(.key)=\(.value|tostring)") | .[]' > .env + + - name: Run tests in docker + continue-on-error: ${{ inputs.continue_on_error }} + run: | + docker run --rm --env-file=.env ${{ env.TESTER_IMAGE }} bundle exec rspec ${{ inputs.tests_path }} diff --git a/.github/workflows/devops.cancel-ongoing-sync.yml b/.github/workflows/devops.cancel-ongoing-sync.yml new file mode 100644 index 0000000..b1c4a5a --- /dev/null +++ b/.github/workflows/devops.cancel-ongoing-sync.yml @@ -0,0 +1,45 @@ +name: DevOps Workflow called to cancel ongoing ArgoCD application syncs for PR-environment before publishing a new image +on: + workflow_call: {} + +jobs: + cancel-ongoing-sync: + runs-on: [self-hosted, standard-runner] + steps: + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: generate kubeconfig + run: | + export EKS_CLUSTER_NAME=$(aws ssm get-parameter --name /devops/ci-target/cluster --query 'Parameter.Value' --output text --profile non-prod) + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --profile non-prod + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl version + + - name: get git variables + run: | + set -euo pipefail + REPOSITORY=${{github.repository}} + REPOSITORY_OWNER=${{github.repository_owner}} + REPOSITORY_NAME=${REPOSITORY##${REPOSITORY_OWNER}/} + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV + GITHUB_REF=${{github.ref}} + PULL_NUMBER=$(echo "$GITHUB_REF" | awk -F / '{print $3}') + echo "PULL_NUMBER=${PULL_NUMBER}" >> $GITHUB_ENV + + - name: cancel ongoing syncs + run: | + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd-pr get applications.argoproj.io | awk '/^${{env.REPOSITORY_NAME}}-.*-${{env.PULL_NUMBER}}/{print $1}' | while read app + do + ENV=$(echo ${app} | sed 's/^${{env.REPOSITORY_NAME}}-\(.*\)-${{env.PULL_NUMBER}}/\1/') + echo "::group::Cancelling sync in ${ENV}" + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd-pr patch app ${app} -p '{"operation":null,"spec":{"syncPolicy":{"automated":null}}}' --type merge && echo "Succeeded" || echo "Failed" + echo "::endgroup::" + done + + diff --git a/.github/workflows/devops.ci.yml b/.github/workflows/devops.ci.yml new file mode 100644 index 0000000..036e9aa --- /dev/null +++ b/.github/workflows/devops.ci.yml @@ -0,0 +1,83 @@ +name: Workflow called to run reusable CI steps +on: + workflow_call: + inputs: + dockerfile: + required: false + type: string + default: Dockerfile + image: + required: true + type: string + ci_image: + required: false + type: string + default: ci:test + command: + required: true + type: string + target: + required: false + type: string + default: test + github_user: + required: false + type: string + default: fm-cicd + skip_ci: + required: false + type: boolean + default: false + secrets: {} + +env: + DOCKER_BUILDKIT: 1 + GITHUB_USER: ${{ inputs.github_user }} + GITHUB_PASSWORD: ${{ secrets.API_TOKEN_GITHUB }} + +jobs: + ci: + if: ${{ !inputs.skip_ci }} + timeout-minutes: 60 + runs-on: [self-hosted, large-runner] + steps: + - name: checkout repository + uses: actions/checkout@v3 + + - name: pull built image + run: | + docker pull ${{ inputs.image }} + + - name: build ci image + run: | + docker build --progress=plain --build-arg BUILDKIT_INLINE_CACHE=1 \ + --secret id=github_user,env=GITHUB_USER \ + --secret id=github_password,env=GITHUB_PASSWORD \ + --cache-from ${{ inputs.image }} \ + --target ${{ inputs.target }} \ + -t ${{ inputs.ci_image }} \ + -f ${{ inputs.dockerfile }} . + + - name: prepare environment + run: | + mkdir -p ./coverage ./tmp ./log + chown -R 1000:1000 ./coverage ./tmp ./log + + - name: run command + shell: bash + run: ${{ inputs.command }} + + - name: check coverage data + run: | + COVERAGE_GENERATED=$([ "$(ls -A ./coverage/)" ] && echo true || echo false) + echo "COVERAGE_GENERATED=${COVERAGE_GENERATED}" >> $GITHUB_ENV + COMMAND_SHA=$(echo "${{ inputs.command }}" | sha256sum | cut -c1-8) + echo "COMMAND_SHA=${COMMAND_SHA}" >> $GITHUB_ENV + + - name: upload coverage files + if: ${{ env.COVERAGE_GENERATED == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ env.COMMAND_SHA }} + path: ./coverage + retention-days: 7 diff --git a/.github/workflows/devops.docker-build.yml b/.github/workflows/devops.docker-build.yml new file mode 100644 index 0000000..b1f6cb8 --- /dev/null +++ b/.github/workflows/devops.docker-build.yml @@ -0,0 +1,117 @@ +name: DevOps Workflow called to build an image out of a docker container +on: + workflow_call: + inputs: + dockerfile: + required: false + type: string + default: Dockerfile + target: + required: false + type: string + ref: + required: false + type: string + default: master + github_user: + required: false + type: string + default: fm-cicd + skip_build: + required: false + type: boolean + default: false + outputs: + image: + description: The resulting image + value: ${{ jobs.build.outputs.image }} + secrets: {} +env: + AWS_ACCOUNT: '027159582536' + AWS_REGION: eu-west-1 + DOCKER_BUILDKIT: 1 + GITHUB_PASSWORD: ${{ secrets.API_TOKEN_GITHUB }} + GITHUB_USER: ${{ inputs.github_user }} + REGISTRY: 027159582536.dkr.ecr.eu-west-1.amazonaws.com +jobs: + build: + if: ${{ !inputs.skip_build }} + timeout-minutes: 60 + runs-on: [self-hosted, standard-runner] + outputs: + image: ${{ steps.push.outputs.IMAGE }} + steps: + - name: checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 1 + ref: ${{ inputs.ref }} + + - name: get git variables + run: | + set -euo pipefail + REPOSITORY=${{github.repository}} + REPOSITORY_OWNER=${{github.repository_owner}} + REPOSITORY_NAME=${REPOSITORY##${REPOSITORY_OWNER}/} + LATEST_IMAGE_TAG=$(aws ecr describe-images --no-paginate --output text \ + --registry-id ${{ env.AWS_ACCOUNT }} --repository-name ${REPOSITORY_NAME} \ + --query 'imageDetails | sort_by(@, &imagePushedAt) | reverse(@)[0].imageTags[0]') + GIT_BRANCH="$(git symbolic-ref HEAD --short 2>/dev/null)" + if [ "$GIT_BRANCH" = "" ] ; then + GIT_BRANCH="$(git rev-parse HEAD | xargs git name-rev | cut -d' ' -f2 | sed 's/remotes\/origin\///g')"; + fi + GIT_BRANCH_SLUG=$(echo $GIT_BRANCH | sed -r s/[~\^]+//g | sed -r s/[^a-zA-Z0-9]+/-/g | sed -r s/^-+\|-+$//g | tr A-Z a-z) + GIT_SHORT_COMMIT="$(git log -1 --pretty=%h)" + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV + echo "LATEST_IMAGE_TAG=${LATEST_IMAGE_TAG}" >> $GITHUB_ENV + echo "GIT_BRANCH_SLUG=${GIT_BRANCH_SLUG}" >> $GITHUB_ENV + echo "GIT_SHORT_COMMIT=${GIT_SHORT_COMMIT}" >> $GITHUB_ENV + echo "CACHE_IMAGE=${{ env.REGISTRY }}/${REPOSITORY_NAME}:${LATEST_IMAGE_TAG}" >> $GITHUB_ENV + + - name: set target image name for pull_request + if: github.event_name == 'pull_request' + run: | + echo "TARGET_IMAGE=${{ env.REGISTRY }}/${{ env.REPOSITORY_NAME}}:dev.${{ env.GIT_BRANCH_SLUG }}.${{ env.GIT_SHORT_COMMIT }}" >> $GITHUB_ENV + + - name: set target image name for push + if: contains(fromJSON('["push", "workflow_dispatch"]'), github.event_name) + run: | + echo "TARGET_IMAGE=${{ env.REGISTRY }}/${{ env.REPOSITORY_NAME }}:master.${{ env.GIT_SHORT_COMMIT }}" >> $GITHUB_ENV + echo "LATEST_IMAGE=${{ env.REGISTRY }}/${{ env.REPOSITORY_NAME }}:latest" >> $GITHUB_ENV + + - name: docker cache + run: | + docker pull ${{ env.CACHE_IMAGE }} || true + + - name: docker build + id: docker_build + run: | + TARGET="" + if [ ! -z "${{ inputs.target }}" ] ; then + TARGET="--target ${{ inputs.target }}" + fi + docker build --progress=plain --build-arg BUILDKIT_INLINE_CACHE=1 \ + --secret id=github_user,env=GITHUB_USER \ + --secret id=github_password,env=GITHUB_PASSWORD \ + --pull --cache-from ${{ env.CACHE_IMAGE }} \ + $TARGET \ + -t ${{ env.TARGET_IMAGE }} \ + -f ${{ inputs.dockerfile }} . + + - name: docker push + id: push + run: | + docker push ${{ env.TARGET_IMAGE }} + echo "IMAGE=${{ env.TARGET_IMAGE }}" >> $GITHUB_OUTPUT + + - name: docker push latest image + if: github.event_name == 'push' + run: | + docker tag ${{ env.TARGET_IMAGE }} ${{ env.LATEST_IMAGE }} + docker push ${{ env.LATEST_IMAGE }} + + - name: generate report + run: | + echo "# Development image" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`${{ env.TARGET_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/devops.get-pr-envs.yml b/.github/workflows/devops.get-pr-envs.yml new file mode 100644 index 0000000..85b1514 --- /dev/null +++ b/.github/workflows/devops.get-pr-envs.yml @@ -0,0 +1,71 @@ +name: DevOps Workflow called to get links to ArgoCD application for PR-environments +on: + workflow_call: {} + +jobs: + get-pr-envs: + runs-on: [self-hosted, standard-runner] + steps: + - name: checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: generate kubeconfig + run: | + export EKS_CLUSTER_NAME=$(aws ssm get-parameter --name /devops/ci-target/cluster --query 'Parameter.Value' --output text --profile non-prod) + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --profile non-prod + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl version + + - name: get git variables + run: | + set -euo pipefail + REPOSITORY=${{github.repository}} + REPOSITORY_OWNER=${{github.repository_owner}} + REPOSITORY_NAME=${REPOSITORY##${REPOSITORY_OWNER}/} + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV + GITHUB_REF=${{github.ref}} + PULL_NUMBER=$(echo "$GITHUB_REF" | awk -F / '{print $3}') + echo "PULL_NUMBER=${PULL_NUMBER}" >> $GITHUB_ENV + + - name: get pr env urls + run: | + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd get applicationsets.argoproj.io | awk '/^${{env.REPOSITORY_NAME}}-.*/{print $1}' | while read appset + do + ENV=$(echo ${appset} | sed 's/^${{env.REPOSITORY_NAME}}-\(.*\)$/\1/') + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd get applicationsets.argoproj.io ${appset} -o json | jq -e '.spec.generators[] | select(.matrix) | .matrix.generators[] | select(.pullRequest) | .pullRequest | has("github")' && \ + ( + echo "# ${ENV} environment" >> $GITHUB_STEP_SUMMARY + echo "ArgoCD application: [${appset}-${{env.PULL_NUMBER}}](https://argocd-non-prod.fm-tech.io/applications/argocd/${appset}-${{env.PULL_NUMBER}}), Groundcover: [app](https://app.groundcover.com/workloads/workload-status?start=&end=&duration=Last+15+minutes&src_cluster=non-prod-v9&backendId=groundcover&workload=${appset}-${{env.PULL_NUMBER}}-api&namespace=staging&cluster=non-prod-v9)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "$(yq e '.appValues.snsSubscriptions' kube/values.yaml)" != "null" ]; then + ENDPOINT_NAMES=$(yq e '.appValues.snsSubscriptions[].endpointName' kube/values.yaml) + if [ -n "$ENDPOINT_NAMES" ]; then + echo "AWS SQS Queues:" >> $GITHUB_STEP_SUMMARY + for ENDPOINT_NAME in $ENDPOINT_NAMES; do + echo "Queue: [${appset}-${{env.PULL_NUMBER}}-$ENDPOINT_NAME](https://eu-west-1.console.aws.amazon.com/sqs/v3/home?region=eu-west-1#/queues/https%3A%2F%2Fsqs.eu-west-1.amazonaws.com%2F027159582536%2Ffairmoney-staging-${{env.PULL_NUMBER}}-$ENDPOINT_NAME)" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + fi + fi + if [ "$(yq e '.appValues.workers' kube/values.yaml)" != "null" ]; then + WORKERS=$(yq e '.appValues.workers|keys' kube/values.yaml | awk '{gsub("- ", ""); print}') + if [ -n "$WORKERS" ]; then + echo "Workers:" >> $GITHUB_STEP_SUMMARY + for WORKER in $WORKERS; do + echo "Groundcover: [${appset}-${{env.PULL_NUMBER}}-$WORKER](https://app.groundcover.com/workloads/workload-status?start=&end=&duration=Last+15+minutes&src_cluster=non-prod-v9&backendId=groundcover&workload=${appset}-${{env.PULL_NUMBER}}-worker-$WORKER&namespace=staging&cluster=non-prod-v9)" >> $GITHUB_STEP_SUMMARY + done + fi + fi + ) || true + done diff --git a/.github/workflows/devops.promote-docker.yml b/.github/workflows/devops.promote-docker.yml new file mode 100644 index 0000000..a25ff2f --- /dev/null +++ b/.github/workflows/devops.promote-docker.yml @@ -0,0 +1,38 @@ +name: DevOps Workflow called to promote a validated image to production +on: + workflow_call: + inputs: + image: + type: string + required: true + outputs: + image: + description: The resulting image + value: ${{ jobs.pull_request_build.outputs.image }} + secrets: {} +env: + PRODUCTION_REGISTRY: 878858384475.dkr.ecr.eu-west-1.amazonaws.com + NON_PROD_REGISTRY: 027159582536.dkr.ecr.eu-west-1.amazonaws.com +jobs: + promote_docker: + timeout-minutes: 60 + runs-on: [self-hosted, standard-runner] + outputs: + image: ${{ steps.push.outputs.IMAGE }} + steps: + - name: get production image name + run: | + IMAGE=$(echo ${{ inputs.image }} | sed "s/^${{ env.NON_PROD_REGISTRY }}/${{ env.PRODUCTION_REGISTRY }}/") + echo "TARGET_IMAGE=${IMAGE}" >> $GITHUB_ENV + + - name: push image to production + run: | + docker pull ${{ inputs.image }} + docker tag ${{ inputs.image }} ${{ env.TARGET_IMAGE }} + docker push ${{ env.TARGET_IMAGE }} + + - name: generate report + run: | + echo "# Production image" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`${{ env.TARGET_IMAGE }}\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/devops.re-enable-sync.yml b/.github/workflows/devops.re-enable-sync.yml new file mode 100644 index 0000000..d64e79d --- /dev/null +++ b/.github/workflows/devops.re-enable-sync.yml @@ -0,0 +1,45 @@ +name: DevOps Workflow called to re-enable ArgoCD application syncs for PR-environment after publishing a new image +on: + workflow_call: {} + +jobs: + re-enable-sync: + runs-on: [self-hosted, standard-runner] + steps: + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: generate kubeconfig + run: | + export EKS_CLUSTER_NAME=$(aws ssm get-parameter --name /devops/ci-target/cluster --query 'Parameter.Value' --output text --profile non-prod) + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --profile non-prod + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl version + + - name: get git variables + run: | + set -euo pipefail + REPOSITORY=${{github.repository}} + REPOSITORY_OWNER=${{github.repository_owner}} + REPOSITORY_NAME=${REPOSITORY##${REPOSITORY_OWNER}/} + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV + GITHUB_REF=${{github.ref}} + PULL_NUMBER=$(echo "$GITHUB_REF" | awk -F / '{print $3}') + echo "PULL_NUMBER=${PULL_NUMBER}" >> $GITHUB_ENV + + - name: re-enable syncs + run: | + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd-pr get applications.argoproj.io | awk '/^${{env.REPOSITORY_NAME}}-.*-${{env.PULL_NUMBER}}/{print $1}' | while read app + do + ENV=$(echo ${app} | sed 's/^${{env.REPOSITORY_NAME}}-\(.*\)-${{env.PULL_NUMBER}}/\1/') + echo "::group::Re-enabling sync in ${ENV}" + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n argocd-pr patch app ${app} -p '{"spec":{"syncPolicy":{"automated":{"prune":true,"selfHeal":true}}}}' --type merge && echo "Succeeded" || echo "Failed" + echo "::endgroup::" + done + + diff --git a/.github/workflows/devops.validate-values.yml b/.github/workflows/devops.validate-values.yml new file mode 100644 index 0000000..a762484 --- /dev/null +++ b/.github/workflows/devops.validate-values.yml @@ -0,0 +1,213 @@ +name: DevOps Workflow called to validate kubernetes input values from ./kube/ folder +on: + workflow_call: + inputs: + ref: + required: false + type: string + default: master + wrapper_chart: + required: false + type: string + default: app-wrapper + wrapper_ref: + required: false + type: string + default: master + base_chart: + required: false + type: string + default: fm-charts/base + base_ref: + required: false + type: string + default: master +env: + AWS_ACCOUNT: '027159582536' + AWS_REGION: eu-west-1 +jobs: + validate-helm: + runs-on: [self-hosted, standard-runner] + steps: + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: generate kubeconfig + run: | + export EKS_CLUSTER_NAME=$(aws ssm get-parameter --name /devops/ci-target/cluster --query 'Parameter.Value' --output text --profile non-prod) + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --profile non-prod + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl version + + - name: checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 1 + ref: ${{ inputs.ref }} + + - name: checkout kube-state repository + uses: actions/checkout@v3 + with: + path: kube-state + repository: fairmoney/kube-state + ref: ${{inputs.wrapper_ref}} + token: ${{secrets.HELM_TOKEN_GITHUB}} + fetch-depth: 1 + + - name: checkout helm-charts repository + uses: actions/checkout@v3 + with: + path: helm-charts + repository: fairmoney/helm-charts + ref: ${{inputs.base_ref}} + token: ${{secrets.HELM_TOKEN_GITHUB}} + fetch-depth: 1 + + - name: get git variables + run: | + set -euo pipefail + REPOSITORY=${{github.repository}} + REPOSITORY_OWNER=${{github.repository_owner}} + REPOSITORY_NAME=${REPOSITORY##${REPOSITORY_OWNER}/} + echo "REPOSITORY_NAME=${REPOSITORY_NAME}" >> $GITHUB_ENV + + - name: generate test data + run: | + cat << EOF > kube/values-global-test.yaml + name: ${{env.REPOSITORY_NAME}}-ci + repoURL: https://github.com/fairmoney/helm-charts + project: default + containerRegistry: ${{env.AWS_ACCOUNT}}.dkr.ecr.${{env.AWS_REGION}}.amazonaws.com + argocdImageUpdater: + allowTags: 'regexp:^master\..*$' + updateStrategy: newest-build + deletionPolicy: Orphan + appValues: + application: ${{env.REPOSITORY_NAME}} + environment: ci + defaultImage: + repository: ${{env.AWS_ACCOUNT}}.dkr.ecr.${{env.AWS_REGION}}.amazonaws.com + tag: latest + additionalServiceDomains: [] + aivenKafka: non-prod + aivenProject: fm-non-prod + albDropInvalidHeaderFields: true + appNamespaces: + - ci + awsAccountId: "${{env.AWS_ACCOUNT}}" + awsOu: o-abcd/r-efgh/ou-ijklm-nopqr + awsOuAccounts: + groundcover-non-prod: "1234567890" + non-prod: "2345678901" + non-prod-cdk: "3456789012" + production: "633978989662" + production-backend: "508863430045" + production-card: "262635985392" + production-cdk: "306048654954" + production-websites: "973362048477" + service: "745379883438" + awsOuDataAccounts: + production-data-ingestions: "797185887781" + production-data-science-api: "861400907731" + sandbox: "4567890123" + staging-data-ingestions: "113798767495" + staging-data-science-api: "421393153235" + staging-data-science-research: "750219364171" + awsRegion: ${{env.AWS_REGION}} + awsRootAccountId: "5678901234" + awsServiceAccountId: "6789012345" + certArns: + fmtechio: arn:aws:acm:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:certificate/00000000-1111-2222-3333-444444444444 + fairmoneyai: arn:aws:acm:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:certificate/00000000-1111-2222-3333-444444444444 + fairmoneycomng: arn:aws:acm:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:certificate/00000000-1111-2222-3333-444444444444 + fairmoneyio: arn:aws:acm:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:certificate/00000000-1111-2222-3333-444444444444 + fairmoneyin: arn:aws:acm:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:certificate/00000000-1111-2222-3333-444444444444 + clusterName: ci-v0 + clusterOidcIssuer: oidc.eks.eu-west-1.amazonaws.com/id/00000000000000000000000000DEMO00 + clusterSecretStoreName: secretsmanager-store + clusterWeight: 100 + cognitoAuthorizationEndpoint: https://auth.fm-tech.io/oauth2/authorize + cognitoIssuer: https://cognito-idp.${{env.AWS_REGION}}.amazonaws.com/${{env.AWS_REGION}}_abcdefghi + cognitoTokenEndpoint: https://auth.fm-tech.io/oauth2/token + cognitoUserInfoEndpoint: https://auth.fm-tech.io/oauth2/userInfo + crossplaneDeletionPolicy: Orphan + databaseSubnets: + - subnet-01234567890123456 + - subnet-12345678901234567 + databaseZoneId: Z0000000000000000000 + databaseZoneName: ci.fm-tech.io + datadogSite: datadoghq.eu + dbUserGroups: + production_backend: + - user1 + - user2 + disableHeavyDedicated: false + dnsZones: + - fm-tech.io + gcpProjects: + non-prod-project: "987654321012" + production-project: "123456789012" + groundcoverNamespace: groundcover + iamPermBoundaryArn: arn:aws:iam::${{env.AWS_REGION}}:policy/crossplane-provider-aws-permission-boundary-policy + iamPermBoundaryForInfraArn: arn:aws:iam::${{env.AWS_REGION}}:policy/crossplane-provider-aws-permission-boundary-policy-for-infra + iamRoleNamePrefix: eks-ci-v0 + iamTagName: managed-by + iamTagValue: crossplane + ingressControllerSA: + name: aws-load-balancer-controller + namespace: aws-lb-controller + ingressGroupAppExt: app-ext + ingressGroupAppInt: app-int + ingressGroupSystemExt: system-ext + ingressGroupSystemInt: system-int + kmsKeyARN: arn:aws:kms:${{env.AWS_REGION}}:${{env.AWS_ACCOUNT}}:key/00000000-1111-2222-3333-444444444444 + namespacedIngressGroup: true + priorityClassName: null + serviceDomain: fm-tech.io + stsMaxSessionDuration: 43200 + tier: non-prod + vpcCidrs: + - 127.0.0.0/8 + vpcId: vpc-01234567890abcdef + webAclArn: null + EOF + + - name: run helm dependency build + run: | + helm dependency build kube-state/${{ inputs.wrapper_chart }} + helm dependency build helm-charts/${{ inputs.base_chart }} + + - name: run helm template + run: | + declare -a OVERRIDES=("") + OVERRIDES+=($(find kube/ -name values-\*-override.yaml)) + if [ ${#OVERRIDES[*]} -gt 0 ] + then + for key in "${!OVERRIDES[@]}" ; do + OPTION="" + if [ ! -z "${OVERRIDES[$key]}" ] ; then + OPTION="-f ${OVERRIDES[$key]}" + fi + echo "::group::Wrapper input (${OVERRIDES[$key]})" + helm template -f kube/values-global-test.yaml -f kube/values.yaml ${OPTION} --namespace ci ${{env.REPOSITORY_NAME}}-wrapper kube-state/${{inputs.wrapper_chart}} | tee argocd-app.yaml + echo "::endgroup::" + echo "::group::Base chart input (${OVERRIDES[$key]})" + cat argocd-app.yaml | yq .spec.source.helm.values | tee base-values.yaml + echo "::endgroup::" + echo "::group::Helm templating input (${OVERRIDES[$key]})" + helm template -f base-values.yaml --namespace ci ${{env.REPOSITORY_NAME}}-ci helm-charts/${{inputs.base_chart}} | tee helm-template.yaml + echo "::endgroup::" + echo "::group::Run kubectl apply --dry-run (${OVERRIDES[$key]})" + if yq -e eval-all 'select(. != null)' helm-template.yaml >/dev/null 2>&1; then + env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl -n ci create --dry-run=client -f helm-template.yaml + else + echo "::warning::No resources to apply" + fi + echo "::endgroup::" + done + fi diff --git a/.github/workflows/push-generated-clients.ci.yml b/.github/workflows/push-generated-clients.ci.yml new file mode 100644 index 0000000..539e308 --- /dev/null +++ b/.github/workflows/push-generated-clients.ci.yml @@ -0,0 +1,84 @@ +name: Generate and push API clients + +on: + workflow_call: + inputs: + ref: + required: false + type: string + default: master + path_filters: + required: false + type: string + default: | + openapi: + - 'openapi/**' + source_directory: + required: true + type: string + target_directory: + required: true + type: string + target_branch: + required: false + type: string + default: master + destination_repository: + required: true + type: string + target_directory_ignored_file_name: + required: false + type: string + default: '' + github_user: + required: false + type: string + default: fm-cicd + go_generate_command: + required: false + type: string + default: | + cd openapi/go + sh gen.sh + cd ../.. + +jobs: + build: + runs-on: [self-hosted, standard-runner] + env: + GOPRIVATE: github.com/fairmoney/* + steps: + - name: checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + - uses: fairmoney/paths-filter@master + id: path_filter + with: + filters: ${{ inputs.path_filters }} + - name: setup go environment + if: steps.path_filter.outputs.openapi == 'true' + uses: actions/setup-go@v4 + with: + go-version-file: go.mod + cache: false + - name: Configure private git repositories + if: steps.path_filter.outputs.openapi == 'true' + run: git config --global url.https://${{ inputs.github_user }}:${{ secrets.API_TOKEN_GITHUB }}@github.com/.insteadOf https://github.com/ + - name: generate Golang clients + if: steps.path_filter.outputs.openapi == 'true' + run: ${{ inputs.go_generate_command }} + - name: push to fairmoney/${{ inputs.destination_repository }} + if: steps.path_filter.outputs.openapi == 'true' + uses: fairmoney/github-action-push-to-another-repository@main + env: + API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} + with: + user-name: ${{ github.actor }} + source-directory: ${{ inputs.source_directory }} + destination-github-username: fairmoney + destination-repository-name: ${{ inputs.destination_repository }} + target-directory: ${{ inputs.target_directory }} + target-branch: ${{ inputs.target_branch }} + target-directory-ignored-file-name: ${{ inputs.target_directory_ignored_file_name }} diff --git a/.github/workflows/push-openapi.ci.yml b/.github/workflows/push-openapi.ci.yml new file mode 100644 index 0000000..01383e5 --- /dev/null +++ b/.github/workflows/push-openapi.ci.yml @@ -0,0 +1,51 @@ +name: Push OpenAPI specification to the Stoplight repository + +on: + workflow_call: + inputs: + ref: + required: false + type: string + default: master + path_filters: + required: false + type: string + default: | + openapi: + - 'openapi/**' + source_directory: + required: true + type: string + target_directory: + required: true + type: string + destination_repository: + required: false + type: string + default: backend-openapi + +jobs: + build: + runs-on: [self-hosted, standard-runner] + steps: + - name: checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + ref: ${{ inputs.ref }} + - uses: fairmoney/paths-filter@master + id: path_filter + with: + filters: ${{ inputs.path_filters }} + - name: push to fairmoney/${{ inputs.destination_repository }} + if: steps.path_filter.outputs.openapi == 'true' + uses: fairmoney/github-action-push-to-another-repository@main + env: + API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }} + with: + user-name: ${{ github.actor }} + source-directory: ${{ inputs.source_directory }} + destination-github-username: fairmoney + destination-repository-name: ${{ inputs.destination_repository }} + target-directory: ${{ inputs.target_directory }} + target-branch: master diff --git a/.github/workflows/security.image-scan.yml b/.github/workflows/security.image-scan.yml new file mode 100644 index 0000000..da34bd3 --- /dev/null +++ b/.github/workflows/security.image-scan.yml @@ -0,0 +1,136 @@ +on: + workflow_call: + inputs: + image: + required: true + type: string + +jobs: + image_scan: + runs-on: [self-hosted, standard-runner] + steps: + + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: evaluate image scan + run: | + # Function to format JSON attributes into Markdown list items + format_attributes() { + local attr_string="" + local attributes="$1" + local count=$(echo "$attributes" | jq '. | length') + for ((i = 0; i < count; i++)); do + local key=$(echo "$attributes" | jq -r ".[$i].key") + local value=$(echo "$attributes" | jq -r ".[$i].value") + attr_string+="\n- **$key:** $value" + done + echo "$attr_string" + } + + # Function to display JSON as beautiful Markdown + display_json_as_markdown() { + local json_input="$1" + + local formatted_output="" + formatted_output+="### Vulnerability Details\n" + + # Loop through each JSON object and format it + local count=$(echo "$json_input" | jq '. | length') + for ((i = 0; i < count; i++)); do + local name=$(echo "$json_input" | jq -r ".[$i].name") + local uri=$(echo "$json_input" | jq -r ".[$i].uri") + local severity=$(echo "$json_input" | jq -r ".[$i].severity") + local attributes=$(echo "$json_input" | jq -c ".[$i].attributes") + + formatted_output+="\n### $name\n" + formatted_output+="- **Severity:** $severity\n" + formatted_output+="- **CVE Link:** [$name]($uri)" + + attr_string=$(format_attributes "$attributes") + formatted_output+="$attr_string\n" + done + + echo -e "$formatted_output" + } + + REPOSITORY=$(echo ${{ inputs.image }} | sed 's/\(.*\)\/\(.*\):\(.*\)/\2/') + TAG=$(echo ${{ inputs.image }} | sed 's/\(.*\)\/\(.*\):\(.*\)/\3/') + + aws ecr wait image-scan-complete --repository-name "$REPOSITORY" --image-id "{\"imageTag\": \"$TAG\"}" --profile non-prod + + SCAN_RESULTS=$(aws ecr describe-images \ + --repository-name "$REPOSITORY" \ + --no-paginate \ + --query 'sort_by(imageDetails,& imagePushedAt)[-2:].{digest: imageDigest, scanResult: imageScanFindingsSummary.findingSeverityCounts}' \ + --profile non-prod) + + SCAN_RESULTS_COUNT=$(echo "$SCAN_RESULTS" | jq '. | length') + + if [ "$SCAN_RESULTS_COUNT" -ge 2 ]; then + NEW_IMAGE_DIGEST=$(echo "$SCAN_RESULTS" | jq -r '.[1].digest') + + LAST_SCAN_DETAILS=$(aws ecr describe-image-scan-findings \ + --repository-name "$REPOSITORY" \ + --image-id "imageDigest=$NEW_IMAGE_DIGEST" \ + --query 'imageScanFindings.findings' \ + --profile non-prod) + + NEW_IMAGE_VULNS=$(echo "$SCAN_RESULTS" | jq .[1].scanResult) + OLD_IMAGE_VULNS=$(echo "$SCAN_RESULTS" | jq .[0].scanResult) + + for category in CRITICAL HIGH MEDIUM LOW INFORMATIONAL UNDEFINED; do + OLD_CAT_VULNS=$(echo "$OLD_IMAGE_VULNS" | jq ".${category} // 0") + NEW_CAT_VULNS=$(echo "$NEW_IMAGE_VULNS" | jq ".${category} // 0") + + if [ "$NEW_CAT_VULNS" -gt "$OLD_CAT_VULNS" ]; then + echo "Last build added $(( $NEW_CAT_VULNS - $OLD_CAT_VULNS )) new vulnerabilities of category: ${category}." | tee -a $GITHUB_STEP_SUMMARY + fi + done + else + NEW_IMAGE_DIGEST=$(echo "$SCAN_RESULTS" | jq -r '.[0].digest') + + LAST_SCAN_DETAILS=$(aws ecr describe-image-scan-findings \ + --repository-name "$REPOSITORY" \ + --image-id "imageDigest=$NEW_IMAGE_DIGEST" \ + --query 'imageScanFindings.findings' \ + --profile non-prod) + + NEW_IMAGE_VULNS=$(echo "$SCAN_RESULTS" | jq .[0].scanResult) + fi + + echo "## Scan Results" >> $GITHUB_STEP_SUMMARY + display_json_as_markdown "$LAST_SCAN_DETAILS" | tee -a $GITHUB_STEP_SUMMARY + + CRITICAL_COUNT=$(echo "$NEW_IMAGE_VULNS" | jq ".CRITICAL // 0") + HIGH_COUNT=$(echo "$NEW_IMAGE_VULNS" | jq ".HIGH // 0") + MEDIUM_COUNT=$(echo "$NEW_IMAGE_VULNS" | jq ".MEDIUM // 0") + + error_message="" + exit_code=0 + + if [ "$CRITICAL_COUNT" -gt 0 ]; then + error_message+="More than 0 critical vulnerabilities found. " + exit_code=1 + fi + + if [ "$HIGH_COUNT" -gt 5 ]; then + error_message+="More than 5 high vulnerabilities found. " + exit_code=1 + fi + + if [ $(( $MEDIUM_COUNT + $HIGH_COUNT )) -gt 10 ]; then + error_message+="More than 10 medium+high vulnerabilities found. " + exit_code=1 + fi + + if [ "$exit_code" -eq 1 ]; then + echo "Rejecting! $error_message" + exit 1 + fi diff --git a/.github/workflows/wait-for-deployment.yml b/.github/workflows/wait-for-deployment.yml new file mode 100644 index 0000000..0daa6a6 --- /dev/null +++ b/.github/workflows/wait-for-deployment.yml @@ -0,0 +1,92 @@ +name: Workflow to wait for an image to be deployed +on: + workflow_call: + inputs: + image: + required: true + type: string + + +env: + KUBECTL: env -i PATH=/usr/local/bin:/usr/bin:/bin HOME=/home/runner kubectl + NS: staging + TIMEOUT_SECONDS: 600 + POLL_INTERVAL_SECONDS: 30 + LABEL: "app.kubernetes.io/instance" + +jobs: + wait: + runs-on: [self-hosted, standard-runner] + steps: + + - name: generate aws config + run: | + mkdir -p ${HOME}/.aws + cat << EOF > ${HOME}/.aws/config + [profile non-prod] + web_identity_token_file = /var/run/secrets/eks.amazonaws.com/serviceaccount/token + role_arn = ${{ secrets.NON_PROD_EKS_ROLE_ARN }} + EOF + + - name: generate kubeconfig + run: | + export EKS_CLUSTER_NAME=$(aws ssm get-parameter --name /devops/ci-target/cluster --query 'Parameter.Value' --output text --profile non-prod) + aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --profile non-prod + $KUBECTL version + + - name: Wait for deployment to finish + run: | + REGISTRY=$(echo ${{ inputs.image }} | sed 's/\(.*\)\/\(.*\):\(.*\)/\1/') + APP=$(echo ${{ inputs.image }} | sed 's/\(.*\)\/\(.*\):\(.*\)/\2/') + DESIRED_TAG=$(echo ${{ inputs.image }} | sed 's/\(.*\)\/\(.*\):\(.*\)/\3/') + + function wait_for_image { + set -euo pipefail + + APP=$1 + NS=$2 + REGISTRY=$3 + LABEL=$4 + DESIRED_TAG=$5 + + # we check the current digest and compare it with the existing one in the cluster later + # this way we can skip waiting in case the image-tag changed but the digest didn't which + # leads to the new image never being deployed + NEW_DIGEST=$(aws ecr describe-images --repository-name "$APP" --image-id "{\"imageTag\": \"$DESIRED_TAG\"}" --profile non-prod | jq .imageDetails[0].imageDigest) + OLD_DIGEST="NULL" + + while true; do + echo "Waiting for desired image to be deployed: $DESIRED_TAG ..." + + PAT="/$REGISTRY\/$APP/{ print \$NF }" + TAG=$($KUBECTL -n "$NS" get deployments -l "$LABEL=$APP-$NS" \ + -o jsonpath="{range .items[*]}{range .spec.template.spec.containers[*]}{ .image }{'\n'}{ end }{end}" | + sort -fu | awk -F: "$PAT") + + if [ "$OLD_DIGEST" == "NULL" ]; then + OLD_DIGEST=$(aws ecr describe-images --repository-name "$APP" --image-id "{\"imageTag\": \"$TAG\"}" --profile non-prod | jq .imageDetails[0].imageDigest) + fi + + echo "Images in cluster: $TAG" + if [ "$DESIRED_TAG" == "$TAG" ] || [ "$NEW_DIGEST" == "$OLD_DIGEST" ]; then + echo "Success! New image rolled out or no change to previous image." + break + fi + + sleep "$POLL_INTERVAL_SECONDS" + done + } + + export -f wait_for_image + if ! timeout "$TIMEOUT_SECONDS" bash -c "wait_for_image $APP $NS $REGISTRY $LABEL $DESIRED_TAG"; then + echo "Timeout or error waiting for image to be deployed!" + exit 1 + fi + + echo "Waiting for pods to become ready..." + + # We filter all completed jobs. This is safe to do so because argocd will update + # deployments only when the pre-up jobs successfully completed + $KUBECTL rollout status deployment -n $NS -l "$LABEL=$APP-$NS" --timeout="${TIMEOUT_SECONDS}s" + + echo "All pods are ready. Deployment complete."