Skip to content

Cross-Tenant Pipelines

A cross-tenant call reaches its target only through Varroa. The core build step cannot cross a tenant boundary; it starts a job in the same tenant only. The varroa global variable carries every cross-tenant call, and Varroa checks the target tenant’s export grant on each one.

A call inside one tenant needs no export, even when the two jobs sit on different slots. A build job: step that waits cannot wait on another slot’s run, so varroa.build is the supported waiting call there. Roll the recorder image before the plugin image: the recorder accepts the forwarded key before the plugin sends it.

varroa exposes five entry points. A cross-tenant call resolves the target tenant’s own cell, on the core or on a member cluster.

CallReturnsWhat it does
varroa.build(tenant:, job:, params:, wait:, propagate:)a run handlestarts a build and, by default, waits for it
varroa.lastSuccessful(tenant:, job:)a run handle, or nullreads the last successful run
handle.copyArtifact(path:, target:)the copied file’s pathcopies one artifact from a run the caller already holds a handle for
varroa.lock(name:, tenant:, timeout:) { ... }the block’s return valueacquires a fleet lock around the block
varroa.summary(text)nothingattaches one line of text to the calling run
def run = varroa.build(
tenant: 'platform',
job: 'release',
params: [version: '1.4.0'],
)
echo "Started ${run.tenant}/${run.job}, result: ${run.result}"

wait defaults to true. A waiting call polls the target run for up to 24 hours, and logs a warning at 10 minutes. Wrap the call in the core timeout step for a shorter bound:

timeout(time: 15, unit: 'MINUTES') {
varroa.build(tenant: 'platform', job: 'release')
}

propagate defaults to true and fails the caller’s build when the target run does not succeed. A run the record store refuses ends TRIGGER_FAILED, and the failure names the refusal code, such as (FENCED: <text>). Set wait: false to fire and move on: the handle starts in state pending. handle.refresh() reads the current state into a new handle and does not change the old one. Assign the result to read it:

def run = varroa.build(tenant: 'platform', job: 'release', wait: false)
run = run.refresh()
echo "Platform release state: ${run.state}"
def last = varroa.lastSuccessful(tenant: 'platform', job: 'release')
if (last != null) {
echo "Platform's last release: ${last.buildNumber}, result ${last.result}"
}

lastSuccessful returns null when the job has never succeeded. A refused grant fails the step.

def run = varroa.build(tenant: 'platform', job: 'release')
run.copyArtifact(path: 'dist/app.tar.gz', target: 'artifacts/app.tar.gz')

A cross-tenant artifact read needs the S3 blob tier. Set recordStore.blobs.mode to an S3 driver, and give the agent egress to the blob endpoint. A Postgres blob tier answers with an error naming the setting to change.

varroa.lock(name: 'deploy-prod', timeout: 300) {
sh 'deploy.sh'
}

The block runs only after the lock is held. The lock releases when the block ends, on success, on failure, or on abort. Add tenant: to take a lock that another tenant owns:

varroa.lock(name: 'deploy-prod', tenant: 'platform') {
sh 'deploy.sh'
}

See The fleet lock for the full contract.

varroa.build and varroa.lastSuccessful return a run handle. It carries runId, tenant, job, buildNumber, state, result, startedAt, endedAt, url, and artifacts.

stateMeaning
pendingVarroa accepted the request; the target build has not started
queuedthe target build is queued
runningthe target build is running
completedthe target build finished
interruptedthe target build was marked interrupted

A job can start automatically after another tenant’s job finishes. Declare a varroaUpstream trigger in the job’s items YAML entry:

- kind: pipeline
name: integration
triggers:
- varroaUpstream:
tenant: platform
job: release
result: SUCCESS
definition:
script: echo integration

A Jenkinsfile triggers {} block also works, but only the items form survives a cell restart.

result is a threshold: SUCCESS, UNSTABLE, or FAILURE. The default is SUCCESS, meaning this result or better. A job declares one varroaUpstream trigger. A job that needs two producers calls varroa.build from each producer’s pipeline instead.

The trigger reaches the subscriber job’s home slot.

A new trigger goes live only after the subscriber’s cell exports its schedules, within ten minutes. A producer run that completes before that export starts no subscriber build.

A tenant exports nothing by default. Grant another tenant access on the producing tenant’s Team:

apiVersion: varroa.dev/v1alpha1
kind: Team
metadata:
name: platform
spec:
namespaces: ["team-platform"]
pipelines:
profile: jenkins-lts
agentNamespace: team-platform
exports:
- job: "release/*"
callers: ["payments", "search"]
- locks: ["deploy-prod", "staging-*"]
callers: ["payments"]

Each entry names exactly one of two kinds, a job glob or a locks glob, plus callers, the Team names allowed to use it. callers is required and holds at least one Team name. A job glob matches the tenant’s own job paths. A locks glob matches the bare names of the tenant’s own locks. Three metacharacters apply: * matches any characters except /, ** crosses /, and ? matches one character.

An exports edit reaches the enforcing record store within one reconcile tick.

An ungranted call fails with a message that starts “cross-tenant triggers go through Varroa.”

A job’s page shows its declared upstream and downstream cross-tenant relationships on the Triggers card, as an Upstream row and a Downstream row. Each row names the other tenant, the other job, and the result threshold, and marks a row grant missing when the producer has not exported the job.

The API carries the same data:

"dependencies": {
"upstream": [{"tenant": "platform", "job": "release", "jobPath": "platform/release", "result": "SUCCESS", "granted": true, "lastRun": {"tenant": "platform", "jobPath": "platform/release", "runId": "0196f6b2-0000-7000-8000-000000000001", "buildNumber": 12}}],
"downstream": [{"tenant": "search", "job": "rebuild", "jobPath": "search/rebuild", "result": "SUCCESS", "granted": false}]
}

job is the name in the other tenant’s folder. jobPath is the full job path, and each row links to it. lastRun is the newest run on the edge within 30 days. It is absent when no such run exists.

A run that another run started names that run as its Triggered by link. The build page shows the link in the Run links card. A build that the run started appears under Downstream, newest first. The first 20 downstream runs come with the run. Read the rest with GET /runs/{run}/downstream, which takes limit and cursor.

The recorder links a run only to an upstream run that the recorder itself triggered, or to a run of the same tenant. A build that names another upstream run gets no Triggered by link.

A link to a run that the reader may not read keeps the tenant name alone. It has no job path, run id, or status. get_run carries the same upstream and downstream fields.

Each tenant owns a lock namespace. varroa.lock names a lock with two arguments:

ArgumentMeaning
namethe bare lock name, with no /
tenantthe tenant that owns the lock. Omit it for the caller’s own tenant.

A caller takes another tenant’s lock only when that tenant exports the name under locks.

The lock list shows each lock as the owner tenant, a /, and the bare name. A controller tenant is <namespace>/<controller>, so its lock deploy-prod shows as team-pay/ci/deploy-prod.

Inside the block, varroa.lock.name reads the bare name, and varroa.lock.tenant reads the owner tenant. Both read null outside a block.

The step handles each recorder refusal as follows.

ReasonStep behavior
FENCED, CROSS_TENANT, NOT_HOME, INVALID_NAME, RUN_ENDEDfails the build with the recorder’s message, on an acquire or a renew. A renew failure also stops the block. RUN_ENDED answers an acquire for a run that already ended.
NOT_READY, THROTTLEDkeeps polling, because the refusal is temporary

A lock moves through three states.

StateMeaning
heldone run holds the lock
waitingruns wait for the holder; a row leaves when its run ends
releasingthe holder’s run was marked interrupted (the move takes at most one minute); the lock is not yet free

A lock leaves releasing when the holder’s run reaches a terminal state, when the operator confirms the holder’s cell instance gone, or on a force release. After a move, the old pod is confirmed once gone. A releasing lock blocks every other caller. A Jenkins restart inside its pod fires neither event. Restart that cell to release the lock.

Each acquire mints a fencing token, a number that increases fleet-wide. The block reads it as varroa.lock.token or $VARROA_LOCK_TOKEN.

Use the token to protect a resource the lock guards, such as a deployment target:

varroa.lock(name: 'deploy-prod', timeout: 300) {
sh 'deploy.sh --fence-token "$VARROA_LOCK_TOKEN"'
}

deploy.sh stores the highest token it accepted and refuses a lower one. A holder can lose its lock without knowing it, after a network partition. Its late write carries the old, lower token, so the target refuses it.

After a Jenkins restart, the block waits up to 5 minutes for the recorder to confirm the lock. A lost or unconfirmed lock stops it. An agent process the block started runs until its step stops.

A failed call fails its step with <step>: <code>: <text>.

CodeCause
UNAVAILABLEa hop did not answer
NOT_FOUNDunknown run or artifact path
INTERNALany other recorder fault
a veto reason, for example CROSS_TENANTa refusal; the text is the veto message

Each of four hops carries its own timeout. Each hop’s budget is shorter than the one outside it.

HopVariableDefault
the plugin’s connectVARROA_CROSSTENANT_CONNECT_TIMEOUT_MS1000 ms
the plugin’s readVARROA_CROSSTENANT_READ_TIMEOUT_MS10000 ms
the mite’s wait for a responseVARROA_CROSSTENANT_MITE_TIMEOUT_MS9000 ms
the gateway’s wait for the record storeVARROA_CROSSTENANT_GATEWAY_TIMEOUT_MS8500 ms

Raise the outer budget before an inner one. Varroa logs a warning when a budget is not shorter than the budget inside it.

The mite waits at most 9000 ms. The mite’s loopback listener stops each reply after 10 seconds, so a longer wait loses the reply. A larger value logs one warning, and the mite uses 9000 ms. The mite counts the wait from the moment it receives the request.

Every cross-tenant call needs a cell role: the calling controller must carry a placement record naming its tenant. A controller with no placement record is refused before Varroa checks any grant.