diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 8aba20fda6e..16c706bd648 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -5991,7 +5991,6 @@ "src/main/updater.quit-and-install.test.ts": 4994, "src/main/updater.startup-scheduling.test.ts": 3378, "src/main/updater/mac-browser-sidecar-recovery.test.ts": 81, - "src/main/updater/rollout-flags.test.ts": 285, "src/main/updater/serve-supervisor-install-park.test.ts": 151, "src/main/updater/shipit-leftover-recovery.test.ts": 103, "src/main/usage-worktree-canonicalizer.test.ts": 32, diff --git a/src/main/updater-nudge.test.ts b/src/main/updater-nudge.test.ts index e08b664c2d0..4eb1c98c406 100644 --- a/src/main/updater-nudge.test.ts +++ b/src/main/updater-nudge.test.ts @@ -9,12 +9,10 @@ vi.mock('electron', () => ({ })) import { fetchNudge, versionMatchesRange, shouldApplyNudge } from './updater-nudge' -import { isRolloutFlagActive, resetRolloutConfigForTests } from './updater/rollout-flags' describe('updater-nudge', () => { beforeEach(() => { netFetchMock.mockReset() - resetRolloutConfigForTests() }) describe('fetchNudge', () => { @@ -128,40 +126,6 @@ describe('updater-nudge', () => { }) }) - describe('rollout block on the same request', () => { - const install = { appVersion: '1.5.0', installId: 'install-a' } - const killSwitch = { version: 1, flags: { 'pinned-relay-default': { state: 'on' } } } - - it('records the block without changing the nudge it rides on', async () => { - netFetchMock.mockResolvedValue({ - ok: true, - json: async () => ({ id: 'campaign-1', minVersion: '1.0.0', rollout: killSwitch }) - }) - - await expect(fetchNudge()).resolves.toEqual({ id: 'campaign-1', minVersion: '1.0.0' }) - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(true) - expect(netFetchMock).toHaveBeenCalledTimes(1) - }) - - it('reads a block even when there is no nudge campaign', async () => { - netFetchMock.mockResolvedValue({ ok: true, json: async () => ({ rollout: killSwitch }) }) - - await expect(fetchNudge()).resolves.toBeNull() - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(true) - }) - - it('keeps the last block when a later request fails', async () => { - netFetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ rollout: killSwitch }) }) - await fetchNudge() - netFetchMock.mockResolvedValueOnce({ ok: false }) - await fetchNudge() - netFetchMock.mockRejectedValueOnce(new Error('network down')) - await fetchNudge() - - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(true) - }) - }) - describe('versionMatchesRange', () => { it('bounded range match', () => { expect(versionMatchesRange('1.1.5', { minVersion: '1.1.0', maxVersion: '1.1.19' })).toBe(true) diff --git a/src/main/updater-nudge.ts b/src/main/updater-nudge.ts index d5b3695a491..93ef8c1c105 100644 --- a/src/main/updater-nudge.ts +++ b/src/main/updater-nudge.ts @@ -1,6 +1,5 @@ import { net } from 'electron' import { compareVersions, isValidVersion } from './updater-fallback' -import { parseRolloutConfig, recordRolloutConfig } from './updater/rollout-flags' export type NudgeConfig = { id: string @@ -18,8 +17,6 @@ export async function fetchNudge(): Promise { } const json: unknown = await res.json() - // Why here: the rollout block rides on this same request; older builds ignore the key. - recordRolloutConfig(parseRolloutConfig(json)) if (!json || typeof json !== 'object' || Array.isArray(json)) { return null } diff --git a/src/main/updater/rollout-flags.test.ts b/src/main/updater/rollout-flags.test.ts deleted file mode 100644 index 8ad8c4dd527..00000000000 --- a/src/main/updater/rollout-flags.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { - BAKED_ROLLOUT_FLAGS, - ROLLOUT_FLAG_NAMES, - isRolloutFlagActive, - parseRolloutConfig, - readE2ERolloutConfigOverride, - recordRolloutConfig, - resetRolloutConfigForTests, - resolveRolloutFlag, - rolloutBucket, - type RolloutConfig -} from './rollout-flags' - -const install = { appVersion: '1.5.0', installId: 'install-a' } - -function campaign(flags: Record, version: unknown = 1): unknown { - return { id: 'campaign-1', minVersion: '1.0.0', rollout: { version, flags } } -} - -describe('rollout flags', () => { - beforeEach(() => { - resetRolloutConfigForTests() - }) - - it('ships every flip inactive', () => { - expect(ROLLOUT_FLAG_NAMES.every((name) => BAKED_ROLLOUT_FLAGS[name] === false)).toBe(true) - }) - - it('resolves to the baked value when the block is absent, invalid or a newer version', () => { - for (const payload of [ - null, - 'nudge', - [], - { id: 'campaign-1', minVersion: '1.0.0' }, - { rollout: 'on' }, - { rollout: { version: 1 } }, - campaign({ 'pinned-relay-default': { state: 'on' } }, 2) - ]) { - const config = parseRolloutConfig(payload) - expect(config).toBeNull() - expect(resolveRolloutFlag('pinned-relay-default', { ...install, config })).toBe(false) - } - }) - - it('drops only the entries it cannot read, and ignores flags it does not know', () => { - const config = parseRolloutConfig( - campaign({ - 'pinned-relay-default': { state: 'on', percent: 100, futureField: true }, - 'managed-servers-visible': { state: 'sideways' }, - 'serve-on-orcad-default': { state: 'on', percent: 140 }, - 'legacy-relay-dir-sweep': { state: 'on', minVersion: '2.0.0', maxVersion: '1.0.0' }, - 'flag-from-a-newer-build': { state: 'on' } - }) - ) - expect(config).toEqual({ 'pinned-relay-default': { state: 'on', percent: 100 } }) - }) - - it('keeps the kill switch on and lets "default" defer to the build', () => { - const config: RolloutConfig = { - 'pinned-relay-default': { state: 'off' }, - 'managed-servers-visible': { state: 'default' } - } - expect(resolveRolloutFlag('pinned-relay-default', { ...install, config })).toBe(false) - expect(resolveRolloutFlag('managed-servers-visible', { ...install, config })).toBe(false) - expect(resolveRolloutFlag('serve-on-orcad-default', { ...install, config })).toBe(false) - }) - - it('applies an entry only inside its version range', () => { - const config: RolloutConfig = { - 'pinned-relay-default': { state: 'on', minVersion: '1.5.0', maxVersion: '1.5.9' } - } - expect(resolveRolloutFlag('pinned-relay-default', { ...install, config })).toBe(true) - for (const appVersion of ['1.4.9', '1.6.0', 'not-a-version']) { - expect(resolveRolloutFlag('pinned-relay-default', { ...install, appVersion, config })).toBe( - false - ) - } - }) - - it('admits about the requested share of installs, stably per install and flag', () => { - const config: RolloutConfig = { 'pinned-relay-default': { state: 'on', percent: 25 } } - const ids = Array.from({ length: 4000 }, (_, index) => `install-${index}`) - const admitted = ids.filter((installId) => - resolveRolloutFlag('pinned-relay-default', { ...install, installId, config }) - ) - expect(admitted.length / ids.length).toBeGreaterThan(0.22) - expect(admitted.length / ids.length).toBeLessThan(0.28) - expect(rolloutBucket('install-a', 'pinned-relay-default')).toBe( - rolloutBucket('install-a', 'pinned-relay-default') - ) - // Why: each flip draws its own cohort, so one 25% step doesn't pick the same users every time. - const sameForOtherFlag = ids.filter( - (installId) => - rolloutBucket(installId, 'pinned-relay-default') === - rolloutBucket(installId, 'managed-servers-visible') - ) - expect(sameForOtherFlag.length).toBeLessThan(ids.length / 10) - }) - - it('keeps an install it cannot bucket on the baked value, except at 0% or 100%', () => { - const partial: RolloutConfig = { 'pinned-relay-default': { state: 'on', percent: 50 } } - const full: RolloutConfig = { 'pinned-relay-default': { state: 'on' } } - const none: RolloutConfig = { 'pinned-relay-default': { state: 'on', percent: 0 } } - const noId = { appVersion: '1.5.0', installId: null } - expect(resolveRolloutFlag('pinned-relay-default', { ...noId, config: partial })).toBe(false) - expect(resolveRolloutFlag('pinned-relay-default', { ...noId, config: full })).toBe(true) - expect(resolveRolloutFlag('pinned-relay-default', { ...install, config: none })).toBe(false) - }) - - it('reads the last recorded block until the next one replaces it', () => { - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(false) - recordRolloutConfig(parseRolloutConfig(campaign({ 'pinned-relay-default': { state: 'on' } }))) - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(true) - recordRolloutConfig(parseRolloutConfig({ id: 'campaign-2', minVersion: '1.0.0' })) - expect(isRolloutFlagActive('pinned-relay-default', install)).toBe(false) - }) -}) - -describe('e2e rollout override', () => { - const dirs: string[] = [] - afterEach(() => { - vi.unstubAllEnvs() - for (const dir of dirs.splice(0)) { - rmSync(dir, { recursive: true, force: true }) - } - }) - - function flagsFile(flags: unknown): string { - const dir = mkdtempSync(join(tmpdir(), 'rollout-e2e-')) - dirs.push(dir) - const file = join(dir, 'flags.json') - writeFileSync(file, JSON.stringify(flags)) - return file - } - - it('reads the flags file only in an unpackaged e2e launch', () => { - const file = flagsFile({ 'legacy-relay-dir-sweep': { state: 'on' } }) - const env = { ORCA_E2E_USER_DATA_DIR: '/e2e', ORCA_E2E_ROLLOUT_FLAGS_FILE: file } - expect(readE2ERolloutConfigOverride(env, false)).toEqual({ - 'legacy-relay-dir-sweep': { state: 'on' } - }) - expect(readE2ERolloutConfigOverride({ ORCA_E2E_ROLLOUT_FLAGS_FILE: file }, false)).toBeNull() - }) - - it('never reads the flags file in a packaged release', () => { - const file = flagsFile({ 'legacy-relay-dir-sweep': { state: 'on' } }) - const env = { ORCA_E2E_USER_DATA_DIR: '/e2e', ORCA_E2E_ROLLOUT_FLAGS_FILE: file } - expect(readE2ERolloutConfigOverride(env, true)).toBeNull() - vi.stubEnv('ORCA_E2E_USER_DATA_DIR', env.ORCA_E2E_USER_DATA_DIR) - vi.stubEnv('ORCA_E2E_ROLLOUT_FLAGS_FILE', file) - const context = { appVersion: '1.4.218', installId: null } - installFakeAppEnvironment({ isPackaged: () => true }) - expect(isRolloutFlagActive('legacy-relay-dir-sweep', context)).toBe(false) - installFakeAppEnvironment({ isPackaged: () => false }) - expect(isRolloutFlagActive('legacy-relay-dir-sweep', context)).toBe(true) - }) - - it('treats a missing or unreadable file as no override', () => { - const env = { ORCA_E2E_USER_DATA_DIR: '/e2e' } - expect(readE2ERolloutConfigOverride(env, false)).toBeNull() - expect( - readE2ERolloutConfigOverride( - { ...env, ORCA_E2E_ROLLOUT_FLAGS_FILE: '/missing/flags.json' }, - false - ) - ).toBeNull() - }) -}) diff --git a/src/main/updater/rollout-flags.ts b/src/main/updater/rollout-flags.ts deleted file mode 100644 index 395cb1ad214..00000000000 --- a/src/main/updater/rollout-flags.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Server-side kill switches and percent cohorts for the Node runtime rollout flips. - * - * The block rides on the update-campaign payload Orca already polls (`nudge.json`); older builds - * ignore it. Every flag resolves to its baked-in value when the block is absent, invalid, out of - * the flag's version range, or was never received, so the payload can only move what it names. - */ -import { createHash } from 'node:crypto' -import { readFileSync } from 'node:fs' -import { z } from 'zod' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' -import { compareVersions, isValidVersion } from '../updater-fallback' - -export const ROLLOUT_FLAG_NAMES = [ - 'pinned-relay-default', - 'managed-servers-visible', - 'serve-on-orcad-default', - 'legacy-npm-rung-retired', - 'legacy-relay-dir-sweep', - 'windows-relay-wmi-fallback-retired' -] as const -export type RolloutFlagName = (typeof ROLLOUT_FLAG_NAMES)[number] - -/** Each flip's shipped behaviour: none of them is active in this build. */ -export const BAKED_ROLLOUT_FLAGS: Readonly> = { - 'pinned-relay-default': false, - 'managed-servers-visible': false, - 'serve-on-orcad-default': false, - 'legacy-npm-rung-retired': false, - 'legacy-relay-dir-sweep': false, - 'windows-relay-wmi-fallback-retired': false -} - -const versionString = z.string().refine(isValidVersion) - -// Why not strict: a newer server may add fields; an entry this build can't read falls back alone. -const rolloutFlagEntrySchema = z - .object({ - // `off` is the kill switch; `on` admits `percent` of installs; `default` defers to the build. - state: z.enum(['default', 'off', 'on']), - percent: z.number().min(0).max(100).optional(), - minVersion: versionString.optional(), - maxVersion: versionString.optional() - }) - .refine( - (entry) => - entry.minVersion === undefined || - entry.maxVersion === undefined || - compareVersions(entry.minVersion, entry.maxVersion) <= 0 - ) - -export type RolloutFlagEntry = z.infer -export type RolloutConfig = Partial> - -const campaignRolloutSchema = z.object({ - rollout: z.object({ - version: z.literal(1), - flags: z.record(z.string(), z.unknown()) - }) -}) - -/** Reads the optional `rollout` block of a campaign payload; anything unreadable is null. */ -export function parseRolloutConfig(payload: unknown): RolloutConfig | null { - const campaign = campaignRolloutSchema.safeParse(payload) - if (!campaign.success) { - return null - } - const config: RolloutConfig = {} - for (const name of ROLLOUT_FLAG_NAMES) { - const parsed = rolloutFlagEntrySchema.safeParse(campaign.data.rollout.flags[name]) - if (parsed.success) { - config[name] = parsed.data - } - } - return config -} - -/** Stable per install and flag in [0, 100); salting by flag keeps the flips' cohorts independent. */ -export function rolloutBucket(installId: string, name: RolloutFlagName): number { - const digest = createHash('sha256').update(`${name}:${installId}`).digest() - return (digest.readUInt32BE(0) % 10_000) / 100 -} - -function inVersionRange(version: string, entry: RolloutFlagEntry): boolean { - return ( - (entry.minVersion === undefined || compareVersions(version, entry.minVersion) >= 0) && - (entry.maxVersion === undefined || compareVersions(version, entry.maxVersion) <= 0) - ) -} - -export type RolloutFlagContext = { - config: RolloutConfig | null - appVersion: string - installId: string | null -} - -export function resolveRolloutFlag(name: RolloutFlagName, context: RolloutFlagContext): boolean { - const baked = BAKED_ROLLOUT_FLAGS[name] - const entry = context.config?.[name] - if (!entry || entry.state === 'default') { - return baked - } - if (!isValidVersion(context.appVersion) || !inVersionRange(context.appVersion, entry)) { - return baked - } - if (entry.state === 'off') { - return false - } - const percent = entry.percent ?? 100 - if (percent >= 100) { - return true - } - // Why baked without an id: an unbucketable install must not join every cohort at once. - if (!context.installId) { - return baked - } - return rolloutBucket(context.installId, name) < percent -} - -let lastRolloutConfig: RolloutConfig | null = null - -/** - * Called with each successfully read campaign payload's block. A failed fetch keeps the last - * block, so a transient network error can never lift a kill switch mid-session. - */ -export function recordRolloutConfig(config: RolloutConfig | null): void { - lastRolloutConfig = config -} - -export function isRolloutFlagActive( - name: RolloutFlagName, - context: Omit -): boolean { - return resolveRolloutFlag(name, { - ...context, - config: readE2ERolloutConfigOverride() ?? lastRolloutConfig - }) -} - -/** - * E2E launches only: a `flags` block read from `ORCA_E2E_ROLLOUT_FLAGS_FILE` on every check, so a - * spec can flip a flag between two connects without the campaign fetch or a relaunch. - */ -export function readE2ERolloutConfigOverride( - env: NodeJS.ProcessEnv = process.env, - // Why fail closed: a packaged release must never take rollout flags from an env-named path. - isPackaged: boolean = !hasAppEnvironment() || getAppEnvironment().isPackaged() -): RolloutConfig | null { - const file = env.ORCA_E2E_ROLLOUT_FLAGS_FILE - if (isPackaged || !env.ORCA_E2E_USER_DATA_DIR || !file) { - return null - } - try { - return parseRolloutConfig({ - rollout: { version: 1, flags: JSON.parse(readFileSync(file, 'utf8')) } - }) - } catch { - return null - } -} - -export function resetRolloutConfigForTests(): void { - lastRolloutConfig = null -} diff --git a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts index c781045608b..6283e96606a 100644 --- a/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts +++ b/tests/e2e/ssh-orcad-windows-cli-matrix.spec.ts @@ -12,7 +12,7 @@ * Host: `ORCA_E2E_ORCAD_CONVERT_HOST`, a Windows host-cell descriptor. Template: * `ORCA_E2E_ORCAD_CONVERT_TEMPLATE`, built for that host's target. */ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import type { ElectronApplication, Page } from '@stablyai/playwright-test' @@ -38,7 +38,6 @@ import type { const HOST = process.env[ORCAD_CONVERT_HOST_ENV] const TEMPLATE = process.env.ORCA_E2E_ORCAD_CONVERT_TEMPLATE const SCRATCH = path.join(os.tmpdir(), `orca-orcad-cli-matrix-${process.pid}`) -const FLAGS_FILE = path.join(SCRATCH, 'rollout-flags.json') const MANAGED_TIMEOUT_MS = 8 * 60_000 type HostRow = { @@ -59,13 +58,11 @@ function skipUnlessWindowsHost(): void { function resetScratch(): void { rmSync(SCRATCH, { recursive: true, force: true }) mkdirSync(SCRATCH, { recursive: true }) - writeFileSync(FLAGS_FILE, '{}') } function launchEnv(): Record { return { - ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE!, - ORCA_E2E_ROLLOUT_FLAGS_FILE: FLAGS_FILE + ORCA_ORCAD_TEMPLATE_PATH: TEMPLATE! } } @@ -564,7 +561,7 @@ test('@orcad-cli-convert a seeded relay-era profile converts its Windows host, k const upgraded = await session.launch({ onStderr: sshStderr }) app = upgraded.app await waitForSessionReady(upgraded.page) - await convertThenRetire(upgraded.page, session.userDataDir, seeded, FLAGS_FILE) + await convertThenRetire(upgraded.page, session.userDataDir, seeded) const environmentId = await waitForManaged(upgraded.page, seeded.targetId) expect(await environmentIds(session.userDataDir)).toContain(environmentId)