@zuke/canary
Canary releases for Zuke builds. `canary(...)` turns a settings lambda into
@zuke/canary on JSR ↗ 19 symbols
Symbols
function canary(configure: Configure<CanarySettings>): Canary Declare a canary rollout. The lambda runs when the build is constructed and decides which targets exist; a misconfigured one throws there, naming the fix.
class Deploy extends Build {
rollout = canary((c) =>
c.platform(myPlatform)
.steps(10, 50)
.bake("10m")
.analysis(httpProbe((h) => h.url("https://api.example.com/healthz")))
.lock((l) => l.lockKey("deploy", "api").withTtl("24h"))
);
ship = target().dependsOn(this.rollout.promote).executes(() => {});
}interface Canary The targets canary(...) creates. Assigned to a build field — rollout = canary(…) — they are named under it: rollout.stage, rollout.step1.expose, rollout.promote, rollout.abort. Depend on this.rollout.promote to run after a successful rollout.
| Member | Signature | Doc |
|---|---|---|
stage | stage: TargetBuilder | Puts the candidate in place at 0 % and takes the rollout's lock. |
approve? | approve?: TargetBuilder | The external-signal gate before promotion, when one is configured. |
promote | promote: TargetBuilder | Sends all traffic to the candidate. Re-driven by a resume. |
abort | abort: TargetBuilder | The rollback: every failure path runs it, and it can be run by hand. |
soak? | soak?: CanaryPhaseTargets | The single phase of a canary with no steps, when it bakes or analyses. |
interface CanaryAbortContext What the engine hands CanaryPlatform.abort: the context, and why.
| Member | Signature | Doc |
|---|---|---|
reason | reason: CanaryAbortReason | Why abort runs — see CanaryAbortReason. |
type CanaryAbortReason = rollback | hand-run Why abort runs:
- "rollback" — the engine is undoing this rollout: a stage that failed or was cancelled, a failed step, zuke cancel. With nothing in state, nothing was staged, so there is nothing to undo. - "hand-run" — rollout.abort was run on its own, outside any rollout. With nothing in state, the platform falls back to the stable version its own configuration names.
interface CanaryAnalysis A health check run against a canary: throw to fail it, which rolls the rollout back. Any core Validation is one, since a function that accepts a plain ValidationContext accepts this richer context too.
| Member | Signature | Doc |
|---|---|---|
validate | validate(context: CanaryAnalysisContext): void | Promise<void> | Run the check; throw to fail it. |
name? | name?: string | A name for diagnostics (optional). |
interface CanaryAnalysisContext What an analysis is handed: everything a core ValidationContext carries — the target's name, the run's redactor, and a signal — plus where the rollout stands.
| Member | Signature | Doc |
|---|---|---|
step | step: number | The 1-based step being analysed; 0 for a canary with no steps. |
requested | requested: number | The exposure this step asked for, in percent. |
exposure | exposure: number | The exposure the platform reported achieving, in percent. |
interface CanaryContext What the engine hands a CanaryPlatform on each call.
state is the one place a platform keeps the handles a later call needs — the revision it staged, the stable one to return to. Every call of one rollout gets the same durable state, so abort can read what stage wrote, even in another process after a resume. Never put a secret in it: it is persisted in plain JSON and shown by zuke runs show.
| Member | Signature | Doc |
|---|---|---|
reportSummary | reportSummary(pairs: SummaryPairs): void | Add key/value pairs to the calling target's row in the build summary. |
state | state: TargetStateHandle | The rollout's durable platform state, shared by every call. |
signal | signal: AbortSignal | Aborted when the run is cancelled. Pass it to any network call. A compensation (abort during a rollback) is handed one that never aborts: the rollback is finished, not interrupted. |
interface CanaryPhaseTargets The targets of one phase of a rollout.
| Member | Signature | Doc |
|---|---|---|
expose? | expose?: TargetBuilder | Sets the step's exposure; absent for the soak of a canary with no steps. |
bake? | bake?: TargetBuilder | Waits out the bake; absent when the phase does not bake. |
analyze? | analyze?: TargetBuilder | Runs the analyses once at the end; absent when there are none. |
interface CanaryPlatform A place a canary runs. The engine stages the candidate, moves its exposure step by step, and then promotes it or aborts it — through these calls only.
Rules the engine relies on:
- expose is absolute (set exposure to percent, not add to it) and returns the exposure actually achieved, which may differ on a "replicas" platform. A repeated call with the same value is harmless — a resumed run repeats one that was interrupted. - promote and abort are idempotent: either can run twice. - abort must also work with empty state. A rollback reads the state stage wrote, but zuke <rollout>.abort run by hand is a fresh run that has none, so abort falls back to what its own configuration says the stable version is. It does so only when ctx.reason is "hand-run": a "rollback" with empty state means nothing was staged, and moving traffic to the configured stable version would change a service no rollout touched. So stage records what a rollback needs before it changes anything — with ctx.state.trySet(...), stopping when it returns false — or a rollback after a lost write would find nothing to undo.
| Member | Signature | Doc |
|---|---|---|
describe | describe(): string | A short description for the build summary ("Cloud Run service api"). |
stage | stage(ctx: CanaryContext): Promise<void> | Put the candidate in place at 0 % exposure; record handles in ctx.state. |
expose | expose(percent: number, ctx: CanaryContext): Promise<number> | Set exposure to percent (0–100) and return the exposure achieved. |
promote | promote(ctx: CanaryContext): Promise<void> | Send all traffic to the candidate and retire the old version. |
abort | abort(ctx: CanaryAbortContext): Promise<void> | Return all traffic to the stable version and remove the candidate. |
exposure | exposure: ExposureKind | How this platform measures exposure. |
class CanarySettings How a canary rolls out, configured through canary((c) => …):
rollout = canary((c) =>
c.platform(myPlatform)
.steps(10, 25, 50)
.bake("10m")
.analysis(httpProbe((h) => h.url(HEALTH_URL)))
.approval("canary-approved")
.lock((l) => l.lockKey("deploy", "api").withTtl("24h"))
);
The lambda runs once, when the build is constructed, because it decides which targets exist. Anything that reads a parameter — an image tag, a URL — belongs inside the platform's or the analysis's own lambda, which runs when the target does.
| Member | Signature | Doc |
|---|---|---|
platform | platform(platform: CanaryPlatform): this | Where the canary runs: a platform adapter, or a hand-written object. |
steps | steps(...percents: number[]): this | The exposure after each step, in percent and increasing — steps(10, 25, 50) exposes 10 %, then 25 %, then 50 %, and promotion takes it to 100 %. A "channel" platform has no percentage and declares no steps. |
bake | bake(duration: string | number): this | How long every step bakes before its final analysis ("10m", or ms). |
bakeStep | bakeStep(step: number, duration: string | number): this | How long one step (1-based) bakes, overriding bake for it. |
analysis | analysis(...analyses: CanaryAnalysis[]): this | Health checks run against each step: on an interval while it bakes (inline bakes only) and once when it ends. Any core Validation works. |
analysisInterval | analysisInterval(duration: string | number): this | How often an inline bake runs its analyses (default "1m"). |
approval | approval(signal: string): this | Wait for an external signal before promoting — zuke resume <run-id> --signal <name> delivers it. |
approvalTimeout | approvalTimeout(duration: string | number): this | How long the approval gate waits before rolling the canary back. |
lock | lock(configure: Configure<LockSettings>): this | Lock the rollout for the whole run, from staging to promotion or rollback — a core lock settings lambda, held with holdForRun(). Set its TTL to cover the longest the run can stay parked at a gate. |
durable | durable(): this | Bake by suspending the run instead of sleeping in the process: each bake becomes a gate that zuke resume --check reopens once its time is up. Needs a state store. In-flight analyses do not run (no process is there to run them); each step's final analysis does. |
platform_? | platform_?: CanaryPlatform | Where the canary runs (set by platform). |
steps_ | steps_: number[] | The exposure after each step, in percent (set by steps). |
bake_? | bake_?: string | number | How long each step bakes by default (set by bake). |
bakeAt_ | bakeAt_: Map<number, string | number> | Per-step bake overrides, by 1-based step (set by bakeStep). |
analyses_ | analyses_: CanaryAnalysis[] | The analyses each step runs (set by analysis). |
analysisInterval_? | analysisInterval_?: string | number | How often a bake runs its analyses in flight (set by analysisInterval). |
approval_? | approval_?: string | The signal the approval gate waits for (set by approval). |
approvalTimeout_? | approvalTimeout_?: string | number | How long the approval gate waits (set by approvalTimeout). |
lock_? | lock_?: Configure<LockSettings> | The rollout's lock, held for the whole run (set by lock). |
durable_ | durable_: boolean | Whether bakes suspend the run instead of sleeping (set by durable). |
type ExposureKind = traffic | replicas | channel How a platform exposes a candidate:
- "traffic" — a share of requests, set exactly (a traffic split). - "replicas" — a share of instances, quantised to whole replicas, so the achieved exposure can differ from the one requested. - "channel" — a release channel (a dist-tag, a pre-release); there is no percentage, so a canary on it declares no steps.
function httpProbe(configure: Configure<HttpProbeSettings>): CanaryAnalysis An analysis that requests a URL a number of times and fails the canary when more than maxFailures of them fail — a status outside the expected ones, a timeout, or a connection error:
httpProbe((h) => h.url("https://api.example.com/healthz").samples(30))
The lambda runs on each check, so it may read resolved parameters. The failure message passes through the run's redactor.
class HttpProbeSettings Settings for httpProbe: what to request, how often, how strictly.
| Member | Signature | Doc |
|---|---|---|
url | url(url: string): this | The URL to request. |
samples | samples(count: number): this | How many requests to make (default 10). |
maxFailures | maxFailures(count: number): this | How many failed requests are tolerated before the probe fails (default 0). |
expectStatus | expectStatus(...statuses: number[]): this | The statuses that count as healthy; any 2xx when none are set. |
timeout | timeout(duration: string | number): this | How long one request may take ("5s", or ms; default 5 s). |
interval | interval(duration: string | number): this | The pause between requests ("1s", or ms; default 1 s). |
header | header(name: string, value: string): this | Add a request header. |
fetch | fetch(fetcher: fetch): this | The fetch to use — the seam a test answers requests through. |
url_? | url_?: string | The URL to request (set by url). |
samples_ | samples_: number | How many requests to make (set by samples). |
maxFailures_ | maxFailures_: number | How many failed requests are tolerated (set by maxFailures). |
expectStatus_ | expectStatus_: number[] | The statuses that count as healthy; any 2xx when empty (set by expectStatus). |
timeout_ | timeout_: number | How long one request may take, in ms (set by timeout). |
interval_ | interval_: number | The pause between requests, in ms (set by interval). |
headers_ | headers_: Record<string, string> | Request headers (set by header). |
fetch_ | fetch_: fetch | The fetch to use (set by fetch); the global one by default. |
type MetricReader = unknown Reads the metric a metricThreshold judges.
function metricThreshold(configure: Configure<MetricThresholdSettings>): CanaryAnalysis An analysis that reads a number and fails the canary when it is out of bounds:
metricThreshold((m) =>
m.name("error ratio").read(() => readErrorRatio()).max(0.01)
)
The lambda runs on each check, so a reader may use resolved parameters.
class MetricThresholdSettings Settings for metricThreshold: a name, a reader, and the bounds the value must stay within.
| Member | Signature | Doc |
|---|---|---|
name | name(name: string): this | What to call the metric when it is out of bounds. |
read | read(reader: MetricReader): this | How to read the value — handed where the rollout stands. |
min | min(value: number): this | Fail when the value is below value. |
max | max(value: number): this | Fail when the value is above value. |
name_ | name_: string | What the metric is called in a failure (set by name). |
read_? | read_?: MetricReader | How to read the value (set by read). |
min_? | min_?: number | The lowest acceptable value (set by min). |
max_? | max_?: number | The highest acceptable value (set by max). |
function prometheus(configure: Configure<PrometheusSettings>): CanaryAnalysis An analysis that runs an instant PromQL query and fails the canary when any sample is out of bounds:
prometheus((p) =>
p.url("https://prometheus.internal")
.query('sum(rate(http_errors_total{rev="canary"}[5m])) or vector(0)')
.max(0.01)
)
The lambda runs on each check, so it may read resolved parameters. The query is sent by PrometheusTasks.query from @zuke/prometheus, as a GET with the expression in the URL — as this analysis always has. A URL sent a credential must be https: unless it is loopback (or ZUKE_ALLOW_INSECURE_URL is set). A credential is an Authorization, Proxy-Authorization or Cookie header, a header whose name looks like a credential's (X-API-Key, X-Auth-Token), user:password@ or a credential parameter in the URL, or anything set through connection(...) — a secretHeader(...), sigv4(), google(), azure(). Any other header, such as an X-Scope-OrgID tenant id, leaves an in-cluster http://prometheus.monitoring.svc:9090 usable. Failure messages pass through the run's redactor, so a secret parameter in the URL or the query is masked, and a header value (eight or more characters) never appears in them.
class PrometheusSettings Settings for prometheus: where, what to ask, and the bounds.
| Member | Signature | Doc |
|---|---|---|
name | name(name: string): this | What to call the query when it fails. |
url | url(url: string): this | The Prometheus base URL, such as https://prometheus.internal. |
query | query(promql: string): this | The PromQL expression. It must return a vector or a scalar; an empty vector fails, so write … or vector(0) when no data means healthy. |
min | min(value: number): this | Fail when any sample is below value. |
max | max(value: number): this | Fail when any sample is above value. |
header | header(name: string, value: string): this | Add a plain request header, such as a tenant id: header("X-Scope-OrgID", tenant). Authenticate through connection instead, with connection((c) => c.bearerToken(token)) or c.basicAuth(…) / c.secretHeader(…), so @zuke/prometheus handles the credential itself: bearerToken refuses an empty token, most likely a missing secret, rather than sending a blank Bearer . As in @zuke/prometheus, a plain header leaves an in-cluster http:// URL usable, while Authorization, Proxy-Authorization, Cookie and a credential-looking name (X-API-Key, X-Auth-Token) are credentials wherever they are set and need https: (unless loopback, or ZUKE_ALLOW_INSECURE_URL is set). |
timeout | timeout(duration: string | number): this | How long the query may take ("10s", or ms; default 30 s). |
fetch | fetch(fetcher: fetch): this | The fetch to use — the seam a test answers queries through. |
connection | connection(configure: Configure<PrometheusConnectionSettings>): this | Configure the rest of the connection with @zuke/prometheus's own settings — a bearer token, the managed services' credentials, an API-key header, a response cap: ``ts prometheus((p) => p.url("https://aps-workspaces.us-east-1.amazonaws.com/workspaces/ws-1") .query(ERROR_RATIO).max(0.01) .connection((c) => c.sigv4((a) => a.region("us-east-1"))) ) ` Applied after url, header, timeout and fetch`, so a request signer configured here signs the headers set above. |
name_ | name_: string | What the query is called in a failure (set by name). |
url_? | url_?: string | The Prometheus base URL (set by url). |
query_? | query_?: string | The PromQL expression (set by query). |
min_? | min_?: number | The lowest acceptable sample (set by min). |
max_? | max_?: number | The highest acceptable sample (set by max). |
headers_ | headers_: Record<string, string> | Request headers, such as Authorization (set by header). |
timeout_ | timeout_: number | The request timeout in ms (set by timeout). |
fetch_ | fetch_: fetch | The fetch to use (set by fetch); the global one by default. |
connection_ | connection_: Configure<PrometheusConnectionSettings> | Further connection settings (set by connection). |
interface ThresholdBounds Inclusive bounds a metric must stay within; either side may be open.
| Member | Signature | Doc |
|---|---|---|
min? | min?: number | The lowest acceptable value, if any. |
max? | max?: number | The highest acceptable value, if any. |