kadet-hub logokadet-hub

Semaphore UI on Kubernetes without Helm, with Kapitan

Semaphore UI 2.19.17, a web UI and API for Ansible, Terraform, OpenTofu and shell tasks, as one replica with its admin account, rendered by the kadet-hub kubernetes generator 1.14.0 from Semaphore’s Helm chart 16.2.2. The database is the included recipe postgresql.

What it deploys

Required parameters

applications.semaphore.admin.username and applications.semaphore.admin.password are the login and password of the admin account. The init container admin creates the account on the first start and leaves it alone afterwards, so a later change of the password in the target does not reach it. Change it in Semaphore.

applications.semaphore.cookie_hash, applications.semaphore.cookie_encryption and applications.semaphore.access_key_encryption each hold 32 random bytes in base64, as openssl rand -base64 32 prints them. Semaphore signs and encrypts its session cookies with the first two. It encrypts the keys and secrets that projects store with the third, so keep that one for the life of the database. applications.semaphore.runner_token is a random string. A runner that presents it registers itself, also while remote runners are off.

Set the password, the three keys and the token to refs with the backend of your choice. Without one of them the compile stops with Interpolation key '<key>' not found. postgresql requires no parameter.

Include

A target includes the recipe and its options as classes and can override anything the recipe sets:

classes:
  - recipes.semaphore
parameters:
  namespace: semaphore
  kapitan:
    vars:
      target: semaphore
  applications:
    semaphore:
      admin:
        username: <login>
        password: <ref>
      cookie_hash: <ref>
      cookie_encryption: <ref>
      access_key_encryption: <ref>
      runner_token: <ref>

Fetching it

The quickstart goes through these steps on a kind cluster with the uptime-kuma recipe, from installing Kapitan to the running app.

A separate fetch inventory pins the recipe by version and digest. This is fetch/targets/recipes.yml for version 1.0.2:

parameters:
  kapitan:
    dependencies:
      - type: oci
        source: ghcr.io/kadet-hub/recipes/semaphore:1.0.2@sha256:ae9f0ec39bbeff18b4c9c08fe09941206d98a301399b9a1df182bb00513ee7f0
        output_path: inventory/classes/recipes/semaphore

Version 1.0.2 includes postgresql 1.1.1. The consumer workflow adds the included recipes at the digests the index lists. Kapitan's --fetch reads no index, so for a local compile pull them by digest first:

tmp=$(mktemp -d)
oras pull -o "$tmp" ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9
mkdir -p inventory/classes/recipes/postgresql
tar -xf "$tmp/postgresql.tar" -C inventory/classes/recipes/postgresql --no-same-owner

The first command fetches the recipe into inventory/classes/recipes/semaphore, the second the generators and compiles:

kapitan compile --inventory-path fetch --fetch --output-path .
kapitan compile --fetch

The first command exits with status 1 because the fetch target compiles nothing. Kapitan's --fetch checks neither the digest nor the attestation. This verifies the attestation of the recipe and of the recipes it includes:

gh attestation verify \
  oci://ghcr.io/kadet-hub/recipes/semaphore@sha256:ae9f0ec39bbeff18b4c9c08fe09941206d98a301399b9a1df182bb00513ee7f0 \
  --owner kadet-hub \
  --cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main

gh attestation verify \
  oci://ghcr.io/kadet-hub/recipes/postgresql@sha256:1e8465058c256cc86995dc8d6f1eb2f095df244fadcaf756935599f4d9ead6a9 \
  --owner kadet-hub \
  --cert-identity https://github.com/kadet-hub/registry/.github/workflows/release.yml@refs/heads/main

The attested index must also list each digest under its recipe with the pinned version, and not as yanked. Verifying by hand shows how to read it.

Settings

<c> stands for components.semaphore.

Chart value Inventory
image.tag <c>.image, tag and digest
admin.fullname, admin.email applications.semaphore.admin.fullname and .email, Admin and admin@localhost
database.name, database.host applications.postgresql.database and .cluster, which postgresql’s README lists
database.options <c>.env.SEMAPHORE_DB_OPTIONS, JSON, {"sslmode":"require"}
general.maxParallelTasks <c>.env.SEMAPHORE_MAX_PARALLEL_TASKS, "0" for no limit
general.passwordLoginDisable <c>.env.SEMAPHORE_PASSWORD_LOGIN_DISABLED, "false"
general.nonAdminCanCreateProject <c>.env.SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT, "false"
general.useRemoteRunner <c>.env.SEMAPHORE_USE_REMOTE_RUNNER, "false"
persistence.size, persistence.storageClass <c>.persistent_volume_claims.semaphore-workdir.storage, .storage_class, 10G
extraEnvVariables <c>.env
resources <c>.resources
nodeSelector, tolerations, affinity <c>.node_selector, <c>.tolerations, <c>.affinity

The init container takes each variable of the chart from <c>.env by name, so a change there reaches both containers. A variable added to <c>.env reaches the server only. Values are strings, so quote "false" and "0".

The chart sends sslmode=disable to PostgreSQL unless database.options sets one. The recipe sets sslmode=require, as tests/values.yaml does for the chart, because CloudNativePG serves TLS. Both containers run with runAsNonRoot: true, the chart’s podSecurityContext in tests/values.yaml. The image runs as UID 1001.

The pod annotations checksum/* are the chart’s hashes of its ConfigMap and Secrets for the values of tests/values.yaml. They do not follow the target’s values, so after changing a Secret restart Semaphore with kubectl rollout restart deployment/semaphore. The init container’s script and config.json are the chart’s, with its license in semaphore.yml.LICENSE.

The chart’s Ingress, LDAP, OIDC, the mail, Telegram and Slack alerts, extra Python packages and a custom CA bundle are off by default and not part of the recipe. Its MariaDB and PostgreSQL subcharts stay off.

Any list set in the target is appended to the recipe’s list. To replace one, set the key to ~ in a class included after the recipe and the new list in the target.

Proof

check semaphore --level deploys with tests/values.yaml and tests/target.yml, on kind for each Kubernetes minor CI tests, with cnpg installed first. The objects of postgresql are left out of the comparison and applied with the recipe. The Deployment and the Service select on name, the chart on app.kubernetes.io/name and instance, and both select the same pods. The pod and the ServiceAccount set automountServiceAccountToken: true, the API default the chart leaves unset. The chart binds no role. The chart’s Helm test Pod/semaphore-test-connection is left out. tests/target.yml runs one PostgreSQL instance.

tests/smoke waits for the Cluster and gets /api/ping through the Service and the API server’s service proxy. It then logs in as the admin through /api/auth/login, which needs the account the init container wrote into PostgreSQL, and reads /api/user, /api/projects and /api/info with the session cookie. /api/info must report the version of the Deployment’s image tag and remote runners off. It runs no task.

Versions

The recipe's files are in kadet-hub/recipes. All recipes