Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
{
"description": "Frozen cohort assignments for soroban-core@1.5.0 with stages [1, 10, 50, 100]. Changing tenantBucket reshuffles cohorts mid-rollout; this snapshot must only change together with a documented migration.",
"packId": "soroban-core",
"candidateVersion": "1.5.0",
"stagesBasisPoints": [100, 1000, 5000, 10000],
"assignments": [
{
"tenantId": "acme-defi",
"bucket": 3188,
"candidateFromStage": 2
},
{
"tenantId": "gasguard-dogfood",
"bucket": 9800,
"candidateFromStage": 3
},
{
"tenantId": "regulated-bank-eu",
"bucket": 7286,
"candidateFromStage": 3
},
{
"tenantId": "stellar-anchor-co",
"bucket": 8183,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0001",
"bucket": 2038,
"candidateFromStage": 2
},
{
"tenantId": "tenant-0002",
"bucket": 9234,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0003",
"bucket": 9599,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0004",
"bucket": 8009,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0005",
"bucket": 9840,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0006",
"bucket": 5325,
"candidateFromStage": 3
},
{
"tenantId": "tenant-0007",
"bucket": 1942,
"candidateFromStage": 2
},
{
"tenantId": "tenant-0008",
"bucket": 634,
"candidateFromStage": 1
},
{
"tenantId": "org:example/project",
"bucket": 1161,
"candidateFromStage": 2
},
{
"tenantId": "Tenant.With.Dots",
"bucket": 5289,
"candidateFromStage": 3
},
{
"tenantId": "u@example",
"bucket": 4443,
"candidateFromStage": 2
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"description": "Rollout of soroban-core 1.5.0 (new storage-rent rules) over 1.4.2, scanned against a Soroban AMM repository that also uses an unmanaged EVM pack.",
"stableVersion": "1.4.2",
"plan": {
"packId": "soroban-core",
"candidateVersion": "1.5.0",
"stages": [1, 10, 50, 100],
"allowTenants": ["gasguard-dogfood"],
"denyTenants": ["regulated-bank-eu"],
"gate": { "minSamples": 5, "maxFailureRatio": 0.2, "autoRollback": true }
},
"otherManagedPacks": [
{ "packId": "soroban-security", "stableVersion": "2.0.1" }
],
"scan": {
"tenantId": "acme-defi",
"repository": "github.com/acme/soroban-amm",
"commit": "9f2c4b1e7a3d5f60c8b2e4a1d7f9c3b5e2a4d6f8",
"scanId": "acme-defi:soroban-amm:9f2c4b1e:ci-4812",
"packIds": ["soroban-security", "soroban-core", "evm-gas"]
}
}
7 changes: 7 additions & 0 deletions apps/api/src/rule-pack-rollout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export * from "./rule-pack-rollout-types";
export * from "./rule-pack-rollout-config";
export * from "./rule-pack-rollout-planner";
export * from "./rule-pack-rollout-store";
export * from "./rule-pack-rollout-metrics";
export * from "./rule-pack-rollout.service";
export * from "./rule-pack-rollout-worker";
91 changes: 91 additions & 0 deletions apps/api/src/rule-pack-rollout/rule-pack-rollout-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
DEFAULT_RULE_PACK_ROLLOUT_CONFIG,
loadRulePackRolloutConfigFromEnv,
resolveRulePackRolloutConfig,
} from "./rule-pack-rollout-config";

describe("resolveRulePackRolloutConfig", () => {
it("returns the defaults when given no overrides", () => {
expect(resolveRulePackRolloutConfig()).toEqual(
DEFAULT_RULE_PACK_ROLLOUT_CONFIG,
);
});

it("merges overrides and ignores undefined values", () => {
const config = resolveRulePackRolloutConfig({
defaultMinSamples: 50,
maxStages: undefined,
});
expect(config.defaultMinSamples).toBe(50);
expect(config.maxStages).toBe(DEFAULT_RULE_PACK_ROLLOUT_CONFIG.maxStages);
});

it("accepts boundary values", () => {
expect(() =>
resolveRulePackRolloutConfig({
defaultMaxFailureRatio: 0,
maxConflictRetries: 0,
maxListedTenants: 0,
defaultMinSamples: 1,
}),
).not.toThrow();
expect(() =>
resolveRulePackRolloutConfig({ defaultMaxFailureRatio: 1 }),
).not.toThrow();
});

it.each([
[{ enabled: "yes" as unknown as boolean }, "enabled"],
[{ defaultAutoRollback: 1 as unknown as boolean }, "defaultAutoRollback"],
[{ defaultMinSamples: 0 }, "defaultMinSamples"],
[{ defaultMinSamples: 1.5 }, "defaultMinSamples"],
[{ defaultMaxFailureRatio: -0.1 }, "defaultMaxFailureRatio"],
[{ defaultMaxFailureRatio: 1.01 }, "defaultMaxFailureRatio"],
[{ defaultMaxFailureRatio: Number.NaN }, "defaultMaxFailureRatio"],
[{ maxStages: 0 }, "maxStages"],
[{ maxListedTenants: -1 }, "maxListedTenants"],
[{ maxConflictRetries: -1 }, "maxConflictRetries"],
[{ pinRetentionMs: 0 }, "pinRetentionMs"],
[{ maxEventsPerRollout: 0 }, "maxEventsPerRollout"],
])("rejects %j naming %s", (overrides, field) => {
expect(() => resolveRulePackRolloutConfig(overrides)).toThrow(
new RegExp(`^${field} `),
);
});
});

describe("loadRulePackRolloutConfigFromEnv", () => {
it("keeps defaults for unset and empty variables", () => {
expect(
loadRulePackRolloutConfigFromEnv({ RULE_PACK_ROLLOUT_MAX_STAGES: " " }),
).toEqual(DEFAULT_RULE_PACK_ROLLOUT_CONFIG);
});

it("parses booleans and numbers", () => {
const config = loadRulePackRolloutConfigFromEnv({
RULE_PACK_ROLLOUT_ENABLED: "off",
RULE_PACK_ROLLOUT_AUTO_ROLLBACK: "FALSE",
RULE_PACK_ROLLOUT_MIN_SAMPLES: "100",
RULE_PACK_ROLLOUT_MAX_FAILURE_RATIO: "0.01",
});
expect(config.enabled).toBe(false);
expect(config.defaultAutoRollback).toBe(false);
expect(config.defaultMinSamples).toBe(100);
expect(config.defaultMaxFailureRatio).toBe(0.01);
});

it("throws on unparseable values, naming the variable", () => {
expect(() =>
loadRulePackRolloutConfigFromEnv({ RULE_PACK_ROLLOUT_ENABLED: "maybe" }),
).toThrow("RULE_PACK_ROLLOUT_ENABLED must be a boolean");
expect(() =>
loadRulePackRolloutConfigFromEnv({ RULE_PACK_ROLLOUT_MAX_STAGES: "ten" }),
).toThrow("RULE_PACK_ROLLOUT_MAX_STAGES must be a number");
});

it("validates parsed values", () => {
expect(() =>
loadRulePackRolloutConfigFromEnv({ RULE_PACK_ROLLOUT_MIN_SAMPLES: "0" }),
).toThrow("defaultMinSamples must be a positive integer");
});
});
163 changes: 163 additions & 0 deletions apps/api/src/rule-pack-rollout/rule-pack-rollout-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Configuration surface for staged rule-pack rollouts.
*
* Every option has a conservative default so the feature is safe to enable
* without tuning. Values are validated once at construction; an invalid value
* fails fast with a `RangeError` naming the offending option rather than
* silently falling back.
*/

export interface RulePackRolloutConfig {
/**
* Master switch and kill switch. When false, new resolutions return the
* stable version for every tenant and rollout mutations are refused with
* ROLLOUT_DISABLED. Existing scan pins are still honoured so in-flight
* retries stay deterministic.
*/
enabled: boolean;
/** Gate default: candidate outcomes needed before a stage passes or fails. */
defaultMinSamples: number;
/** Gate default: highest tolerated failure ratio in a stage (0-1). */
defaultMaxFailureRatio: number;
/** Gate default: roll back automatically when a stage breaches the gate. */
defaultAutoRollback: boolean;
/** Largest number of stages a plan may have. */
maxStages: number;
/** Largest allow list plus deny list size. */
maxListedTenants: number;
/** Attempts to re-read and re-apply a channel write after a conflict. */
maxConflictRetries: number;
/** How long scan pins are kept before `purgeExpiredPins` removes them. */
pinRetentionMs: number;
/** Rollout history entries kept per rollout (oldest dropped first). */
maxEventsPerRollout: number;
}

export const DEFAULT_RULE_PACK_ROLLOUT_CONFIG: Readonly<RulePackRolloutConfig> =
Object.freeze({
enabled: true,
defaultMinSamples: 20,
defaultMaxFailureRatio: 0.05,
defaultAutoRollback: true,
maxStages: 10,
maxListedTenants: 1_000,
maxConflictRetries: 5,
pinRetentionMs: 7 * 24 * 60 * 60 * 1000,
maxEventsPerRollout: 100,
});

const ENV_KEYS: Record<keyof RulePackRolloutConfig, string> = {
enabled: "RULE_PACK_ROLLOUT_ENABLED",
defaultMinSamples: "RULE_PACK_ROLLOUT_MIN_SAMPLES",
defaultMaxFailureRatio: "RULE_PACK_ROLLOUT_MAX_FAILURE_RATIO",
defaultAutoRollback: "RULE_PACK_ROLLOUT_AUTO_ROLLBACK",
maxStages: "RULE_PACK_ROLLOUT_MAX_STAGES",
maxListedTenants: "RULE_PACK_ROLLOUT_MAX_LISTED_TENANTS",
maxConflictRetries: "RULE_PACK_ROLLOUT_MAX_CONFLICT_RETRIES",
pinRetentionMs: "RULE_PACK_ROLLOUT_PIN_RETENTION_MS",
maxEventsPerRollout: "RULE_PACK_ROLLOUT_MAX_EVENTS",
};

const BOOLEAN_FIELDS: ReadonlySet<keyof RulePackRolloutConfig> = new Set([
"enabled",
"defaultAutoRollback",
]);

/** Merge `overrides` over the defaults and validate the result. */
export function resolveRulePackRolloutConfig(
overrides: Partial<RulePackRolloutConfig> = {},
): RulePackRolloutConfig {
const config: RulePackRolloutConfig = {
...DEFAULT_RULE_PACK_ROLLOUT_CONFIG,
...stripUndefined(overrides),
};

for (const field of BOOLEAN_FIELDS) {
if (typeof config[field] !== "boolean") {
throw new RangeError(`${field} must be a boolean`);
}
}
requirePositiveInteger(config.defaultMinSamples, "defaultMinSamples");
requireRatio(config.defaultMaxFailureRatio, "defaultMaxFailureRatio");
requirePositiveInteger(config.maxStages, "maxStages");
requireNonNegativeInteger(config.maxListedTenants, "maxListedTenants");
requireNonNegativeInteger(config.maxConflictRetries, "maxConflictRetries");
requirePositiveInteger(config.pinRetentionMs, "pinRetentionMs");
requirePositiveInteger(config.maxEventsPerRollout, "maxEventsPerRollout");
return config;
}

/**
* Build a config from `RULE_PACK_ROLLOUT_*` environment variables.
*
* Unset or empty variables keep their defaults; set-but-unparseable values
* throw so a typo in deployment config is caught at startup.
*/
export function loadRulePackRolloutConfigFromEnv(
env: Record<string, string | undefined> = process.env,
): RulePackRolloutConfig {
const overrides: Partial<RulePackRolloutConfig> = {};
for (const [field, envKey] of Object.entries(ENV_KEYS) as [
keyof RulePackRolloutConfig,
string,
][]) {
const raw = env[envKey];
if (raw === undefined || raw.trim() === "") {
continue;
}
const value = raw.trim();
if (BOOLEAN_FIELDS.has(field)) {
(overrides as Record<string, boolean>)[field] = parseBoolean(
value,
envKey,
);
} else {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
throw new RangeError(`${envKey} must be a number`);
}
(overrides as Record<string, number>)[field] = parsed;
}
}
return resolveRulePackRolloutConfig(overrides);
}

function parseBoolean(value: string, envKey: string): boolean {
const lowered = value.toLowerCase();
if (["true", "1", "yes", "on"].includes(lowered)) {
return true;
}
if (["false", "0", "no", "off"].includes(lowered)) {
return false;
}
throw new RangeError(`${envKey} must be a boolean`);
}

function stripUndefined<T extends object>(value: T): Partial<T> {
return Object.fromEntries(
Object.entries(value).filter(([, v]) => v !== undefined),
) as Partial<T>;
}

function requirePositiveInteger(value: number, field: string): void {
if (!Number.isInteger(value) || value <= 0) {
throw new RangeError(`${field} must be a positive integer`);
}
}

function requireNonNegativeInteger(value: number, field: string): void {
if (!Number.isInteger(value) || value < 0) {
throw new RangeError(`${field} must be a non-negative integer`);
}
}

function requireRatio(value: number, field: string): void {
if (
typeof value !== "number" ||
!Number.isFinite(value) ||
value < 0 ||
value > 1
) {
throw new RangeError(`${field} must be a number between 0 and 1`);
}
}
Loading
Loading