commit 993e5c7327cb080e5e4f45bae0cf8a7172fdd6ba Author: Ole Valente Date: Thu Aug 27 10:46:36 2026 +0200 initial commit diff --git a/.gitea/workflows/build-and-push.yaml b/.gitea/workflows/build-and-push.yaml new file mode 100644 index 0000000..7487c61 --- /dev/null +++ b/.gitea/workflows/build-and-push.yaml @@ -0,0 +1,54 @@ +# ============================================================================= +# Gitea Actions workflow (active for THIS repository). +# Builds examples/Dockerfile and pushes it to Harbor as a smoke test of the +# k3s runner + DinD + Harbor pipeline. +# +# Required repo/org Actions secrets: +# HARBOR_REGISTRY, HARBOR_USERNAME, HARBOR_PASSWORD +# Optional repo/org Actions variable: +# HARBOR_PROJECT (default: "library") +# ============================================================================= +name: build-and-push + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-22.04 + env: + HARBOR_PROJECT: ${{ vars.HARBOR_PROJECT || 'library' }} + IMAGE_NAME: build-on-k3s-sample + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Compute image tags + id: meta + run: | + IMAGE="${{ secrets.HARBOR_REGISTRY }}/${HARBOR_PROJECT}/${IMAGE_NAME}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" + + - name: Log in to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ secrets.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ./examples + file: ./examples/Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:latest + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80fd85e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Never commit filled-in secrets — only the *.example templates are tracked. +k3s/10-runner-secret.yaml +k3s/40-harbor-secret.yaml +*.secret.yaml + +# Local kube configs +kubeconfig +*.kubeconfig diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec1e65b --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# Build Docker images on k3s with Gitea Actions → push to Harbor + +This repository contains a complete, reproducible configuration that lets you: + +1. Run a **Gitea Actions runner** (`act_runner`) **on a k3s node**. +2. Build **Docker/OCI container images** inside the CI pipeline using a + **Docker-in-Docker (DinD)** sidecar (so plain `docker build` / `docker push` work). +3. **Push the built images to a Harbor** registry. + +The whole pipeline is driven by **Gitea Actions** (Gitea's built-in, GitHub-Actions-compatible +CI/CD), so no external CI system (Jenkins/Drone/etc.) is required. + +``` +┌──────────────┐ registers ┌─────────────────────────────────────┐ +│ Gitea │◄────────────────│ act_runner (Deployment on k3s) │ +│ (Actions) │ job dispatch │ ┌───────────────┐ ┌──────────────┐ │ +│ │────────────────►│ │ act_runner │ │ dockerd │ │ +└──────────────┘ │ │ container │─►│ (DinD side- │ │ + ▲ │ └───────────────┘ │ car, │ │ + │ git push │ docker build │ privileged)│ │ + ┌────┴─────┐ │ docker push └──────────────┘ │ + │ developer│ └───────────────────────────┬─────────┘ + └──────────┘ │ push image + ▼ + ┌──────────────────┐ + │ Harbor registry │ + └──────────────────┘ +``` + +--- + +## Repository layout + +| File | Purpose | +|------|---------| +| [k3s/00-namespace.yaml](k3s/00-namespace.yaml) | Dedicated `gitea-runner` namespace. | +| [k3s/10-runner-secret.yaml.example](k3s/10-runner-secret.yaml.example) | Gitea registration token secret (copy & fill in). | +| [k3s/20-runner-config.yaml](k3s/20-runner-config.yaml) | `act_runner` config (`config.yaml`) as a ConfigMap. | +| [k3s/30-runner-deployment.yaml](k3s/30-runner-deployment.yaml) | The `act_runner` + DinD Deployment. | +| [k3s/40-harbor-secret.yaml.example](k3s/40-harbor-secret.yaml.example) | Optional cluster-side Harbor pull secret. | +| [workflows/build-and-push.yaml](workflows/build-and-push.yaml) | Example Gitea Actions workflow (put in your app repo). | +| [examples/Dockerfile](examples/Dockerfile) | Minimal sample image to build. | +| [scripts/deploy.sh](scripts/deploy.sh) | Helper to apply all manifests. | +| [.gitea/workflows/build-and-push.yaml](.gitea/workflows/build-and-push.yaml) | Same workflow, pre-placed so *this* repo self-builds. | + +--- + +## Prerequisites + +* A running **k3s** cluster and `kubectl` access to it (`~/.kube/config` or `/etc/rancher/k3s/k3s.yaml`). +* A reachable **Gitea** instance (>= 1.20) with **Actions enabled** + (`[actions] ENABLED = true` in Gitea's `app.ini`). +* A running **Harbor** registry and a **robot account** (recommended) with `push` permission + on the target project. + +--- + +## Step 1 — Enable Gitea Actions (on the Gitea server) + +In Gitea's `app.ini`: + +```ini +[actions] +ENABLED = true +``` + +Restart Gitea. Then get a **runner registration token**: + +* **Instance level:** *Site Administration → Actions → Runners → Create new Runner* → copy the token. +* **Org/Repo level:** *Settings → Actions → Runners → Create new Runner*. + +--- + +## Step 2 — Create the runner registration secret + +```bash +cp k3s/10-runner-secret.yaml.example k3s/10-runner-secret.yaml +# edit k3s/10-runner-secret.yaml and paste your GITEA_INSTANCE_URL + token +kubectl apply -f k3s/10-runner-secret.yaml +``` + +## Step 3 — Deploy the runner on k3s + +```bash +./scripts/deploy.sh +# or manually: +kubectl apply -f k3s/00-namespace.yaml +kubectl apply -f k3s/20-runner-config.yaml +kubectl apply -f k3s/30-runner-deployment.yaml +``` + +Verify the runner appears **online** in Gitea (*Actions → Runners*) and that both containers are ready: + +```bash +kubectl -n gitea-runner rollout status deploy/act-runner +kubectl -n gitea-runner get pods +``` + +## Step 4 — Configure Harbor credentials for the pipeline + +In your **Gitea repository** (or org) → *Settings → Actions → Secrets*, add: + +| Secret name | Value | +|-------------|-------| +| `HARBOR_REGISTRY` | e.g. `harbor.example.com` | +| `HARBOR_USERNAME` | Harbor user / robot account, e.g. `robot$ci` | +| `HARBOR_PASSWORD` | the robot account token / password | + +> Using a **Harbor robot account** (Project → Robot Accounts) instead of a personal +> login is strongly recommended. + +## Step 5 — Add the workflow to your app repo + +Copy [workflows/build-and-push.yaml](workflows/build-and-push.yaml) into your application repository at +`.gitea/workflows/build-and-push.yaml`, commit, and push. Gitea will dispatch the job to your +k3s runner, which builds the image via DinD and pushes it to Harbor. + +--- + +## How image building works (no Docker on k3s needed) + +k3s uses **containerd**, not Docker, and has no image-build capability. This setup therefore runs a +**dockerd DinD sidecar** next to `act_runner`. The runner container talks to it over +`tcp://localhost:2376` (TLS), so inside jobs the standard `docker` CLI works transparently — +`docker build`, `docker login`, `docker push` all behave like on a normal Docker host. + +If you prefer a **rootless / non-privileged** approach (Kaniko or Buildah), see the +"Alternative: rootless builds" section at the bottom of +[workflows/build-and-push.yaml](workflows/build-and-push.yaml). + +## Security notes + +* The DinD sidecar requires `privileged: true`. Keep this runner in its own namespace and, + ideally, on a dedicated node (see the `nodeSelector`/`tolerations` comments in the Deployment). +* Prefer Harbor **robot accounts** with the least privilege (push to one project only). +* Store all credentials as Kubernetes/Gitea secrets — never commit the filled-in + `*.yaml` (non-`.example`) files. See [.gitignore](.gitignore). diff --git a/examples/Dockerfile b/examples/Dockerfile new file mode 100644 index 0000000..091bafc --- /dev/null +++ b/examples/Dockerfile @@ -0,0 +1,6 @@ +# Minimal sample image used to smoke-test the build-and-push pipeline. +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates + +CMD ["sh", "-c", "echo 'Built on k3s via Gitea Actions and pushed to Harbor.'"] diff --git a/k3s/00-namespace.yaml b/k3s/00-namespace.yaml new file mode 100644 index 0000000..b28e804 --- /dev/null +++ b/k3s/00-namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: gitea-runner + labels: + app.kubernetes.io/name: gitea-act-runner + app.kubernetes.io/part-of: ci-cd diff --git a/k3s/10-runner-secret.yaml.example b/k3s/10-runner-secret.yaml.example new file mode 100644 index 0000000..9cc7034 --- /dev/null +++ b/k3s/10-runner-secret.yaml.example @@ -0,0 +1,23 @@ +# ----------------------------------------------------------------------------- +# Gitea act_runner registration secret. +# +# 1. Copy this file: cp 10-runner-secret.yaml.example 10-runner-secret.yaml +# 2. Fill in the two stringData values below. +# 3. Apply: kubectl apply -f 10-runner-secret.yaml +# +# GITEA_INSTANCE_URL : Base URL of your Gitea server (no trailing /actions). +# GITEA_RUNNER_REGISTRATION_TOKEN : +# Token from Gitea -> (Site Admin | Org | Repo) -> Actions -> Runners +# -> "Create new Runner". +# +# The runner registers itself on first start using these values. +# ----------------------------------------------------------------------------- +apiVersion: v1 +kind: Secret +metadata: + name: act-runner-registration + namespace: gitea-runner +type: Opaque +stringData: + GITEA_INSTANCE_URL: "https://gitea.example.com" + GITEA_RUNNER_REGISTRATION_TOKEN: "REPLACE_WITH_REGISTRATION_TOKEN" diff --git a/k3s/20-runner-config.yaml b/k3s/20-runner-config.yaml new file mode 100644 index 0000000..5c601b4 --- /dev/null +++ b/k3s/20-runner-config.yaml @@ -0,0 +1,38 @@ +# ----------------------------------------------------------------------------- +# act_runner configuration, mounted into the runner container as config.yaml. +# Docs: https://docs.gitea.com/usage/actions/act-runner +# ----------------------------------------------------------------------------- +apiVersion: v1 +kind: ConfigMap +metadata: + name: act-runner-config + namespace: gitea-runner +data: + config.yaml: | + log: + level: info + + runner: + # Max parallel jobs this runner will execute. + capacity: 2 + timeout: 3h + # Labels advertise which job environments this runner provides. + # The workflow selects one with `runs-on:`. + labels: + - "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" + - "ubuntu-22.04:docker://catthehacker/ubuntu:act-22.04" + - "dind:docker://catthehacker/ubuntu:act-22.04" + + cache: + enabled: true + + container: + # Talk to the DinD sidecar over TCP (TLS) instead of a mounted socket. + docker_host: "tcp://localhost:2376" + # Job containers join the default docker network created by dockerd. + network: "host" + # Keep pulled images between jobs to speed builds up. + force_pull: false + # Give job containers access to the DinD TLS certs. + valid_volumes: + - "**" diff --git a/k3s/30-runner-deployment.yaml b/k3s/30-runner-deployment.yaml new file mode 100644 index 0000000..44896db --- /dev/null +++ b/k3s/30-runner-deployment.yaml @@ -0,0 +1,153 @@ +# ----------------------------------------------------------------------------- +# Gitea act_runner + Docker-in-Docker (DinD) sidecar, running on a k3s node. +# +# - The `dind` container runs a full dockerd (privileged) and exposes it over +# TLS on localhost:2376. Its TLS client certs are shared via an emptyDir. +# - The `runner` container registers with Gitea and dispatches each job to the +# dockerd inside the same pod, so `docker build` / `docker push` "just work". +# +# NOTE: DinD requires a privileged container. Keep this in its own namespace +# and consider pinning it to a dedicated build node (see nodeSelector). +# ----------------------------------------------------------------------------- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: act-runner + namespace: gitea-runner + labels: + app: act-runner +spec: + # Keep at 1: the registered runner state lives in the runner PVC. + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: act-runner + template: + metadata: + labels: + app: act-runner + spec: + # --- Optionally pin builds to a dedicated node ------------------------- + # nodeSelector: + # ci-build: "true" # label a node: kubectl label node ci-build=true + # tolerations: + # - key: "ci-build" + # operator: "Exists" + # effect: "NoSchedule" + # ---------------------------------------------------------------------- + containers: + # ===================================================================== + # Docker-in-Docker daemon + # ===================================================================== + - name: dind + image: docker:27-dind + securityContext: + privileged: true + env: + - name: DOCKER_TLS_CERTDIR + value: /certs + args: + - "--host=tcp://0.0.0.0:2376" + - "--tlsverify" + - "--tlscacert=/certs/ca/cert.pem" + - "--tlscert=/certs/server/cert.pem" + - "--tlskey=/certs/server/key.pem" + volumeMounts: + - name: docker-certs + mountPath: /certs + - name: docker-storage + mountPath: /var/lib/docker + readinessProbe: + exec: + command: ["docker", "-H", "tcp://localhost:2376", "--tlsverify", + "--tlscacert=/certs/ca/cert.pem", + "--tlscert=/certs/client/cert.pem", + "--tlskey=/certs/client/key.pem", "info"] + initialDelaySeconds: 15 + periodSeconds: 10 + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "2" + memory: "4Gi" + + # ===================================================================== + # Gitea Actions runner + # ===================================================================== + - name: runner + image: gitea/act_runner:0.2.11 + env: + # Registration data (from the Secret). + - name: GITEA_INSTANCE_URL + valueFrom: + secretKeyRef: + name: act-runner-registration + key: GITEA_INSTANCE_URL + - name: GITEA_RUNNER_REGISTRATION_TOKEN + valueFrom: + secretKeyRef: + name: act-runner-registration + key: GITEA_RUNNER_REGISTRATION_TOKEN + - name: GITEA_RUNNER_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + # Point the runner at the DinD daemon over TLS. + - name: DOCKER_HOST + value: "tcp://localhost:2376" + - name: DOCKER_CERT_PATH + value: "/certs/client" + - name: DOCKER_TLS_VERIFY + value: "1" + # Path to the mounted act_runner config. + - name: CONFIG_FILE + value: "/config/config.yaml" + volumeMounts: + - name: docker-certs + mountPath: /certs + readOnly: true + - name: runner-config + mountPath: /config + readOnly: true + - name: runner-data + mountPath: /data + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "1" + memory: "1Gi" + + volumes: + # Shared TLS certs between dockerd and the runner/job containers. + - name: docker-certs + emptyDir: {} + # dockerd image/layer storage (ephemeral; use a PVC for a persistent cache). + - name: docker-storage + emptyDir: {} + - name: runner-config + configMap: + name: act-runner-config + # Persists the runner registration (.runner) across restarts. + - name: runner-data + persistentVolumeClaim: + claimName: act-runner-data +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: act-runner-data + namespace: gitea-runner +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + # k3s default storage class (local-path). Change if you use another provisioner. + # storageClassName: local-path diff --git a/k3s/40-harbor-secret.yaml.example b/k3s/40-harbor-secret.yaml.example new file mode 100644 index 0000000..19b45e6 --- /dev/null +++ b/k3s/40-harbor-secret.yaml.example @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# OPTIONAL: cluster-side Harbor pull secret. +# +# This is NOT needed for the CI build/push (that uses Gitea Actions secrets). +# Create it only if the same k3s cluster must *pull* the private images that the +# pipeline pushes to Harbor (e.g. when you later deploy those images here). +# +# Easiest way to create it (recommended over editing this file by hand): +# +# kubectl create secret docker-registry harbor-pull \ +# --namespace \ +# --docker-server=harbor.example.com \ +# --docker-username='robot$ci' \ +# --docker-password='REPLACE_WITH_ROBOT_TOKEN' +# +# Then reference it from your workloads: +# spec.imagePullSecrets: [{ name: harbor-pull }] +# +# The manifest below is the equivalent declarative form (base64 dockerconfigjson). +# ----------------------------------------------------------------------------- +apiVersion: v1 +kind: Secret +metadata: + name: harbor-pull + namespace: default +type: kubernetes.io/dockerconfigjson +data: + # echo -n '{"auths":{"harbor.example.com":{"username":"robot$ci","password":"TOKEN","auth":""}}}' | base64 -w0 + .dockerconfigjson: REPLACE_WITH_BASE64_DOCKERCONFIGJSON diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..e086668 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Deploy the Gitea act_runner (with DinD) onto k3s. +# +# Usage: +# ./scripts/deploy.sh +# +# Prerequisites: +# - kubectl configured against your k3s cluster +# (e.g. export KUBECONFIG=/etc/rancher/k3s/k3s.yaml) +# - k3s/10-runner-secret.yaml created from the .example and filled in. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +K3S_DIR="${ROOT_DIR}/k3s" + +if [[ ! -f "${K3S_DIR}/10-runner-secret.yaml" ]]; then + echo "ERROR: ${K3S_DIR}/10-runner-secret.yaml not found." >&2 + echo " cp ${K3S_DIR}/10-runner-secret.yaml.example ${K3S_DIR}/10-runner-secret.yaml" >&2 + echo " then fill in GITEA_INSTANCE_URL and the registration token." >&2 + exit 1 +fi + +echo ">> Applying namespace" +kubectl apply -f "${K3S_DIR}/00-namespace.yaml" + +echo ">> Applying runner registration secret" +kubectl apply -f "${K3S_DIR}/10-runner-secret.yaml" + +echo ">> Applying runner config" +kubectl apply -f "${K3S_DIR}/20-runner-config.yaml" + +echo ">> Applying runner deployment" +kubectl apply -f "${K3S_DIR}/30-runner-deployment.yaml" + +echo ">> Waiting for rollout" +kubectl -n gitea-runner rollout status deploy/act-runner --timeout=180s + +echo ">> Done. Runner pods:" +kubectl -n gitea-runner get pods -o wide diff --git a/workflows/build-and-push.yaml b/workflows/build-and-push.yaml new file mode 100644 index 0000000..0236f3a --- /dev/null +++ b/workflows/build-and-push.yaml @@ -0,0 +1,97 @@ +# ============================================================================= +# Gitea Actions workflow: build a Docker image and push it to Harbor. +# +# Place this file in your application repository at: +# .gitea/workflows/build-and-push.yaml +# +# Required repo/org Actions secrets (Settings -> Actions -> Secrets): +# HARBOR_REGISTRY e.g. harbor.example.com +# HARBOR_USERNAME e.g. robot$ci +# HARBOR_PASSWORD the robot account token +# +# Optional repo/org Actions variables (Settings -> Actions -> Variables): +# HARBOR_PROJECT Harbor project name (default: "library") +# IMAGE_NAME image name (default: repository name) +# ============================================================================= +name: build-and-push + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +jobs: + build-and-push: + # Uses the label advertised by the act_runner on k3s. + runs-on: ubuntu-22.04 + env: + HARBOR_PROJECT: ${{ vars.HARBOR_PROJECT || 'library' }} + IMAGE_NAME: ${{ vars.IMAGE_NAME || gitea.event.repository.name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Compute image tags + id: meta + run: | + REGISTRY="${{ secrets.HARBOR_REGISTRY }}" + IMAGE="${REGISTRY}/${HARBOR_PROJECT}/${IMAGE_NAME}" + SHA_TAG="${GITHUB_SHA::12}" + echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" + echo "sha_tag=${SHA_TAG}" >> "$GITHUB_OUTPUT" + # Use the git tag as the version when the ref is a tag, else 'latest'. + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + echo "version=latest" >> "$GITHUB_OUTPUT" + fi + + - name: Log in to Harbor + uses: docker/login-action@v3 + with: + registry: ${{ secrets.HARBOR_REGISTRY }} + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} + ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha_tag }} + + - name: Summary + run: | + echo "Pushed:" + echo " ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }}" + echo " ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.sha_tag }}" + +# ============================================================================= +# Alternative: rootless builds (no privileged DinD sidecar) +# ----------------------------------------------------------------------------- +# If you cannot run a privileged DinD sidecar, build with Kaniko instead. +# Replace the "Build and push" step (and drop the Buildx/login steps) with a +# container step running the Kaniko executor. Kaniko logs in to Harbor via a +# generated docker config: +# +# - name: Build and push with Kaniko +# uses: https://github.com/int128/kaniko-action@v1 +# with: +# push: true +# tags: ${{ steps.meta.outputs.image }}:${{ steps.meta.outputs.version }} +# registry: ${{ secrets.HARBOR_REGISTRY }} +# username: ${{ secrets.HARBOR_USERNAME }} +# password: ${{ secrets.HARBOR_PASSWORD }} +# +# In that case the act_runner does not need the DinD sidecar; the Kaniko +# executor image performs the build inside the job container. +# =============================================================================