Jérôme Decoster

Jérôme Decoster

3x AWS Certified - Architect, Developer, Cloud Practionner

21 Dec 2022

Create temporary environment from Pull Request with ArgoCD ApplicationSet

The Goal
  • Build a voting app with Nodejs and Postgres
  • Docker images are pushed to a private ECR repository
  • Deploy ArgoCD in a Kind cluster
  • Each git push on the master branch will build an image and update the app in kubernetes
  • Creating a Pull Request on Github will create a new environment available in a specific port
  • Each git push on this git branch will build an image and update the app in this environment
  • Closing the pull request will terminate the environment and clean the docker registry

    banner.png

    The application

    This project is composed by :

    • vote : the voting application (a website in Nodejs)
    • infra : this module is used to manage the infrastructure
    • terraform : several terraform projects to manage the different stages of creation of the main project (reduce bash scripts and replace them with terraform code)
    • manifests : the kubenetes templates
    • argocd : the templates that define argocd applications
    • workflows : the voting app use 3 github actions workflows

    You can fork this 2 repositories on your machine

    Important : make sure your repository is private as it will contain sensitive data !

    Setup

    Let’s start by initializing the infra module

    The env-create script creates an .env file at the root of the project :

    # create .env file
    make env-create
    

    You must modify the generated .env file with your own variables :

    • AWS_REGION
    • GITHUB_OWNER
    • GITHUB_REPO_URL_INFRA
    • GITHUB_REPO_URL_VOTE
    • GITHUB_TOKEN

    You need to create a Github Token

    You need to select repo :

    token-repo.png

    You need to select admin:public_key :

    token-admin-key.png

    This Github Token is used by Terraform’s github provider :

    provider "github" {
      owner = var.github_owner
      token = var.github_token
    }
    

    To assign an SSH key to your Github account :

    resource "github_user_ssh_key" "ssh_key" {
      title = var.project_name
      key   = tls_private_key.private_key.public_key_openssh
    }
    

    Let’s now initialize terraform projects :

    # terraform init (upgrade) + validate
    make terraform-init
    

    Setup the infrastructure

    # terraform create ecr repo + ssh key
    make infra-create
    

    Terraform is used to :

    • Create an SSH key and add it to your Github account so you can interact with a private repository
    • Create an ECR repository

    ecr-repo.png

    The github key is created :

    github-key.png

    Start Kind, install ArgoCD

    # setup kind + argocd
    make kind-argocd-create
    

    Terraform is used to :

    kubectl get ns
    NAME                 STATUS   AGE
    argocd               Active   10s
    default              Active   80s
    kube-node-lease      Active   90s
    kube-public          Active   90s
    kube-system          Active   90s
    local-path-storage   Active   70s
    

    We open the ArgoCD web interface :

    # open argocd (website)
    make argocd-open
    

    argocd-init.png

    Create namespaces + secrets

    # create namespaces + secrets
    make secrets-create
    

    Terraform is used to :

    The ECR token is used to allow Kubernetes to download your images from a private ECR repository

    The token generated by AWS is only valid 12 hours !

    We therefore need to create a CronJob which will update this token every 10 hours

    For this demo a new token is requested every 3 minutes

    The aws-ecr-auth-docker-config-updater job is used to update the ECR credentials :

    schedule: "*/2 * * * *"
    jobTemplate:
        spec:
          template:
            spec:
              serviceAccountName: aws-ecr-auth-docker-config-updater
              restartPolicy: Never
              volumes:
              - emptyDir:
                  medium: Memory
                name: store
              initContainers:
                - image: amazon/aws-cli
                  name: get-token
                  envFrom:
                  - secretRef:
                      name: aws-access-keys
                  volumeMounts:
                    - mountPath: /store
                      name: store
                  command:
                    - /bin/sh
                    - -ce
                    - aws ecr get-login-password --region ${aws_region} > /store/token
              containers:
                - image: bitnami/kubectl
                  name: kubectl
                  volumeMounts:
                    - mountPath: /store
                      name: store
                  command:
                    - /bin/sh
                    - -c
                    - |-
                      date "+%Y-%d-%m %H:%M:%S config-updater"
                      
                      DATE=$(date "+%Y-%m-%dT%H:%M:%SZ")
                      kubectl create secret docker-registry aws-ecr-auth-docker-config \
                        --docker-server=${docker_server} \
                        --docker-username=AWS \
                        --docker-password="$(cat /store/token)" \
                        --dry-run=client \
                        --namespace vote \
                        --output json |
                        jq --arg v $DATE 'del(.metadata.creationTimestamp) | .metadata.annotations.updateTimestamp = $v' |
                        kubectl apply -f -
    

    The previously created secret is only valid in a single namespace

    In our project, each Pull Request will create a new environment within a dedicated namespace

    It is therefore necessary to copy this automatically updated secret into these new namespaces

    A trick is to create a job that runs continuously and duplicates the referral secret every x seconds in the other namespaces :

    template:
      spec:
        serviceAccountName: aws-ecr-auth-docker-config-replicator
        restartPolicy: Never
        containers:
          - image: bitnami/kubectl
            name: kubectl
            command:
              - /bin/sh
              - -c
              - |-
                while true; do
                date "+%Y-%d-%m %H:%M:%S config-replicator"
                kubectl get ns -o custom-columns=:.metadata.name | 
                grep vote-pr |
                while read ns; do 
                    kubectl get secret aws-ecr-auth-docker-config \
                    --namespace vote \
                    --output json | 
                    jq "del(.metadata | .resourceVersion, .uid) | .metadata.namespace=\"$ns\"" |
                    kubectl apply -f -
                done
                sleep 10; 
                done
    

    Let’s test our job with this command in the terminal :

    watch -n 1 kubectl get secret -A
    

    The output :

    NAMESPACE     NAME                           TYPE                             DATA   AGE
    argocd        argocd-initial-admin-secret    Opaque                           1      4m0s
    argocd        argocd-notifications-secret    Opaque                           0      6m0s
    argocd        argocd-secret                  Opaque                           5      6m0s
    argocd        github-token                   Opaque                           1      10s
    vote          aws-access-keys                Opaque                           2      10s
    vote          aws-ecr-auth-docker-config     kubernetes.io/dockerconfigjson   1      10s
    

    In an other terminal window we create a new namespace named vote-pr-test :

    kubectl create ns vote-pr-test
    

    Our secret is added a few seconds later :

    NAMESPACE      NAME                           TYPE                             DATA   AGE
    argocd         argocd-initial-admin-secret    Opaque                           1      5m0s
    argocd         argocd-notifications-secret    Opaque                           0      7m0s
    argocd         argocd-secret                  Opaque                           5      7m0s
    argocd         github-token                   Opaque                           1      40s
    vote-pr-test   aws-ecr-auth-docker-config     kubernetes.io/dockerconfigjson   1      1s
    vote           aws-access-keys                Opaque                           2      40s
    vote           aws-ecr-auth-docker-config     kubernetes.io/dockerconfigjson   1      40s
    

    We can see our aws-access-keys secret with this command :

    kubectl get secret aws-access-keys -n vote -o yaml
    

    And the aws-ecr-auth-docker-config secret with this command:

    kubectl get secret aws-ecr-auth-docker-config -n vote -o json | 
      jq -r '.data[".dockerconfigjson"]' | 
      base64 -d
    

    Build manifest files from templates

    # create files using templates
    make templates-create
    

    Terraform is used to :

    Important : generated files must be added to your git repository :

    git add . && git commit -m update && git push -u origin master
    

    The vote repository

    When you push a commit to the vote repository, the cd.yml github workflow will build the docker image and push it to ECR :

    - name: Build, tag, and push image to Amazon ECR
      id: build-image
      run: |
        log()   { echo -e "\e[30;47m ${1} \e[0m ${@:2}"; }
    
        VERSION=${{ env.VERSION }}
        log VERSION ${VERSION}
    
        TAG_VERSION=${{ steps.login-ecr.outputs.registry }}/${{ env.REPOSITORY_NAME }}:${VERSION}
        log TAG_VERSION ${TAG_VERSION}
    
        TAG_SHA=${{ steps.login-ecr.outputs.registry }}/${{ env.REPOSITORY_NAME }}:${GITHUB_SHA}
        log TAG_SHA ${TAG_SHA}
        
        cd vote
        docker image build --tag ${TAG_VERSION} --tag ${TAG_SHA} .
        docker push ${TAG_VERSION}
        docker push ${TAG_SHA}
    

    The building step :

    vote-workflow-cd-build.png

    Then create or replace the kustomization.yaml template in the /manifests/overlays/master/ path of the argocd-pull-request-infra repository:

    - name: Push to infra repo
      env:
        OVERLAY_PATH: "manifests/overlays/master"
      run: |
        cd infra
        mkdir --parents ${{ env.OVERLAY_PATH }}
        export vote_image=${{ env.TAG_VERSION }}
        export vote_version=${{ env.VERSION }}
        export vote_nodeport=30000
        envsubst < manifests/overlays/.tmpl/kustomization.yaml > ${{ env.OVERLAY_PATH }}/kustomization.yaml
        git config user.name github-actions
        git config user.email github-actions@github.com
        git add .
        git commit -m "github actions: ${{ env.OVERLAY_PATH }}"
        git push
    

    The ECR repository contains now our docker image defined by 2 tags :

    vote-workflow-cd-push.png

    Master Application

    In our argocd-pull-request-infra project, we create our ArgoCD Application via this command :

    make master-app-create
    

    It installs the application previously generated via this template :

    apiVersion: argoproj.io/v1alpha1
    kind: Application
    metadata:
      name: ${project_name}-master
      namespace: argocd # /!\ important
      finalizers:
        - resources-finalizer.argocd.argoproj.io
    spec:
      project: default
    
      source:
        repoURL: ${github_repo_url_infra}
        targetRevision: HEAD
        path: manifests/overlays/master
      destination: 
        server: https://kubernetes.default.svc
        namespace: vote # default
    
      syncPolicy:
        syncOptions:
        - CreateNamespace=true
        automated:
          selfHeal: true
          prune: true
    

    It is important to note that the targeted path is manifests/overlays/master

    Its content was generated by the Push to infra repo step of the workflow cd.yml of the repository vote

    The application is being installed :

    argocd-master-app.png

    The application is installed correctly :

    argocd-master-content.png

    We open our browser on http://0.0.0.0:9000 :

    localhost-0.0.1.png

    We can query the application type with kubectl :

    kubectl get application -n argocd
    NAME                  SYNC STATUS   HEALTH STATUS
    pull-request-master   Synced        Healthy
    

    Pull Request ApplicationSet

    We create our ApplicationSet via this command :

    make pull-request-appset-create
    

    The ApplicationSet is declared but nothing new is visible yet within the ArgoCD interface :

    argocd-no-applicationset.png

    We can query the applicationset type with kubectl :

    kubectl get applicationset -n argocd
    NAME              AGE
    pull-request-pr   50s
    

    It installs the ApplicationSet generated previously via this template :

    apiVersion: argoproj.io/v1alpha1
    kind: ApplicationSet
    metadata:
      name: ${project_name}-pr
      namespace: argocd
      finalizers:
        - resources-finalizer.argocd.argoproj.io
    spec:
      generators:
      - pullRequest:
          github:
            # gitHub organization or user
            owner: ${github_owner}
            # The Github repository
            repo: ${github_repo_name_vote}
            # reference to a secret containing an access token
            tokenRef:
              secretName: github-token
              key: token
            # labels is used to filter the PRs that you want to target
            labels:
            - preview
          requeueAfterSeconds: 90
      # https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/Generators-Pull-Request/#template
      template:
        metadata:
          name: '${project_name}-{{branch}}-{{number}}'
          namespace: argocd
        spec:
          project: default
    
          source:
            repoURL: ${github_repo_url_infra}
            # targetRevision: '{{head_sha}}'
            path: manifests/overlays/pr-{{number}}
          destination:
            server: https://kubernetes.default.svc
            namespace: vote-pr-{{number}} # /!\ important : must be uniq
            
          syncPolicy:
            syncOptions:
              - CreateNamespace=true
            automated:
              selfHeal: true
              prune: true
    

    It is important to note that the targeted path is manifests/overlays/pr-{{number}}

    Its content was generated by the step Push to infra repo of the workflow pull-request.yml of the repository vote :

    - name: Push to infra repo
      run: |
        cd infra
        mkdir --parents ${{ env.OVERLAY_PATH }}
        export vote_namespace=vote-pr-${{ github.event.pull_request.number }}
        export vote_image=${{ env.TAG_SHA }}
        export vote_version=${GITHUB_SHA}
        export vote_nodeport=${{ env.WEBSITE_PORT }}
        envsubst < manifests/overlays/.tmpl/kustomization.yaml > ${{ env.OVERLAY_PATH }}/kustomization.yaml
        
        git config user.name github-actions
        git config user.email github-actions@github.com
        git add .
        git commit -m "github actions: ${{ env.OVERLAY_PATH }}"
        git push
    

    The kustomization.yaml file will be generated in the path defined by :

    env: 
      OVERLAY_PATH: "manifests/overlays/pr-${{ github.event.pull_request.number }}"
    

    First Pull Request using github website

    Back to our argocd-pull-request-vote project

    Our goal is to update the background color of the website

    We are on the master branch :

    git branch
    * master
    

    We create a new branch change-background-color using the checkout command :

    git checkout -b "change-background-color"
    

    We change the color in the main.css file :

    body {
    -    background-color: #cfd8dc;
    +    background-color: indianred;
        font-size: 1em;
        overflow: hidden;
    }
    

    We commit and push this new branch :

    git add .
    git commit -m indianred
    git push --set-upstream origin change-background-color
    

    We create the Pull Request by clicking this button :

    github-create-pr.png

    We change the Pull Request title add this comment :

    github-pr-1-comment.png

    The Pull Request is created :

    github-pr-1-created.png

    The pull-request.yml workflow is started :

    github-pr-1-workflow.png

    A new ECR repository is created :

    ecr-repo-pr-1.png

    This ECR repository contains a docker image defined by 1 tag :

    ecr-repo-pr-1-image.png

    The infra repository is updated :

    infra-overlays.png

    The preview label is added :

    github-pr-1-label.png

    Managing labels documentation

    The Pull Request with preview label is detected by ArgoCD

    The Application pull-request-change-background-color-1 is created :

    argocd-pr-1-app.png

    After a few moments we can open http://0.0.0.0:9001 :

    localhost-pr-1.png

    The kustomization.yaml file is the key to generating the environment :

    bases:
    - ../../base
    
    namespace: vote-pr-1
    
    patches:
    - target:
        kind: Service
        name: vote
        namespace: vote
      patch: |-
        - op: replace
          path: /spec/ports/0/nodePort
          value: 30001
    
    patchesStrategicMerge:
    - |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: vote
        namespace: vote
      spec:
        template:
          spec:
            containers:
              - name: vote
                image: xxxxx.dkr.ecr.xxx.amazonaws.com/pull-request-vote-pr-1:c0ac463cd6c8bc215db0e10014643243dc7770dd
                env:
                - name: VERSION
                  value: c0ac463cd6c8bc215db0e10014643243dc7770dd
    

    We repeat these steps to change the color to slateblue :

    body {
    -    background-color: #indianred;
    +    background-color: slateblue;
        font-size: 1em;
        overflow: hidden;
    }
    

    The pull-request workflow is started again :

    github-pr-1-updated.png

    A new docker image is added :

    ecr-repo-pr-1-image-added.png

    We reload our browser tab http://0.0.0.0:9001 :

    localhost-pr-1-updated.png

    Second Pull Request using github cli

    Our new goal is to add a title to the website

    Important : make sure we start from the master branch to create our new branch

    git checkout master
    
    git branch
      change-background-color
    * master
    

    We create a new branch add-title using checkout :

    git checkout -b "add-title"
    

    We uncomment the div in the index.njk file :

    -    {# <div class="title">Voting App</div> #}
    +    <div class="title">Voting App</div>
    

    We commit and push this new branch :

    git add .
    git commit -m title
    git push --set-upstream origin add-title
    

    We create the Pull Request with the gh pr create command :

    gh pr create --title "add-title" --body "add a title"
    

    The Pull Request is created :

    github-pr-2-created.png

    The pull-request workflow is started :

    github-pr-2-started.png

    The workflow is completed :

    github-pr-2-workflow-done.png

    A new ECR repository is created :

    ecr-repo-pr-2.png

    The infra repository is updated

    The Pull Request associated with the preview label is detected by ArgoCD

    The application pull-request-add-title-2 is created :

    argocd-pr-2-app.png

    After a few moments we can open http://0.0.0.0:9002 :

    localhost-pr-2.png

    Merging to master using github website

    We merge the #2 Pull Request by clicking this button :

    github-pr-1-merge.png

    To disable the cd.yml workflow, we add [no ci] to the commit message :

    github-pr-1-merge-comment.png

    On our local machine we switch to the master branch then pull the remote content :

    git checkout master
    
    git pull
    

    Then we update the application version :

    -  "version": "0.0.1",
    +  "version": "0.0.2",
    

    We commit and push this update :

    git add .
    git commit -m 0.0.2
    git push
    

    The workflow is started :

    github-push-0.0.2.png

    After a few moments the overlays/master/kustomization.yml file is updated :

    bases:
    - ../../base
    
    # /!\ important !
    # https://kubectl.docs.kubernetes.io/references/kustomize/builtins/#_namespacetransformer_
    namespace: 
    
    patches:
    - target:
        kind: Service
        name: vote
        namespace: vote
      patch: |-
        - op: replace
          path: /spec/ports/0/nodePort
          value: 30000
    patchesStrategicMerge:
    - |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: vote
        namespace: vote
      spec:
        template:
          spec:
            containers:
              - name: vote
                image: xxxxx.dkr.ecr.eu-west-3.amazonaws.com/pull-request-vote:0.0.2
                env:
                - name: VERSION
                  value: 0.0.2
    

    We reload the URL http://0.0.0.0:9000 to see that the website has changed :

    localhost-0.0.2.png

    The pull-request-close.yaml workflow is triggered when a Pull Request is closed :

    on:
      pull_request:
        types: [closed]
    

    It deletes the previously created ECR repository :

    aws ecr delete-repository --repository-name ${{ env.REPOSITORY_NAME }} --force
    

    The pull-request-vote-pr-2 repository is deleted :

    ecr-repo-deleted.png

    Merging to master using github cli

    We merge the #1 Pull Request by using the pr merge command :

    gh pr merge 1 --squash --subject '[no ci]'
    

    The -s, --squash option commits into one commit and merge it into the base branch

    The Pull Request is merged ans closed :

    github-pr-2-merged.png

    The workflow is completed :

    github-pr-workflow-done.png

    The pull-request-vote-pr-1 repository is deleted :

    ecr-repo-deleted-again.png

    The pull-request-change-background-color-1 Application is removed :

    argocd-app-cleared.png

    We checkout and pull :

    git checkout master
    git pull
    

    Then I update the application version :

    -  "version": "0.0.2",
    +  "version": "0.0.3",
    

    We commit and push this update :

    git add .
    git commit -m 0.0.3
    git push
    

    A new docker image is added :

    ecr-repo-master-updated.png

    We reload the URL http://0.0.0.0:9000 to see that the website has changed :

    localhost-0.0.3.png

    Cleaning

    This demonstration is now over, we are destroying the resources :

    # destroy master application
    make master-app-destroy
    
    # terraform destroy namespaces + secrets
    make secrets-destroy
    
    # terraform destroy kind + argocd
    make kind-argocd-destroy
    
    # terraform destroy ecr repo + ssh key
    make infra-destroy