AArgo CD LabHands-on GitOps path
Overall progress0%
0 of 178 tasks
Interactive study guide

Learn the GitOps loop by building it yourself.

Work through one task at a time, copy commands when needed, and tick each checkbox directly here.

MergeBuild imageUpdate GitArgo CD syncCronJob runs
Study roadmap

Implementing GitOps Deployment for a Kubernetes CronJob with Argo CD

argo-cdkubernetescronjobgitopsgithub-actionsghcrkustomizeci-cdcontainer-imagesdeployment-automationstudy-plan
0 of 0 complete

Goal

Understand the complete path from merging code to automatically deploying a new CronJob image, while keeping Git as the deployment source of truth.

Interactive dashboard

Use the hosted study dashboard to tick tasks directly in the browser and keep progress without editing these Markdown files.

Intended structure

TEXT
app/                    # Small program executed by the CronJob
Dockerfile              # Container image definition
deploy/base/            # Base Kubernetes CronJob manifest
deploy/overlays/local/  # Local Kustomize image configuration
argocd/                 # Argo CD Application manifest
.github/workflows/      # Image build and Git tag-update workflow
docs/                   # Study plan and project documentation

Phase 1 — Build the scheduled application

  • Create a small application that prints its execution time and Git commit version.
  • Containerize it and run the image manually.
  • Verify its output before introducing Kubernetes.

Milestone: The container runs locally and prints the expected version.

Phase 2 — Run it as a Kubernetes CronJob

  • Create a local Kind cluster.
  • Write a batch/v1 CronJob manifest that initially runs every two minutes.
  • Learn schedule, concurrencyPolicy, job history, retry behavior, and suspend.
  • Trigger a manual Job from the CronJob and inspect its logs.

Milestone: Kubernetes creates Jobs on schedule and the logs are understandable.

Phase 3 — Deploy through Argo CD

  • Install Argo CD in the local cluster.
  • Create a Kustomize base and local overlay.
  • Create an Argo CD Application that watches deploy/overlays/local on main.
  • Enable automatic sync, pruning, self-healing, and namespace creation.
  • Change the CronJob schedule in Git and observe Argo CD synchronize it.

Milestone: A Git commit changes the cluster without deploying through kubectl or CI.

Phase 4 — Build an image after every merge

  • Add a GitHub Actions workflow triggered by application changes merged into main.
  • Test and build the container.
  • Tag it as sha-<full-commit-sha> and push it to GHCR.
  • Use immutable tags instead of latest.

Milestone: Every application merge produces a traceable GHCR image.

Phase 5 — Update the desired image tag in Git

  • Let CI update only the Kustomize newTag value after publishing the image.
  • Commit and push that configuration change back to this repository.
  • Restrict workflow paths so the automated configuration commit does not start another image build.
  • Let Argo CD discover and deploy the new desired state.

Milestone: CI hands deployment responsibility to Git, and Argo CD performs the deployment.

Phase 6 — Verify the complete GitOps loop

For one merged pull request, verify:

  1. GitHub Actions succeeds.
  2. GHCR contains the merge commit's image tag.
  3. Kustomize contains that exact tag.
  4. Argo CD reports the application as synced.
  5. The next scheduled Job prints the new version.

Phase 7 — Practice failures and rollback

  • Diagnose an invalid image tag and ImagePullBackOff.
  • Diagnose an application that exits unsuccessfully.
  • Test concurrencyPolicy: Forbid with a long-running Job.
  • Test Argo CD self-healing after a manual cluster change.
  • Revert an image-tag commit and verify that the following Job uses the previous image.

Milestone: Failures are diagnosed from evidence, and rollback is performed through Git.

Optional Phase 8 — Compare Argo CD Image Updater

  • Replace the CI tag-edit step with the CRD-based Argo CD Image Updater.
  • Track SHA tags with the newest-build strategy and Git write-back.
  • Compare asynchronous registry reconciliation with the deterministic CI-driven update used above.

Definition of done

  • A merge builds one immutable image tied to its commit SHA.
  • Git records the exact image selected for deployment.
  • Argo CD automatically synchronizes the cluster from Git.
  • The next CronJob execution uses the selected image.
  • A Git revert performs a successful rollback.

Primary references

Phase 1

Building and Containerizing the Scheduled Application

argo-cdkubernetescronjobdockercolimashell-scriptcontainer-imagesimplementationlocal-development
0 of 19 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
pwd
Terminal
brew install colima kind argocd kustomize
Terminal
colima start \
  --runtime docker \
  --cpu 4 \
  --memory 6

docker context use colima
docker info --format 'Docker daemon ready: {{.ServerVersion}}'
Terminal
colima status
docker version
git --version
gh --version
kind version
kubectl version --client
argocd version --client
kustomize version
Terminal
gh auth status
Terminal
cat > app/run.sh <<'EOF'
#!/bin/sh
set -eu

printf 'execution_time=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'app_version=%s\n' "${APP_VERSION:-unknown}"

sleep_seconds="${SLEEP_SECONDS:-0}"
if [ "$sleep_seconds" -gt 0 ]; then
  printf 'sleep_seconds=%s\n' "$sleep_seconds"
  sleep "$sleep_seconds"
fi

if [ "${FORCE_FAILURE:-false}" = "true" ]; then
  printf 'result=forced-failure\n' >&2
  exit 1
fi

printf 'result=success\n'
EOF

chmod +x app/run.sh
Terminal
sh -n app/run.sh
APP_VERSION=sha-local ./app/run.sh
Terminal
cat > Dockerfile <<'EOF'
FROM alpine:3.22

ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION

COPY app/run.sh /usr/local/bin/run
RUN chmod 0555 /usr/local/bin/run

USER 65532:65532
ENTRYPOINT ["/usr/local/bin/run"]
EOF
Terminal
docker build \
  --build-arg APP_VERSION=sha-local \
  --tag argocd-cronjob-gitops-lab:local \
  .
Terminal
docker run --rm argocd-cronjob-gitops-lab:local
Terminal
docker run --rm \
  --env FORCE_FAILURE=true \
  argocd-cronjob-gitops-lab:local

echo $?
Terminal
git status --short
sed -n '1,240p' Dockerfile
sed -n '1,240p' app/run.sh
Terminal
git add Dockerfile app/run.sh
git commit -m "Add scheduled application container"
git push

Completion check

Run every verification command before ticking the completion boxes.

Terminal
sh -n app/run.sh
echo $?

docker run --rm argocd-cronjob-gitops-lab:local

docker run --rm \
  --env FORCE_FAILURE=true \
  argocd-cronjob-gitops-lab:local
echo $?

git status --short --branch
Phase 2

Running the Container as a Kubernetes CronJob on Kind

kubernetescronjobkindkustomizejobcontainer-imagesimplementationlocal-cluster
0 of 22 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
docker image inspect argocd-cronjob-gitops-lab:local
Terminal
kind create cluster --name argocd-study
Terminal
kubectl config use-context kind-argocd-study
kubectl cluster-info
kubectl get nodes
Terminal
kind load docker-image \
  argocd-cronjob-gitops-lab:local \
  --name argocd-study
Terminal
mkdir -p deploy/base
Terminal
cat > deploy/base/cronjob.yaml <<'EOF'
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cronjob-lab
  labels:
    app.kubernetes.io/name: cronjob-lab
spec:
  schedule: "*/2 * * * *"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 60
  successfulJobsHistoryLimit: 2
  failedJobsHistoryLimit: 2
  jobTemplate:
    spec:
      backoffLimit: 1
      activeDeadlineSeconds: 90
      template:
        metadata:
          labels:
            app.kubernetes.io/name: cronjob-lab
        spec:
          restartPolicy: Never
          containers:
            - name: cronjob-lab
              image: argocd-cronjob-gitops-lab:local
              imagePullPolicy: IfNotPresent
EOF
Terminal
cat > deploy/base/kustomization.yaml <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - cronjob.yaml
EOF
Terminal
kustomize build deploy/base
Terminal
kubectl create namespace cronjob-study
Terminal
kubectl apply -k deploy/base --namespace cronjob-study
kubectl get cronjob --namespace cronjob-study
Terminal
manual_job_name="manual-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${manual_job_name}" \
  --namespace cronjob-study
Terminal
kubectl wait \
  --for=condition=complete \
  "job/${manual_job_name}" \
  --namespace cronjob-study \
  --timeout=120s

kubectl logs \
  "job/${manual_job_name}" \
  --namespace cronjob-study
Terminal
kubectl get cronjobs,jobs,pods \
  --namespace cronjob-study \
  --show-labels
Terminal
kubectl get jobs \
  --namespace cronjob-study \
  --watch
Terminal
kubectl get jobs \
  --namespace cronjob-study \
  --sort-by=.metadata.creationTimestamp
Terminal
newest_job_name="$(kubectl get jobs \
  --namespace cronjob-study \
  --sort-by=.metadata.creationTimestamp \
  --output jsonpath='{.items[-1:].metadata.name}')"

kubectl logs \
  "job/${newest_job_name}" \
  --namespace cronjob-study
Terminal
git add deploy/base
git commit -m "Add Kubernetes CronJob base"
git push

Completion check

Phase 3

Deploying the CronJob from a Private Git Repository with Argo CD

argo-cdkubernetescronjobkindkustomizegitopsprivate-repositoryssh-deploy-keyautomated-syncimplementation
0 of 22 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
kubectl config use-context kind-argocd-study
Terminal
kubectl create namespace argocd
kubectl apply \
  --namespace argocd \
  --server-side \
  --force-conflicts \
  --filename https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
Terminal
kubectl wait \
  --for=condition=Available \
  deployment/argocd-server \
  deployment/argocd-repo-server \
  --namespace argocd \
  --timeout=300s
Terminal
mkdir -p .local-secrets
ssh-keygen \
  -t ed25519 \
  -C "argocd-cronjob-gitops-lab" \
  -f .local-secrets/argocd-repository \
  -N ""
Terminal
gh repo deploy-key add \
  .local-secrets/argocd-repository.pub \
  --repo davidleora/argocd-cronjob-gitops-lab \
  --title "Argo CD local lab" \
  --allow-write
Terminal
kubectl port-forward \
  service/argocd-server \
  --namespace argocd \
  8080:443
Terminal
argocd_admin_password="$(kubectl get secret argocd-initial-admin-secret \
  --namespace argocd \
  --output jsonpath='{.data.password}' | base64 -D)"

argocd login localhost:8080 \
  --username admin \
  --password "${argocd_admin_password}" \
  --insecure
Terminal
argocd repo add \
  git@github.com:davidleora/argocd-cronjob-gitops-lab.git \
  --name argocd-cronjob-gitops-lab \
  --ssh-private-key-path .local-secrets/argocd-repository

argocd repo list
Terminal
mkdir -p deploy/overlays/local
cat > deploy/overlays/local/kustomization.yaml <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cronjob-study
resources:
  - ../../base
images:
  - name: argocd-cronjob-gitops-lab
    newName: argocd-cronjob-gitops-lab
    newTag: local
EOF
Terminal
kustomize build deploy/overlays/local
Terminal
mkdir -p argocd
cat > argocd/application.yaml <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cronjob-lab
  namespace: argocd
spec:
  project: default
  source:
    repoURL: git@github.com:davidleora/argocd-cronjob-gitops-lab.git
    targetRevision: main
    path: deploy/overlays/local
  destination:
    server: https://kubernetes.default.svc
    namespace: cronjob-study
  syncPolicy:
    automated:
      enabled: true
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
    retry:
      limit: 5
      backoff:
        duration: 5s
        factor: 2
        maxDuration: 3m
EOF
Terminal
git add deploy/overlays/local argocd/application.yaml
git commit -m "Add Argo CD managed local overlay"
git push
Terminal
kubectl apply --filename argocd/application.yaml
Terminal
argocd app wait cronjob-lab \
  --sync \
  --health \
  --timeout 300

argocd app get cronjob-lab
Terminal
kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output yaml
Terminal
sed -i '' \
  's|schedule: "\*/2 \* \* \* \*"|schedule: "\*/3 \* \* \* \*"|' \
  deploy/base/cronjob.yaml

git diff -- deploy/base/cronjob.yaml
Terminal
git add deploy/base/cronjob.yaml
git commit -m "Change CronJob schedule through GitOps"
git push
Terminal
argocd app wait cronjob-lab --sync --timeout 300

kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.schedule}{"\n"}'

Completion check

Phase 4

Building and Publishing a Multi-Platform Image After Every Merge

github-actionsghcrdockercontainer-imagesmulti-platform-buildarm64amd64ciimmutable-tagsprivate-registryimplementation
0 of 25 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
git switch main
git pull --ff-only
git switch -c phase-4-publish-images
Terminal
mkdir -p .github/workflows
cat > .github/workflows/publish-image.yaml <<'EOF'
name: Publish container image

on:
  push:
    branches:
      - main
    paths:
      - app/**
      - Dockerfile
      - .dockerignore
      - .github/workflows/publish-image.yaml

concurrency:
  group: publish-image-main
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Check out the repository
        uses: actions/checkout@v6

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push the immutable image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          build-args: |
            APP_VERSION=sha-${{ github.sha }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
EOF
Terminal
sed -n '1,240p' .github/workflows/publish-image.yaml
git diff --check
Terminal
git add .github/workflows/publish-image.yaml
git commit -m "Add multi-platform GHCR publishing workflow"
git push -u origin phase-4-publish-images
Terminal
gh pr create \
  --base main \
  --head phase-4-publish-images \
  --title "Add GHCR image publishing" \
  --body "Build and publish an immutable multi-platform image after application changes reach main."
Terminal
pr_number="$(gh pr view --json number --jq '.number')"
gh pr merge "${pr_number}" --squash --delete-branch
Terminal
merge_sha="$(gh pr view "${pr_number}" \
  --json mergeCommit \
  --jq '.mergeCommit.oid')"

printf 'expected_tag=sha-%s\n' "${merge_sha}"
Terminal
run_id="$(gh run list \
  --workflow publish-image.yaml \
  --branch main \
  --limit 1 \
  --json databaseId \
  --jq '.[0].databaseId')"

gh run watch "${run_id}" --exit-status
Terminal
gh auth refresh \
  --hostname github.com \
  --scopes read:packages
Terminal
gh auth token | docker login ghcr.io \
  --username davidleora \
  --password-stdin
Terminal
docker pull \
  "ghcr.io/davidleora/argocd-cronjob-gitops-lab:sha-${merge_sha}"
Terminal
docker run --rm \
  "ghcr.io/davidleora/argocd-cronjob-gitops-lab:sha-${merge_sha}"
Terminal
git switch main
git pull --ff-only
git status --short --branch
Terminal
kubectl create secret docker-registry ghcr-pull \
  --namespace cronjob-study \
  --docker-server=ghcr.io \
  --docker-username=davidleora \
  --docker-password="$(gh auth token)" \
  --dry-run=client \
  --output yaml | kubectl apply --filename -
Terminal
cat > deploy/overlays/local/image-pull-secret-patch.yaml <<'EOF'
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cronjob-lab
spec:
  jobTemplate:
    spec:
      template:
        spec:
          imagePullSecrets:
            - name: ghcr-pull
EOF
Terminal
cat >> deploy/overlays/local/kustomization.yaml <<'EOF'
patches:
  - path: image-pull-secret-patch.yaml
EOF
Terminal
cd deploy/overlays/local
kustomize edit set image \
  "argocd-cronjob-gitops-lab=ghcr.io/davidleora/argocd-cronjob-gitops-lab:sha-${merge_sha}"
cd ../../..
Terminal
kustomize build deploy/overlays/local | \
  grep -E 'image:|imagePullSecrets:|- name: ghcr-pull'
Terminal
git add deploy/overlays/local
git commit -m "Deploy first GHCR image through Argo CD"
git push
Terminal
argocd app wait cronjob-lab --sync --timeout 300

manual_job_name="ghcr-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${manual_job_name}" \
  --namespace cronjob-study

kubectl wait \
  --for=condition=complete \
  "job/${manual_job_name}" \
  --namespace cronjob-study \
  --timeout=180s

kubectl logs \
  "job/${manual_job_name}" \
  --namespace cronjob-study

Completion check

Phase 5

Updating the Desired Image Tag in Git After Every Merge

github-actionsgitopsargo-cdkustomizeghcrimage-tagsgit-write-backdeployment-automationimplementation
0 of 17 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
git switch main
git pull --ff-only
git switch -c phase-5-update-image-tag
Terminal
cat > .github/workflows/publish-image.yaml <<'EOF'
name: Publish container image

on:
  push:
    branches:
      - main
    paths:
      - app/**
      - Dockerfile
      - .dockerignore
      - .github/workflows/publish-image.yaml

concurrency:
  group: publish-image-main
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write
    steps:
      - name: Check out the repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push the immutable image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          build-args: |
            APP_VERSION=sha-${{ github.sha }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}

      - name: Update the desired image tag in Git
        env:
          IMAGE_TAG: sha-${{ github.sha }}
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git fetch origin main
          git checkout main
          git pull --ff-only origin main
          sed -i "s|^[[:space:]]*newTag:.*|    newTag: ${IMAGE_TAG}|" \
            deploy/overlays/local/kustomization.yaml
          grep "newTag:" deploy/overlays/local/kustomization.yaml
          if git diff --quiet -- deploy/overlays/local/kustomization.yaml; then
            exit 0
          fi
          git add deploy/overlays/local/kustomization.yaml
          git commit -m "chore: deploy ${IMAGE_TAG}"
          git push origin main
EOF
Terminal
sed -n '1,260p' .github/workflows/publish-image.yaml
git diff --check
Terminal
git add .github/workflows/publish-image.yaml
git commit -m "Update desired image tag after publishing"
git push -u origin phase-5-update-image-tag
Terminal
gh pr create \
  --base main \
  --head phase-5-update-image-tag \
  --title "Automate GitOps image tag updates" \
  --body "Publish the merge image, commit its immutable tag to Kustomize, and let Argo CD deploy it."

pr_number="$(gh pr view --json number --jq '.number')"
gh pr merge "${pr_number}" --squash --delete-branch
Terminal
merge_sha="$(gh pr view "${pr_number}" \
  --json mergeCommit \
  --jq '.mergeCommit.oid')"

printf 'expected_tag=sha-%s\n' "${merge_sha}"
Terminal
run_id="$(gh run list \
  --workflow publish-image.yaml \
  --branch main \
  --limit 1 \
  --json databaseId \
  --jq '.[0].databaseId')"

gh run watch "${run_id}" --exit-status
Terminal
git switch main
git pull --ff-only
git log --oneline -3
Terminal
grep "newTag:" deploy/overlays/local/kustomization.yaml
test "$(sed -n 's/^[[:space:]]*newTag: //p' deploy/overlays/local/kustomization.yaml)" = "sha-${merge_sha}"
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300
Terminal
kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}{"\n"}'
Terminal
manual_job_name="gitops-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${manual_job_name}" \
  --namespace cronjob-study

kubectl wait \
  --for=condition=complete \
  "job/${manual_job_name}" \
  --namespace cronjob-study \
  --timeout=180s

kubectl logs \
  "job/${manual_job_name}" \
  --namespace cronjob-study

Completion check

Phase 6

Verifying the Complete Merge-to-CronJob GitOps Loop

argo-cdgitopsgithub-actionsghcrkubernetescronjobkustomizeend-to-end-testingverification
0 of 23 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
git switch main
git pull --ff-only
git status --short --branch
Terminal
git switch -c verify-complete-gitops-loop
Terminal
cat >> app/run.sh <<'EOF'
printf 'message=complete-gitops-loop\n'
EOF

sh -n app/run.sh
APP_VERSION=sha-local ./app/run.sh
Terminal
docker build \
  --build-arg APP_VERSION=sha-local-e2e \
  --tag argocd-cronjob-gitops-lab:e2e \
  .

docker run --rm argocd-cronjob-gitops-lab:e2e
Terminal
git add app/run.sh
git commit -m "Add GitOps verification message"
git push -u origin verify-complete-gitops-loop
Terminal
gh pr create \
  --base main \
  --head verify-complete-gitops-loop \
  --title "Verify the complete GitOps loop" \
  --body "Use a visible application message to verify merge, image publishing, Git write-back, Argo CD sync, and CronJob execution."
Terminal
pr_number="$(gh pr view --json number --jq '.number')"
gh pr merge "${pr_number}" --squash --delete-branch
Terminal
merge_sha="$(gh pr view "${pr_number}" \
  --json mergeCommit \
  --jq '.mergeCommit.oid')"

expected_tag="sha-${merge_sha}"
printf 'expected_tag=%s\n' "${expected_tag}"
Terminal
run_id="$(gh run list \
  --workflow publish-image.yaml \
  --branch main \
  --limit 1 \
  --json databaseId \
  --jq '.[0].databaseId')"

gh run watch "${run_id}" --exit-status
Terminal
git switch main
git pull --ff-only
git log --oneline -3
grep "newTag:" deploy/overlays/local/kustomization.yaml
test "$(sed -n 's/^[[:space:]]*newTag: //p' deploy/overlays/local/kustomization.yaml)" = "${expected_tag}"
Terminal
docker pull \
  "ghcr.io/davidleora/argocd-cronjob-gitops-lab:${expected_tag}"
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --health --timeout 300
Terminal
git_tag="$(sed -n 's/^[[:space:]]*newTag: //p' deploy/overlays/local/kustomization.yaml)"
live_image="$(kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}')"

printf 'git_tag=%s\n' "${git_tag}"
printf 'live_image=%s\n' "${live_image}"
Terminal
case "${live_image}" in
  *":${expected_tag}") printf 'image_match=true\n' ;;
  *) printf 'image_match=false\n'; exit 1 ;;
esac
Terminal
manual_job_name="e2e-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${manual_job_name}" \
  --namespace cronjob-study
Terminal
kubectl wait \
  --for=condition=complete \
  "job/${manual_job_name}" \
  --namespace cronjob-study \
  --timeout=180s

job_output="$(kubectl logs \
  "job/${manual_job_name}" \
  --namespace cronjob-study)"

printf '%s\n' "${job_output}"
Terminal
printf '%s\n' "${job_output}" | grep "app_version=${expected_tag}"
printf '%s\n' "${job_output}" | grep "message=complete-gitops-loop"
Terminal
git status --short --branch
argocd app get cronjob-lab
kubectl get cronjobs,jobs,pods --namespace cronjob-study

Completion check

Phase 7

Troubleshooting CronJob Failures and Performing Git Rollbacks

argo-cdkubernetescronjobgitopstroubleshootingimagepullbackoffjob-failureconcurrency-policyself-healingrollbackincident-response
0 of 28 complete

Exercise 1 — Invalid image tag

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
git switch main
git pull --ff-only
git status --short --branch

kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}{"\n"}'
Terminal
cd deploy/overlays/local
kustomize edit set image \
  argocd-cronjob-gitops-lab=ghcr.io/davidleora/argocd-cronjob-gitops-lab:sha-does-not-exist
cd ../../..

git diff -- deploy/overlays/local/kustomization.yaml
Terminal
git add deploy/overlays/local/kustomization.yaml
git commit -m "test: deploy a nonexistent image tag"
broken_commit="$(git rev-parse HEAD)"
git push
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}{"\n"}'
Terminal
broken_job_name="broken-image-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${broken_job_name}" \
  --namespace cronjob-study
Terminal
kubectl get pods \
  --namespace cronjob-study \
  --selector "job-name=${broken_job_name}" \
  --watch
Terminal
broken_pod_name="$(kubectl get pods \
  --namespace cronjob-study \
  --selector "job-name=${broken_job_name}" \
  --output jsonpath='{.items[0].metadata.name}')"

kubectl describe pod "${broken_pod_name}" --namespace cronjob-study
Terminal
git revert --no-edit "${broken_commit}"
git push
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}{"\n"}'

Exercise 2 — Application exits unsuccessfully

Terminal
cat > deploy/overlays/local/forced-failure-patch.yaml <<'EOF'
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cronjob-lab
spec:
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: cronjob-lab
              env:
                - name: FORCE_FAILURE
                  value: "true"
EOF

cat >> deploy/overlays/local/kustomization.yaml <<'EOF'
  - path: forced-failure-patch.yaml
EOF
Terminal
kustomize build deploy/overlays/local | grep -A3 FORCE_FAILURE
Terminal
git add deploy/overlays/local
git commit -m "test: force the CronJob application to fail"
failure_commit="$(git rev-parse HEAD)"
git push
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

failure_job_name="forced-failure-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${failure_job_name}" \
  --namespace cronjob-study
Terminal
kubectl wait \
  --for=condition=failed \
  "job/${failure_job_name}" \
  --namespace cronjob-study \
  --timeout=240s

kubectl logs \
  "job/${failure_job_name}" \
  --namespace cronjob-study

kubectl describe \
  "job/${failure_job_name}" \
  --namespace cronjob-study
Terminal
git revert --no-edit "${failure_commit}"
git push
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

Exercise 3 — Forbid concurrent executions

Terminal
cat > deploy/overlays/local/long-running-patch.yaml <<'EOF'
apiVersion: batch/v1
kind: CronJob
metadata:
  name: cronjob-lab
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      activeDeadlineSeconds: 240
      template:
        spec:
          containers:
            - name: cronjob-lab
              env:
                - name: SLEEP_SECONDS
                  value: "180"
EOF

cat >> deploy/overlays/local/kustomization.yaml <<'EOF'
  - path: long-running-patch.yaml
EOF
Terminal
git add deploy/overlays/local
git commit -m "test: verify forbidden CronJob concurrency"
concurrency_commit="$(git rev-parse HEAD)"
git push

argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300
Terminal
kubectl get jobs \
  --namespace cronjob-study \
  --watch
Terminal
kubectl get jobs --namespace cronjob-study
kubectl describe cronjob cronjob-lab --namespace cronjob-study
Terminal
git revert --no-edit "${concurrency_commit}"
git push
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

Exercise 4 — Argo CD self-healing

Terminal
kubectl patch cronjob cronjob-lab \
  --namespace cronjob-study \
  --type merge \
  --patch '{"spec":{"suspend":true}}'
Terminal
kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --watch \
  --output custom-columns='NAME:.metadata.name,SUSPEND:.spec.suspend'
Terminal
argocd app get cronjob-lab
git status --short --branch
git log --oneline -8

Completion check

Optional phase 8

Comparing Argo CD Image Updater with CI-Driven Tag Updates

argo-cdargo-cd-image-updaterkubernetesgitopsghcrprivate-registrykustomizenewest-buildgit-write-backcomparisonoptional
0 of 22 complete

Checklist

Terminal
cd /Users/davidleora/Projects/argocd-cronjob-gitops-lab
git switch main
git pull --ff-only
git switch -c phase-8-image-updater
Terminal
cat > .github/workflows/publish-image.yaml <<'EOF'
name: Publish container image

on:
  push:
    branches:
      - main
    paths:
      - app/**
      - Dockerfile
      - .dockerignore
      - .github/workflows/publish-image.yaml

concurrency:
  group: publish-image-main
  cancel-in-progress: true

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Check out the repository
        uses: actions/checkout@v6

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push the immutable image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          build-args: |
            APP_VERSION=sha-${{ github.sha }}
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
EOF
Terminal
git add .github/workflows/publish-image.yaml
git commit -m "Delegate image tag updates to Argo CD Image Updater"
git push -u origin phase-8-image-updater

gh pr create \
  --base main \
  --head phase-8-image-updater \
  --title "Compare Argo CD Image Updater" \
  --body "Keep image publishing in CI and move Git tag write-back to Argo CD Image Updater."

pr_number="$(gh pr view --json number --jq '.number')"
gh pr merge "${pr_number}" --squash --delete-branch
Terminal
run_id="$(gh run list \
  --workflow publish-image.yaml \
  --branch main \
  --limit 1 \
  --json databaseId \
  --jq '.[0].databaseId')"

gh run watch "${run_id}" --exit-status

git switch main
git pull --ff-only
git log --oneline -3
Terminal
kubectl apply \
  --namespace argocd \
  --filename https://raw.githubusercontent.com/argoproj-labs/argocd-image-updater/stable/config/install.yaml

kubectl wait \
  --for=condition=Available \
  deployment/argocd-image-updater \
  --namespace argocd \
  --timeout=300s
Terminal
kubectl create secret docker-registry ghcr-image-updater \
  --namespace argocd \
  --docker-server=ghcr.io \
  --docker-username=davidleora \
  --docker-password="$(gh auth token)" \
  --dry-run=client \
  --output yaml | kubectl apply --filename -
Terminal
argocd repo list
gh repo deploy-key list \
  --repo davidleora/argocd-cronjob-gitops-lab
Terminal
cat > argocd/image-updater.yaml <<'EOF'
apiVersion: argocd-image-updater.argoproj.io/v1alpha1
kind: ImageUpdater
metadata:
  name: cronjob-lab
  namespace: argocd
spec:
  writeBackConfig:
    method: git
    gitConfig:
      repository: git@github.com:davidleora/argocd-cronjob-gitops-lab.git
      branch: main
      writeBackTarget: kustomization
  applicationRefs:
    - namePattern: cronjob-lab
      images:
        - alias: cronjob
          imageName: ghcr.io/davidleora/argocd-cronjob-gitops-lab
          commonUpdateSettings:
            updateStrategy: newest-build
            allowTags: "regexp:^sha-[0-9a-f]{40}$"
            pullSecret: "pullsecret:argocd/ghcr-image-updater"
          manifestTargets:
            kustomize:
              name: argocd-cronjob-gitops-lab
EOF
Terminal
git add argocd/image-updater.yaml
git commit -m "Add CRD-based Argo CD Image Updater configuration"
git push
Terminal
kubectl apply --filename argocd/image-updater.yaml
kubectl get imageupdater --namespace argocd
Terminal
kubectl logs \
  deployment/argocd-image-updater \
  --namespace argocd \
  --follow
Terminal
kubectl get imageupdater cronjob-lab \
  --namespace argocd \
  --output yaml
Terminal
git pull --ff-only
git log --oneline -5
grep -A3 '^images:' deploy/overlays/local/kustomization.yaml
Terminal
argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

kubectl get cronjob cronjob-lab \
  --namespace cronjob-study \
  --output jsonpath='{.spec.jobTemplate.spec.template.spec.containers[0].image}{"\n"}'
Terminal
git switch -c verify-image-updater
cat >> app/run.sh <<'EOF'
printf 'image_updater_verification=success\n'
EOF

git add app/run.sh
git commit -m "Add Image Updater verification output"
git push -u origin verify-image-updater

gh pr create \
  --base main \
  --head verify-image-updater \
  --title "Verify Argo CD Image Updater" \
  --body "Publish a new immutable image and let Image Updater select and commit its tag."

pr_number="$(gh pr view --json number --jq '.number')"
gh pr merge "${pr_number}" --squash --delete-branch
Terminal
run_id="$(gh run list \
  --workflow publish-image.yaml \
  --branch main \
  --limit 1 \
  --json databaseId \
  --jq '.[0].databaseId')"

gh run watch "${run_id}" --exit-status

kubectl logs \
  deployment/argocd-image-updater \
  --namespace argocd \
  --follow
Terminal
git switch main
git pull --ff-only
git log --oneline -5

argocd app get cronjob-lab --hard-refresh
argocd app wait cronjob-lab --sync --timeout 300

manual_job_name="image-updater-$(date +%s)"
kubectl create job \
  --from=cronjob/cronjob-lab \
  "${manual_job_name}" \
  --namespace cronjob-study

kubectl wait \
  --for=condition=complete \
  "job/${manual_job_name}" \
  --namespace cronjob-study \
  --timeout=180s

kubectl logs \
  "job/${manual_job_name}" \
  --namespace cronjob-study

Completion check