@crewhaus/canary-controller 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ import type { AuditLog } from "@crewhaus/audit-log";
2
+ import type { DeploymentController } from "@crewhaus/deployment-controller";
3
+ import { CrewhausError } from "@crewhaus/errors";
4
+ import type { RegistryAdapter } from "@crewhaus/spec-registry";
5
+ export declare class CanaryError extends CrewhausError {
6
+ readonly name = "CanaryError";
7
+ constructor(message: string, cause?: unknown);
8
+ }
9
+ export type CanaryRoutingDecision = {
10
+ readonly version: string;
11
+ /** Whether this request is on the canary side. */
12
+ readonly isCanary: boolean;
13
+ /** Hash bucket value, 0-99. */
14
+ readonly bucket: number;
15
+ };
16
+ export type RegressionGate = (input: {
17
+ readonly fromVersion: string;
18
+ readonly toVersion: string;
19
+ }) => Promise<{
20
+ readonly verdict: "pass" | "fail";
21
+ readonly reason?: string;
22
+ }>;
23
+ /** Stub gate that always passes. Replaced by §29 regression-runner integration. */
24
+ export declare const PASSING_GATE: RegressionGate;
25
+ export type CanaryConfig = {
26
+ readonly name: string;
27
+ /** Currently-pinned version (control). */
28
+ readonly fromVersion: string;
29
+ /** Candidate version (treatment). */
30
+ readonly toVersion: string;
31
+ /** 0-100. Percent of traffic routed to `toVersion`. */
32
+ readonly trafficPercent: number;
33
+ /** Optional environment to update on promote/rollback. Default: "prod". */
34
+ readonly env?: string;
35
+ /** Optional tenant scope. */
36
+ readonly tenantId?: string;
37
+ };
38
+ export type CanaryEvalOptions = {
39
+ readonly intervalMs: number;
40
+ readonly gate: RegressionGate;
41
+ };
42
+ export interface CanaryController {
43
+ /**
44
+ * Decide which version to use for the given request id. Hash bucket
45
+ * computed from `sha256(tenantId|requestId) mod 100`.
46
+ */
47
+ route(config: CanaryConfig, requestId: string): CanaryRoutingDecision;
48
+ /**
49
+ * Run the eval gate. On pass: promote (re-pin env to toVersion). On
50
+ * fail: auto-rollback (re-pin env to fromVersion) and audit-log the
51
+ * regression reason.
52
+ */
53
+ evaluate(config: CanaryConfig, evalOpts: CanaryEvalOptions): Promise<{
54
+ readonly verdict: "pass" | "fail";
55
+ readonly reason?: string;
56
+ readonly action?: "promote" | "rollback";
57
+ }>;
58
+ }
59
+ export type CanaryControllerOptions = {
60
+ readonly registry: RegistryAdapter;
61
+ readonly deploymentController: DeploymentController;
62
+ readonly auditLog?: AuditLog;
63
+ /** Optional override for `now()` to make tests deterministic. */
64
+ readonly now?: () => number;
65
+ };
66
+ export declare function createCanaryController(opts: CanaryControllerOptions): CanaryController;
package/dist/index.js ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Section 28 — `canary-controller`. Shifts a configurable percentage of
3
+ * incoming requests to a new version of a spec. Hash routing on
4
+ * `(tenantId, requestId-hash mod 100 < trafficPercent)` so a given user
5
+ * stays on the same side of the canary across requests.
6
+ *
7
+ * After `evalIntervalMs` elapses, runs an eval-spec against both versions
8
+ * in parallel and gates promotion on `regression-runner` (Section 29 —
9
+ * but ships pre-§29 with a stub gate that returns "pass" until the real
10
+ * regression-runner lands).
11
+ *
12
+ * Without an eval gate (manual mode), the controller waits for an
13
+ * explicit `crewhaus deploy promote` call.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { CrewhausError } from "@crewhaus/errors";
17
+ export class CanaryError extends CrewhausError {
18
+ name = "CanaryError";
19
+ constructor(message, cause) {
20
+ super("config", message, cause);
21
+ }
22
+ }
23
+ /** Stub gate that always passes. Replaced by §29 regression-runner integration. */
24
+ export const PASSING_GATE = async () => ({ verdict: "pass" });
25
+ export function createCanaryController(opts) {
26
+ return {
27
+ route(config, requestId) {
28
+ if (!Number.isFinite(config.trafficPercent) ||
29
+ config.trafficPercent < 0 ||
30
+ config.trafficPercent > 100) {
31
+ throw new CanaryError(`trafficPercent must be in 0..100; got ${config.trafficPercent}`);
32
+ }
33
+ const bucket = computeBucket(config.tenantId, requestId);
34
+ const isCanary = bucket < config.trafficPercent;
35
+ return {
36
+ version: isCanary ? config.toVersion : config.fromVersion,
37
+ isCanary,
38
+ bucket,
39
+ };
40
+ },
41
+ async evaluate(config, evalOpts) {
42
+ const env = config.env ?? "prod";
43
+ const result = await evalOpts.gate({
44
+ fromVersion: config.fromVersion,
45
+ toVersion: config.toVersion,
46
+ });
47
+ if (result.verdict === "pass") {
48
+ // Promote: re-pin env to toVersion.
49
+ if (config.tenantId) {
50
+ await opts.registry.pinForTenant(config.tenantId, config.name, env, config.toVersion);
51
+ }
52
+ else {
53
+ await opts.registry.pin(config.name, env, config.toVersion);
54
+ }
55
+ if (opts.auditLog) {
56
+ await opts.auditLog.append({
57
+ kind: "deployment_action",
58
+ payload: {
59
+ action: "promote",
60
+ name: config.name,
61
+ env,
62
+ fromVersion: config.fromVersion,
63
+ toVersion: config.toVersion,
64
+ ...(config.tenantId !== undefined ? { tenantId: config.tenantId } : {}),
65
+ source: "canary-controller",
66
+ ts: (opts.now ?? Date.now)(),
67
+ },
68
+ });
69
+ }
70
+ return {
71
+ verdict: "pass",
72
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
73
+ action: "promote",
74
+ };
75
+ }
76
+ // Rollback: re-pin env to fromVersion.
77
+ if (config.tenantId) {
78
+ await opts.registry.pinForTenant(config.tenantId, config.name, env, config.fromVersion);
79
+ }
80
+ else {
81
+ await opts.registry.pin(config.name, env, config.fromVersion);
82
+ }
83
+ if (opts.auditLog) {
84
+ await opts.auditLog.append({
85
+ kind: "deployment_action",
86
+ payload: {
87
+ action: "rollback",
88
+ name: config.name,
89
+ env,
90
+ fromVersion: config.toVersion,
91
+ toVersion: config.fromVersion,
92
+ ...(config.tenantId !== undefined ? { tenantId: config.tenantId } : {}),
93
+ source: "canary-controller",
94
+ reason: result.reason ?? "regression detected",
95
+ ts: (opts.now ?? Date.now)(),
96
+ },
97
+ });
98
+ }
99
+ return {
100
+ verdict: "fail",
101
+ ...(result.reason !== undefined ? { reason: result.reason } : {}),
102
+ action: "rollback",
103
+ };
104
+ },
105
+ };
106
+ }
107
+ function computeBucket(tenantId, requestId) {
108
+ const seed = `${tenantId ?? ""}|${requestId}`;
109
+ const hash = createHash("sha256").update(seed).digest();
110
+ // Take first 4 bytes as uint32, mod 100.
111
+ const v = ((hash[0] ?? 0) << 24) | ((hash[1] ?? 0) << 16) | ((hash[2] ?? 0) << 8) | (hash[3] ?? 0);
112
+ return Math.abs(v) % 100;
113
+ }
package/package.json CHANGED
@@ -1,21 +1,24 @@
1
1
  {
2
2
  "name": "@crewhaus/canary-controller",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Percent-of-traffic rollout with eval-gated promotion. Hash-routes requests across two pinned versions and auto-rolls-back on regression.",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/audit-log": "0.1.3",
16
- "@crewhaus/deployment-controller": "0.1.3",
17
- "@crewhaus/errors": "0.1.3",
18
- "@crewhaus/spec-registry": "0.1.3"
18
+ "@crewhaus/audit-log": "0.1.5",
19
+ "@crewhaus/deployment-controller": "0.1.5",
20
+ "@crewhaus/errors": "0.1.5",
21
+ "@crewhaus/spec-registry": "0.1.5"
19
22
  },
20
23
  "license": "Apache-2.0",
21
24
  "author": {
@@ -35,5 +38,5 @@
35
38
  "publishConfig": {
36
39
  "access": "public"
37
40
  },
38
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
41
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
39
42
  }
package/src/index.test.ts DELETED
@@ -1,228 +0,0 @@
1
- /**
2
- * Section 28 — `canary-controller` tests:
3
- * - T3 simulated-traffic test (1000 requests across canary at 10%/50%/100%)
4
- * with auto-rollback on injected regression
5
- * - T7 24-hour stability fixture (no traffic-bucket drift)
6
- */
7
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { mkdtempSync, rmSync } from "node:fs";
9
- import { tmpdir } from "node:os";
10
- import { join } from "node:path";
11
- import { type AuditRecord, openAuditLog } from "@crewhaus/audit-log";
12
- import { createDeploymentController } from "@crewhaus/deployment-controller";
13
- import { createFileBackedRegistry } from "@crewhaus/spec-registry";
14
- import { CanaryError, PASSING_GATE, type RegressionGate, createCanaryController } from "./index";
15
-
16
- let tmpRoot = "";
17
-
18
- beforeEach(() => {
19
- tmpRoot = mkdtempSync(join(tmpdir(), "canary-test-"));
20
- });
21
-
22
- afterEach(() => {
23
- rmSync(tmpRoot, { recursive: true, force: true });
24
- });
25
-
26
- async function readAudit(rootDir: string): Promise<AuditRecord[]> {
27
- const log = await openAuditLog({ rootDir });
28
- const out: AuditRecord[] = [];
29
- for await (const r of log.read()) out.push(r);
30
- return out;
31
- }
32
-
33
- describe("canary-controller — T3 traffic routing", () => {
34
- test("0% traffic: every request goes to fromVersion", async () => {
35
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
36
- const deploy = createDeploymentController({ registry: reg });
37
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
38
- const config = { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 0 };
39
- let canary = 0;
40
- for (let i = 0; i < 1000; i++) {
41
- const decision = ctrl.route(config, `req-${i}`);
42
- if (decision.isCanary) canary++;
43
- expect(decision.version).toBe(decision.isCanary ? "v2" : "v1");
44
- }
45
- expect(canary).toBe(0);
46
- });
47
-
48
- test("100% traffic: every request goes to toVersion", async () => {
49
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
50
- const deploy = createDeploymentController({ registry: reg });
51
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
52
- const config = { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 100 };
53
- let stable = 0;
54
- for (let i = 0; i < 1000; i++) {
55
- const decision = ctrl.route(config, `req-${i}`);
56
- if (!decision.isCanary) stable++;
57
- }
58
- expect(stable).toBe(0);
59
- });
60
-
61
- test("50% traffic distributes within ±10% over 1000 requests", async () => {
62
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
63
- const deploy = createDeploymentController({ registry: reg });
64
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
65
- const config = { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 50 };
66
- let canary = 0;
67
- for (let i = 0; i < 1000; i++) {
68
- if (ctrl.route(config, `req-${i}`).isCanary) canary++;
69
- }
70
- expect(canary).toBeGreaterThan(400);
71
- expect(canary).toBeLessThan(600);
72
- });
73
-
74
- test("hash routing is stable per requestId", async () => {
75
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
76
- const deploy = createDeploymentController({ registry: reg });
77
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
78
- const config = { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 50 };
79
- const a1 = ctrl.route(config, "user-1234");
80
- const a2 = ctrl.route(config, "user-1234");
81
- expect(a1.bucket).toBe(a2.bucket);
82
- expect(a1.isCanary).toBe(a2.isCanary);
83
- });
84
-
85
- test("invalid trafficPercent throws", async () => {
86
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
87
- const deploy = createDeploymentController({ registry: reg });
88
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
89
- expect(() =>
90
- ctrl.route({ name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 150 }, "r"),
91
- ).toThrow(CanaryError);
92
- });
93
-
94
- test("negative trafficPercent throws", async () => {
95
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
96
- const deploy = createDeploymentController({ registry: reg });
97
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
98
- expect(() =>
99
- ctrl.route({ name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: -1 }, "r"),
100
- ).toThrow(CanaryError);
101
- });
102
-
103
- test("NaN trafficPercent throws instead of silently routing all traffic to control", async () => {
104
- // Regression: `NaN < 0` and `NaN > 100` are both false, so without an
105
- // explicit finiteness check a NaN percentage slips past validation and
106
- // every request silently falls back to fromVersion (isCanary always false).
107
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
108
- const deploy = createDeploymentController({ registry: reg });
109
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
110
- expect(() =>
111
- ctrl.route(
112
- { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: Number.NaN },
113
- "r",
114
- ),
115
- ).toThrow(CanaryError);
116
- expect(() =>
117
- ctrl.route(
118
- { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: Number.NaN },
119
- "r",
120
- ),
121
- ).toThrow(/trafficPercent must be in 0\.\.100/);
122
- });
123
-
124
- test("Infinity trafficPercent throws", async () => {
125
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
126
- const deploy = createDeploymentController({ registry: reg });
127
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
128
- expect(() =>
129
- ctrl.route(
130
- { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: Number.POSITIVE_INFINITY },
131
- "r",
132
- ),
133
- ).toThrow(CanaryError);
134
- });
135
- });
136
-
137
- describe("canary-controller — eval gate", () => {
138
- test("pass: promote re-pins env to toVersion + audit-logs", async () => {
139
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
140
- await reg.put("hello", "v1", "x");
141
- await reg.put("hello", "v2", "y");
142
- await reg.pin("hello", "prod", "v1");
143
- const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
144
- const deploy = createDeploymentController({ registry: reg });
145
- const ctrl = createCanaryController({
146
- registry: reg,
147
- deploymentController: deploy,
148
- auditLog: audit,
149
- });
150
- const result = await ctrl.evaluate(
151
- { name: "hello", fromVersion: "v1", toVersion: "v2", trafficPercent: 50 },
152
- { intervalMs: 0, gate: PASSING_GATE },
153
- );
154
- expect(result.verdict).toBe("pass");
155
- expect(result.action).toBe("promote");
156
- expect(await reg.aliasFor("hello", "prod")).toBe("v2");
157
- const records = await readAudit(join(tmpRoot, "audit"));
158
- expect(records.length).toBe(1);
159
- expect((records[0]?.payload as { action: string }).action).toBe("promote");
160
- });
161
-
162
- test("fail: rollback re-pins env to fromVersion + audit-logs reason", async () => {
163
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
164
- await reg.put("hello", "v1", "x");
165
- await reg.put("hello", "v2", "y");
166
- await reg.pin("hello", "prod", "v2"); // canary already deployed
167
- const audit = await openAuditLog({ rootDir: join(tmpRoot, "audit") });
168
- const deploy = createDeploymentController({ registry: reg });
169
- const failingGate: RegressionGate = async () => ({
170
- verdict: "fail",
171
- reason: "pass-rate dropped from 0.95 to 0.62",
172
- });
173
- const ctrl = createCanaryController({
174
- registry: reg,
175
- deploymentController: deploy,
176
- auditLog: audit,
177
- });
178
- const result = await ctrl.evaluate(
179
- { name: "hello", fromVersion: "v1", toVersion: "v2", trafficPercent: 50 },
180
- { intervalMs: 0, gate: failingGate },
181
- );
182
- expect(result.verdict).toBe("fail");
183
- expect(result.action).toBe("rollback");
184
- expect(result.reason).toContain("pass-rate dropped");
185
- expect(await reg.aliasFor("hello", "prod")).toBe("v1");
186
- const records = await readAudit(join(tmpRoot, "audit"));
187
- expect((records[0]?.payload as { reason: string }).reason).toContain("pass-rate dropped");
188
- });
189
-
190
- test("tenant-scoped canary updates only the tenant overlay", async () => {
191
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
192
- await reg.put("hello", "v1", "x");
193
- await reg.put("hello", "v2", "y");
194
- await reg.pin("hello", "prod", "v1");
195
- const deploy = createDeploymentController({ registry: reg });
196
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
197
- await ctrl.evaluate(
198
- {
199
- name: "hello",
200
- fromVersion: "v1",
201
- toVersion: "v2",
202
- trafficPercent: 50,
203
- tenantId: "tenant-a",
204
- },
205
- { intervalMs: 0, gate: PASSING_GATE },
206
- );
207
- expect(await reg.aliasFor("hello", "prod")).toBe("v1");
208
- expect(await reg.aliasForTenant("tenant-a", "hello", "prod")).toBe("v2");
209
- });
210
- });
211
-
212
- describe("canary-controller — T7 stability fixture", () => {
213
- test("hash routing has no drift across simulated 24-hour cycle", async () => {
214
- // Take 1000 fixed requestIds, route 100 times each, assert decisions are stable.
215
- const reg = createFileBackedRegistry({ rootDir: join(tmpRoot, "specs") });
216
- const deploy = createDeploymentController({ registry: reg });
217
- const ctrl = createCanaryController({ registry: reg, deploymentController: deploy });
218
- const config = { name: "x", fromVersion: "v1", toVersion: "v2", trafficPercent: 25 };
219
- const requestIds = Array.from({ length: 100 }, (_, i) => `req-${i}`);
220
- for (const id of requestIds) {
221
- const decisions = new Set<boolean>();
222
- for (let t = 0; t < 100; t++) {
223
- decisions.add(ctrl.route(config, id).isCanary);
224
- }
225
- expect(decisions.size).toBe(1);
226
- }
227
- });
228
- });
package/src/index.ts DELETED
@@ -1,190 +0,0 @@
1
- /**
2
- * Section 28 — `canary-controller`. Shifts a configurable percentage of
3
- * incoming requests to a new version of a spec. Hash routing on
4
- * `(tenantId, requestId-hash mod 100 < trafficPercent)` so a given user
5
- * stays on the same side of the canary across requests.
6
- *
7
- * After `evalIntervalMs` elapses, runs an eval-spec against both versions
8
- * in parallel and gates promotion on `regression-runner` (Section 29 —
9
- * but ships pre-§29 with a stub gate that returns "pass" until the real
10
- * regression-runner lands).
11
- *
12
- * Without an eval gate (manual mode), the controller waits for an
13
- * explicit `crewhaus deploy promote` call.
14
- */
15
- import { createHash } from "node:crypto";
16
- import type { AuditLog } from "@crewhaus/audit-log";
17
- import type { DeploymentController } from "@crewhaus/deployment-controller";
18
- import { CrewhausError } from "@crewhaus/errors";
19
- import type { RegistryAdapter } from "@crewhaus/spec-registry";
20
-
21
- export class CanaryError extends CrewhausError {
22
- override readonly name = "CanaryError";
23
- constructor(message: string, cause?: unknown) {
24
- super("config", message, cause);
25
- }
26
- }
27
-
28
- export type CanaryRoutingDecision = {
29
- readonly version: string;
30
- /** Whether this request is on the canary side. */
31
- readonly isCanary: boolean;
32
- /** Hash bucket value, 0-99. */
33
- readonly bucket: number;
34
- };
35
-
36
- export type RegressionGate = (input: {
37
- readonly fromVersion: string;
38
- readonly toVersion: string;
39
- }) => Promise<{ readonly verdict: "pass" | "fail"; readonly reason?: string }>;
40
-
41
- /** Stub gate that always passes. Replaced by §29 regression-runner integration. */
42
- export const PASSING_GATE: RegressionGate = async () => ({ verdict: "pass" });
43
-
44
- export type CanaryConfig = {
45
- readonly name: string;
46
- /** Currently-pinned version (control). */
47
- readonly fromVersion: string;
48
- /** Candidate version (treatment). */
49
- readonly toVersion: string;
50
- /** 0-100. Percent of traffic routed to `toVersion`. */
51
- readonly trafficPercent: number;
52
- /** Optional environment to update on promote/rollback. Default: "prod". */
53
- readonly env?: string;
54
- /** Optional tenant scope. */
55
- readonly tenantId?: string;
56
- };
57
-
58
- export type CanaryEvalOptions = {
59
- readonly intervalMs: number;
60
- readonly gate: RegressionGate;
61
- };
62
-
63
- export interface CanaryController {
64
- /**
65
- * Decide which version to use for the given request id. Hash bucket
66
- * computed from `sha256(tenantId|requestId) mod 100`.
67
- */
68
- route(config: CanaryConfig, requestId: string): CanaryRoutingDecision;
69
- /**
70
- * Run the eval gate. On pass: promote (re-pin env to toVersion). On
71
- * fail: auto-rollback (re-pin env to fromVersion) and audit-log the
72
- * regression reason.
73
- */
74
- evaluate(
75
- config: CanaryConfig,
76
- evalOpts: CanaryEvalOptions,
77
- ): Promise<{
78
- readonly verdict: "pass" | "fail";
79
- readonly reason?: string;
80
- readonly action?: "promote" | "rollback";
81
- }>;
82
- }
83
-
84
- export type CanaryControllerOptions = {
85
- readonly registry: RegistryAdapter;
86
- readonly deploymentController: DeploymentController;
87
- readonly auditLog?: AuditLog;
88
- /** Optional override for `now()` to make tests deterministic. */
89
- readonly now?: () => number;
90
- };
91
-
92
- export function createCanaryController(opts: CanaryControllerOptions): CanaryController {
93
- return {
94
- route(config, requestId): CanaryRoutingDecision {
95
- if (
96
- !Number.isFinite(config.trafficPercent) ||
97
- config.trafficPercent < 0 ||
98
- config.trafficPercent > 100
99
- ) {
100
- throw new CanaryError(`trafficPercent must be in 0..100; got ${config.trafficPercent}`);
101
- }
102
- const bucket = computeBucket(config.tenantId, requestId);
103
- const isCanary = bucket < config.trafficPercent;
104
- return {
105
- version: isCanary ? config.toVersion : config.fromVersion,
106
- isCanary,
107
- bucket,
108
- };
109
- },
110
-
111
- async evaluate(
112
- config,
113
- evalOpts,
114
- ): Promise<{
115
- verdict: "pass" | "fail";
116
- reason?: string;
117
- action?: "promote" | "rollback";
118
- }> {
119
- const env = config.env ?? "prod";
120
- const result = await evalOpts.gate({
121
- fromVersion: config.fromVersion,
122
- toVersion: config.toVersion,
123
- });
124
- if (result.verdict === "pass") {
125
- // Promote: re-pin env to toVersion.
126
- if (config.tenantId) {
127
- await opts.registry.pinForTenant(config.tenantId, config.name, env, config.toVersion);
128
- } else {
129
- await opts.registry.pin(config.name, env, config.toVersion);
130
- }
131
- if (opts.auditLog) {
132
- await opts.auditLog.append({
133
- kind: "deployment_action",
134
- payload: {
135
- action: "promote",
136
- name: config.name,
137
- env,
138
- fromVersion: config.fromVersion,
139
- toVersion: config.toVersion,
140
- ...(config.tenantId !== undefined ? { tenantId: config.tenantId } : {}),
141
- source: "canary-controller",
142
- ts: (opts.now ?? Date.now)(),
143
- },
144
- });
145
- }
146
- return {
147
- verdict: "pass",
148
- ...(result.reason !== undefined ? { reason: result.reason } : {}),
149
- action: "promote",
150
- };
151
- }
152
- // Rollback: re-pin env to fromVersion.
153
- if (config.tenantId) {
154
- await opts.registry.pinForTenant(config.tenantId, config.name, env, config.fromVersion);
155
- } else {
156
- await opts.registry.pin(config.name, env, config.fromVersion);
157
- }
158
- if (opts.auditLog) {
159
- await opts.auditLog.append({
160
- kind: "deployment_action",
161
- payload: {
162
- action: "rollback",
163
- name: config.name,
164
- env,
165
- fromVersion: config.toVersion,
166
- toVersion: config.fromVersion,
167
- ...(config.tenantId !== undefined ? { tenantId: config.tenantId } : {}),
168
- source: "canary-controller",
169
- reason: result.reason ?? "regression detected",
170
- ts: (opts.now ?? Date.now)(),
171
- },
172
- });
173
- }
174
- return {
175
- verdict: "fail",
176
- ...(result.reason !== undefined ? { reason: result.reason } : {}),
177
- action: "rollback",
178
- };
179
- },
180
- };
181
- }
182
-
183
- function computeBucket(tenantId: string | undefined, requestId: string): number {
184
- const seed = `${tenantId ?? ""}|${requestId}`;
185
- const hash = createHash("sha256").update(seed).digest();
186
- // Take first 4 bytes as uint32, mod 100.
187
- const v =
188
- ((hash[0] ?? 0) << 24) | ((hash[1] ?? 0) << 16) | ((hash[2] ?? 0) << 8) | (hash[3] ?? 0);
189
- return Math.abs(v) % 100;
190
- }