Episode 0x0E: Go Hugo!

Table of Contents

NOTE: Many commands in this post make use of specific constants tied to my own setup. Make sure to tailor these to your own needs. These examples should serve as a guide, not as direct instructions to copy and paste.

NOTE: Check out the final code at geekembly repo on my Github account.

Introduction

Today, we try to get the pieces needed to set up a project for the Geekembly blog together, which will have a fully functioning CI/CD pipeline. The website will be powered by Hugo, a fast and modern static site generator.

Geekembly Blog

Builder Dockerfile

We need a build environment with Hugo and Git installed. Create a file named Dockerfile.builder with the following content:

FROM alpine:3.19

WORKDIR /workspace

RUN apk add git
RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community hugo

Later in the CI pipeline, We’ll build and tag this image as geekembly:builder.

Main Dockerfile

With the builder image, we can build our markdown files using Hugo and serve them with Nginx. Here is the main Dockerfile:

FROM docker.registry.<your-domain>.com/geekembly:builder as builder

WORKDIR /workspace

# Copy our weblog contents to the image
COPY ./geekembly .

# Build the contents.
RUN ./scripts/build.sh

FROM nginx:1.27-alpine

# Copy the website static files to the where nginx serves them from
COPY --from=builder /workspace/build/geekembly/public /usr/share/nginx/html

Build Script

The build script, located at ./scripts/build.sh, will configure and build the static site:

#!/bin/sh

set -e

WORK_DIR=$(pwd)
BUILD_DIR=$WORK_DIR/build

echo "creating hugo project"
mkdir $BUILD_DIR && cd $BUILD_DIR

hugo new site geekembly
cd geekembly

git clone https://github.com/hugo-sid/hugo-blog-awesome.git themes/hugo-blog-awesome
sed -i 's/\$narrow-size: 720px;/\$narrow-size: 900px;/' themes/hugo-blog-awesome/assets/sass/main.scss
cp -r $WORK_DIR/hugo.toml $BUILD_DIR/geekembly
cp -r $WORK_DIR/geekembly/* $BUILD_DIR/geekembly/content/
cp -r $WORK_DIR/assets/* $BUILD_DIR/geekembly/assets/

HUGO_ENV=production hugo --minify

We will tag and push this image to our private container registry.

ArgoCD Application

We’ll isolate this application from the default ArgoCD project by creating a dedicated ArgoCD user and project.

Create ArgoCD User

Open and edit the relevant configmap:

kubectl edit -n argocd configmaps argocd-cm

Add a new user:

data:
  accounts.geekembly: apiKey,login

Set User Password

Log in as admin:

argocd login argocd.geekembly.com --username 'admin' --password '<admin-pass>'

Set the new user’s password:

argocd account update-password --account geekembly --current-password '<admin-pass>' --new-password '<new-pass>'

Test the newly created account. You should be login using the UI at argocd.<your-domain>.com

Create ArgoCD Project

Next, we create an isolated ArgoCD project:

kubectl apply -f - <<EOF
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: geekembly
  namespace: argocd
spec:
  clusterResourceWhitelist:
  - group: '*'
    kind: '*'
  destinations:
  - namespace: geekembly
    server: https://kubernetes.default.svc
  - namespace: argocd
    server: https://kubernetes.default.svc
  namespaceResourceWhitelist:
  - group: '*'
    kind: '*'
  sourceRepos:
  - https://github.com/Cih2001/geekembly.git

Setup RBAC for the Project

kubectl edit -n argocd configmaps argocd-rbac-cm

Add the following:

data:
  policy.csv: |
    g, geekembly, role:geekembly-role
    p, role:geekembly-role, applications, *, geekembly/*, allow
    p, role:geekembly-role, applications, list, *, allow
    p, role:geekembly-role, namespaces, *, geekembly, allow
    p, role:geekembly-role, namespaces, list, *, allow
    p, role:geekembly-role, projects, *, geekembly, allow
    p, role:geekembly-role, projects, list, *, allow    

This RBAC configuration ensures that the geekembly user has full access to application and projects within the geekembly namespace. To underestand the RBAC configuration you can take a look at the official documentation.

Create the ArgoCD Application

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: geekembly.com
  namespace: argocd
spec:
  destination:
    namespace: geekembly
    server: https://kubernetes.default.svc
  project: geekembly
  source:
    path: deployment
    repoURL: https://github.com/Cih2001/geekembly.git
  syncPolicy:
    automated:
      prune: true
    syncOptions:
      - CreateNamespace=true

Since this application is in the namespace geekembly and our new argo user has access to all resources in that namespace, it can view and manage it.

Deployment

First, we need to provide our container registry cred to kubernetes, so it can pull images from. So create a secret called regcred:

export USER=<your-docker-registry-user>
export PASS=<your-docker-registry-pass>
export CRED=$(echo -n $USER:$PASS | base64)
k apply --dry-run=client -o yaml -f - <<EOF | kubeseal -o yaml
apiVersion: v1
kind: Secret
type: kubernetes.io/dockerconfigjson
metadata:
  name: regcred
  namespace: geekembly
stringData:
  .dockerconfigjson: |
    {
        "auths": {
            "https://docker.registry.<your-domain>.com/":{"username":"$USER","password":"$PASS","auth":"$CRED"},
            "docker.registry.<your-domain>.com":{"username":"$USER","password":"$PASS","auth":"$CRED"},
            "registry-svc.registry":{"username":"$USER","password":"$PASS","auth":"$CRED"}
        }
    }
EOF

We’ll use Kustomize to manage our deployment files.

Create deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: geekembly-dpl
spec:
  selector:
    matchLabels:
      app: geekembly
  replicas: 1
  template:
    metadata:
      labels:
        app: geekembly
    spec:
      containers:
        - name: geekembly
          image: geekembly:latest
          ports:
            - containerPort: 80
              name: http
      imagePullSecrets: # make regcred available to kubernetes
        - name: regcred
---
apiVersion: v1
kind: Service
metadata:
  name: geekembly-svc
  labels:
    app: geekembly
spec:
  ports:
    - port: 80
      name: http
      protocol: TCP
  selector:
    app: geekembly

Create kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - deployment.yaml

namespace: geekembly
images:
  - name: geekembly
    newName: docker.registry.<your-domain>.com/geekembly
    newTag: "faaa72f02174027e14538af1be7d601b8ad184bc"

CI Pipeline

To build, and push the latest image of our website, we need a bunch of small workflow templates, which we will discuss them one by one in the following subsections. They are all combined in a big workflow template called cicd.yaml which you can find at geekembly

Git Clone

github-clone template can clone our repository given an SSH key stored in github-ssh-key, so create an SSH key, share it’s public in GitHub, and store the private they the github-ssh-key sealed secret.

spec:
  templates:
    - name: github-clone
      script:
        image: bitnami/git:latest
        command: [sh]
        source: |
          #!/bin/bash
          mkdir -p /root/.ssh
          cp /github/id_rsa /root/.ssh/
          chmod 600 /root/.ssh/id_rsa
          touch /root/.ssh/known_hosts && ssh-keyscan github.com >> /root/.ssh/known_hosts

          cd /workspace
          git clone git@github.com:Cih2001/geekembly.git          

        volumeMounts:
          - name: workspace
            mountPath: /workspace
          - name: ssh-vol
            mountPath: /github

Image Existence Check

Another operation we often want to do, it to check if the image we want is already in our private container registry or not. We can write a python script, and access the registry from it.

spec:
  templates:
    - name: image-check
      inputs:
        parameters:
          - name: DOCKER_REGISTRY_SERVER_PROTOCOL
            value: "http"
          - name: DOCKER_REGISTRY_ADDRESS
          - name: DOCKER_REGISTRY_IMAGE_NAME
          - name: DOCKER_REGISTRY_IMAGE_TAG
      script:
        image: python:alpine3.6
        command: ["sh"]
        source: |
          #!/bin/sh
          pip install requests &> /dev/null

          python - << EOF
          import os
          import requests
          from requests.auth import HTTPBasicAuth

          protocol = os.getenv('DOCKER_REGISTRY_SERVER_PROTOCOL')
          address = os.getenv('DOCKER_REGISTRY_ADDRESS')
          username = os.getenv('DOCKER_REGISTRY_USERNAME')
          password = os.getenv('DOCKER_REGISTRY_PASSWORD')
          image = os.getenv('DOCKER_REGISTRY_IMAGE_NAME')
          tag = os.getenv('DOCKER_REGISTRY_IMAGE_TAG')

          # Check if the credentials are available
          if username is None or password is None:
              print("Error: Environment variables for username and/or password are not set.")
              exit(1)

          url = f"{protocol}://{address}/v2/{image}/tags/list"
          response = requests.get(url, auth=HTTPBasicAuth(username, password))

          # Check if the request was successful
          tags = []
          if response.status_code == 200:
              tags = response.json()["tags"]

          if tag in tags:
              print("true")
          else:
              print("false")
          EOF          

        env:
          - name: DOCKER_REGISTRY_USERNAME
            valueFrom:
              secretKeyRef:
                name: geekembly-registry-cred
                key: username
          - name: DOCKER_REGISTRY_PASSWORD
            valueFrom:
              secretKeyRef:
                name: geekembly-registry-cred
                key: password
          - name: DOCKER_REGISTRY_SERVER_PROTOCOL
            value: "{{inputs.parameters.DOCKER_REGISTRY_SERVER_PROTOCOL}}"
          - name: DOCKER_REGISTRY_ADDRESS
            value: "{{inputs.parameters.DOCKER_REGISTRY_ADDRESS}}"
          - name: DOCKER_REGISTRY_IMAGE_NAME
            value: "{{inputs.parameters.DOCKER_REGISTRY_IMAGE_NAME}}"
          - name: DOCKER_REGISTRY_IMAGE_TAG
            value: "{{inputs.parameters.DOCKER_REGISTRY_IMAGE_TAG}}"

This requires our container registry cred stored in a sealed secret named geekembly-registry-cred.

Building and Pushing Docker Images

To build docker images, there are a couple of options available. We can use kaniko, BuildKit or, dind. The easiets and the most straight forward one to use and setup is Kaniko.

spec:
  templates:
    - name: kaniko-build
      inputs:
        parameters:
          - name: DOCKER_REGISTRY_ADDRESS
          - name: DOCKER_REGISTRY_IMAGE_NAME
          - name: DOCKER_REGISTRY_IMAGE_TAG
          - name: DOCKER_FILE
      container:
        image: gcr.io/kaniko-project/executor:latest
        command:
          - /kaniko/executor
        args:
          - "--dockerfile={{inputs.parameters.DOCKER_FILE}}"
          - "--context=dir:///workspace"
          - "--destination={{inputs.parameters.DOCKER_REGISTRY_ADDRESS}}/{{inputs.parameters.DOCKER_REGISTRY_IMAGE_NAME}}:{{inputs.parameters.DOCKER_REGISTRY_IMAGE_TAG}}"
        volumeMounts:
          - name: workspace
            mountPath: /workspace
          - name: docker-vol
            mountPath: /kaniko/.docker/config.json
            subPath: .dockerconfigjson
          - name: docker-vol
            mountPath: /kaniko/.docker/.dockerconfigjson
            subPath: .dockerconfigjson

This requires the regcred secret we made in the deployment step.

Git Tree Hash

To tag our images, we can use the git tree hash:

spec:
  templates:
    - name: github-tree-hash
      inputs:
        parameters:
          - name: path
          - name: git-dir
            value: .
      script:
        image: bitnami/git:latest
        command: [sh]
        source: |
          #!/bin/bash

          cd {{inputs.parameters.git-dir}}
          git ls-tree HEAD {{inputs.parameters.path}} --format='%(objectname)'          

        volumeMounts:
          - name: workspace
            mountPath: /workspace

CD Pipeline

The CD pipeline involves updating the deployment image tag and triggering an ArgoCD sync.

Update Deployment Tag

Automatically updating the deployment tag and pushing the changes to the repository:

spec:
  templates:
    - name: update-deployment-tag
      inputs:
        parameters:
          - name: deployment-dir
          - name: release-tag
          - name: git-user
            value: release-bot
          - name: git-email
            value: release@bot.com
          - name: branch
            value: main
      script:
        image: bitnami/git
        command: [sh]
        source: |
          #!/bin/bash

          set -eu

          mkdir -p /root/.ssh
          cp /github/id_rsa /root/.ssh/
          chmod 600 /root/.ssh/id_rsa
          touch /root/.ssh/known_hosts && ssh-keyscan github.com >> /root/.ssh/known_hosts

          cd {{inputs.parameters.deployment-dir}}

          echo BEFORE:
          cat kustomization.yaml

          echo AFTER:
          cat kustomization.yaml | sed -e 's@newTag.*@newTag: "{{inputs.parameters.release-tag}}"@g'| tee kustomization.yaml

          git config user.name "{{inputs.parameters.git-user}}"
          git config user.email "{{inputs.parameters.git-email}}"
          git checkout {{inputs.parameters.branch}}
          git pull
          git add kustomization.yaml
          git commit -m "updated deployment with the release-tag {{inputs.parameters.release-tag}}"
          git push -u origin {{inputs.parameters.branch}} -f          

        volumeMounts:
          - name: workspace
            mountPath: /workspace
          - name: ssh-vol
            mountPath: /github

This will push the latest image tag to the github under the name of a git user called release-bot.

ArgoCD sync

At the last step, we need to manually trigger the ArgoCD sync, so it pulls the latest deployment files after the commit made by the release-bot.

spec:
  templates:
    - name: argocd-sync-and-wait
      inputs:
        parameters:
          - name: argocd-version
            value: v1.6.0
          - name: application-name
          - name: revision
            value: HEAD
          - name: flags
            description: additional flags to pass to argocd
            value: --
          - name: argocd-server-address
          - name: argocd-credentials-secret
      script:
        image: argoproj/argocd:{{inputs.parameters.argocd-version}}
        command: [bash]
        env:
          - name: ARGOCD_AUTH_TOKEN
            valueFrom:
              secretKeyRef:
                name: "{{inputs.parameters.argocd-credentials-secret}}"
                key: token
                optional: true
          - name: ARGOCD_SERVER
            value: "{{inputs.parameters.argocd-server-address}}"
        source: |
          #!/bin/bash

          set -euo pipefail

          if [ -z $ARGOCD_AUTH_TOKEN ]; then
            echo "ARGOCD_AUTH_TOKEN must be specified."
            exit 1
          fi 

          echo "Running as ArgoCD User:"
          argocd account get-user-info {{inputs.parameters.flags}}

          argocd app sync {{inputs.parameters.application-name}} --revision {{inputs.parameters.revision}} {{inputs.parameters.flags}}
          argocd app wait {{inputs.parameters.application-name}} --health {{inputs.parameters.flags}}          

For this to work, we need an ArgoCD authentication token for the ArgoCD user we created in ArgoCD Application. So first login with geekembly user in the argocd and create a token for it:

Token

and then add store it in a secret:

kubectl create secret generic argocd-cred -o yaml --dry-run=client --from-literal='token=<api-token-here>' -n geekembly | kubeseal -o yaml

Conclusion

In this blog, we detailed the process of creating an isolated Argo project and deploying it with a CI/CD pipeline using Hugo and ArgoCD. In the next part, we will glue these components with Argo Events. Stay tuned! 🚀