How It Works
glci sits between your .gitlab-ci.yml and the official gitlab-runner. Instead of talking to a real GitLab instance, the runner talks to a mock GitLab API server running as a daemon-scoped container. Jobs execute in real Docker containers with the same shell scripts, image pulls, and lifecycle that GitLab CI uses in production — no simulation, no approximation.
Component overview#
Data flow#
The mock server is the central hub — the only component both the daemon and the runner communicate with. Jobs push/pull artifacts, cache, and container images directly to/from the mock server via extra_hosts (host-gateway) with FF_NETWORK_PER_BUILD.
Infrastructure#
Daemon-managed (shared across pipelines)#
glci-mock— mock server container (API + embedded OCI registry on port 39741 by default, configurable viamock_server_port). Started once by the daemon; jobs reach it viaextra_hosts(host-gateway) withFF_NETWORK_PER_BUILD.Runner containers — persistent
gitlab/gitlab-runnercontainers managed byRunnerManager. Three tiers:- Shared runner (
glci-runner-{suffix}) — handles projects with default config; jobs without matching named-runner tags go here. - Per-project runner (
glci-runner-{suffix}-{project}) — created when a project defines a custom[runner] config_template. Isolated from the shared runner. - Named runner (
glci-runner-{suffix}-{project}-{name}) — one per[runners.<name>]config entry. Jobs whose tags match the runner name are routed here viarun_untagged=false.
Each runner container polls the mock server for jobs via tag-based dispatch. Config is written to a Docker volume and reloaded via SIGHUP. When a
config_templateproduces multiple[[runners]]blocks, glci derives sub-tokens and registers each runner separately.- Shared runner (
All containers are long-lived. They start on first use and stop when the daemon shuts down. If a container is missing (manually removed, Docker restart), the daemon recreates it on demand. Per-project and named runners are created lazily when a pipeline first needs them.
Per-pipeline#
glci-net-{id}— Docker bridge network. The shared mock and runner containers are connected to it for the pipeline’s lifetime.- DinD jobs are serialized via the
_glci_dindresource group to prevent concurrent DinD jobs from conflicting on thedockerservice alias. runner-*— Transient job execution containers, spawned by the runner’s Docker executor. One per job, destroyed after the job finishes.
Pipeline execution lifecycle#
This is the sequence of events when you run glci run [jobs...]:
Detailed steps#
- CLI sends StartPipeline to daemon via Unix socket (daemon auto-starts if not running). If another pipeline is already being prepared for the same directory, the request is queued instead of rejected — the daemon holds the HTTP connection open and starts the queued pipeline automatically when the current preparation finishes. Cancelling the request (Ctrl+C) dequeues it. A maximum queue depth of 3 prevents resource exhaustion.
- Daemon parses
.gitlab-ci.yml— offline parser resolves includes, extends,!referencetags; evaluates workflow/job rules; expandsparallel: matrix:; builds execution plan (stages, DAG, auto-include upstream deps) - Daemon creates per-pipeline Docker network (
glci-net-{id}) - Daemon prepares source code — creates a bare git repo from the working directory (including uncommitted/untracked files in dirty mode), tar.gz for upload
- Daemon ensures mock container is running — connects to pipeline network, registers per-pipeline state (secret), configures S3 cache credentials, uploads git repo
- Daemon ensures runner container is running — connects to pipeline network, Docker socket or
DOCKER_HOSTfor remote Docker - Scheduler dispatches jobs — concurrent within stages, DAG-aware, respects
resource_groupexclusions. Per job: queue on mock server with HMAC token, persistent runner polls for job, clones source, downloads artifacts, executes scripts, uploads artifacts, streams trace, reports completion - Trigger jobs spawn child pipelines sharing the same mock and runner containers (max depth: 2)
- Daemon persists artifacts to
~/.glci/projects/{key}/pipelines/{id}/ - EventBus streams events to all connected CLI clients
Mock server API#
The mock server (pkg/mockserver/) is the central hub. It implements the subset of the GitLab API that gitlab-runner needs, plus infrastructure services and an internal control API consumed by the daemon.
GitLab runner API#
These endpoints are consumed by the runner, which thinks it is talking to a real GitLab instance:
| Endpoint | Purpose |
|---|---|
POST /api/v4/jobs/request | Token-routed job dispatch — each job is routed to the runner presenting its unique HMAC-derived token |
PUT /api/v4/jobs/{id} | Job state updates (running/success/failed) |
PATCH /api/v4/jobs/{id}/trace | Trace output chunks streamed in real time |
POST /api/v4/jobs/{id}/artifacts | Artifact upload (multipart) |
GET /api/v4/jobs/{id}/artifacts | Artifact download for needs:/dependencies: |
GET /api/v4/personal_access_tokens/self | Token validation for glab auth login --job-token |
POST /api/v4/projects/{path}/releases | Release creation |
PUT /api/v4/projects/{id}/packages/generic/... | Generic package upload |
GET /api/v4/projects/{id}/packages/generic/... | Generic package download |
GET /api/v4/projects/{path}/repository/tags/{tag} | Tag info lookup |
GET /api/v4/projects/{path}/releases/{tag} | Release info |
POST /api/v4/projects/{path}/releases/{tag}/assets/links | Release asset link creation |
Infrastructure services#
| Endpoint | Purpose |
|---|---|
GET /{path}/info/refs | Git smart HTTP ref advertisement (dynamic path per pipeline) |
POST /{path}/git-upload-pack | Git smart HTTP pack data |
PUT /cache/{key} | S3-compatible cache upload (AWS Signature V4 auth) |
GET /cache/{key} | S3-compatible cache download |
HEAD /cache/{key} | S3-compatible cache existence check |
GET /cache | S3 GetBucketLocation (region detection) |
/v2/* | OCI container registry (embedded, see Embedded Registry) |
Internal API (daemon to mock server)#
These endpoints are consumed by the daemon to manage pipelines. They are not authenticated via the runner protocol — they go through the docker exec transport:
| Endpoint | Purpose |
|---|---|
POST /internal/config | Set pipeline-wide configuration |
POST /internal/pipeline | Register a new pipeline (multi-pipeline routing) |
DELETE /internal/pipeline/{id} | Tear down per-pipeline state |
POST /internal/queue-job | Queue a job for dispatch to runner |
POST /internal/cancel-job | Cancel running/pending jobs for a pipeline (or a single job) |
POST /internal/register-runner | Register a project runner for tag-based dispatch |
POST /internal/unregister-runner | Unregister a project runner |
POST /internal/upload-git-repo | Upload bare git repo as tar.gz |
GET /internal/git-repo-sha | Get SHA of uploaded git repo |
POST /internal/reuse-git-repo | Reuse git repo from a previous pipeline |
POST /internal/apply-git-overlay | Apply incremental overlay to git repo |
POST /internal/artifact/{jobID} | Inject reused artifact data |
GET /internal/artifact/{jobID} | Retrieve artifact data after job completion |
GET /internal/trace/{jobID} | Retrieve complete trace for a job |
GET /internal/events | Hybrid binary/JSON event stream |
GET /internal/status | Ground-truth job state (crash recovery) |
GET /internal/health | Health check |
GET /internal/cache/keys | List all cache keys |
GET /internal/cache/get/{key} | Retrieve cached entry |
PUT /internal/cache/preload/{key} | Preload cache entry |
GET /internal/releases | List releases created by pipelines |
GET /internal/packages/keys | List generic package keys |
GET /internal/packages/get/{key} | Retrieve generic package |
POST /internal/push-through-config | Set push-through mirror configuration |
GET /internal/registry-stats | Registry storage statistics |
POST /internal/registry-clean | Clean registry blobs |
Docker exec transport#
The daemon communicates with the mock server container exclusively through docker exec, which enables remote Docker daemon support (TCP via DOCKER_HOST, Docker contexts, SSH tunnels, cloud Docker).
Three transport implementations exist in pkg/daemon/transport.go:
| Transport | How it works | Used for |
|---|---|---|
httpTransport | Direct HTTP client | Unit tests with in-process mock server |
execTransport | One-shot docker exec <container> glci internal proxy <METHOD> <PATH> | Long-lived streaming (/internal/events) |
persistentExecTransport | Long-lived docker exec -i <container> glci internal proxy-session with binary framing | All control-plane requests |
The persistentExecTransport keeps a single subprocess alive, multiplexing all requests over shared stdin/stdout with the muxproto binary protocol. This avoids the overhead of spawning a new docker exec for every API call.
Muxproto binary protocol#
pkg/muxproto/ implements a length-prefixed binary protocol for multiplexing HTTP-like request/response pairs over a byte stream:
Request: int64(methodLen) method | int64(pathLen) path | int64(ctLen) contentType | int64(bodyLen) body
Response: int32(statusCode) | int64(bodyLen) body
All integers are big-endian. Max frame size is 1 GB (matching the artifact upload limit). The transport auto-restarts on stream corruption.
Event streaming#
The mock server emits events on /internal/events as a hybrid binary/JSON stream:
- Control events (
job_result,job_dispatched) are JSON lines - Trace data uses a binary frame format to avoid JSON overhead for high-volume output:
\x00 | uint64(pipelineID) | uint64(jobID) | uint32(offset) | uint32(dataLen) | data
Both event types flow on a single FIFO channel per subscriber (buffer 4096), so a job_result never overtakes an earlier trace frame. Trace drops are tolerated and reconciled via GET /internal/trace. Control drops fall back to /internal/status polling. The daemon also polls /internal/status every 5 seconds as a safety net for results missed during event stream reconnection.
EventBus#
The daemon’s EventBus (pkg/daemon/eventbus.go) manages in-memory event storage and streaming for CLI consumers:
- Two-tier storage: Status events (
stage_start,job_status,pipeline_done) are kept permanently. Log events (job_log) are bounded by byte size (default 10 MB) with slice compaction. - SubscribeWithReplay: Atomically returns replay history and registers a subscriber under a write lock, preventing event loss between replay and subscription.
- Unbounded subscriber queues: Each subscriber gets an
eventQueuebacked by a mutex-protected slice and a drain goroutine. Publish never blocks and never drops events. The drain goroutine feeds a 64-element output channel the consumer reads from.
Scheduler#
The scheduler (pkg/scheduler/) provides DAG-aware concurrent job dispatch with concurrency limits and resource_group support.
Scheduling modes#
| Mode | When used | Behavior |
|---|---|---|
| Stage-based | Jobs without needs: | All jobs in a stage run in parallel. Next stage starts when all jobs in the current stage complete. |
| DAG | Jobs with needs: (even empty needs: []) | Job starts as soon as all its declared dependencies complete, regardless of stage boundaries. needs: [] means no dependencies — start immediately. |
| Mixed | Pipeline has both types | DAG jobs follow their needs:; stage-based jobs wait for all prior stages. |
Job lifecycle states#
Jobs move through these states: pending -> running -> done (or skipped). Manual jobs (when: manual) start in a separate manual state and move to pending only when triggered.
Skip logic#
on_successjobs are skipped when the pipeline has failed and their dependencies are completeon_failurejobs are skipped when the pipeline has not failed and no running jobs remain that could cause a failure- Cascading skips: if a job’s dependency was skipped, the job itself is skipped (DAG mode only)
resource_group: only one job per resource group runs at a time
Authentication#
Pipeline-level security without a real GitLab instance:
- Pipeline secret: 32 bytes from
crypto/rand, generated per pipeline - Job tokens:
HMAC-SHA256(secret, jobID)— each job gets a unique token - Runner dispatch: job routed to the runner presenting the matching token (prevents FIFO dispatch races when multiple jobs are queued)
- Git auth: HTTP Basic with job token
- Registry auth: Docker-standard token auth
- Cache (S3): AWS Signature V4 with daemon-secret-derived credentials (stable across pipeline runs)
Crash recovery#
The daemon performs recovery on startup (pkg/daemon/recovery.go):
- Find recoverable pipelines: Scan
~/.glci/active-workdirs/markers, read execution state from~/.glci/projects/{key}/pipelines/{id}/execution.json, check if both mock and runner containers are still alive viadocker inspect. - Preserve resources: Build sets of container names, volume names, and network names belonging to recoverable pipelines.
- Clean orphaned resources: Remove any
glci-*containers, volumes, and networks not in the preserve sets. Also clean stale temp files. - Finalize stuck pipelines: Mark pipelines without surviving containers as failed in history.
- Resume: Reconnect to containers via
docker exec, query ground-truth state viaGET /internal/status, reconcile with persisted state, and re-dispatch pending jobs.
Execution state is written atomically (temp file + rename) on every job state transition. It contains container names, job states, runner tokens, pipeline secret, and the original StartPipelineRequest for re-parsing CI config during resume.
CI config parsing#
glci includes a full offline .gitlab-ci.yml parser as the primary parsing path (pkg/config/). It implements GitLab’s processing pipeline in Go:
- Load YAML with
yaml.Node-based parser preserving!referencetags - Interpolate inputs (
$[[ inputs.key ]]) before and after include resolution - Resolve all includes —
local:(disk),remote:(HTTP),template:(GitLab CDN),project:(API),component:(API). Component version selectors (@~latest,@~N,@~N.M) are resolved to the latest matching semver tag via the GitLab Tags API before fetching. Includes are cached and deduplicated. - Resolve
extends:— recursive deep merge with circular/depth detection (10 levels max) - Resolve
!referencetags — path lookup with cycle detection, sequence flattening - Inject edge stages (
.pre/.post) - Apply
default:inheritance withinherit:control (true/false/[keys]) - Parse
workflow:rules withauto_cancel:support - Parse all job fields into enriched types (trigger, release, retry, environment, hooks, id_tokens, etc.)
- Expand
parallel: matrix:jobs
Also handles: rules: changes: (git diff matching), rules: exists: (filesystem glob), YAML anchors/aliases, multi-document YAML.
API-assisted mode (optional)#
Set GLCI_PREFER_API=1 to use the GitLab CI Lint API for YAML resolution. This requires a GitLab token but delegates include:, extends:, and !reference resolution to the server. The offline parser is the default because it requires no network access and handles the vast majority of CI configurations.
API Lint response — field inventory#
When using API-assisted mode, glci calls POST /projects/:id/ci/lint with include_jobs: true, dry_run: false. The API returns merged_yaml (fully resolved YAML) and a jobs array. These fields are available from each source:
| Feature | In merged_yaml? | In jobs array? | Notes |
|---|---|---|---|
include: resolution | Yes (key removed) | N/A | Recursively merged |
extends: resolution | Partially (keys remain) | N/A | Values merged, key not removed |
!reference tags | Yes | N/A | Resolved to actual values |
rules: evaluation | dry_run only | dry_run only | Static returns ALL jobs |
needs: | N/A | Static mode only | NOT in dry_run mode |
image:, services:, variables:, artifacts:, cache: | Yes | No | Must parse from merged_yaml |
parallel: matrix: | Yes (unexpanded) | No | Must expand ourselves |
$[[ inputs ]] | Yes (expanded) | N/A | Component interpolation done |
$VAR expansion | No | No | Shell expands at runtime |
Daemon API#
The daemon runs as a background process and exposes an HTTP/JSON API over a Unix socket (~/.glci/daemon.sock). The CLI is the primary consumer.
| Endpoint | Purpose |
|---|---|
POST /api/start-pipeline | Start a pipeline (accepts parsed jobs, variables, options). If another pipeline is being prepared for the same directory, the request is queued (max depth 3) and the connection is held open until preparation finishes. Context cancellation dequeues the request. |
POST /api/cancel-pipeline | Cancel a running pipeline |
POST /api/cancel-job | Cancel a single job |
POST /api/trigger-manual-job | Trigger a manual (when: manual) job |
GET /api/pipeline-status | Pipeline status for a project |
GET /api/watch/{pipelineID} | Event stream for a pipeline (NDJSON) |
GET /api/logs/{pipelineID}/{jobName} | Stream job log output |
GET /api/registry-info | Registry connection info |
GET /api/registry-catalog | List all repositories and tags in the embedded registry |
GET /api/registry-manifest | Fetch a manifest by repo and ref (tag/digest) |
GET /api/registry-blob | Download a blob by repo and digest |
POST /api/registry-clean | Clean up registry data |
GET /api/active-pipelines | List all active pipelines and their job statuses |
GET /api/health | Daemon health check |
The daemon auto-starts on the first glci run and auto-stops after 30 minutes of idle. A version guard auto-restarts it if the CLI/daemon build versions mismatch.