Overview

Quality Gates (CIT)

flowchart LR
    subgraph dbuild ["dbuild (The Engine)"]
        direction LR
        B[Build] --> T[Test]
        T --> P[Push]
    end
 
    subgraph CIT ["CIT (The Gates)"]
        direction TB
        T1[Shell]
        T2[Port]
        T3[Health]
        T4[Screenshot]
    end
 
    T -.-> CIT

While dbuild manages the container lifecycle (start, stop, cleanup), CIT defines the success criteria through a cumulative mode system.

Cumulative modes#

Each mode includes all checks from lower modes:

screenshot = health + visual regression
health     = port   + HTTP endpoint check
port       = shell  + TCP port listening
shell      = container starts successfully
Mode What it checks Use case
shell Container starts, echo ok via exec Base images
port Shell + TCP port is listening Services with network listeners
health Port + HTTP endpoint returns non-error Web apps with health endpoints
screenshot Health + visual regression against baseline Web UIs
command Image runs to completion, exit code + output regex One-shot CLI tools (run-and-exit)

command mode sits outside the cumulative ladder — it's for images whose entrypoint runs once and exits (e.g. ffmpeg, immich-cli), so there's no live process for shell/port/health/screenshot to probe:

cit:
  mode: command
  command: ["--version"]          # args after the entrypoint (omit = image CMD)
  expect_exit: 0                  # exit code that means success (default 0)
  expect_output: 'ffmpeg version \d'  # regex matched against stdout/stderr

command mode is podman-only and does not support compose: true.

Mode auto-detection#

If no mode is set in config, CIT picks the highest applicable mode: a screenshot baseline image → screenshot; health set → health; port set → port; otherwise → shell.

Gate details#

Shell test

Verifies the container starts successfully: s6-overlay initializes, services start without errors, and echo ok succeeds via exec. This is the baseline gate — every mode includes it.

Port binding

Waits for the application to bind to a TCP port:

cit:
  port: 7878

CIT polls the socket until it becomes available or the wait timeout expires.

Health check

Sends an HTTP GET request to the specified endpoint:

cit:
  port: 7878
  health: /ping

Expects a non-error HTTP response (2xx or 4xx). A 502 or 503 indicates the app isn't ready yet, and CIT will retry. For HTTPS-only apps, add https: true.

Visual regression (SSIM)

Captures a browser screenshot and compares it against a known-good baseline using Structural Similarity Index (SSIM):

cit:
  mode: screenshot
  port: 7878
  health: /ping
  screenshot_wait: 10
  • Threshold: SSIM ≥ 0.95 (95% structural similarity)
  • Catches UI regressions, broken CSS, missing assets
  • Uses scikit-image for SSIM comparison and Selenium + Chromium for capture
  • If screenshot dependencies are missing, the mode automatically downgrades to health

Baseline search order: .daemonless/baseline-{tag}.png.daemonless/baselines/baseline-{tag}.png.daemonless/baseline.png.daemonless/baselines/baseline.png. If a baseline exists and no mode is configured, screenshot mode is auto-selected.

Readiness detection#

Before running port/health checks, CIT watches container logs for a readiness pattern. The default pattern matches common startup messages:

Warmup complete | services.d.*done | Application started | Startup complete | listening on | is ready

Override with the ready field:

cit:
  ready: "Server initialized"
  wait: 300

The wait timeout (default: 120 seconds) applies to the readiness check. If the pattern isn't seen within the timeout, CIT proceeds to port/health checks anyway — the timeout is not fatal on its own.

Compose testing#

For multi-service stacks (app + database), set compose: true:

cit:
  compose: true
  port: 8080
  health: /api/health

This uses podman-compose with the compose file at .daemonless/compose.yaml — separate from the top-level compose.yaml, so you can define a test-specific stack without touching the production deployment file. Shell exec tests are skipped for compose stacks since they don't support single-container exec. If .daemonless/compose.yaml is absent when compose: true is set, the test fails immediately.

Testing backends#

By default, dbuild test runs the container using Podman. For images deployed to FreeBSD jails, you can also test with AppJail to validate behavior in a real jail context:

dbuild test                    # Podman only (default)
dbuild test --backend appjail  # AppJail only
dbuild test --backend all      # Both

The AppJail backend runs the container via appjail oci run instead of podman run, exercising the full jail stack — shared host network, syscall restrictions, mount namespaces, and jail annotations all apply. It's auto-selected when appjail: true is set in compose.yaml and AppJail is installed on the host; if configured but not installed, dbuild warns and falls back to Podman. compose: true is Podman-only, so the AppJail backend is skipped when compose mode is set.

dbuild generate produces three AppJail deployment files (Makejail, appjail-director.yml, .env) from bundled templates. To customize any of them, place an override in .daemonless/appjail/ — any file found there is copied as-is, and files not present fall back to the auto-generated template.

Jail annotations#

Some applications require specific FreeBSD jail permissions:

cit:
  annotations:
    - "org.freebsd.jail.allow.mlock=true"
    - "org.freebsd.jail.allow.sysvipc=true"
Annotation Required by
allow.mlock .NET apps (Radarr, Sonarr, Prowlarr, Lidarr)
allow.sysvipc PostgreSQL

These annotations are passed to podman run during testing; in production they're set via --annotation in the deploy playbook.

Platform QA#

CIT serves as a functional regression suite for the entire FreeBSD container stack — the FreeBSD 15 kernel (syscalls, socket binding, process management), the ocijail runtime (jail isolation), and s6-overlay (init system behavior). Every image build runs CIT in a real FreeBSD VM, not emulation. The push step is unreachable if any gate fails, so ghcr.io/daemonless/* contains only validated containers.

flowchart LR
    A[Build Image] --> B[Run CIT]
    B -->|All Pass| C[Push to ghcr.io]
    B -->|Any Fail| D[Build Fails]
    D --> E[No Push]

Configuration reference#

Full cit: schema for .daemonless/config.yaml:

cit:
  mode: health              # shell | port | health | screenshot | command (auto-detected if omitted)
  port: 8080                 # TCP port to check
  health: /health             # Health endpoint path
  https: false                # Use HTTPS for health checks
  wait: 120                   # Startup timeout in seconds
  ready: "Server started"     # Log pattern to wait for before testing (regex)
  compose: false               # Use podman-compose for multi-service stacks
  screenshot_wait: 10          # Extra wait in seconds before screenshot capture
  annotations:
    - "org.freebsd.jail.allow.mlock=true"
    - "org.freebsd.jail.allow.sysvipc=true"
Field Default Description
mode Auto-detected Test mode: shell, port, health, screenshot, or command
port TCP port to check
health / Health endpoint path
https false Use HTTPS for health checks
wait 120 Startup timeout in seconds
ready Built-in pattern Log regex to wait for before testing
compose false Use podman-compose with .daemonless/compose.yaml
screenshot_wait Extra seconds to wait before screenshot capture
annotations [] Jail annotations for the test container
command [] (image CMD) command mode: args appended to the entrypoint
expect_exit 0 command mode: exit code that counts as success
expect_output command mode: regex that must match stdout/stderr
See it run in CI

Every gate on this page runs automatically on every push, on real FreeBSD VMs.

Updated

Was this page helpful?