Data-access runners
A data-access runner is the process that dials a tenant source on CORE’s behalf. It is not a CI runner; GitHub Actions runners are a separate thing (docs/runners/cloud-runners.md). Design: docs/design/core-data-runners.md. Operator guide: docs/runners/self-hosted-data-runner.md.
The page is DataEx › Runners (?tab=runners). It was born under DevEx › Cluster and moved to DataEx on 2026-09-26, keeping its tab name. It is the shared org-runink/ui datasources RunnersService, mounted by datasources_ui.go.
RunnerService/Execute is declared in runners.proto and core-runner answers UNIMPLEMENTED. Every Resolve action and CapEx pull routed to a self-hosted runner is refused with runner_offline, and the detail says “connected; dispatch arrives in rollout step 6”. Use the Runink managed runner in the meantime.The two kinds
| Kind on the wire | Display | What it is |
|---|---|---|
managed | Runink managed, id console | the console process itself. It is never stored and is synthesized on every read. It can never be revoked, re-addressed or enrolled. |
self_hosted | the name you gave it | a core-runner process on a host you run, stored as kind enrolled in the registry (var/lib/core-runners) |
Registry limits (runners_store.go): 100 runners, 100 tokens, 8 labels per runner, and a 512 KiB budget. Writes over a limit are refused, never truncated.
CORE dials the runner
The owner reversed decision D3 on 2026-09-26: CORE connects out to each self-hosted runner at the address an admin entered. There is no runner listener, port, DNS record or devgateway pass-through on CORE’s side, and no CORE_RUNNER_ENDPOINT. The link is mutual TLS on the runner CA (core-system/core-runner-ca, a separate trust root from the mesh CA). The core-operator mints it once, and it is mounted at CORE_RUNNER_CA_DIR. Identity is the certificate, never the address, so IPv4, IPv6 and FQDN addresses all work, and changing an address is only a reconnect.
The core-operator pod network on the platform box is IPv6 single-stack today, so a runner must be reachable from the pod over IPv6, an AAAA FQDN, or NAT64.
Address grammar
host:port, where the host is an IPv4 address, a [bracketed] IPv6 address or an RFC 1123 DNS name, and the port is 1–65535 (runners_address.go). The console refuses URL schemes, userinfo (@), IPv6 zone ids, unbracketed IPv6, the unspecified and multicast addresses, and host labels that look like a token or key.
Enrol a self-hosted runner
You must be allowed by CORE_RUNNER_ADMINS. When it is unset, every signed-in identity is allowed.
Create the runner
Add runner on the page, or POST /api/runners {name, address, zone, labels}. The name is a DNS label. The response is {runner, ticket, command}. The runner starts PENDING.
Copy the ticket, which is shown once
The ticket is crt1. followed by base64url of the enrolment ticket: the token, the runner id and name, the expiry, and the runner CA’s SHA-256. It carries no endpoint. It is valid for 15 minutes, works once, and enrols only this runner. CORE stores only the token’s SHA-256, and holds the enrolment proof key in memory only. If the console restarts before the runner is reachable, issue a new token.
Start the runner on its host
CORE_RUNNER_TICKET='crt1.…' core-runner serve --listen :7443The ticket goes in the environment. A ticket on the command line is refused, and the variable is unset as soon as it has been read. core-runner flags: --listen (env CORE_RUNNER_LISTEN, default :7443) and --state-dir (env CORE_RUNNER_STATE_DIR, default /var/lib/core-runner, mode 0700). A runner that CORE deployed reads its ticket from CORE_RUNNER_TICKET_FILE instead. That file must be regular and 0600 or 0400, and setting both variables is refused.
CORE enrols it
CORE dials the address, then retries with backoff. Connect now (POST /api/runners/{id}/connect) tries at once. Both sides prove they hold the token with an HMAC bound to that TLS connection’s exporter, so a relay fails. The runner sends a CSR for a key it just generated. CORE signs a 24-hour runner server certificate that names only spiffe://core.runink/runner/<id>, with no DNS or IP name, and consumes the token in one step. The runner is ACTIVE.
State on the runner host: identity.pem (certificate and private key, 0600), ca.pem and runner.json, key.pending.pem (present only during an enrolment or renewal), and revoked (written on revocation, after which the runner refuses to start).
Operating a runner
| Action | Route | Notes |
|---|---|---|
| Health | polled every 15 s (RunnerService/Health) | every figure is a runner claim and is labelled so. Health never touches a source |
| Renewal | automatic | before a third of the leaf’s life is left, CORE asks for a CSR over a fresh key, records it as the NEXT fingerprint, installs it, then promotes it. The old leaf is then refused |
| Edit address | PATCH /api/runners/{id} | audited with the old and new address. The certificate stays valid. A zone or label change on a PENDING runner revokes its live token |
| New ticket | POST /api/runners/{id}/token | PENDING runners only. The old token is revoked |
| Revoke token | DELETE /api/runner-tokens/{id} | an unused token |
| Revoke runner | POST /api/runners/{id}/revoke | sends Goodbye(REVOKED) best-effort and never dials again. The runner writes revoked and exits 0 |
GET /api/runners is session-gated and never dials. For each runner it serves id, name, displayName, kind, address, zone, labels, state and health.{connected, lastSeen, version, certNotAfter, inFlight, limit}. The concurrency limit is 4 (decision D9), reported with limitEnforced:false until step 6. An unreadable registry answers 503, never “only console”.
Choosing the runner per action
Every Resolve action and CapEx PullFeed accepts an optional runner:
- absent: use the connection’s binding, whose empty value is
console; console: Runink managed, explicitly, even for a connection bound to a self-hosted runner;- a name: that runner, with no fallback.
The runner is picked at audit time on Intelligence › Data audit. A connection’s default binding is set in the Connections wizard with the same picker. The effective runner and whether it was “chosen for this action” or “the connection’s binding” are recorded in the audit detail.
Refusal codes
From atlas_res.go. None of them opens a credential bundle or calls the engine, and none falls back to another runner:
| Condition | Outcome |
|---|---|
| name not registered | runner_unknown |
| runner REVOKED | runner_revoked |
| runner PENDING | runner_offline (not enrolled yet) |
| ACTIVE, not connected | runner_offline |
| ACTIVE and connected | runner_offline, detail “connected; dispatch arrives in rollout step 6” |
| registry unreadable | failed, “runner registry unavailable” (never runner_unknown) |
runner_busy is reserved for step 6, when the concurrency cap is enforced.
Runners CORE deploys, and the outbound channel
runners_deploy.go lets CORE provision a runner itself: on an on-premise host over SSH, on a Compute Engine VM in your GCP project, as a Snowpark Container Services service, or on a Databricks job cluster. The ticket is handed over inside the target. Deploying is admin-only with a stricter rule than the rest of the page: an unset CORE_RUNNER_ADMINS refuses everyone. Credentials are referenced by id under CORE_DEPLOY_SECRETS_DIR/<id>/<field>, never typed. The operator guide says Snowflake and Databricks are listed but not yet deployable.
A runner on Snowflake SPCS or a Databricks job cluster cannot be dialled. For those targets only, and pending owner confirmation, the direction is reversed (runners_channel.go). CORE listens on CORE_RUNNER_CHANNEL_LISTEN and hands deployed runners CORE_RUNNER_CHANNEL_ADDR. Both must be set, or those targets are unavailable. Such a runner is registered with no address and runs core-runner serve with CORE_RUNNER_CHANNEL set. It gets a runner client certificate, and CORE runs Health and renewal over that connection. A runner that has an address can neither enrol nor serve over the channel. On-premise and GCP runners are unchanged.
What a runner never does
It never dials CORE, except over the outbound channel above. It holds no source configuration and no stored credentials, schedules nothing, and its Health never touches a source.