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:
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! 🚀