Introducting Drone

As I mentioned in my last post, this blog is published as a static site inside of a container which is rebuilt and deployed to the Kubernetes cluster whenever I check-in changes to the Gitea-hosted repository. Drone is the continuous integration platform which does the heavy lifting. I will cover the Drone configuration inside of the repository in a future post.

As with other applications I deploy, the pattern is similar. First, I add some Ansible variables to the k3s_cluster group and create a Longhorn volume with a specific name (drone-vol) using the Longhorn dashboard. I can refer to the named volume when creating the Kubernetes persistent volume. This makes it much easier to manage than using a random volume name generated by the cluster storage provisioner.

Drone Ansible Role

inventory/group_vars/k3s_cluster:

drone_namespace: drone
drone_build_namespace: drone-build
drone_hostname: drone.domain.tld
drone_chart_version: 0.1.7
drone_server_version: 1.9.0
drone_rpc_shared_secret: shared_secret
drone_gitea_client_id: client_id
drone_gitea_client_secret: client_secret
drone_k8s_runner_chart_version: 0.1.5
drone_k8s_runner_version: 1.0.0-beta.6
drone_vol_size: 8Gi

The Gitea client ID and secret are created by adding a new OAuth2 application to Gitea (Settings / Applications). The RPC shared secret is how the Drone runners and the central server authenticate:

$ openssl rand -hex 16

Now to create the Ansible role tasks. The role will create the namespaces need for the Drone central server and the build namespace, install the Drone helm chart, and deploy the additional Kubernetes manifests.

roles/k3s_cluster/drone/tasks/main.yml:

- name: Drone namespaces
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/namespace.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Add Drone chart repo
  kubernetes.core.helm_repository:
    name: drone
    repo_url: "https://charts.drone.io"
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Drone Ingress
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/ingress.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Drone Persistent volume
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/pv.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Drone Persistent volume claim
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/pvc.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Drone Secrets
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/secrets.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Install Drone Server Chart
  kubernetes.core.helm:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    release_name: drone
    chart_ref: drone/drone
    chart_version: "{{ drone_chart_version }}"
    release_namespace: "{{ drone_namespace }}"
    update_repo_cache: yes
    values:
      image:
        tag: "{{ drone_server_version }}"
      persistentVolume:
        existingClaim: drone
      env:
        ## REQUIRED: Set the user-visible Drone hostname, sans protocol.
        ## Ref: https://docs.drone.io/installation/reference/drone-server-host/
        ##
        DRONE_SERVER_HOST: "{{ drone_hostname }}"
        ## The protocol to pair with the value in DRONE_SERVER_HOST (http or https).
        ## Ref: https://docs.drone.io/installation/reference/drone-server-proto/
        ##
        DRONE_SERVER_PROTO: https
        ## REQUIRED: Set the secret secret token that the Drone server and its Runners will use
        ## to authenticate. This is commented out in order to leave you the ability to set the
        ## key via a separately provisioned secret (see existingSecretName above).
        ## Ref: https://docs.drone.io/installation/reference/drone-rpc-secret/
        ##
        DRONE_RPC_SECRET: "{{ drone_rpc_shared_secret }}"
        DRONE_GITEA_CLIENT_ID: "{{ drone_gitea_client_id }}"
        DRONE_GITEA_CLIENT_SECRET: "{{ drone_gitea_client_secret }}"
        DRONE_GITEA_SERVER: "https://{{ gitea_hostname }}"
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Install Drone K8S Runner Chart
  kubernetes.core.helm:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    release_name: drone-runner-kube 
    chart_ref: drone/drone-runner-kube 
    chart_version: "{{ drone_k8s_runner_chart_version }}"
    release_namespace: "{{ drone_namespace }}"
    update_repo_cache: yes
    values:
      image:
        tag: "{{ drone_k8s_runner_version }}"
      rbac:
        buildNamespaces:
          - default
          - "{{ drone_build_namespace }}"
      env:
        ## The hostname/IP (and optionally the port) for your Kubernetes runner. Defaults to the "drone"
        ## service that the drone server Chart creates by default.
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-rpc-host/
        ##
        DRONE_RPC_HOST: drone

        ## The protocol to use for communication with Drone server.
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-rpc-proto/
        ##
        DRONE_RPC_PROTO: http

        ## Determines the default Kubernetes namespace for Drone builds to run in.
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-namespace-default/
        ##
        DRONE_NAMESPACE_DEFAULT: "{{ drone_build_namespace }}"
        DRONE_RPC_SECRET: "{{ drone_rpc_shared_secret }}"
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-secret-plugin-endpoint/
        #
        DRONE_SECRET_PLUGIN_ENDPOINT: http://drone-kubernetes-secrets:3000
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-secret-plugin-token/
        #
        DRONE_SECRET_PLUGIN_TOKEN: "{{ drone_rpc_shared_secret }}"        
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Install Drone Kubernetes Secrets Extension
  kubernetes.core.helm:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    release_name: drone-kubernetes-secrets
    chart_ref: drone/drone-kubernetes-secrets
    release_namespace: "{{ drone_namespace }}"
    update_repo_cache: yes
    values:
      rbac:
        ## The namespace that the extension is allowed to fetch secrets from. Unless
        ## rbac.restrictToSecrets is set below, the extension will be able to pull all secrets in
        ## the namespace specified here.
        ##
        secretNamespace: "{{ drone_namespace }}"

      ## The keys within the "env" map are mounted as environment variables on the secrets extension pod.
      ##
      env:
        ## REQUIRED: Shared secret value for comms between the Kubernetes runner and this secrets plugin.
        ## Must match the value set in the runner's env.DRONE_SECRET_PLUGIN_TOKEN.
        ## Ref: https://kube-runner.docs.drone.io/installation/reference/drone-secret-plugin-token/
        ##
        SECRET_KEY: "{{ drone_rpc_shared_secret }}"
        ## The Kubernetes namespace to retrieve secrets from.
        ##
        KUBERNETES_NAMESPACE: "{{ drone_namespace }}"
  delegate_to: "{{ ansible_host }}"
  run_once: true

roles/k3s_cluster/manifests/namespace.j2:

---
apiVersion: v1
kind: Namespace
metadata:
  name: {{ drone_namespace }}
---
apiVersion: v1
kind: Namespace
metadata:
  name: {{ drone_build_namespace }}

roles/k3s_cluster/manifests/ingress.j2:

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: drone
  namespace: {{ drone_namespace }}
spec:
  entryPoints:
    - web
    - websecure
  routes:
    - match: Host(`{{ drone_hostname }}`)
      kind: Rule
      services:
        - name: drone
          port: 80

roles/k3s_cluster/pv.j2:

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: drone-vol
  labels:
    backup: daily
spec:
  capacity:
    storage: {{ drone_vol_size }}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: longhorn-static
  csi:
    driver: driver.longhorn.io
    fsType: ext4
    volumeAttributes:
      numberOfReplicas: "3"
      staleReplicaTimeout: "2880"
    volumeHandle: drone-vol

roles/k3s_cluster/pvc.j2:

--
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: drone
  namespace: {{ drone_namespace }}
  labels:
    backup: daily
spec:
  storageClassName: longhorn-static
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: {{ drone_vol_size }}

roles/k3s_cluster/secrets.j2:

--
apiVersion: v1
kind: Secret
type: Opaque
stringData:
  url: "https://{{ registry_hostname }}"
  username: "{{ registry_user1_username }}"
  password: "{{ registry_user1_password  }}"
  email: "{{ registry_user1_email }}"
metadata:
  name: registry
  namespace: "{{ drone_namespace }}"

I’m hosting a container registry rather than relying on something like Docker Hub. Those credentials go into the k3s_cluster group variables as well.

Container Registry Role

This role will deploy the self-hosted container registry along with Redis. The Longhorn volume, registry-vol, is 25Gi.

inventory/group_vars/k3s_cluster:

registry_namespace: registry
registry_vol_size: 25G
registry_version: 2.8.1
registry_hostname: hub.domain.tld
registry_http_secret: random_shared_secret_for_load_balanced_registries
registry_redis_password: redis_password
registry_redis_tag: 6.2.6
registry_user1: encoded_username_password_pair
registry_user1_username: username
registry_user1_email: me@domain.tld
registry_user1_password: password

The registry_user1 variable is created using the htpasswd command and base64 encoding it:

$ htpasswd -nb user password | openssl base64

roles/k3s_cluster/registry/tasks/main.yml:

- name: Registry namespace
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/namespace.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Registry Persistent Volume
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/pv.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Registry Persistent Volume Claim
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/pvc.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Registry Deployment
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/deployment.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Registry Service
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/service.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Redis Config
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/redis-config.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Redis Deployment
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/redis-deployment.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Redis Service
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/redis-service.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true
- name: Registry Ingress
  kubernetes.core.k8s:
    kubeconfig: "/var/lib/rancher/k3s/server/cred/admin.kubeconfig"
    state: present
    definition: "{{ lookup('template', 'manifests/ingress.j2') }}"
    validate:
      fail_on_error: yes
  delegate_to: "{{ ansible_host }}"
  run_once: true

roles/k3s_cluster/registry/namespace.j2:

apiVersion: v1
kind: Namespace
metadata:
 name: {{ registry_namespace }}

roles/k3s_cluster/registry/pv.j2:

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: registry-vol-pv
  labels:
    backup: daily
spec:
  capacity:
    storage: {{ registry_vol_size }}
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  storageClassName: longhorn-static
  csi:
    driver: driver.longhorn.io
    fsType: ext4
    volumeAttributes:
      numberOfReplicas: "3"
      staleReplicaTimeout: "2880"
    volumeHandle: registry-vol

roles/k3s_cluster/registry/pvc.j2:

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: registry-vol-pvc
  namespace: {{ registry_namespace }}
  labels:
    backup: daily
spec:
  storageClassName: longhorn-static
  volumeMode: Filesystem
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: {{ registry_vol_size }}

roles/k3s_cluster/registry/deployment.j2:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: registry
  namespace: "{{ registry_namespace }}"
  labels:
    app: registry
spec:
  replicas: 1
  selector:
    matchLabels:
      app: registry
  strategy:
      type: Recreate  
  template:
    metadata:
      labels:
        app: registry
    spec:
      containers:
      - name: registry
        env:
        - name: REGISTRY_HTTP_SECRET
          value: "{{ registry_http_secret }}"
        - name: REGISTRY_REDIS_ADDR
          value: redis
        - name: REGISTRY_REDIS_PASSWORD
          value: "{{ registry_redis_password }}"
        - name: REGISTRY_STORAGE_DELETE_ENABLED
          value: "true"
        image: "registry:{{ registry_version }}"
        volumeMounts:
        - name: repo-vol
          mountPath: "/var/lib/registry"
        ports:
        - containerPort: 5000
          name: web
          protocol: TCP
      volumes:
      - name: repo-vol
        persistentVolumeClaim:
          claimName: registry-vol-pvc

roles/k3s_cluster/registry/redis-service.j2:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: "{{ registry_namespace }}"
  labels:
    app: registry
    component: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: registry
      component: redis
  template:
    metadata:
      labels:
        app: registry
        component: redis
    spec:
      containers:
      - name: redis
        image: "redis:{{ registry_redis_tag }}"
        command:
          - redis-server
          - "/redis-master/redis.conf"
        env:
        - name: MASTER
          value: "true"
        ports:
        - containerPort: 6379
        resources:
          limits:
            cpu: "0.1"
        volumeMounts:
        - mountPath: /redis-master-data
          name: data
        - mountPath: /redis-master
          name: config
      volumes:
        - name: data
          emptyDir: {}
        - name: config
          configMap:
            name: registry-redis-config
            items:
            - key: redis-config
              path: redis.conf

roles/k3s_cluster/registry/service.j2:

apiVersion: v1
kind: Service
metadata:
  labels:
    app: registry
  name: hub
  namespace: "{{ registry_namespace}}"
spec:
  ports:
  - name: web
    port: 5000
    protocol: TCP
    targetPort: 5000
  selector:
    app: registry
  type: ClusterIP

roles/k3s_cluster/registry/redis-service.j2:

apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: "{{ registry_namespace }}"
spec:
  selector:
    app: registry
    component: redis
  ports:
  - name: tcp
    port: 6379

roles/k3s_cluster/registry/redis-config.j2:

apiVersion: v1
kind: ConfigMap
metadata:
  name: registry-redis-config
  namespace: {{ registry_namespace }}
data:
  redis-config: |
    maxmemory 2mb
    maxmemory-policy allkeys-lru     

roles/k3s_cluster/registry/ingress.j2:

---
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: hub-ingressroute
  namespace: "{{ registry_namespace }}"
spec:
  entryPoints:
    - web
    - websecure
  routes:
    - match: Host(`{{ registry_hostname }}`)
      kind: Rule
      services:
        - name: hub
          port: 5000
      middlewares:
      - name: registry-auth-basic
        namespace: "{{ registry_namespace }}"
      - name: registery-buffering
---
# Registry users
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: registry-auth-basic
  namespace: "{{ registry_namespace }}"
spec:
  basicAuth:
    secret: authsecret
    realm: "{{ registry_namespace }}"
---
# Note: in a kubernetes secret the string (e.g. generated by htpasswd) must be base64-encoded first.
# To create an encoded user:password pair, the following command can be used:
# htpasswd -nb user password | openssl base64
apiVersion: v1
kind: Secret
metadata:
  name: authsecret
  namespace: "{{ registry_namespace }}"
data:
  users: |2
    {{ registry_user1 }}
---
# Registry users
apiVersion: traefik.containo.us/v1alpha1
kind: Middleware
metadata:
  name: registery-buffering
  namespace: "{{ registry_namespace }}"
spec:
  buffering:
    memResponseBodyBytes: 2000000
    retryExpression: "IsNetworkError() && Attempts() < 5"

Summary

I’m amazed at what Drone can do. It has dramatically changed my workflow for publishing the weekly blog posts and I love that I’m hosting it entirely on my infrastructure. It’s ease-of-use has played a big part in the increased frequency of my posts. I look forward to exploring more of what it can do in future projects.

In my next post, I’ll write about how all of this comes together!