Bring your own CI/CD.

by | Jun 3, 2021

As a developer I couldn’t imagine working without one of these three things:

  • a search engine – which saves me thinking by myself
  • an IDE – which saves me typing function names completely
  • and continuous integration – which saves me running unit tests by myself on every pull request I make

For projects on GitHub the built-in actions should do the latter job fine in most cases. But as everything else they have limits. The more PRs, the more different tests per pull request and the longer those tests run, the longer different PRs have to wait for each other for the continuous integration to run.

Recently, while preparing Icinga v2.12.4 and v2.11.9, we ran into this problem by ourselves: we’ve made a bunch of pull requests backporting bugfixes from v2.13.0 to the mentioned versions at once. Each of the PRs built each GNU/Linux distro we support officially. And every build took tens of minutes.

My private Golang projects don’t build that long, but there was another problem: the GitHub actions matrix limit which allowed me only 256 jobs. So I could generate all combinations of $GOOS/$GOARCH and $GOARCH-specific variables, but only for a few recent Go versions. And even those almost hit my limit of minutes per month. But there was a light at the end of the tunnel…

Drone CI

Self-hosted continuous integrations like Drone have one big advantage: whoever pays the piper, calls the tune. No matrix limits, no restrictions on minutes per month and if you’re tired of waiting for pipelines to start running, feel free to connect Drone to a Kubernetes via the respective runner. But for now I’d like to keep it as simple as possible.

Prerequisites

  • a GitHub account
  • a machine with a public IP address (the size doesn’t matter)
  • a domain pointing to the machine

GitHub OAuth application

Create a new OAuth App here. Fill in your app’s data…

… and “Register application”. Finally “Generate a new client secret”…

… and copy it and the “Client ID”.

Drone itself

On the machine install the packages docker-ce and docker-compose as described here and create a docker-compose.yml file like this:

version: '3'
volumes:
  drone: {}
services:
  drone:
    image: drone/drone:1
    ports:
      - 80:80
      - 443:443
    environment:
      DRONE_SERVER_PROTO: https
      # Copy from your OAuth App
      DRONE_GITHUB_CLIENT_ID: 8dd32c9be1787f9a815e
      DRONE_GITHUB_CLIENT_SECRET: 54099182d0e1c099cf8764e368a409b44c3fa7c2
      # Generate with: openssl rand -hex 16
      DRONE_RPC_SECRET: 90fad69e94e7ec256b9d916c3f161617
      # Substitute as needed
      DRONE_SERVER_HOST: drone.al2klimov.de
      DRONE_USER_CREATE: username:Al2Klimov,admin:true
      # Enables Let's encrypt
      DRONE_TLS_AUTOCERT: 'true'
      # Optional, read on
      DRONE_JSONNET_ENABLED: 'true'
    volumes:
      - drone:/data
  runner:
    image: drone/drone-runner-docker:1
    environment:
      # Same as above
      DRONE_RPC_PROTO: https
      DRONE_RPC_HOST: drone.al2klimov.de
      DRONE_RPC_SECRET: 90fad69e94e7ec256b9d916c3f161617
      # One job at a time not to blow up the machine
      DRONE_RUNNER_CAPACITY: '1'
      DRONE_RUNNER_MAX_PROCS: '1'
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

Finally, start it by running docker-compose up -d in the same directory as docker-compose.yml.

It’s alive!™

Once you visit the domain in your web browser the first time, you have to authorize the OAuth application:

Then you can choose any of your projects…

… and activate it:

Once done, all branches and PRs are being tested as specified:

Jsonnet

Enabling Jsonnet in the Docker Compose file above and in the project settings…

… allows you to reuse declarations even more flexibly than with YaML anchors:

local newest = 116;
local ppc64 = function(go) [ [[ "GOPPC64", "power8" ]], [[ "GOPPC64", "power9" ]] ];
local mips = function(go) [ [[ "GOMIPS", "hardfloat" ]], [[ "GOMIPS", "softfloat" ]] ];
local mips64 = function(go) [ [[ "GOMIPS64", "hardfloat" ]], [[ "GOMIPS64", "softfloat" ]] ];
local gover(i) = std.floor(i / 100) + "." + std.mod(i, 100);
local env2cmd(e) = std.join("", [kv[0] + "=" + kv[1] + " " for kv in e]);

local machines(go) = (if go >= 112 then [
  { GOOS: "aix", GOARCHs: [ "ppc64" ] }
] else [ ]) + [
  { GOOS: "android", GOARCHs: [ "386", "amd64", "arm", "arm64" ] },
  { GOOS: "darwin", GOARCHs: [ "amd64", "arm64" ] },
  { GOOS: "dragonfly", GOARCHs: [ "amd64" ] },
  { GOOS: "freebsd", GOARCHs: [ "386", "amd64", "arm" ] }
] + (if go >= 113 then [
  { GOOS: "illumos", GOARCHs: [ "amd64" ] }
] else [ ]) + (if go >= 116 then [
  { GOOS: "ios", GOARCHs: [ "arm64" ] },
] else [ ]) + [
  { GOOS: "js", GOARCHs: [ "wasm" ] },
  {
    GOOS: "linux",
    GOARCHs: [
      "386", "amd64", "arm", "arm64", "ppc64", "ppc64le",
      "mips", "mipsle", "mips64", "mips64le"
    ] + (if go >= 114 then [ "riscv64" ] else [ ]) + [
      "s390x"
    ]
  },
  { GOOS: "netbsd", GOARCHs: [ "386", "amd64", "arm" ] },
  {
    GOOS: "openbsd",
    GOARCHs: [ "386", "amd64", "arm" ] + (if go >= 113 then [ "arm64" ] else [ ])
  },
  { GOOS: "plan9", GOARCHs: [ "386", "amd64", "arm" ] },
  { GOOS: "solaris", GOARCHs: [ "amd64" ] },
  { GOOS: "windows", GOARCHs: [ "386", "amd64" ] }
];

local envs = {
  "386": function(go) [
    [[ "GO386", if go >= 116 then "softfloat" else "387" ]],
    [[ "GO386", "sse2" ]]
  ],
  arm: function(go) [ [[ "GOARM", "5" ]], [[ "GOARM", "6" ]], [[ "GOARM", "7" ]] ],
  mips: mips,
  mipsle: mips,
  mips64: mips64,
  mips64le: mips64,
  ppc64: ppc64,
  ppc64le: ppc64,
  wasm: function(go) [ [ ], [[ "GOWASM", "satconv" ]], [[ "GOWASM", "signext" ]] ]
};

[
  {
    kind: "pipeline",
    type: "docker",
    name: "Go",
    steps: [
      {
        name: gover(go),
        image: "golang:" + gover(go),
        commands: [
          env2cmd([
            [ "GOOS", os.GOOS ],
            [ "GOARCH", GOARCH ]
          ] + env + [
            [ "CGO_ENABLED", CGO_ENABLED ]
          ]) + "go build ./..."
          for os in machines(go)
          for GOARCH in os.GOARCHs
          for env in (if std.objectHas(envs, GOARCH) then envs[GOARCH](go) else [ [ ] ])
          for CGO_ENABLED in [ 0, 1 ]
        ] + [
          "go test -race -v ./..."
        ] + (if go == newest then [
          "bash -exo pipefail -c 'FILES=\"$(gofmt -d -e .)\"; cat <<<\"$FILES\"; test -z \"$FILES\"'"
        ] else [ ])
      }
      for go in std.range(111, newest)
    ]
  }
]

Try it yourself!

For the ones of you who need auto-scaling we already provide a managed Kubernetes and for the others we have the NWS cloud.

You May Also Like…

Subscribe to our Newsletter

A monthly digest of the latest Icinga news, releases, articles and community topics.